Files
kapteins-daagbok/server/src/routes/sync.ts
T
elpatron 95cfc3872b fix: Sync-Warteschlange im Online-Modus zuverlässig leeren
Lösch-Sync schlug serverseitig an JSON.parse('') fehl; clientseitig werden Duplikate zusammengeführt, parallele Läufe nachgeholt und die Queue bis zum Leeren durchgeschoben.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-29 20:15:47 +02:00

288 lines
11 KiB
TypeScript

import { Router } from 'express'
import { prisma } from '../db.js'
const router = Router()
// Middleware to extract user ID from headers
const requireUser = (req: any, res: any, next: any) => {
const userId = req.headers['x-user-id']
if (!userId) {
return res.status(401).json({ error: 'Unauthorized: X-User-Id header missing' })
}
req.userId = userId
next()
}
router.use(requireUser)
// 1. Push local changes to the server
router.post('/push', async (req: any, res) => {
try {
const { items } = req.body
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'items array is required' })
}
const results = []
for (const item of items) {
const { action, type, payloadId, logbookId, data, updatedAt } = item
const itemUpdatedAt = new Date(updatedAt)
try {
// Authorize: Check if logbook belongs to user
// Exception: If action is create logbook, the logbook might not exist yet,
// so we authorize based on user creating a logbook with their userId.
if (type === 'logbook' && action === 'create') {
const existing = await prisma.logbook.findUnique({
where: { id: logbookId }
})
if (existing && existing.userId !== req.userId) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Logbook belongs to another user' })
continue
}
const parsed = JSON.parse(data)
await prisma.logbook.upsert({
where: { id: logbookId },
create: {
id: logbookId,
userId: req.userId,
encryptedTitle: parsed.encryptedTitle,
encryptedKey: parsed.encryptedKey || null,
iv: parsed.iv || null,
tag: parsed.tag || null,
updatedAt: itemUpdatedAt
},
update: {
encryptedTitle: parsed.encryptedTitle,
encryptedKey: parsed.encryptedKey || null,
iv: parsed.iv || null,
tag: parsed.tag || null,
updatedAt: itemUpdatedAt
}
})
results.push({ payloadId, status: 'success' })
continue
}
// Standard Authorization: Logbook must exist and belong to user or collaborator
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId }
})
if (!logbook) {
results.push({ payloadId, status: 'error', error: 'Logbook not found' })
continue
}
const isOwner = logbook.userId === req.userId
const isCollaborator = await prisma.collaboration.findUnique({
where: {
logbookId_userId: {
logbookId,
userId: req.userId
}
}
})
if (!isOwner && !isCollaborator) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
continue
}
if (type === 'logbook' && action === 'delete') {
if (!isOwner) {
results.push({ payloadId, status: 'error', error: 'Forbidden: Only owner can delete logbook' })
continue
}
await prisma.logbook.delete({
where: { id: logbookId }
})
results.push({ payloadId, status: 'success' })
continue
}
if (action === 'delete') {
if (type === 'yacht') {
await prisma.yachtPayload.deleteMany({ where: { logbookId } })
} else if (type === 'deviation') {
await prisma.deviationPayload.deleteMany({ where: { logbookId } })
} else if (type === 'crew') {
await prisma.crewPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'entry') {
await prisma.entryPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'photo') {
await prisma.photoPayload.deleteMany({ where: { logbookId, payloadId } })
} else if (type === 'gpsTrack') {
await prisma.gpsTrackPayload.deleteMany({ where: { logbookId, entryId: payloadId } })
} else {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue
}
results.push({ payloadId, status: 'success' })
continue
}
// Parse payload for create/update operations
const parsed = JSON.parse(data)
const encryptedData = parsed.encryptedData || parsed.ciphertext
const { iv, tag } = parsed
if (type === 'yacht') {
{
const existing = await prisma.yachtPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.yachtPayload.upsert({
where: { logbookId },
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'deviation') {
{
const existing = await prisma.deviationPayload.findUnique({ where: { logbookId } })
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.deviationPayload.upsert({
where: { logbookId },
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'crew') {
{
const existing = await prisma.crewPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.crewPayload.upsert({
where: { logbookId_payloadId: { logbookId, payloadId } },
create: { logbookId, payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'entry') {
{
const existing = await prisma.entryPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.entryPayload.upsert({
where: { logbookId_payloadId: { logbookId, payloadId } },
create: { logbookId, payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'photo') {
{
const existing = await prisma.photoPayload.findUnique({
where: { logbookId_payloadId: { logbookId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
const entryId = parsed.entryId || ''
await prisma.photoPayload.upsert({
where: { logbookId_payloadId: { logbookId, payloadId } },
create: { logbookId, payloadId, entryId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'gpsTrack') {
{
const existing = await prisma.gpsTrackPayload.findUnique({
where: { entryId: payloadId }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.gpsTrackPayload.upsert({
where: { entryId: payloadId },
create: { logbookId, entryId: payloadId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
}
results.push({ payloadId, status: 'success' })
} catch (err: any) {
console.error(`Error processing sync item ${payloadId}:`, err)
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
}
}
return res.json({ results })
} catch (error: any) {
console.error('Error during sync push:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
// 2. Pull server updates to the client
router.get('/pull', async (req: any, res) => {
try {
const { logbookId } = req.query
if (!logbookId) {
return res.status(400).json({ error: 'logbookId is required' })
}
// Authorize: Check if logbook belongs to user or is collaborator
const logbook = await prisma.logbook.findUnique({
where: { id: logbookId }
})
if (!logbook) {
return res.status(404).json({ error: 'Logbook not found' })
}
const isOwner = logbook.userId === req.userId
const isCollaborator = await prisma.collaboration.findUnique({
where: {
logbookId_userId: {
logbookId,
userId: req.userId
}
}
})
if (!isOwner && !isCollaborator) {
return res.status(403).json({ error: 'Forbidden: Access denied' })
}
const yacht = await prisma.yachtPayload.findUnique({ where: { logbookId } })
const deviation = await prisma.deviationPayload.findUnique({ where: { logbookId } })
const crews = await prisma.crewPayload.findMany({ where: { logbookId } })
const entries = await prisma.entryPayload.findMany({ where: { logbookId } })
const photos = await prisma.photoPayload.findMany({ where: { logbookId } })
const gpsTracks = await prisma.gpsTrackPayload.findMany({ where: { logbookId } })
return res.json({
yacht,
deviation,
crews,
entries,
photos,
gpsTracks
})
} catch (error: any) {
console.error('Error during sync pull:', error)
return res.status(500).json({ error: error.message || 'Internal server error' })
}
})
export default router