diff --git a/apps/backend/prisma/migrations/20260319132056_add_band_approval/migration.sql b/apps/backend/prisma/migrations/20260319132056_add_band_approval/migration.sql new file mode 100644 index 0000000..ec3baa6 --- /dev/null +++ b/apps/backend/prisma/migrations/20260319132056_add_band_approval/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Band" ADD COLUMN "isApproved" BOOLEAN NOT NULL DEFAULT false; diff --git a/apps/backend/prisma/migrations/20260323150841_add_created_at_to_band_membership/migration.sql b/apps/backend/prisma/migrations/20260323150841_add_created_at_to_band_membership/migration.sql new file mode 100644 index 0000000..7202f7e --- /dev/null +++ b/apps/backend/prisma/migrations/20260323150841_add_created_at_to_band_membership/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "BandMembership" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP; diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index e00e147..a621df1 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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]) } diff --git a/apps/backend/src/auth/jwt.strategy.ts b/apps/backend/src/auth/jwt.strategy.ts index 9588bb2..b6f61e8 100644 --- a/apps/backend/src/auth/jwt.strategy.ts +++ b/apps/backend/src/auth/jwt.strategy.ts @@ -1,11 +1,12 @@ -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, @@ -13,7 +14,11 @@ export class JwtStrategy extends PassportStrategy(Strategy) { }); } - validate(payload: User): User { - return payload; + async validate(payload: User): Promise { + const user = await this.prisma.user.findUnique({ where: { id: payload.id } }); + if (!user) { + throw new UnauthorizedException('User not found'); + } + return user; } } diff --git a/apps/backend/src/auth/optional-jwt-auth.guard.ts b/apps/backend/src/auth/optional-jwt-auth.guard.ts new file mode 100644 index 0000000..28726ef --- /dev/null +++ b/apps/backend/src/auth/optional-jwt-auth.guard.ts @@ -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; + } +} diff --git a/apps/backend/src/bands/bands.service.ts b/apps/backend/src/bands/bands.service.ts index 4d1e44b..da02329 100644 --- a/apps/backend/src/bands/bands.service.ts +++ b/apps/backend/src/bands/bands.service.ts @@ -11,13 +11,59 @@ import { Band } from './entities/band.entity'; export class BandsService { constructor(private readonly prisma: PrismaService) {} - async create(createBandDto: CreateBandDto): Promise { + async create(createBandDto: CreateBandDto, userId?: number): Promise { + 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 { + async findAll(user?: User): Promise { + 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 } }, + ], + }, + }, + }, + ], + }; + } + 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; } @@ -44,10 +90,28 @@ export class BandsService { } } + async isAcceptedMember(bandId: number, userId: number): Promise { + const count = await this.prisma.bandMembership.count({ + where: { + bandId, + userId, + status: BandMembershipStatus.ACCEPTED, + }, + }); + return count > 0; + } + async findMembers(id: number): Promise { 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); @@ -58,6 +122,18 @@ export class BandsService { async addMember(bandId: number, userId: number): Promise { try { + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + + // Clean up expired pending invites before checking for existing + await this.prisma.bandMembership.deleteMany({ + where: { + bandId, + userId, + status: BandMembershipStatus.PENDING, + createdAt: { lt: sevenDaysAgo }, + }, + }); + const existing = await this.prisma.bandMembership.findFirst({ where: { bandId, userId } }); if (existing) { throw new ConflictException('User is already a member of this band'); @@ -87,10 +163,22 @@ export class BandsService { async approveMember(bandId: number, userId: number) { try { - return await this.prisma.bandMembership.updateMany({ - where: { bandId, userId }, + const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); + const res = await this.prisma.bandMembership.updateMany({ + where: { + bandId, + userId, + status: BandMembershipStatus.PENDING, + createdAt: { gte: sevenDaysAgo }, + }, data: { status: BandMembershipStatus.ACCEPTED }, }); + + if (res.count === 0) { + throw new NotFoundException('No valid invitation found'); + } + + return res; } catch (error) { throw new NotFoundException('No member found'); } diff --git a/apps/backend/src/bands/bandsController.ts b/apps/backend/src/bands/bandsController.ts index 82736cd..00247c4 100644 --- a/apps/backend/src/bands/bandsController.ts +++ b/apps/backend/src/bands/bandsController.ts @@ -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'; @@ -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') @@ -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 an accepted member of the band + const isMember = await this.bandsService.isAcceptedMember(bandId, req.user.id); + if (!isMember) { + throw new ForbiddenException('Csak a zenekar tagjai hívhatnak meg másokat.'); + } + } 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); } } diff --git a/apps/backend/src/bands/dto/update-band.dto.ts b/apps/backend/src/bands/dto/update-band.dto.ts index 3ef04f6..0866d35 100644 --- a/apps/backend/src/bands/dto/update-band.dto.ts +++ b/apps/backend/src/bands/dto/update-band.dto.ts @@ -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; +} diff --git a/apps/frontend/src/app/bands/page.tsx b/apps/frontend/src/app/bands/page.tsx index 8e3abe7..fc44fc0 100644 --- a/apps/frontend/src/app/bands/page.tsx +++ b/apps/frontend/src/app/bands/page.tsx @@ -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'; @@ -16,6 +16,12 @@ export default function Bands() { const [searchTerm, setSearchTerm] = useState(''); const { user } = useUser(); + const knownGenres = useMemo(() => { + const genreSet = new Set(); + 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; @@ -37,7 +43,7 @@ export default function Bands() {

Zenekarok

- {user && fetchBands()} />} + {user && fetchBands()} knownGenres={knownGenres} />}
- - {filteredData.length ? ( - filteredData.map((band) => ) - ) : ( + {filteredData.length ? ( + filteredData.map((band) => ) + ) : ( + - No results. + Nincs találat. - )} - + + )}
); diff --git a/apps/frontend/src/components/band/band-form-dialog.tsx b/apps/frontend/src/components/band/band-form-dialog.tsx index cdeb4a8..fc754b8 100644 --- a/apps/frontend/src/components/band/band-form-dialog.tsx +++ b/apps/frontend/src/components/band/band-form-dialog.tsx @@ -1,6 +1,6 @@ 'use client'; -import { ReactNode, useEffect, useMemo, useState } from 'react'; +import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; @@ -22,6 +22,7 @@ type BandFormDialogProps = { onOpenChange: (open: boolean) => void; onSuccess?: (band: Band) => void; trigger?: ReactNode; + knownGenres?: string[]; }; const MAX_NAME_LENGTH = 100; @@ -30,7 +31,15 @@ const MAX_WEBPAGE_LENGTH = 200; const MAX_DESCRIPTION_LENGTH = 1000; const MAX_GENRES_LENGTH = 200; -export default function BandFormDialog({ mode, band, open, onOpenChange, onSuccess, trigger }: BandFormDialogProps) { +export default function BandFormDialog({ + mode, + band, + open, + onOpenChange, + onSuccess, + trigger, + knownGenres, +}: BandFormDialogProps) { const { user } = useUser(); const isEdit = useMemo(() => mode === 'edit', [mode]); @@ -41,6 +50,8 @@ export default function BandFormDialog({ mode, band, open, onOpenChange, onSucce const [webPage, setWebPage] = useState(band?.webPage || ''); const [description, setDescription] = useState(band?.description || ''); const [genresInput, setGenresInput] = useState((band?.genres || []).join(', ')); + const [showSuggestions, setShowSuggestions] = useState(false); + const genresWrapperRef = useRef(null); useEffect(() => { if (!open) { @@ -51,6 +62,7 @@ export default function BandFormDialog({ mode, band, open, onOpenChange, onSucce setDescription(band?.description || ''); setGenresInput((band?.genres || []).join(', ')); setSubmitting(false); + setShowSuggestions(false); } else if (isEdit) { // when opening edit, ensure fields reflect latest band setName(band?.name || ''); @@ -67,6 +79,39 @@ export default function BandFormDialog({ mode, band, open, onOpenChange, onSucce } }, [open, isEdit, mode, band?.name, band?.email, band?.webPage, band?.description, band?.genres]); + // Derive suggestions: filter knownGenres by the current token (text after last comma) + // and exclude genres already present in the input. + const genreSuggestions = useMemo(() => { + if (!knownGenres?.length || !showSuggestions) return []; + + const parts = genresInput.split(','); + const currentToken = parts[parts.length - 1].trim().toLowerCase(); + const already = new Set( + parts + .slice(0, -1) + .map((g) => g.trim().toLowerCase()) + .filter(Boolean) + ); + + return knownGenres.filter((g) => { + const lower = g.toLowerCase(); + return !already.has(lower) && (currentToken === '' || lower.includes(currentToken)); + }); + }, [knownGenres, genresInput, showSuggestions]); + + const handleSelectSuggestion = (genre: string) => { + const parts = genresInput.split(','); + // Replace the last token (currently being typed) with the selected suggestion + parts[parts.length - 1] = genre.trim(); + // Rejoin with comma-space and append a trailing comma-space + const newVal = `${parts + .map((p) => p.trim()) + .filter(Boolean) + .join(', ')}, `; + setGenresInput(newVal); + // Keep suggestions open so they can rapidly click multiple genres + }; + const handleSubmit = async () => { if (!name.trim()) return; setSubmitting(true); @@ -152,12 +197,33 @@ export default function BandFormDialog({ mode, band, open, onOpenChange, onSucce onChange={(e) => setDescription(sanitizeUtfInput(e.target.value))} maxLength={MAX_DESCRIPTION_LENGTH} /> - setGenresInput(sanitizeUtfInput(e.target.value))} - maxLength={MAX_GENRES_LENGTH} - /> +
+ setGenresInput(sanitizeUtfInput(e.target.value))} + maxLength={MAX_GENRES_LENGTH} + onFocus={() => setShowSuggestions(true)} + onBlur={() => { + // Delay so a click on a suggestion registers first + setTimeout(() => setShowSuggestions(false), 150); + }} + /> + {genreSuggestions.length > 0 && ( +
    + {genreSuggestions.map((genre) => ( +
  • e.preventDefault()} // prevent blur before click + onClick={() => handleSelectSuggestion(genre)} + className='cursor-pointer px-3 py-2 text-sm hover:bg-accent hover:text-accent-foreground transition-colors' + > + {genre} +
  • + ))} +
+ )} +
+ )} + + ))} +
+ {isPending && ( +
+ Meghívásod van ebbe a zenekarba! +
+ + +
+
+ )} + {/* Approve button for admins */} + {user?.role === 'ADMIN' && !band.isApproved && ( + + )} {/* Join button removed intentionally */} - {isMember && user && ( + {(isMember || user?.role === 'ADMIN') && user && ( <>
window.location.reload()} + knownGenres={knownGenres} trigger={
- + {isMember && ( + + )}
- + - Tag hozzáadása + Tag meghívása