From 363d4acc607e7f1bca6021104cfa986c414f8d3a Mon Sep 17 00:00:00 2001 From: ryaken-nakamoto Date: Wed, 1 Apr 2026 12:12:53 -0400 Subject: [PATCH 1/2] BOS-83: implemented auth --- .../src/anthology/anthology.controller.ts | 40 +++++- .../backend/src/anthology/anthology.entity.ts | 4 +- .../backend/src/anthology/anthology.module.ts | 3 +- .../src/anthology/anthology.service.ts | 4 - .../anthology/dtos/create-anthology.dto.ts | 5 - apps/backend/src/app.module.ts | 22 +++- apps/backend/src/auth/auth.module.ts | 22 +++- .../src/auth/guards/jwt-auth.guard.spec.ts | 85 +++++++++++++ .../backend/src/auth/guards/jwt-auth.guard.ts | 45 +++++++ .../src/auth/guards/omchai.guard.spec.ts | 117 ++++++++++++++++++ apps/backend/src/auth/guards/omchai.guard.ts | 75 +++++++++++ .../src/auth/guards/user-status.guard.spec.ts | 68 ++++++++++ .../src/auth/guards/user-status.guard.ts | 56 +++++++++ apps/backend/src/auth/roles.decorator.ts | 13 ++ .../1775058503311-nullable-publish-date.ts | 17 +++ .../src/story-draft/story-draft.controller.ts | 6 +- .../src/story-draft/story-draft.module.ts | 3 +- apps/backend/src/story/story.controller.ts | 5 - apps/backend/src/story/story.module.ts | 3 +- apps/backend/src/users/users.service.spec.ts | 109 +++++++++++++++- apps/backend/src/users/users.service.ts | 7 ++ package.json | 11 +- 22 files changed, 677 insertions(+), 43 deletions(-) create mode 100644 apps/backend/src/auth/guards/jwt-auth.guard.spec.ts create mode 100644 apps/backend/src/auth/guards/jwt-auth.guard.ts create mode 100644 apps/backend/src/auth/guards/omchai.guard.spec.ts create mode 100644 apps/backend/src/auth/guards/omchai.guard.ts create mode 100644 apps/backend/src/auth/guards/user-status.guard.spec.ts create mode 100644 apps/backend/src/auth/guards/user-status.guard.ts create mode 100644 apps/backend/src/auth/roles.decorator.ts create mode 100644 apps/backend/src/migrations/1775058503311-nullable-publish-date.ts diff --git a/apps/backend/src/anthology/anthology.controller.ts b/apps/backend/src/anthology/anthology.controller.ts index 7f2d36fff..fb484e960 100644 --- a/apps/backend/src/anthology/anthology.controller.ts +++ b/apps/backend/src/anthology/anthology.controller.ts @@ -4,16 +4,22 @@ import { Post, Body, Delete, + Patch, Param, ParseIntPipe, - UseGuards, NotFoundException, + HttpCode, + HttpStatus, } from '@nestjs/common'; import { AnthologyService } from './anthology.service'; import { Anthology } from './anthology.entity'; -import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { FilterSortAnthologyDto } from './dtos/filter-anthology.dto'; +import { OmchaiRoles, UserStatus } from '../auth/roles.decorator'; +import { OmchaiRole } from 'src/omchai/omchai.entity'; +import { CreateAnthologyDto } from './dtos/create-anthology.dto'; +import { UpdateAnthologyDto } from './dtos/update-anthology.dto'; +import { Status } from 'src/users/types'; @ApiTags('Anthologies') @Controller('anthologies') @@ -44,7 +50,6 @@ export class AnthologyController { } @ApiBearerAuth() - @UseGuards(AuthGuard('jwt')) @Delete('/:anthologyId') async removeAnthology( @Param('anthologyId', ParseIntPipe) anthologyId: number, @@ -52,4 +57,33 @@ export class AnthologyController { await this.anthologyService.remove(anthologyId); return { message: 'Anthology deleted successfully' }; } + + @ApiBearerAuth() + @UserStatus(Status.ADMIN) + @Post() + @HttpCode(HttpStatus.CREATED) + async createAnthology( + @Body() createAnthologyDto: CreateAnthologyDto, + ): Promise { + return this.anthologyService.create( + createAnthologyDto.title, + createAnthologyDto.description, + createAnthologyDto.status, + createAnthologyDto.pub_level, + createAnthologyDto.programs, + createAnthologyDto.photo_url, + createAnthologyDto.isbn, + createAnthologyDto.shopify_url, + ); + } + + @ApiBearerAuth() + @OmchaiRoles(OmchaiRole.OWNER, OmchaiRole.MANAGER) + @Patch(':id') + async updateAnthology( + @Param('id', ParseIntPipe) id: number, + @Body() updateAnthologyDto: UpdateAnthologyDto, + ): Promise { + return this.anthologyService.update(id, updateAnthologyDto); + } } diff --git a/apps/backend/src/anthology/anthology.entity.ts b/apps/backend/src/anthology/anthology.entity.ts index fee74cae1..4ea1b1abe 100644 --- a/apps/backend/src/anthology/anthology.entity.ts +++ b/apps/backend/src/anthology/anthology.entity.ts @@ -40,8 +40,8 @@ export class Anthology { @Column({ type: 'simple-array', default: [] }) triggers: string[]; - @Column({ name: 'published_date', type: 'date' }) - publishedDate: Date; + @Column({ name: 'published_date', type: 'date', nullable: true }) + publishedDate?: Date; @Column({ type: 'simple-array', nullable: true }) programs?: string[]; diff --git a/apps/backend/src/anthology/anthology.module.ts b/apps/backend/src/anthology/anthology.module.ts index 1c71d802a..5ea3700af 100644 --- a/apps/backend/src/anthology/anthology.module.ts +++ b/apps/backend/src/anthology/anthology.module.ts @@ -3,14 +3,13 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AnthologyController } from './anthology.controller'; import { AnthologyService } from './anthology.service'; import { Anthology } from './anthology.entity'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthModule } from '../auth/auth.module'; import { UsersModule } from '../users/users.module'; @Module({ imports: [TypeOrmModule.forFeature([Anthology]), AuthModule, UsersModule], controllers: [AnthologyController], - providers: [AnthologyService, CurrentUserInterceptor], + providers: [AnthologyService], exports: [AnthologyService], }) export class AnthologyModule {} diff --git a/apps/backend/src/anthology/anthology.service.ts b/apps/backend/src/anthology/anthology.service.ts index d2024955c..28a64d5c4 100644 --- a/apps/backend/src/anthology/anthology.service.ts +++ b/apps/backend/src/anthology/anthology.service.ts @@ -25,7 +25,6 @@ export class AnthologyService { async create( title: string, description: string, - publishedDate: string, status: AnthologyStatus, pubLevel: AnthologyPubLevel, programs?: string[], @@ -33,12 +32,9 @@ export class AnthologyService { isbn?: string, shopifyUrl?: string, ) { - const anthologyId = (await this.repo.count()) + 1; const anthology = this.repo.create({ - id: anthologyId, title, description, - publishedDate, status, pubLevel, programs, diff --git a/apps/backend/src/anthology/dtos/create-anthology.dto.ts b/apps/backend/src/anthology/dtos/create-anthology.dto.ts index 3f6e82890..75b527b3a 100644 --- a/apps/backend/src/anthology/dtos/create-anthology.dto.ts +++ b/apps/backend/src/anthology/dtos/create-anthology.dto.ts @@ -8,7 +8,6 @@ import { import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { AnthologyStatus, AnthologyPubLevel } from '../types'; -//TODO: outdated DTO needs to match the schema export class CreateAnthologyDto { @ApiProperty({ description: 'Title of the anthology' }) @IsString() @@ -18,10 +17,6 @@ export class CreateAnthologyDto { @IsString() description: string; - @ApiProperty({ description: 'Year the anthology was published' }) - @IsNumber() - published_year: number; - @ApiProperty({ description: 'Status of the anthology', enum: AnthologyStatus, diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index af4228390..0df7887e6 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -4,6 +4,7 @@ import { TypeOrmModule } from '@nestjs/typeorm'; import { AppController } from './app.controller'; import { AppService } from './app.service'; import { AuthorModule } from './author/author.module'; +import { AnthologyModule } from './anthology/anthology.module'; import { InventoryModule } from './inventory/inventory.module'; import { InventoryHoldingModule } from './inventory-holding/inventory-holding.module'; import AppDataSource from './data-source'; @@ -13,6 +14,10 @@ import { OmchaiModule } from './omchai/omchai.module'; import { UsersModule } from './users/users.module'; import { StoryDraftModule } from './story-draft/story-draft.module'; import { AuthModule } from './auth/auth.module'; +import { APP_GUARD } from '@nestjs/core'; +import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; +import { OmchaiGuard } from './auth/guards/omchai.guard'; +import { UserStatusGuard } from './auth/guards/user-status.guard'; @Module({ imports: [ @@ -21,6 +26,7 @@ import { AuthModule } from './auth/auth.module'; migrations: [], // ensures migrations not run on app startup }), AuthorModule, + AnthologyModule, InventoryModule, InventoryHoldingModule, ProductionInfoModule, @@ -31,6 +37,20 @@ import { AuthModule } from './auth/auth.module'; AuthModule, ], controllers: [AppController], - providers: [AppService], + providers: [ + AppService, + { + provide: APP_GUARD, + useClass: JwtAuthGuard, + }, + { + provide: APP_GUARD, + useClass: UserStatusGuard, + }, + { + provide: APP_GUARD, + useClass: OmchaiGuard, + }, + ], }) export class AppModule {} diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index fd7b1af79..ce2819d18 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -7,7 +7,9 @@ import { AuthService } from './auth.service'; import { UsersService } from '../users/users.service'; import { User } from '../users/user.entity'; import { JwtStrategy } from './jwt.strategy'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { JwtAuthGuard } from './guards/jwt-auth.guard'; +import { OmchaiGuard } from './guards/omchai.guard'; +import { UserStatusGuard } from './guards/user-status.guard'; @Module({ imports: [ @@ -15,7 +17,21 @@ import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor PassportModule.register({ defaultStrategy: 'jwt' }), ], controllers: [AuthController], - providers: [AuthService, JwtStrategy, UsersService, CurrentUserInterceptor], - exports: [AuthService, JwtStrategy, UsersService, CurrentUserInterceptor], + providers: [ + AuthService, + JwtStrategy, + UsersService, + JwtAuthGuard, + OmchaiGuard, + UserStatusGuard, + ], + exports: [ + AuthService, + JwtStrategy, + UsersService, + JwtAuthGuard, + OmchaiGuard, + UserStatusGuard, + ], }) export class AuthModule {} diff --git a/apps/backend/src/auth/guards/jwt-auth.guard.spec.ts b/apps/backend/src/auth/guards/jwt-auth.guard.spec.ts new file mode 100644 index 000000000..71b2b8a92 --- /dev/null +++ b/apps/backend/src/auth/guards/jwt-auth.guard.spec.ts @@ -0,0 +1,85 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ExecutionContext } from '@nestjs/common'; +import { JwtAuthGuard } from './jwt-auth.guard'; +import { UsersService } from '../../users/users.service'; +import { User } from '../../users/user.entity'; +import { Status } from '../../users/types'; +import { Omchai, OmchaiRole } from '../../omchai/omchai.entity'; + +describe('JwtAuthGuard', () => { + let guard: JwtAuthGuard; + let usersService: jest.Mocked; + + const mockOmchai: Omchai = { + id: 1, + anthologyId: 1, + userId: 1, + role: OmchaiRole.OWNER, + datetimeAssigned: new Date(), + user: null, + anthology: null, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + JwtAuthGuard, + { + provide: UsersService, + useValue: { + findWithOmchai: jest.fn(), + }, + }, + ], + }).compile(); + + guard = module.get(JwtAuthGuard); + usersService = module.get(UsersService) as jest.Mocked; + + jest + .spyOn(Object.getPrototypeOf(Object.getPrototypeOf(guard)), 'canActivate') + .mockResolvedValue(true); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('canActivate', () => { + it('should return false when parent guard returns false', async () => { + const mockRequest = { + user: { email: 'test@example.com' }, + }; + + const mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + + jest.spyOn(guard, 'canActivate').mockResolvedValueOnce(false); + + const result = await guard.canActivate(mockContext); + + expect(result).toBe(false); + }); + + it('should handle requests with no user email gracefully', async () => { + const mockRequest = { + user: {}, + }; + + const mockContext = { + switchToHttp: () => ({ + getRequest: () => mockRequest, + }), + } as unknown as ExecutionContext; + + const result = await guard.canActivate(mockContext); + // we can still let this pass thru since JWT was valid + // but will fail any other guards if they have roles associated + expect(result).toBe(true); + expect(usersService.findWithOmchai).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/apps/backend/src/auth/guards/jwt-auth.guard.ts b/apps/backend/src/auth/guards/jwt-auth.guard.ts new file mode 100644 index 000000000..0a3a7d079 --- /dev/null +++ b/apps/backend/src/auth/guards/jwt-auth.guard.ts @@ -0,0 +1,45 @@ +import { Injectable, ExecutionContext, Logger } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; +import { UsersService } from '../../users/users.service'; + +@Injectable() +export class JwtAuthGuard extends AuthGuard('jwt') { + private readonly logger = new Logger(JwtAuthGuard.name); + + constructor(private usersService: UsersService) { + super(); + } + + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const { method, url } = request; + + let result: boolean; + try { + result = await (super.canActivate(context) as Promise); + } catch (err) { + this.logger.warn( + `JWT validation failed for ${method} ${url}: ${err.message}`, + ); + throw err; + } + + if (!result) { + this.logger.warn(`JWT guard denied ${method} ${url}`); + return false; + } + + const email = request.user?.email; + if (email) { + const users = await this.usersService.findWithOmchai(email); + if (users.length > 0) { + request.user = users[0]; + this.logger.debug(`Authenticated user ${email} for ${method} ${url}`); + } else { + this.logger.warn(`JWT valid but no user found for email ${email}`); + } + } + + return result; + } +} diff --git a/apps/backend/src/auth/guards/omchai.guard.spec.ts b/apps/backend/src/auth/guards/omchai.guard.spec.ts new file mode 100644 index 000000000..6a145eb71 --- /dev/null +++ b/apps/backend/src/auth/guards/omchai.guard.spec.ts @@ -0,0 +1,117 @@ +import { ExecutionContext, Logger } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { OmchaiGuard } from './omchai.guard'; +import { OMCHAI_ROLES } from '../roles.decorator'; +import { OmchaiRole } from 'src/omchai/omchai.entity'; + +function makeContext( + user: unknown, + params: Record = {}, +): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user, params }), + }), + } as unknown as ExecutionContext; +} + +const baseUser = { + id: 1, + email: 'test@example.com', + omchaiAssignments: [ + { anthologyId: 42, role: OmchaiRole.MANAGER }, + { anthologyId: 99, role: OmchaiRole.HELPER }, + ], +}; + +describe('OmchaiGuard', () => { + let reflector: Reflector; + let guard: OmchaiGuard; + + beforeEach(() => { + reflector = new Reflector(); + guard = new OmchaiGuard(reflector); + }); + + it('allows access when no roles are required', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + const context = makeContext(baseUser, { id: '42' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('allows access when required roles list is empty', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([]); + const context = makeContext(baseUser, { id: '42' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('denies access when user has no omchai assignments', () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([OmchaiRole.MANAGER]); + const context = makeContext( + { id: 1, email: 'test@example.com' }, + { id: '42' }, + ); + expect(guard.canActivate(context)).toBe(false); + }); + + it('denies access and logs a warning when anthology ID is missing from params', () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([OmchaiRole.MANAGER]); + const warnSpy = jest.spyOn(Logger.prototype, 'warn').mockImplementation(); + const context = makeContext(baseUser, {}); + + expect(guard.canActivate(context)).toBe(false); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('no anthology ID found in params'), + ); + }); + + it('allows access when user has the required role for the anthology', () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([OmchaiRole.OWNER, OmchaiRole.MANAGER]); + const context = makeContext(baseUser, { id: '42' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('denies access when user has an assignment for the anthology but with insufficient role', () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([OmchaiRole.OWNER, OmchaiRole.MANAGER]); + const context = makeContext(baseUser, { id: '99' }); + expect(guard.canActivate(context)).toBe(false); + }); + + it('denies access when user has no assignment for the requested anthology', () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([OmchaiRole.MANAGER]); + const context = makeContext(baseUser, { id: '7' }); + expect(guard.canActivate(context)).toBe(false); + }); + + it('resolves anthology ID from anthologyId param when id is absent', () => { + jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue([OmchaiRole.MANAGER]); + const context = makeContext(baseUser, { anthologyId: '42' }); + expect(guard.canActivate(context)).toBe(true); + }); + + it('uses OMCHAI_ROLES when reading metadata', () => { + const spy = jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(undefined); + const context = makeContext(baseUser, { id: '42' }); + guard.canActivate(context); + expect(spy).toHaveBeenCalledWith( + OMCHAI_ROLES, + expect.arrayContaining([expect.any(Object), expect.any(Object)]), + ); + }); +}); diff --git a/apps/backend/src/auth/guards/omchai.guard.ts b/apps/backend/src/auth/guards/omchai.guard.ts new file mode 100644 index 000000000..cbadc6290 --- /dev/null +++ b/apps/backend/src/auth/guards/omchai.guard.ts @@ -0,0 +1,75 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { OMCHAI_ROLES } from '../roles.decorator'; +import { OmchaiRole } from 'src/omchai/omchai.entity'; +import { User } from 'src/users/user.entity'; + +@Injectable() +export class OmchaiGuard implements CanActivate { + private readonly logger = new Logger(OmchaiGuard.name); + + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredRoles = this.reflector.getAllAndOverride( + OMCHAI_ROLES, + [context.getHandler(), context.getClass()], + ); + + // no decorator + if (!requiredRoles || requiredRoles.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user as User; + + // no omchai + if (!user || !user.omchaiAssignments) { + this.logger.warn( + `OmchaiGuard: denied — user has no omchai assignments (required: [${requiredRoles.join( + ', ', + )}])`, + ); + return false; + } + + const params = request.params; + // INVARIANT: anthology must always be passed in as "id" or "anthologyId" + const anthologyId = parseInt(params.id ?? params.anthologyId); + + if (isNaN(anthologyId)) { + this.logger.warn( + `OmchaiGuard: route requires roles [${requiredRoles.join( + ', ', + )}] but no anthology ID found in params`, + ); + return false; + } + + const assignment = user.omchaiAssignments.find( + (a) => a.anthologyId === anthologyId, + ); + + const allowed = !!assignment && requiredRoles.includes(assignment.role); + if (allowed) { + this.logger.debug( + `OmchaiGuard: granted — user role "${assignment.role}" on anthology ${anthologyId}`, + ); + } else { + this.logger.warn( + `OmchaiGuard: denied — user has role "${ + assignment?.role ?? 'none' + }" on anthology ${anthologyId}, required: [${requiredRoles.join( + ', ', + )}]`, + ); + } + return allowed; + } +} diff --git a/apps/backend/src/auth/guards/user-status.guard.spec.ts b/apps/backend/src/auth/guards/user-status.guard.spec.ts new file mode 100644 index 000000000..a3419fd4a --- /dev/null +++ b/apps/backend/src/auth/guards/user-status.guard.spec.ts @@ -0,0 +1,68 @@ +import { ExecutionContext } from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { UserStatusGuard } from './user-status.guard'; +import { USER_STATUS } from '../roles.decorator'; +import { Status } from 'src/users/types'; + +function makeContext(user: unknown): ExecutionContext { + return { + getHandler: () => ({}), + getClass: () => ({}), + switchToHttp: () => ({ + getRequest: () => ({ user }), + }), + } as unknown as ExecutionContext; +} + +const adminUser = { id: 1, email: 'admin@example.com', status: Status.ADMIN }; +const volunteerUser = { + id: 2, + email: 'vol@example.com', + status: Status.VOLUNTEER, +}; + +describe('UserStatusGuard', () => { + let reflector: Reflector; + let guard: UserStatusGuard; + + beforeEach(() => { + reflector = new Reflector(); + guard = new UserStatusGuard(reflector); + }); + + it('allows access when no statuses are required', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue(undefined); + expect(guard.canActivate(makeContext(adminUser))).toBe(true); + }); + + it('allows access when required statuses list is empty', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([]); + expect(guard.canActivate(makeContext(adminUser))).toBe(true); + }); + + it('allows access when user has the required status', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Status.ADMIN]); + expect(guard.canActivate(makeContext(adminUser))).toBe(true); + }); + + it('denies access when user does not have the required status', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Status.ADMIN]); + expect(guard.canActivate(makeContext(volunteerUser))).toBe(false); + }); + + it('denies access when there is no user on the request', () => { + jest.spyOn(reflector, 'getAllAndOverride').mockReturnValue([Status.ADMIN]); + expect(guard.canActivate(makeContext(undefined))).toBe(false); + }); + + it('uses USER_STATUS when reading metadata', () => { + const spy = jest + .spyOn(reflector, 'getAllAndOverride') + .mockReturnValue(undefined); + guard.canActivate(makeContext(adminUser)); + expect(spy).toHaveBeenCalledWith( + USER_STATUS, + expect.arrayContaining([expect.any(Object), expect.any(Object)]), + ); + }); +}); diff --git a/apps/backend/src/auth/guards/user-status.guard.ts b/apps/backend/src/auth/guards/user-status.guard.ts new file mode 100644 index 000000000..54b1aad95 --- /dev/null +++ b/apps/backend/src/auth/guards/user-status.guard.ts @@ -0,0 +1,56 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Logger, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { USER_STATUS } from '../roles.decorator'; +import { Status } from 'src/users/types'; +import { User } from 'src/users/user.entity'; + +@Injectable() +export class UserStatusGuard implements CanActivate { + private readonly logger = new Logger(UserStatusGuard.name); + + constructor(private reflector: Reflector) {} + + canActivate(context: ExecutionContext): boolean { + const requiredStatuses = this.reflector.getAllAndOverride( + USER_STATUS, + [context.getHandler(), context.getClass()], + ); + + // no decorator + if (!requiredStatuses || requiredStatuses.length === 0) { + return true; + } + + const request = context.switchToHttp().getRequest(); + const user = request.user as User; + + // no user + if (!user || !user.status) { + this.logger.warn( + `UserStatusGuard: denied — no user or status on request (required: [${requiredStatuses.join( + ', ', + )}])`, + ); + return false; + } + + const allowed = requiredStatuses.includes(user.status); + if (allowed) { + this.logger.debug( + `UserStatusGuard: granted — user status "${user.status}"`, + ); + } else { + this.logger.warn( + `UserStatusGuard: denied — user status "${ + user.status + }", required: [${requiredStatuses.join(', ')}]`, + ); + } + return allowed; + } +} diff --git a/apps/backend/src/auth/roles.decorator.ts b/apps/backend/src/auth/roles.decorator.ts new file mode 100644 index 000000000..32500830b --- /dev/null +++ b/apps/backend/src/auth/roles.decorator.ts @@ -0,0 +1,13 @@ +import { SetMetadata } from '@nestjs/common'; +import { OmchaiRole } from 'src/omchai/omchai.entity'; +import { Status } from 'src/users/types'; + +// Key used to store roles metadata +export const OMCHAI_ROLES = 'omchai_roles'; +// Custom decorator to set roles metadata on route handlers for proper parsing by RolesGuard +export const OmchaiRoles = (...roles: OmchaiRole[]) => + SetMetadata(OMCHAI_ROLES, roles); + +export const USER_STATUS = 'user_status'; +export const UserStatus = (...statuses: Status[]) => + SetMetadata(USER_STATUS, statuses); diff --git a/apps/backend/src/migrations/1775058503311-nullable-publish-date.ts b/apps/backend/src/migrations/1775058503311-nullable-publish-date.ts new file mode 100644 index 000000000..153caff84 --- /dev/null +++ b/apps/backend/src/migrations/1775058503311-nullable-publish-date.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NullablePublishDate1775058503311 implements MigrationInterface { + name = 'NullablePublishDate1775058503311'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "anthologys" ALTER COLUMN "publishedDate" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "anthologys" ALTER COLUMN "publishedDate" SET NOT NULL`, + ); + } +} diff --git a/apps/backend/src/story-draft/story-draft.controller.ts b/apps/backend/src/story-draft/story-draft.controller.ts index 5858e9b88..c6eed928b 100644 --- a/apps/backend/src/story-draft/story-draft.controller.ts +++ b/apps/backend/src/story-draft/story-draft.controller.ts @@ -4,12 +4,10 @@ import { Param, ParseIntPipe, UseGuards, - UseInterceptors, Body, Post, } from '@nestjs/common'; -import { AuthGuard } from '@nestjs/passport'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; +import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { CreateStoryDraftDto } from './dto/create-story-draft.dto'; import { UpdateStoryDraftDto } from './dto/update-story-draft.dto'; @@ -19,8 +17,6 @@ import { EditRound, SubmissionRound } from './types'; @ApiTags('StoryDrafts') @ApiBearerAuth() @Controller('story-drafts') -@UseGuards(AuthGuard('jwt')) -@UseInterceptors(CurrentUserInterceptor) export class StoryDraftController { constructor(private readonly storyDraftService: StoryDraftService) {} diff --git a/apps/backend/src/story-draft/story-draft.module.ts b/apps/backend/src/story-draft/story-draft.module.ts index ee1625664..fec97a750 100644 --- a/apps/backend/src/story-draft/story-draft.module.ts +++ b/apps/backend/src/story-draft/story-draft.module.ts @@ -2,7 +2,6 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { StoryDraftController } from './story-draft.controller'; import { StoryDraft } from './story-draft.entity'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { AuthModule } from '../auth/auth.module'; import { UsersModule } from '../users/users.module'; import { AuthorModule } from '../author/author.module'; @@ -18,7 +17,7 @@ import { EditRound, SubmissionRound } from './types'; AuthorModule, ], controllers: [StoryDraftController], - providers: [StoryDraftService, CurrentUserInterceptor], + providers: [StoryDraftService], exports: [StoryDraftService], }) export class StoryDraftModule {} diff --git a/apps/backend/src/story/story.controller.ts b/apps/backend/src/story/story.controller.ts index f42d6be71..c2c42db2b 100644 --- a/apps/backend/src/story/story.controller.ts +++ b/apps/backend/src/story/story.controller.ts @@ -5,7 +5,6 @@ import { Param, ParseIntPipe, UseGuards, - UseInterceptors, Post, Body, HttpCode, @@ -13,9 +12,7 @@ import { NotFoundException, } from '@nestjs/common'; import { StoryService } from './story.service'; -import { AuthGuard } from '@nestjs/passport'; import { Story } from './story.entity'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { ApiBearerAuth, ApiTags, @@ -30,8 +27,6 @@ import { create } from 'domain'; @ApiTags('Story') @ApiBearerAuth() @Controller('story') -@UseGuards(AuthGuard('jwt')) -@UseInterceptors(CurrentUserInterceptor) export class StoryController { constructor( private storyService: StoryService, diff --git a/apps/backend/src/story/story.module.ts b/apps/backend/src/story/story.module.ts index 638ae6ccc..e8a9a55c6 100644 --- a/apps/backend/src/story/story.module.ts +++ b/apps/backend/src/story/story.module.ts @@ -4,7 +4,6 @@ import { StoryController } from './story.controller'; import { StoryService } from './story.service'; import { Story } from './story.entity'; import { AnthologyModule } from '../anthology/anthology.module'; -import { CurrentUserInterceptor } from '../interceptors/current-user.interceptor'; import { UsersModule } from '../users/users.module'; import { AuthModule } from '../auth/auth.module'; import { AuthorModule } from '../author/author.module'; @@ -18,7 +17,7 @@ import { AuthorModule } from '../author/author.module'; AuthorModule, ], controllers: [StoryController], - providers: [StoryService, CurrentUserInterceptor], + providers: [StoryService], exports: [StoryService], }) export class StoryModule {} diff --git a/apps/backend/src/users/users.service.spec.ts b/apps/backend/src/users/users.service.spec.ts index 2aeab552f..3276ba1a9 100644 --- a/apps/backend/src/users/users.service.spec.ts +++ b/apps/backend/src/users/users.service.spec.ts @@ -5,6 +5,7 @@ import { NotFoundException } from '@nestjs/common'; import { UsersService } from './users.service'; import { User } from './user.entity'; import { Status } from './types'; +import { Omchai, OmchaiRole } from '../omchai/omchai.entity'; export const mockUser: User = { id: 1, @@ -20,13 +21,18 @@ describe('UsersService', () => { let service: UsersService; let repo: Repository; + const mockQueryBuilder = { + select: jest.fn().mockReturnThis(), + getRawOne: jest.fn(), + }; + const mockRepository = { create: jest.fn(), save: jest.fn(), find: jest.fn(), findOneBy: jest.fn(), remove: jest.fn(), - count: jest.fn(), + createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder), }; beforeEach(async () => { @@ -54,17 +60,18 @@ describe('UsersService', () => { describe('create', () => { it('should create a new user', async () => { - mockRepository.count.mockResolvedValue(0); + mockQueryBuilder.getRawOne.mockResolvedValue({ maxId: null }); mockRepository.create.mockReturnValue(mockUser); mockRepository.save.mockResolvedValue(mockUser); const result = await service.create( 'john@example.com', - 'John Doe', + 'John', + 'Doe', Status.VOLUNTEER, ); - expect(repo.count).toHaveBeenCalled(); + expect(mockRepository.createQueryBuilder).toHaveBeenCalled(); expect(repo.create).toHaveBeenCalled(); expect(repo.save).toHaveBeenCalled(); expect(result).toEqual(mockUser); @@ -112,6 +119,100 @@ describe('UsersService', () => { }); }); + describe('findWithOmchai', () => { + it('should return users with omchai assignments when none exist', async () => { + const userWithoutOmchai = { ...mockUser, omchaiAssignments: [] }; + mockRepository.find.mockResolvedValue([userWithoutOmchai]); + + const result = await service.findWithOmchai('john@example.com'); + + expect(repo.find).toHaveBeenCalledWith({ + where: { email: 'john@example.com' }, + relations: { omchaiAssignments: true }, + }); + expect(result).toEqual([userWithoutOmchai]); + expect(result[0].omchaiAssignments).toHaveLength(0); + }); + + it('should return users with one omchai assignment', async () => { + const mockOmchai: Omchai = { + id: 1, + anthologyId: 1, + userId: 1, + role: OmchaiRole.OWNER, + datetimeAssigned: new Date(), + user: null, + anthology: null, + }; + + const userWithOneOmchai = { + ...mockUser, + omchaiAssignments: [mockOmchai], + }; + mockRepository.find.mockResolvedValue([userWithOneOmchai]); + + const result = await service.findWithOmchai('john@example.com'); + + expect(repo.find).toHaveBeenCalledWith({ + where: { email: 'john@example.com' }, + relations: { omchaiAssignments: true }, + }); + expect(result).toEqual([userWithOneOmchai]); + expect(result[0].omchaiAssignments).toHaveLength(1); + expect(result[0].omchaiAssignments[0].role).toBe(OmchaiRole.OWNER); + }); + + it('should return users with many omchai assignments', async () => { + const mockOmchai1: Omchai = { + id: 1, + anthologyId: 1, + userId: 1, + role: OmchaiRole.OWNER, + datetimeAssigned: new Date(), + user: null, + anthology: null, + }; + + const mockOmchai2: Omchai = { + id: 2, + anthologyId: 2, + userId: 1, + role: OmchaiRole.MANAGER, + datetimeAssigned: new Date(), + user: null, + anthology: null, + }; + + const mockOmchai3: Omchai = { + id: 3, + anthologyId: 3, + userId: 1, + role: OmchaiRole.HELPER, + datetimeAssigned: new Date(), + user: null, + anthology: null, + }; + + const userWithManyOmchai = { + ...mockUser, + omchaiAssignments: [mockOmchai1, mockOmchai2, mockOmchai3], + }; + mockRepository.find.mockResolvedValue([userWithManyOmchai]); + + const result = await service.findWithOmchai('john@example.com'); + + expect(repo.find).toHaveBeenCalledWith({ + where: { email: 'john@example.com' }, + relations: { omchaiAssignments: true }, + }); + expect(result).toEqual([userWithManyOmchai]); + expect(result[0].omchaiAssignments).toHaveLength(3); + expect(result[0].omchaiAssignments[0].role).toBe(OmchaiRole.OWNER); + expect(result[0].omchaiAssignments[1].role).toBe(OmchaiRole.MANAGER); + expect(result[0].omchaiAssignments[2].role).toBe(OmchaiRole.HELPER); + }); + }); + describe('update', () => { it('should update a user', async () => { mockRepository.findOneBy.mockResolvedValue(mockUser); diff --git a/apps/backend/src/users/users.service.ts b/apps/backend/src/users/users.service.ts index a58dc2644..9720d808e 100644 --- a/apps/backend/src/users/users.service.ts +++ b/apps/backend/src/users/users.service.ts @@ -50,6 +50,13 @@ export class UsersService { return this.repo.find({ where: { email } }); } + findWithOmchai(email: string) { + return this.repo.find({ + where: { email }, + relations: { omchaiAssignments: true }, + }); + } + async update(id: number, attrs: Partial) { const user = await this.findOne(id); diff --git a/package.json b/package.json index 7c986ec55..52260c3b7 100644 --- a/package.json +++ b/package.json @@ -11,12 +11,12 @@ "prepush:fix": "yarn run format && yarn run lint", "prepare": "husky install", "typeorm": "ts-node --project apps/backend/tsconfig.seed.json ./node_modules/typeorm/cli.js", - "migration:generate": "dotenv -e apps/backend/.env -- yarn typeorm migration:generate -d apps/backend/src/data-source.ts", - "migration:run": "dotenv -e apps/backend/.env -- yarn typeorm migration:run -d apps/backend/src/data-source.ts", - "migration:revert": "dotenv -e apps/backend/.env -- yarn typeorm migration:revert -d apps/backend/src/data-source.ts", + "migration:generate": "yarn typeorm migration:generate apps/backend/src/migrations/nullable-publish-date -d apps/backend/src/data-source.ts", + "migration:run": "yarn typeorm migration:run -d apps/backend/src/data-source.ts", + "migration:revert": "yarn typeorm migration:revert -d apps/backend/src/data-source.ts", "migration:create": "yarn typeorm migration:create", "test": "jest", - "schema:drop": "dotenv -e apps/backend/.env -- yarn typeorm schema:drop -d apps/backend/src/data-source.ts", + "schema:drop": "yarn typeorm schema:drop -d apps/backend/src/data-source.ts", "db:reset": "yarn schema:drop && yarn migration:run && yarn nx run backend:seed" }, "private": true, @@ -96,5 +96,6 @@ "overrides": { "@typescript-eslint/parser": "6.9.1", "@typescript-eslint/eslint-plugin": "6.9.1" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } From ec1229c306016dfa3218c6cdd75e4793769ef539 Mon Sep 17 00:00:00 2001 From: ryaken-nakamoto Date: Wed, 1 Apr 2026 12:38:07 -0400 Subject: [PATCH 2/2] migration bug fix --- apps/backend/src/anthology/anthology.entity.ts | 2 +- apps/backend/src/data-source.ts | 2 +- .../1775061199988-nullable-publish-date.ts | 17 +++++++++++++++++ 3 files changed, 19 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/migrations/1775061199988-nullable-publish-date.ts diff --git a/apps/backend/src/anthology/anthology.entity.ts b/apps/backend/src/anthology/anthology.entity.ts index 4ea1b1abe..9f20602a3 100644 --- a/apps/backend/src/anthology/anthology.entity.ts +++ b/apps/backend/src/anthology/anthology.entity.ts @@ -40,7 +40,7 @@ export class Anthology { @Column({ type: 'simple-array', default: [] }) triggers: string[]; - @Column({ name: 'published_date', type: 'date', nullable: true }) + @Column({ type: 'date', nullable: true }) publishedDate?: Date; @Column({ type: 'simple-array', nullable: true }) diff --git a/apps/backend/src/data-source.ts b/apps/backend/src/data-source.ts index 64c231ea3..90c127b69 100644 --- a/apps/backend/src/data-source.ts +++ b/apps/backend/src/data-source.ts @@ -31,7 +31,7 @@ const AppDataSource = new DataSource({ User, StoryDraft, ], - migrations: ['apps/backend/src/migrations/*.js'], + migrations: ['apps/backend/src/migrations/*.ts'], // Setting synchronize: true shouldn't be used in production - otherwise you can lose production data synchronize: false, namingStrategy: new PluralNamingStrategy(), diff --git a/apps/backend/src/migrations/1775061199988-nullable-publish-date.ts b/apps/backend/src/migrations/1775061199988-nullable-publish-date.ts new file mode 100644 index 000000000..d545a72d5 --- /dev/null +++ b/apps/backend/src/migrations/1775061199988-nullable-publish-date.ts @@ -0,0 +1,17 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class NullablePublishDate1775061199988 implements MigrationInterface { + name = 'NullablePublishDate1775061199988'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "anthologys" ALTER COLUMN "publishedDate" DROP NOT NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "anthologys" ALTER COLUMN "publishedDate" SET NOT NULL`, + ); + } +}