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
19 changes: 19 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ model User {
completions Completion[]
credentials Credential[]
transactions Transaction[]
syncEvents SyncEvent[]

@@map("users")
}
Expand Down Expand Up @@ -83,6 +84,24 @@ enum Role {



model SyncEvent {
id String @id @default(uuid())
idempotencyKey String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
deviceId String
eventType String // progress, completion
payload String // JSON string
clientTimestamp DateTime
serverTimestamp DateTime @default(now())
syncVersion Int
status String @default("applied") // applied, skipped, rejected
rejectionReason String?
createdAt DateTime @default(now())

@@map("sync_events")
}

model WebhookEndpoint {
id String @id @default(uuid())
url String
Expand Down
249 changes: 249 additions & 0 deletions src/controllers/sync.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,249 @@
import { Request, Response } from 'express'
import prisma from '../config/database'
import { asyncHandler } from '../middleware/error.middleware'
import { BadRequestError, UnauthorizedError } from '../utils/errors'

interface ProgressEvent {
idempotencyKey: string
deviceId: string
moduleId: string
progressPercent: number
clientTimestamp: string
syncVersion: number
}

interface CompletionEvent {
idempotencyKey: string
deviceId: string
moduleId: string
score: number
clientTimestamp: string
syncVersion: number
}

type SyncStatus = 'applied' | 'skipped' | 'rejected'

interface SyncResult {
idempotencyKey: string
status: SyncStatus
reason?: string
}

export class SyncController {
/**
* @openapi
* /sync/progress:
* post:
* summary: Upload batched offline progress events
* tags: [Sync]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [events]
* properties:
* events:
* type: array
* items:
* type: object
* required: [idempotencyKey, deviceId, moduleId, progressPercent, clientTimestamp, syncVersion]
* responses:
* 200:
* description: Sync results per item
* 400:
* description: Invalid payload
* 401:
* description: Unauthorized
*/
syncProgress = asyncHandler(async (req: Request, res: Response): Promise<void> => {
const userId = (req as any).user?.id
if (!userId) throw new UnauthorizedError('User ID not found')

const { events } = req.body
if (!Array.isArray(events) || events.length === 0) {
throw new BadRequestError('events must be a non-empty array')
}

const results: SyncResult[] = []

for (const event of events as ProgressEvent[]) {
const { idempotencyKey, deviceId, moduleId, progressPercent, clientTimestamp, syncVersion } = event

if (!idempotencyKey || !deviceId || !moduleId || progressPercent === undefined || !clientTimestamp || syncVersion === undefined) {
results.push({ idempotencyKey: idempotencyKey ?? 'unknown', status: 'rejected', reason: 'Missing required fields' })
continue
}

if (typeof progressPercent !== 'number' || progressPercent < 0 || progressPercent > 100) {
results.push({ idempotencyKey, status: 'rejected', reason: 'progressPercent must be between 0 and 100' })
continue
}

const clientTs = new Date(clientTimestamp)
if (isNaN(clientTs.getTime())) {
results.push({ idempotencyKey, status: 'rejected', reason: 'Invalid clientTimestamp format' })
continue
}

const existing = await prisma.syncEvent.findUnique({ where: { idempotencyKey } })
if (existing) {
results.push({ idempotencyKey, status: 'skipped' })
continue
}

const latestForModule = await prisma.syncEvent.findFirst({
where: { userId, payload: { contains: moduleId }, eventType: 'progress' },
orderBy: { syncVersion: 'desc' },
})

if (latestForModule && latestForModule.syncVersion > syncVersion) {
results.push({ idempotencyKey, status: 'skipped', reason: 'Stale sync version — a newer version already applied' })
continue
}

await prisma.syncEvent.create({
data: {
idempotencyKey,
userId,
deviceId,
eventType: 'progress',
payload: JSON.stringify({ moduleId, progressPercent }),
clientTimestamp: clientTs,
syncVersion,
status: 'applied',
},
})

results.push({ idempotencyKey, status: 'applied' })
}

res.status(200).json({ success: true, data: { results } })
})

/**
* @openapi
* /sync/completions:
* post:
* summary: Reconcile offline quiz/completion attempts
* tags: [Sync]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [events]
* properties:
* events:
* type: array
* items:
* type: object
* required: [idempotencyKey, deviceId, moduleId, score, clientTimestamp, syncVersion]
* responses:
* 200:
* description: Per-item sync results
* 400:
* description: Invalid payload
* 401:
* description: Unauthorized
*/
syncCompletions = asyncHandler(async (req: Request, res: Response): Promise<void> => {
const userId = (req as any).user?.id
if (!userId) throw new UnauthorizedError('User ID not found')

const { events } = req.body
if (!Array.isArray(events) || events.length === 0) {
throw new BadRequestError('events must be a non-empty array')
}

const results: SyncResult[] = []

for (const event of events as CompletionEvent[]) {
const { idempotencyKey, deviceId, moduleId, score, clientTimestamp, syncVersion } = event

if (!idempotencyKey || !deviceId || !moduleId || score === undefined || !clientTimestamp || syncVersion === undefined) {
results.push({ idempotencyKey: idempotencyKey ?? 'unknown', status: 'rejected', reason: 'Missing required fields' })
continue
}

if (typeof score !== 'number' || score < 0 || score > 100) {
results.push({ idempotencyKey, status: 'rejected', reason: 'score must be between 0 and 100' })
continue
}

const clientTs = new Date(clientTimestamp)
if (isNaN(clientTs.getTime())) {
results.push({ idempotencyKey, status: 'rejected', reason: 'Invalid clientTimestamp format' })
continue
}

const existing = await prisma.syncEvent.findUnique({ where: { idempotencyKey } })
if (existing) {
results.push({ idempotencyKey, status: 'skipped' })
continue
}

const module = await prisma.module.findUnique({ where: { id: moduleId } })
if (!module) {
results.push({ idempotencyKey, status: 'rejected', reason: 'Module not found' })
continue
}

const alreadyCompleted = await prisma.completion.findUnique({
where: { userId_moduleId: { userId, moduleId } },
})

if (alreadyCompleted) {
if (score <= alreadyCompleted.score) {
await prisma.syncEvent.create({
data: {
idempotencyKey,
userId,
deviceId,
eventType: 'completion',
payload: JSON.stringify({ moduleId, score }),
clientTimestamp: clientTs,
syncVersion,
status: 'skipped',
rejectionReason: 'Existing completion has equal or higher score',
},
})
results.push({ idempotencyKey, status: 'skipped', reason: 'Existing completion has equal or higher score' })
continue
}

await prisma.completion.update({
where: { userId_moduleId: { userId, moduleId } },
data: { score },
})
} else {
await prisma.completion.create({
data: { userId, moduleId, score },
})
}

await prisma.syncEvent.create({
data: {
idempotencyKey,
userId,
deviceId,
eventType: 'completion',
payload: JSON.stringify({ moduleId, score }),
clientTimestamp: clientTs,
syncVersion,
status: 'applied',
},
})

results.push({ idempotencyKey, status: 'applied' })
}

res.status(200).json({ success: true, data: { results } })
})
}
2 changes: 2 additions & 0 deletions src/routes/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import moduleRoutes from './v1/modules.routes'
import credentialRoutes from './v1/credentials.routes'
import rewardRoutes from './v1/rewards.routes'
import userRoutes from './v1/users.routes'
import syncRoutes from './v1/sync.routes'

const router: Router = Router()

Expand All @@ -18,5 +19,6 @@ router.use('/v1/modules', moduleRoutes)
router.use('/v1/credentials', credentialRoutes)
router.use('/v1/rewards', rewardRoutes)
router.use('/v1/employer', employerRoutes)
router.use('/v1/sync', syncRoutes)

export default router
35 changes: 35 additions & 0 deletions src/routes/v1/sync.routes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { Router } from 'express'
import { SyncController } from '../../controllers/sync.controller'
import { authenticate } from '../../middleware/auth.middleware'
import { authenticatedLimiter } from '../../middleware/rate-limit.middleware'

const router = Router()
const syncController = new SyncController()

/**
* @route POST /api/v1/sync/progress
* @desc Upload batched offline progress events
* @body events - Array of progress events with idempotencyKey, deviceId, moduleId, progressPercent, clientTimestamp, syncVersion
* @access Private
*/
router.post(
'/progress',
authenticate,
authenticatedLimiter,
syncController.syncProgress.bind(syncController),
)

/**
* @route POST /api/v1/sync/completions
* @desc Reconcile offline quiz/completion attempts
* @body events - Array of completion events with idempotencyKey, deviceId, moduleId, score, clientTimestamp, syncVersion
* @access Private
*/
router.post(
'/completions',
authenticate,
authenticatedLimiter,
syncController.syncCompletions.bind(syncController),
)

export default router
Loading
Loading