-
Notifications
You must be signed in to change notification settings - Fork 1
Mobile App Sync api update #178
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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'; | ||
|
|
@@ -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 | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Complex, hard-to-reason repository logic. |
||
| ); | ||
|
|
||
| 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 |
|---|---|---|
|
|
@@ -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', | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| 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 |
|---|---|---|
| @@ -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(), | ||
| }); | ||
|
Comment on lines
+35
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Date schema inconsistency This also should be consistent even if we move this logic to the |
||
|
|
||
| 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 | ||
|
|
@@ -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 }; | ||
There was a problem hiding this comment.
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.tsand 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.