Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions src/domains/bibles/bible-texts/bible-texts.repository.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -53,7 +53,8 @@ interface ChapterKey {

export async function getByChapters(
bibleId: number,
chapters: ChapterKey[]
chapters: ChapterKey[],
updatedAfter?: Date
): Promise<Result<BulkVerseRow[]>> {
try {
const conditions = chapters.map((ch) =>
Expand All @@ -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({
Expand Down
8 changes: 4 additions & 4 deletions src/domains/bibles/bible-texts/bible-texts.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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(
Expand All @@ -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.',
});

Expand Down
16 changes: 12 additions & 4 deletions src/domains/bibles/bible-texts/bible-texts.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
});
Comment on lines +49 to +52
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a minor deviation from the return signature that the codebase follows. While this is not a big deal, it prompts the question of whether we should consider keeping mobile-specific logic in a src/services/mobile-sync.service.ts and the data queries in their respective domains. This is not necessary for this PR but, if there are more sync endpoints that will be implemented, it is worth discussion.

}
9 changes: 8 additions & 1 deletion src/domains/bibles/bible-texts/bible-texts.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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<typeof bulkBibleTextsResponseSchema>;
export type BulkChapterRequest = z.infer<typeof bulkChapterRequestSchema>;
export type BulkChapterTextResponse = z.infer<typeof bulkChapterTextResponseSchema>;

Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Result<ChapterAssignmentRecord[]>> {
try {
const assignments = await db
Expand Down Expand Up @@ -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<Result<ChapterAssignmentWithProjectId[]>> {
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
)
Comment on lines +141 to +158
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Complex, hard-to-reason repository logic.
The ternary where clause in getByProjects tries to handle excludeProjectIds + updatedAfter in a single expression. It produces or(x, undefined) when excludeProjectIds.length > 0 && !updatedAfter, which is brittle. Extracting explicit conditions into a builder variable or using an array filter before passing to and()/or() would be much cleaner and safer.

);

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -19,6 +19,7 @@ import {
assignUserInputSchema,
chapterAssignmentProgressResponseSchema,
chapterAssignmentResponseSchema,
memberChapterAssignmentsResponseSchema,
} from './project-chapter-assignments.types';

const projectIdParam = z.object({ projectId: z.coerce.number().int().positive() });
Expand Down Expand Up @@ -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',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This route deviates from the established patterns in the codebase. Since we are fetching the chapter assignments for a user, we should move this to the user domain and user a route like: /users/{userId}/chapter-assignments/all or /users/{userId}/chapter-assignments/sync or similar.

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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -12,6 +13,7 @@ import type {
AssignSelectedItem,
AssignUserInput,
ChapterAssignmentProgress,
ChapterAssignmentWithProjectId,
} from './project-chapter-assignments.types';

import * as projectRepo from '../projects.repository';
Expand Down Expand Up @@ -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));
}
Original file line number Diff line number Diff line change
@@ -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 ──────────────────────────────────────────────────
Expand Down Expand Up @@ -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(),
});
Comment on lines +35 to +48
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Date schema inconsistency
This memberChapterAssignmentResponseSchema uses z.string().nullable() for timestamps, while the adjacent chapterAssignmentProgressResponseSchema (in the same file) uses z.date().nullable(). If the JSON response actually contains ISO strings, the z.date() schemas elsewhere may fail client-side validation depending on the OpenAPI generator. Align on one representation.

This also should be consistent even if we move this logic to the user domain. DB types are consistent and the code should reflect that.


export const memberChapterAssignmentsResponseSchema = z.object({
syncedAt: z.string(),
data: memberChapterAssignmentResponseSchema.array(),
});

export type MemberChapterAssignmentResponse = z.infer<typeof memberChapterAssignmentResponseSchema>;
// ─── Assign-all input ─────────────────────────────────────────────────────────

export const assignUserInputSchema = z
Expand Down Expand Up @@ -84,3 +107,4 @@ export type ChapterAssignmentProgress = z.infer<typeof chapterAssignmentProgress
export type AssignUserInput = z.infer<typeof assignUserInputSchema>;
export type AssignSelectedItem = z.infer<typeof assignSelectedItemSchema>;
export type AssignSelectedRequest = z.infer<typeof assignSelectedRequestSchema>;
export type ChapterAssignmentWithProjectId = ChapterAssignmentRecord & { projectId: number };
Loading
Loading