Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Band" ADD COLUMN "isApproved" BOOLEAN NOT NULL DEFAULT false;
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "BandMembership" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
14 changes: 8 additions & 6 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -79,17 +79,19 @@ model Band {
webPage String?
description String?
genres String[]
isApproved Boolean @default(false)
members BandMembership[]
reservations Reservation[]
}

model BandMembership {
id Int @id @default(autoincrement())
userId Int?
bandId Int?
status BandMembershipStatus @default(PENDING)
band Band? @relation(fields: [bandId], references: [id])
user User? @relation(fields: [userId], references: [id])
id Int @id @default(autoincrement())
userId Int?
bandId Int?
status BandMembershipStatus @default(PENDING)
createdAt DateTime @default(now())
band Band? @relation(fields: [bandId], references: [id])
user User? @relation(fields: [userId], references: [id])

@@unique([bandId, userId])
}
Expand Down
13 changes: 9 additions & 4 deletions apps/backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,24 @@
import { Injectable } from '@nestjs/common';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { PrismaService } from 'nestjs-prisma';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { User } from 'src/users/entities/user.entity';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
constructor(private readonly prisma: PrismaService) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false,
secretOrKey: process.env.JWT_SECRET,
});
}

validate(payload: User): User {
return payload;
async validate(payload: User): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id: payload.id } });
if (!user) {
throw new UnauthorizedException('User not found');
}
return user;
}
}
14 changes: 14 additions & 0 deletions apps/backend/src/auth/optional-jwt-auth.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';

@Injectable()
export class OptionalJwtAuthGuard extends AuthGuard('jwt') {
// Override handleRequest so it doesn't throw an error if no user is found
handleRequest(err: any, user: any) {
// If there is an error or no user, just return null (do not throw UnauthorizedException)
if (err || !user) {
return null;
}
return user;
}
}
61 changes: 57 additions & 4 deletions apps/backend/src/bands/bands.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,59 @@ import { Band } from './entities/band.entity';
export class BandsService {
constructor(private readonly prisma: PrismaService) {}

async create(createBandDto: CreateBandDto): Promise<Band> {
async create(createBandDto: CreateBandDto, userId?: number): Promise<Band> {
if (userId) {
return await this.prisma.band.create({
data: {
...createBandDto,
members: {
create: {
userId,
status: BandMembershipStatus.ACCEPTED,
},
},
},
});
}
return await this.prisma.band.create({ data: createBandDto });
}

async findAll(): Promise<Band[]> {
async findAll(user?: User): Promise<Band[]> {
let where: any = { isApproved: true };
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);

if (user?.role === 'ADMIN') {
where = {}; // Admins see all bands, approved or not
} else if (user?.id) {
// Normal users see approved bands OR bands where they are a member
where = {
OR: [
{ isApproved: true },
{
members: {
some: {
userId: user.id,
OR: [
{ status: BandMembershipStatus.ACCEPTED },
{ status: BandMembershipStatus.PENDING, createdAt: { gte: sevenDaysAgo } },
],
},
},
},
],
};
}
Comment thread
justnyx marked this conversation as resolved.

const res = await this.prisma.band.findMany({
include: { members: { include: { user: { select: { fullName: true } } } } },
where,
include: {
members: {
where: {
OR: [{ status: 'ACCEPTED' }, { status: 'PENDING', createdAt: { gte: sevenDaysAgo } }],
},
include: { user: { select: { fullName: true } } },
},
},
});
return res;
}
Expand Down Expand Up @@ -46,8 +92,15 @@ export class BandsService {

async findMembers(id: number): Promise<User[]> {
try {
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const bandmemberships = await this.prisma.bandMembership.findMany({
where: { bandId: id },
where: {
bandId: id,
OR: [
{ status: BandMembershipStatus.ACCEPTED },
{ status: BandMembershipStatus.PENDING, createdAt: { gte: sevenDaysAgo } },
],
},
include: { user: true },
});
return bandmemberships.map((membership) => membership.user);
Expand Down
61 changes: 49 additions & 12 deletions apps/backend/src/bands/bandsController.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,21 @@
import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, UseGuards } from '@nestjs/common';
import {
Body,
Controller,
Delete,
ForbiddenException,
Get,
Param,
ParseIntPipe,
Patch,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { ApiBearerAuth } from '@nestjs/swagger';
import { Role } from '@prisma/client';
import { Roles } from 'src/auth/decorators/Roles.decorator';
import { OptionalJwtAuthGuard } from 'src/auth/optional-jwt-auth.guard';
import { RolesGuard } from 'src/auth/roles.guard';

import { BandsService } from './bands.service';
Expand All @@ -16,13 +29,14 @@ export class BandsController {
@Post()
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt')) // Ha JWT-t használsz
create(@Body() createBandDto: CreateBandDto) {
return this.bandsService.create(createBandDto);
create(@Body() createBandDto: CreateBandDto, @Req() req: any) {
return this.bandsService.create(createBandDto, req.user.id);
}

@Get()
findAll() {
return this.bandsService.findAll();
@UseGuards(OptionalJwtAuthGuard)
findAll(@Req() req: any) {
return this.bandsService.findAll(req.user);
}

@Get(':id')
Expand Down Expand Up @@ -54,23 +68,46 @@ export class BandsController {
@Post(':id/members/:userId')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
addMember(@Param('id', ParseIntPipe) bandId: number, @Param('userId', ParseIntPipe) userId: number) {
async addMember(
@Param('id', ParseIntPipe) bandId: number,
@Param('userId', ParseIntPipe) userId: number,
@Req() req: any
) {
if (req.user.role !== Role.ADMIN) {
// Requesting user must be a member of the band
const members = await this.bandsService.findMembers(bandId);
if (!members.some((m) => m.id === req.user.id)) {
throw new ForbiddenException('Csak a zenekar tagjai hívhatnak meg másokat.');
}
Comment thread
justnyx marked this conversation as resolved.
Outdated
}
return this.bandsService.addMember(bandId, userId);
}

@Delete(':id/members/:userId')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(Role.ADMIN)
removeMember(@Param('id', ParseIntPipe) bandId: number, @Param('userId', ParseIntPipe) userId: number) {
@UseGuards(AuthGuard('jwt'))
removeMember(
@Param('id', ParseIntPipe) bandId: number,
@Param('userId', ParseIntPipe) userId: number,
@Req() req: any
) {
if (req.user.role !== Role.ADMIN && req.user.id !== userId) {
throw new ForbiddenException('Csak magadat távolíthatod el.');
}
return this.bandsService.removeMember(bandId, userId);
}

@Patch(':id/members/:userId')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'), RolesGuard)
@Roles(Role.ADMIN)
approveMember(@Param('id', ParseIntPipe) bandId: number, @Param('userId', ParseIntPipe) userId: number) {
@UseGuards(AuthGuard('jwt'))
approveMember(
@Param('id', ParseIntPipe) bandId: number,
@Param('userId', ParseIntPipe) userId: number,
@Req() req: any
) {
if (req.user.role !== Role.ADMIN && req.user.id !== userId) {
throw new ForbiddenException('Csak a saját meghívódat fogadhatod el.');
}
return this.bandsService.approveMember(bandId, userId);
Comment thread
justnyx marked this conversation as resolved.
}
}
7 changes: 6 additions & 1 deletion apps/backend/src/bands/dto/update-band.dto.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { PartialType } from '@nestjs/mapped-types';
import { IsBoolean, IsOptional } from 'class-validator';

import { CreateBandDto } from './create-band.dto';

export class UpdateBandDto extends PartialType(CreateBandDto) {}
export class UpdateBandDto extends PartialType(CreateBandDto) {
@IsOptional()
@IsBoolean()
isApproved?: boolean;
}
24 changes: 15 additions & 9 deletions apps/frontend/src/app/bands/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';

import BandRow from '@/components/band/band-row';
import CreateBandDialog from '@/components/band/create-band-dialog';
Expand All @@ -16,6 +16,12 @@ export default function Bands() {
const [searchTerm, setSearchTerm] = useState('');
const { user } = useUser();

const knownGenres = useMemo(() => {
const genreSet = new Set<string>();
bands.forEach((b) => b.genres?.forEach((g) => genreSet.add(g)));
return Array.from(genreSet).sort();
}, [bands]);

const fetchBands = () => {
axiosApi.get('/bands').then((res) => {
const fetched: Band[] = res.data;
Expand All @@ -37,7 +43,7 @@ export default function Bands() {
<div className='flex items-center justify-between flex-col sm:flex-row gap-3 p-4 bg-background sticky top-0 z-10'>
<h1 className='text-2xl font-semibold text-primary w-full sm:w-auto text-center sm:text-left'>Zenekarok</h1>
<div className='flex gap-2 w-full sm:w-auto flex-col sm:flex-row'>
{user && <CreateBandDialog onCreated={() => fetchBands()} />}
{user && <CreateBandDialog onCreated={() => fetchBands()} knownGenres={knownGenres} />}
<Input
placeholder='Keresés...'
value={searchTerm}
Expand All @@ -47,17 +53,17 @@ export default function Bands() {
</div>
</div>
<Table>
<TableBody>
{filteredData.length ? (
filteredData.map((band) => <BandRow band={band} key={band.id} />)
) : (
{filteredData.length ? (
filteredData.map((band) => <BandRow band={band} key={band.id} knownGenres={knownGenres} />)
) : (
<TableBody>
<TableRow>
<TableCell colSpan={4} className='h-24 text-center'>
No results.
Nincs találat.
</TableCell>
</TableRow>
)}
</TableBody>
</TableBody>
)}
</Table>
</div>
);
Expand Down
Loading
Loading