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) {