diff --git a/src/domains/chapter-assignments/presence/chapter-assignments-presence.repository.ts b/src/domains/chapter-assignments/presence/chapter-assignments-presence.repository.ts index ea1049cb..a502a751 100644 --- a/src/domains/chapter-assignments/presence/chapter-assignments-presence.repository.ts +++ b/src/domains/chapter-assignments/presence/chapter-assignments-presence.repository.ts @@ -1,4 +1,4 @@ -import { and, asc, eq, lt } from 'drizzle-orm'; +import { and, asc, eq, lt, sql } from 'drizzle-orm'; import type { Result } from '@/lib/types'; @@ -16,6 +16,12 @@ export async function upsertAndQueryFirstEditor( ): Promise> { try { return await db.transaction(async (tx) => { + // Serialize concurrent presence registrations for the same chapter so the + // first-editor check can't race: a near-simultaneous second registration + // must observe the first's committed row instead of both deciding they are + // first. The advisory lock is transaction-scoped and auto-released on commit. + await tx.execute(sql`SELECT pg_advisory_xact_lock(${chapterAssignmentId}::bigint)`); + const now = new Date(); const staleTime = new Date(now.getTime() - STALE_THRESHOLD_MINUTES * 60 * 1000); @@ -48,7 +54,7 @@ export async function upsertAndQueryFirstEditor( .from(active_chapter_editors) .innerJoin(users, eq(active_chapter_editors.userId, users.id)) .where(eq(active_chapter_editors.chapterAssignmentId, chapterAssignmentId)) - .orderBy(asc(active_chapter_editors.startedAt)) + .orderBy(asc(active_chapter_editors.startedAt), asc(active_chapter_editors.userId)) .limit(1); if (!firstEditor) { diff --git a/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts b/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts index ac2bc7f2..b0c0768a 100644 --- a/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts +++ b/src/domains/chapter-assignments/presence/chapter-assignments-presence.route.ts @@ -4,6 +4,8 @@ import * as HttpStatusPhrases from 'stoker/http-status-phrases'; import { jsonContent } from 'stoker/openapi/helpers'; import { createMessageObjectSchema } from 'stoker/openapi/schemas'; +import { requireChapterAssignmentAccess } from '@/domains/chapter-assignments/chapter-assignment-auth.middleware'; +import { CHAPTER_ASSIGNMENT_ACTIONS } from '@/domains/chapter-assignments/chapter-assignments.types'; import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; @@ -28,14 +30,30 @@ const registerPresenceRoute = createRoute({ tags: ['Chapter Assignments - Presence'], method: 'post', path: '/chapter-assignments/{chapterAssignmentId}/presence', - middleware: [authenticateUser, requirePermission(PERMISSIONS.CONTENT_UPDATE)] as const, + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.CONTENT_UPDATE), + requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT), + ] as const, request: { params: chapterAssignmentIdParam }, responses: { [HttpStatusCodes.OK]: jsonContent(presenceResponseSchema, 'Presence registered successfully'), + [HttpStatusCodes.BAD_REQUEST]: jsonContent( + createMessageObjectSchema('Bad Request'), + 'Invalid chapter assignment ID' + ), [HttpStatusCodes.UNAUTHORIZED]: jsonContent( createMessageObjectSchema('Unauthorized'), 'Authentication required' ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Access denied' + ), + [HttpStatusCodes.NOT_FOUND]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), + 'Chapter assignment not found' + ), [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), 'Internal server error' @@ -50,14 +68,30 @@ const removePresenceRoute = createRoute({ tags: ['Chapter Assignments - Presence'], method: 'delete', path: '/chapter-assignments/{chapterAssignmentId}/presence', - middleware: [authenticateUser, requirePermission(PERMISSIONS.CONTENT_UPDATE)] as const, + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.CONTENT_UPDATE), + requireChapterAssignmentAccess(CHAPTER_ASSIGNMENT_ACTIONS.IS_PARTICIPANT), + ] as const, request: { params: chapterAssignmentIdParam }, responses: { [HttpStatusCodes.NO_CONTENT]: { description: 'Presence removed successfully' }, + [HttpStatusCodes.BAD_REQUEST]: jsonContent( + createMessageObjectSchema('Bad Request'), + 'Invalid chapter assignment ID' + ), [HttpStatusCodes.UNAUTHORIZED]: jsonContent( createMessageObjectSchema('Unauthorized'), 'Authentication required' ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Access denied' + ), + [HttpStatusCodes.NOT_FOUND]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.NOT_FOUND), + 'Chapter assignment not found' + ), [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), 'Internal server error'