From f80b366d6330f929a5b73e698f0c41a881f803fd Mon Sep 17 00:00:00 2001 From: LijuJacob08 Date: Thu, 4 Jun 2026 14:50:23 +0530 Subject: [PATCH 1/2] Implemented an API to retrieve chapter assignments linked to the user's assigned projects. --- .../bible-texts/bible-texts.repository.ts | 14 ++- .../bibles/bible-texts/bible-texts.route.ts | 8 +- .../bibles/bible-texts/bible-texts.service.ts | 16 +++- .../bibles/bible-texts/bible-texts.types.ts | 9 +- .../project-chapter-assignments.repository.ts | 58 +++++++++++- .../project-chapter-assignments.route.ts | 88 ++++++++++++++++++- .../project-chapter-assignments.service.ts | 39 ++++++++ .../project-chapter-assignments.types.ts | 24 +++++ src/domains/projects/projects.repository.ts | 16 +++- src/domains/projects/projects.service.ts | 4 +- 10 files changed, 255 insertions(+), 21 deletions(-) diff --git a/src/domains/bibles/bible-texts/bible-texts.repository.ts b/src/domains/bibles/bible-texts/bible-texts.repository.ts index ac60dcb..dbeb7e3 100644 --- a/src/domains/bibles/bible-texts/bible-texts.repository.ts +++ b/src/domains/bibles/bible-texts/bible-texts.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, or } from 'drizzle-orm'; +import { and, eq, gt, or } from 'drizzle-orm'; import type { Result } from '@/lib/types'; @@ -53,7 +53,8 @@ interface ChapterKey { export async function getByChapters( bibleId: number, - chapters: ChapterKey[] + chapters: ChapterKey[], + updatedAfter?: Date ): Promise> { try { const conditions = chapters.map((ch) => @@ -69,11 +70,16 @@ export async function getByChapters( text: bible_texts.text, }) .from(bible_texts) - .where(and(eq(bible_texts.bibleId, bibleId), or(...conditions))) + .where( + and( + eq(bible_texts.bibleId, bibleId), + or(...conditions), + updatedAfter ? gt(bible_texts.updatedAt, updatedAfter) : undefined + ) + ) .orderBy(bible_texts.bookId, bible_texts.chapterNumber, bible_texts.verseNumber); if (rows.length === 0) return err(ErrorCode.NOT_FOUND); - return ok(rows); } catch (error) { logger.error({ diff --git a/src/domains/bibles/bible-texts/bible-texts.route.ts b/src/domains/bibles/bible-texts/bible-texts.route.ts index d7106d3..0ee4639 100644 --- a/src/domains/bibles/bible-texts/bible-texts.route.ts +++ b/src/domains/bibles/bible-texts/bible-texts.route.ts @@ -12,8 +12,8 @@ import type { BulkBibleTextsRequest } from './bible-texts.types'; import * as bibleTextsService from './bible-texts.service'; import { bibleTextResponseSchema, + bulkBibleTextsResponseSchema, bulkChapterRequestSchema, - bulkChapterTextResponseSchema, } from './bible-texts.types'; const chapterParams = z.object({ @@ -101,13 +101,13 @@ const getBulkBibleTextsRoute = createRoute({ }), }), body: jsonContentRequired( - bulkChapterRequestSchema, + bulkChapterRequestSchema.openapi('BulkBibleTextsRequest'), 'List of (bookId, chapterNumber) pairs to fetch in one request' ), }, responses: { [HttpStatusCodes.OK]: jsonContent( - bulkChapterTextResponseSchema.array().openapi('BulkBibleTexts'), + bulkBibleTextsResponseSchema.openapi('BulkBibleTexts'), 'Bible texts grouped by book and chapter' ), [HttpStatusCodes.BAD_REQUEST]: jsonContent( @@ -121,7 +121,7 @@ const getBulkBibleTextsRoute = createRoute({ }, summary: 'Get bible texts for multiple chapters (bulk)', description: - 'Returns bible texts grouped by chapter for up to 200 (bookId, chapterNumber) pairs in a single request. ' + + 'Returns bible texts grouped by chapter for up to 1200 (bookId, chapterNumber) pairs in a single request. ' + 'Designed for mobile clients to pre-cache all assigned chapter texts in one round-trip. No authentication required.', }); diff --git a/src/domains/bibles/bible-texts/bible-texts.service.ts b/src/domains/bibles/bible-texts/bible-texts.service.ts index 0ab6884..7014d77 100644 --- a/src/domains/bibles/bible-texts/bible-texts.service.ts +++ b/src/domains/bibles/bible-texts/bible-texts.service.ts @@ -36,10 +36,18 @@ export function getBibleTextsByChapter(bibleId: number, bookId: number, chapterN } export async function getBulkBibleTexts(bibleId: number, body: BulkChapterRequest) { - if (body.chapters.length === 0) return ok([]); + if (body.chapters.length === 0) return ok({ syncedAt: new Date().toISOString(), data: [] }); + const updatedAfter = body.updatedAfter ? new Date(body.updatedAfter) : undefined; - const result = await repo.getByChapters(bibleId, body.chapters); - if (!result.ok) return result; + const result = await repo.getByChapters(bibleId, body.chapters, updatedAfter); - return ok(toBulkChapterTextResponses(result.data)); + if (!result.ok) { + if (updatedAfter) return ok({ syncedAt: new Date().toISOString(), data: [] }); + return result; + } + + return ok({ + syncedAt: new Date().toISOString(), + data: toBulkChapterTextResponses(result.data), + }); } diff --git a/src/domains/bibles/bible-texts/bible-texts.types.ts b/src/domains/bibles/bible-texts/bible-texts.types.ts index fc0e716..c040fcd 100644 --- a/src/domains/bibles/bible-texts/bible-texts.types.ts +++ b/src/domains/bibles/bible-texts/bible-texts.types.ts @@ -20,7 +20,8 @@ export const bulkChapterRequestSchema = z.object({ }) ) .min(1, 'At least one chapter is required') - .max(200, 'Maximum 200 chapters per request'), + .max(1200, 'Maximum 1200 chapters per request'), + updatedAfter: z.string().datetime().optional(), }); export const bulkChapterTextResponseSchema = z.object({ @@ -29,6 +30,12 @@ export const bulkChapterTextResponseSchema = z.object({ verses: bibleTextResponseSchema.array(), }); +export const bulkBibleTextsResponseSchema = z.object({ + syncedAt: z.string(), + data: bulkChapterTextResponseSchema.array(), +}); + +export type BulkBibleTextsResponse = z.infer; export type BulkChapterRequest = z.infer; export type BulkChapterTextResponse = z.infer; diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts index 56e9ef5..0517b42 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, gt, inArray, or } from 'drizzle-orm'; import type { ChapterAssignmentRecord } from '@/domains/chapter-assignments/chapter-assignments.types'; import type { DbTransaction, Result } from '@/lib/types'; @@ -8,6 +8,8 @@ import { chapter_assignments, project_units, projects } from '@/db/schema'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; +import type { ChapterAssignmentWithProjectId } from './project-chapter-assignments.types'; + export async function getByProject(projectId: number): Promise> { try { const assignments = await db @@ -112,3 +114,57 @@ export async function findProjectUnitIdsByAssignmentIds( } export const MAX_CHAPTER_ASSIGNMENTS_PER_REQUEST = 1000; + +export async function getByProjects( + projectIds: number[], + excludeProjectIds: number[] = [], + updatedAfter?: Date +): Promise> { + try { + const assignments = await db + .select({ + id: chapter_assignments.id, + projectUnitId: chapter_assignments.projectUnitId, + projectId: project_units.projectId, + bibleId: chapter_assignments.bibleId, + bookId: chapter_assignments.bookId, + chapterNumber: chapter_assignments.chapterNumber, + assignedUserId: chapter_assignments.assignedUserId, + peerCheckerId: chapter_assignments.peerCheckerId, + status: chapter_assignments.status, + submittedTime: chapter_assignments.submittedTime, + createdAt: chapter_assignments.createdAt, + updatedAt: chapter_assignments.updatedAt, + }) + .from(chapter_assignments) + .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) + .where( + excludeProjectIds.length === 0 && updatedAfter + ? and( + inArray(project_units.projectId, projectIds), + gt(chapter_assignments.updatedAt, updatedAfter) + ) + : or( + inArray( + project_units.projectId, + projectIds.filter((id) => !excludeProjectIds.includes(id)) + ), + excludeProjectIds.length > 0 && updatedAfter + ? and( + inArray(project_units.projectId, excludeProjectIds), + gt(chapter_assignments.updatedAt, updatedAfter) + ) + : undefined + ) + ); + + return ok(assignments); + } catch (error) { + logger.error({ + cause: error, + message: 'Failed to get chapter assignments for projects', + context: { projectIds, updatedAfter }, + }); + return err(ErrorCode.INTERNAL_ERROR); + } +} diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts index 9a89161..fe602b1 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts @@ -9,7 +9,7 @@ import { requireProjectAccess } from '@/domains/projects/project-auth.middleware import { PROJECT_ACTIONS } from '@/domains/projects/projects.types'; import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { authenticateUser, requirePermission, requireSelf } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import * as service from './project-chapter-assignments.service'; @@ -19,6 +19,7 @@ import { assignUserInputSchema, chapterAssignmentProgressResponseSchema, chapterAssignmentResponseSchema, + memberChapterAssignmentsResponseSchema, } from './project-chapter-assignments.types'; const projectIdParam = z.object({ projectId: z.coerce.number().int().positive() }); @@ -295,3 +296,88 @@ server.openapi(assignSelectedRoute, async (c) => { if (result.ok) return c.json(result.data, HttpStatusCodes.OK); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); + +const getUserChapterAssignmentsRoute = createRoute({ + tags: ['Projects - Chapter Assignments'], + method: 'get', + path: '/project/member/{userId}/chapter-assignments', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireSelf(), + ] as const, + summary: 'Get all chapter assignments for a user across all their projects', + description: + 'Fetches all projects the user belongs to, then returns all chapter assignments ' + + 'across those projects in a single flat list — including unassigned ones.', + request: { + params: z.object({ + userId: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: 'userId', in: 'path', required: true }, + example: 1, + }), + }), + query: z.object({ + excludeProjectIds: z + .string() + .optional() + .transform((val) => val?.split(',').map(Number).filter(Boolean) ?? []) + .openapi({ + param: { name: 'excludeProjectIds', in: 'query', required: false }, + description: 'Comma-separated list of project IDs to exclude', + example: '97,98', + }), + updatedAfter: z + .string() + .optional() + .transform((val) => (val ? new Date(val) : undefined)) + .pipe(z.date().optional()) + .openapi({ + param: { name: 'updatedAfter', in: 'query', required: false }, + description: 'Return only assignments updated after this ISO timestamp', + example: '2025-01-01T00:00:00.000Z', + }), + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + memberChapterAssignmentsResponseSchema.openapi('UserChapterAssignments'), + 'Flat list of all chapter assignments across all user projects' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Access denied' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, +}); +server.openapi(getUserChapterAssignmentsRoute, async (c) => { + const { userId } = c.req.valid('param'); + const { excludeProjectIds, updatedAfter } = c.req.valid('query'); + + const result = await service.getChapterAssignmentsByUserId( + userId, + excludeProjectIds, + updatedAfter + ); + if (result.ok) + return c.json( + { + syncedAt: new Date().toISOString(), + data: result.data, + }, + HttpStatusCodes.OK + ); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts index 65a66a9..129e981 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.service.ts @@ -4,6 +4,7 @@ import { db } from '@/db'; import * as chapterAssignmentService from '@/domains/chapter-assignments/chapter-assignments.service'; import { toChapterAssignmentResponse } from '@/domains/chapter-assignments/chapter-assignments.service'; import * as projectsService from '@/domains/projects/projects.service'; +import * as userProjectsService from '@/domains/users/projects/user-projects.service'; import * as usersService from '@/domains/users/users.service'; import { logger } from '@/lib/logger'; import { err, ErrorCode, ok } from '@/lib/types'; @@ -12,6 +13,7 @@ import type { AssignSelectedItem, AssignUserInput, ChapterAssignmentProgress, + ChapterAssignmentWithProjectId, } from './project-chapter-assignments.types'; import * as projectRepo from '../projects.repository'; @@ -203,3 +205,40 @@ export async function assignSelectedChapters( return err(ErrorCode.INTERNAL_ERROR); } } + +function toMemberChapterAssignmentResponse(record: ChapterAssignmentWithProjectId) { + return { + chapterAssignmentId: record.id, + projectId: record.projectId, + projectUnitId: record.projectUnitId, + bibleId: record.bibleId, + bookId: record.bookId, + chapterNumber: record.chapterNumber, + assignedUserId: record.assignedUserId, + peerCheckerId: record.peerCheckerId, + status: record.status, + submittedTime: record.submittedTime, + createdAt: record.createdAt, + updatedAt: record.updatedAt, + }; +} + +export async function getChapterAssignmentsByUserId( + userId: number, + excludeProjectIds: number[] = [], + updatedAfter?: Date +) { + const projectsResult = await userProjectsService.getProjectsByUserId(userId); + if (!projectsResult.ok) return projectsResult; + + const projectIds = projectsResult.data + .map((p) => p.id) + .filter((id) => !excludeProjectIds.includes(id)); + + if (projectIds.length === 0) return ok([]); + + const result = await repo.getByProjects(projectIds, excludeProjectIds, updatedAfter); + if (!result.ok) return result; + + return ok(result.data.map(toMemberChapterAssignmentResponse)); +} diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts index 4b03cf8..3ceb8ae 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts @@ -1,5 +1,7 @@ import { z } from '@hono/zod-openapi'; +import type { ChapterAssignmentRecord } from '@/domains/chapter-assignments/chapter-assignments.types'; + import { chapterAssignmentResponseSchema as sharedAssignmentSchema } from '@/domains/chapter-assignments/chapter-assignments.types'; // ─── Shared response schemas ────────────────────────────────────────────────── @@ -30,6 +32,27 @@ export const chapterAssignmentProgressResponseSchema = z.object({ updatedAt: z.date().nullable(), }); +export const memberChapterAssignmentResponseSchema = z.object({ + chapterAssignmentId: z.number().int(), + projectId: z.number().int(), + projectUnitId: z.number().int(), + bibleId: z.number().int(), + bookId: z.number().int(), + chapterNumber: z.number().int(), + assignedUserId: z.number().int().nullable(), + peerCheckerId: z.number().int().nullable(), + status: z.string(), + submittedTime: z.string().nullable(), + createdAt: z.string().nullable(), + updatedAt: z.string().nullable(), +}); + +export const memberChapterAssignmentsResponseSchema = z.object({ + syncedAt: z.string(), + data: memberChapterAssignmentResponseSchema.array(), +}); + +export type MemberChapterAssignmentResponse = z.infer; // ─── Assign-all input ───────────────────────────────────────────────────────── export const assignUserInputSchema = z @@ -84,3 +107,4 @@ export type ChapterAssignmentProgress = z.infer; export type AssignSelectedItem = z.infer; export type AssignSelectedRequest = z.infer; +export type ChapterAssignmentWithProjectId = ChapterAssignmentRecord & { projectId: number }; diff --git a/src/domains/projects/projects.repository.ts b/src/domains/projects/projects.repository.ts index 396a154..8fe5c3f 100644 --- a/src/domains/projects/projects.repository.ts +++ b/src/domains/projects/projects.repository.ts @@ -1,4 +1,4 @@ -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq, gt, inArray } from 'drizzle-orm'; import type { DbTransaction, Result } from '@/lib/types'; @@ -72,17 +72,25 @@ export async function getByOrganization( } } -export async function getByUserId(userId: number): Promise> { +export async function getByUserId( + userId: number, + updatedAfter?: Date +): Promise> { try { const rawProjects = await baseJoinQuery() .innerJoin(project_users, eq(project_users.projectId, projects.id)) - .where(eq(project_users.userId, userId)); + .where( + and( + eq(project_users.userId, userId), + updatedAfter ? gt(projects.updatedAt, updatedAfter) : undefined + ) + ); return ok(rawProjects.map(mapToProjectWithLanguages)); } catch (error) { logger.error({ cause: error, message: 'Failed to get projects by user ID', - context: { userId }, + context: { userId, updatedAfter }, }); return err(ErrorCode.INTERNAL_ERROR); } diff --git a/src/domains/projects/projects.service.ts b/src/domains/projects/projects.service.ts index 0e9ad01..ce3e984 100644 --- a/src/domains/projects/projects.service.ts +++ b/src/domains/projects/projects.service.ts @@ -13,8 +13,8 @@ export function getProjectsByOrganization(organizationId: number) { return repo.getByOrganization(organizationId); } -export function getProjectsByUserId(userId: number) { - return repo.getByUserId(userId); +export async function getProjectsByUserId(userId: number, updatedAfter?: Date) { + return repo.getByUserId(userId, updatedAfter); } export function getProjectById(id: number) { From cdf5c90ebd3fcdf979c16e622b1a52c73ac0241c Mon Sep 17 00:00:00 2001 From: LijuJacob08 Date: Mon, 8 Jun 2026 16:49:13 +0530 Subject: [PATCH 2/2] review changes --- .../bibles/bible-texts/bible-texts.route.ts | 2 +- .../project-chapter-assignments.repository.ts | 48 ++++++---- .../project-chapter-assignments.route.ts | 88 +----------------- .../project-chapter-assignments.types.ts | 21 ----- .../users-chapter-assignments.route.ts | 93 ++++++++++++++++++- .../users-chapter-assignments.types.ts | 20 ++++ 6 files changed, 142 insertions(+), 130 deletions(-) diff --git a/src/domains/bibles/bible-texts/bible-texts.route.ts b/src/domains/bibles/bible-texts/bible-texts.route.ts index 0ee4639..850e9f5 100644 --- a/src/domains/bibles/bible-texts/bible-texts.route.ts +++ b/src/domains/bibles/bible-texts/bible-texts.route.ts @@ -112,7 +112,7 @@ const getBulkBibleTextsRoute = createRoute({ ), [HttpStatusCodes.BAD_REQUEST]: jsonContent( createMessageObjectSchema('Bad Request'), - 'Invalid request body (chapters array empty or exceeds 200)' + 'Invalid request body (chapters array empty or exceeds 1200)' ), [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts index 0517b42..500c128 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.repository.ts @@ -1,3 +1,5 @@ +import type { SQL } from 'drizzle-orm/sql'; + import { and, eq, gt, inArray, or } from 'drizzle-orm'; import type { ChapterAssignmentRecord } from '@/domains/chapter-assignments/chapter-assignments.types'; @@ -138,25 +140,7 @@ export async function getByProjects( }) .from(chapter_assignments) .innerJoin(project_units, eq(chapter_assignments.projectUnitId, project_units.id)) - .where( - excludeProjectIds.length === 0 && updatedAfter - ? and( - inArray(project_units.projectId, projectIds), - gt(chapter_assignments.updatedAt, updatedAfter) - ) - : or( - inArray( - project_units.projectId, - projectIds.filter((id) => !excludeProjectIds.includes(id)) - ), - excludeProjectIds.length > 0 && updatedAfter - ? and( - inArray(project_units.projectId, excludeProjectIds), - gt(chapter_assignments.updatedAt, updatedAfter) - ) - : undefined - ) - ); + .where(buildAssignmentFilter(projectIds, excludeProjectIds, updatedAfter)); return ok(assignments); } catch (error) { @@ -168,3 +152,29 @@ export async function getByProjects( return err(ErrorCode.INTERNAL_ERROR); } } + +function buildAssignmentFilter( + projectIds: number[], + excludeProjectIds: number[], + updatedAfter: Date | undefined +) { + const conditions: SQL[] = []; + + if (excludeProjectIds.length > 0) { + const newIds = projectIds.filter((id) => !excludeProjectIds.includes(id)); + if (newIds.length > 0) { + conditions.push(inArray(project_units.projectId, newIds)); + } + } + if (updatedAfter) { + const syncedIds = excludeProjectIds.length > 0 ? excludeProjectIds : projectIds; + const incrementalCondition = and( + inArray(project_units.projectId, syncedIds), + gt(chapter_assignments.updatedAt, updatedAfter) + ); + if (incrementalCondition) conditions.push(incrementalCondition); + } + if (conditions.length === 0) return inArray(project_units.projectId, projectIds); + + return conditions.length === 1 ? conditions[0] : or(...conditions); +} diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts index fe602b1..9a89161 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.route.ts @@ -9,7 +9,7 @@ import { requireProjectAccess } from '@/domains/projects/project-auth.middleware import { PROJECT_ACTIONS } from '@/domains/projects/projects.types'; import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission, requireSelf } from '@/middlewares/role-auth'; +import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; import { server } from '@/server/server'; import * as service from './project-chapter-assignments.service'; @@ -19,7 +19,6 @@ import { assignUserInputSchema, chapterAssignmentProgressResponseSchema, chapterAssignmentResponseSchema, - memberChapterAssignmentsResponseSchema, } from './project-chapter-assignments.types'; const projectIdParam = z.object({ projectId: z.coerce.number().int().positive() }); @@ -296,88 +295,3 @@ server.openapi(assignSelectedRoute, async (c) => { if (result.ok) return c.json(result.data, HttpStatusCodes.OK); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); - -const getUserChapterAssignmentsRoute = createRoute({ - tags: ['Projects - Chapter Assignments'], - method: 'get', - path: '/project/member/{userId}/chapter-assignments', - middleware: [ - authenticateUser, - requirePermission(PERMISSIONS.PROJECT_VIEW), - requireSelf(), - ] as const, - summary: 'Get all chapter assignments for a user across all their projects', - description: - 'Fetches all projects the user belongs to, then returns all chapter assignments ' + - 'across those projects in a single flat list — including unassigned ones.', - request: { - params: z.object({ - userId: z.coerce - .number() - .int() - .positive() - .openapi({ - param: { name: 'userId', in: 'path', required: true }, - example: 1, - }), - }), - query: z.object({ - excludeProjectIds: z - .string() - .optional() - .transform((val) => val?.split(',').map(Number).filter(Boolean) ?? []) - .openapi({ - param: { name: 'excludeProjectIds', in: 'query', required: false }, - description: 'Comma-separated list of project IDs to exclude', - example: '97,98', - }), - updatedAfter: z - .string() - .optional() - .transform((val) => (val ? new Date(val) : undefined)) - .pipe(z.date().optional()) - .openapi({ - param: { name: 'updatedAfter', in: 'query', required: false }, - description: 'Return only assignments updated after this ISO timestamp', - example: '2025-01-01T00:00:00.000Z', - }), - }), - }, - responses: { - [HttpStatusCodes.OK]: jsonContent( - memberChapterAssignmentsResponseSchema.openapi('UserChapterAssignments'), - 'Flat list of all chapter assignments across all user projects' - ), - [HttpStatusCodes.UNAUTHORIZED]: jsonContent( - createMessageObjectSchema('Unauthorized'), - 'Authentication required' - ), - [HttpStatusCodes.FORBIDDEN]: jsonContent( - createMessageObjectSchema('Forbidden'), - 'Access denied' - ), - [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( - createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), - 'Internal server error' - ), - }, -}); -server.openapi(getUserChapterAssignmentsRoute, async (c) => { - const { userId } = c.req.valid('param'); - const { excludeProjectIds, updatedAfter } = c.req.valid('query'); - - const result = await service.getChapterAssignmentsByUserId( - userId, - excludeProjectIds, - updatedAfter - ); - if (result.ok) - return c.json( - { - syncedAt: new Date().toISOString(), - data: result.data, - }, - HttpStatusCodes.OK - ); - return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); -}); diff --git a/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts b/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts index 3ceb8ae..107aaa0 100644 --- a/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts +++ b/src/domains/projects/chapter-assignments/project-chapter-assignments.types.ts @@ -32,27 +32,6 @@ export const chapterAssignmentProgressResponseSchema = z.object({ updatedAt: z.date().nullable(), }); -export const memberChapterAssignmentResponseSchema = z.object({ - chapterAssignmentId: z.number().int(), - projectId: z.number().int(), - projectUnitId: z.number().int(), - bibleId: z.number().int(), - bookId: z.number().int(), - chapterNumber: z.number().int(), - assignedUserId: z.number().int().nullable(), - peerCheckerId: z.number().int().nullable(), - status: z.string(), - submittedTime: z.string().nullable(), - createdAt: z.string().nullable(), - updatedAt: z.string().nullable(), -}); - -export const memberChapterAssignmentsResponseSchema = z.object({ - syncedAt: z.string(), - data: memberChapterAssignmentResponseSchema.array(), -}); - -export type MemberChapterAssignmentResponse = z.infer; // ─── Assign-all input ───────────────────────────────────────────────────────── export const assignUserInputSchema = z diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts index 0f2031c..8bfd734 100644 --- a/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts +++ b/src/domains/users/chapter-assignments/users-chapter-assignments.route.ts @@ -8,11 +8,15 @@ import { requireUserAccess } from '@/domains/users/user-auth.middleware'; import { USER_ACTIONS } from '@/domains/users/users.types'; import { PERMISSIONS } from '@/lib/permissions'; import { getHttpStatus } from '@/lib/types'; -import { authenticateUser, requirePermission } from '@/middlewares/role-auth'; +import { authenticateUser, requirePermission, requireSelf } from '@/middlewares/role-auth'; import { server } from '@/server/server'; +import * as service from '../../projects/chapter-assignments/project-chapter-assignments.service'; import * as usersChapterAssignmentsService from './users-chapter-assignments.service'; -import { userChapterAssignmentsByUserResponseSchema } from './users-chapter-assignments.types'; +import { + memberChapterAssignmentsResponseSchema, + userChapterAssignmentsByUserResponseSchema, +} from './users-chapter-assignments.types'; const getChapterAssignmentsByUserIdRoute = createRoute({ tags: ['Users - Chapter Assignments'], @@ -61,3 +65,88 @@ server.openapi(getChapterAssignmentsByUserIdRoute, async (c) => { if (result.ok) return c.json(result.data, HttpStatusCodes.OK); return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); }); + +const getUserChapterAssignmentsRoute = createRoute({ + tags: ['Users - Chapter Assignments'], + method: 'get', + path: '/users/{userId}/chapter-assignments/all', + middleware: [ + authenticateUser, + requirePermission(PERMISSIONS.PROJECT_VIEW), + requireSelf(), + ] as const, + summary: 'Get all chapter assignments for a user across all their projects', + description: + 'Fetches all projects the user belongs to, then returns all chapter assignments ' + + 'across those projects in a single flat list — including unassigned ones.', + request: { + params: z.object({ + userId: z.coerce + .number() + .int() + .positive() + .openapi({ + param: { name: 'userId', in: 'path', required: true }, + example: 1, + }), + }), + query: z.object({ + excludeProjectIds: z + .string() + .optional() + .transform((val) => val?.split(',').map(Number).filter(Boolean) ?? []) + .openapi({ + param: { name: 'excludeProjectIds', in: 'query', required: false }, + description: 'Comma-separated list of project IDs to exclude', + example: '97,98', + }), + updatedAfter: z + .string() + .optional() + .transform((val) => (val ? new Date(val) : undefined)) + .pipe(z.date().optional()) + .openapi({ + param: { name: 'updatedAfter', in: 'query', required: false }, + description: 'Return only assignments updated after this ISO timestamp', + example: '2025-01-01T00:00:00.000Z', + }), + }), + }, + responses: { + [HttpStatusCodes.OK]: jsonContent( + memberChapterAssignmentsResponseSchema.openapi('UserChapterAssignments'), + 'Flat list of all chapter assignments across all user projects' + ), + [HttpStatusCodes.UNAUTHORIZED]: jsonContent( + createMessageObjectSchema('Unauthorized'), + 'Authentication required' + ), + [HttpStatusCodes.FORBIDDEN]: jsonContent( + createMessageObjectSchema('Forbidden'), + 'Access denied' + ), + [HttpStatusCodes.INTERNAL_SERVER_ERROR]: jsonContent( + createMessageObjectSchema(HttpStatusPhrases.INTERNAL_SERVER_ERROR), + 'Internal server error' + ), + }, +}); +server.openapi(getUserChapterAssignmentsRoute, async (c) => { + const { userId } = c.req.valid('param'); + const { excludeProjectIds, updatedAfter } = c.req.valid('query'); + + const result = await service.getChapterAssignmentsByUserId( + userId, + excludeProjectIds, + updatedAfter + ); + if (result.ok) + return c.json( + { + syncedAt: new Date().toISOString(), + data: result.data, + }, + HttpStatusCodes.OK + ); + return c.json({ message: result.error.message }, getHttpStatus(result.error) as never); +}); diff --git a/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts b/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts index 3eb8a70..6fb5089 100644 --- a/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts +++ b/src/domains/users/chapter-assignments/users-chapter-assignments.types.ts @@ -56,3 +56,23 @@ export type UserChapterAssignmentResponse = z.infer; + +export const memberChapterAssignmentResponseSchema = z.object({ + chapterAssignmentId: z.number().int(), + projectId: z.number().int(), + projectUnitId: z.number().int(), + bibleId: z.number().int(), + bookId: z.number().int(), + chapterNumber: z.number().int(), + assignedUserId: z.number().int().nullable(), + peerCheckerId: z.number().int().nullable(), + status: z.string(), + submittedTime: z.date().nullable(), + createdAt: z.date().nullable(), + updatedAt: z.date().nullable(), +}); + +export const memberChapterAssignmentsResponseSchema = z.object({ + syncedAt: z.date(), + data: memberChapterAssignmentResponseSchema.array(), +});