Skip to content
Merged
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
10 changes: 5 additions & 5 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,18 +101,18 @@ 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(
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),
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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Agreed. For reference, we currently have two sync-oriented endpoints: one for chapter assignments and one for bulk Bible texts. While they were introduced to support the mobile sync workflow, they are not limited to mobile clients.

The intent is to use the same endpoints for both full and incremental syncs. An initial sync retrieves the complete dataset, while subsequent syncs use the updatedAfter parameter to fetch only records that have changed since the last sync.

}
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,6 @@
import { and, eq, inArray } from 'drizzle-orm';
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';
import type { DbTransaction, Result } from '@/lib/types';
Expand All @@ -8,6 +10,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 +116,65 @@ 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(buildAssignmentFilter(projectIds, excludeProjectIds, updatedAfter));

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);
}
}

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);
}
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 @@ -84,3 +86,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 };
16 changes: 12 additions & 4 deletions src/domains/projects/projects.repository.ts
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 } from 'drizzle-orm';

import type { DbTransaction, Result } from '@/lib/types';

Expand Down Expand Up @@ -72,17 +72,25 @@ export async function getByOrganization(
}
}

export async function getByUserId(userId: number): Promise<Result<ProjectWithLanguageNames[]>> {
export async function getByUserId(
userId: number,
updatedAfter?: Date
): Promise<Result<ProjectWithLanguageNames[]>> {
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);
}
Expand Down
4 changes: 2 additions & 2 deletions src/domains/projects/projects.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading