847c73fda9
start-dev.sh synchronisiert Schema vor dem Backend; Sync/Collaboration liefern bei fehlenden Tabellen null statt 500. Co-authored-by: Cursor <cursoragent@cursor.com>
391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
import { Router } from 'express'
|
|
import { prisma } from '../db.js'
|
|
import { notifyOwnerOfCollaboratorChanges } from '../services/pushNotify.js'
|
|
import { requireUser } from '../middleware/auth.js'
|
|
|
|
const router = Router()
|
|
|
|
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 = []
|
|
const ownerNotifications = new Map<
|
|
string,
|
|
{ ownerId: string; logbookId: string; count: number }
|
|
>()
|
|
|
|
const recordCollaboratorChange = (
|
|
ownerId: string,
|
|
logbookId: string,
|
|
isOwner: boolean,
|
|
isCollaborator: unknown,
|
|
action: string,
|
|
type: string
|
|
) => {
|
|
if (isOwner || !isCollaborator) return
|
|
if (action !== 'create' && action !== 'update') return
|
|
if (type === 'logbook') return
|
|
const key = `${ownerId}:${logbookId}`
|
|
const entry = ownerNotifications.get(key) ?? { ownerId, logbookId, count: 0 }
|
|
entry.count += 1
|
|
ownerNotifications.set(key, entry)
|
|
}
|
|
|
|
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' || action === 'update')) {
|
|
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,
|
|
...(parsed.encryptedKey !== undefined ? { encryptedKey: parsed.encryptedKey } : {}),
|
|
...(parsed.iv !== undefined ? { iv: parsed.iv } : {}),
|
|
...(parsed.tag !== undefined ? { tag: parsed.tag } : {}),
|
|
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 collaboration = await prisma.collaboration.findUnique({
|
|
where: {
|
|
logbookId_userId: {
|
|
logbookId,
|
|
userId: req.userId
|
|
}
|
|
}
|
|
})
|
|
|
|
if (!isOwner && !collaboration) {
|
|
results.push({ payloadId, status: 'error', error: 'Forbidden: Access denied' })
|
|
continue
|
|
}
|
|
|
|
if (!isOwner && (!collaboration || collaboration.role !== 'WRITE')) {
|
|
results.push({ payloadId, status: 'error', error: 'Forbidden: WRITE access required' })
|
|
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 (!isOwner && (type === 'yacht' || (type === 'crew' && payloadId === 'skipper'))) {
|
|
results.push({
|
|
payloadId,
|
|
status: 'error',
|
|
error: type === 'yacht'
|
|
? 'Forbidden: Only owner can modify vessel data'
|
|
: 'Forbidden: Only owner can modify skipper profile'
|
|
})
|
|
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 if (type === 'logbookCrew') {
|
|
await prisma.logbookCrewSelectionPayload.deleteMany({ where: { logbookId } })
|
|
} else if (type === 'logbookVessel') {
|
|
await prisma.logbookVesselSelectionPayload.deleteMany({ where: { logbookId } })
|
|
} 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 }
|
|
})
|
|
}
|
|
} else if (type === 'logbookCrew') {
|
|
const { hasCrewPoolPrismaModels, CREW_POOL_MIGRATION_HINT } =
|
|
await import('../utils/crewPoolSchema.js')
|
|
if (!hasCrewPoolPrismaModels()) {
|
|
results.push({
|
|
payloadId,
|
|
status: 'error',
|
|
error: CREW_POOL_MIGRATION_HINT
|
|
})
|
|
continue
|
|
}
|
|
{
|
|
const existing = await prisma.logbookCrewSelectionPayload.findUnique({ where: { logbookId } })
|
|
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
|
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
|
continue
|
|
}
|
|
await prisma.logbookCrewSelectionPayload.upsert({
|
|
where: { logbookId },
|
|
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
|
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
|
})
|
|
}
|
|
} else if (type === 'logbookVessel') {
|
|
const { hasVesselPoolPrismaModels, isMissingPrismaTable, VESSEL_POOL_MIGRATION_HINT } =
|
|
await import('../utils/crewPoolSchema.js')
|
|
if (!hasVesselPoolPrismaModels()) {
|
|
results.push({
|
|
payloadId,
|
|
status: 'error',
|
|
error: VESSEL_POOL_MIGRATION_HINT
|
|
})
|
|
continue
|
|
}
|
|
try {
|
|
const existing = await prisma.logbookVesselSelectionPayload.findUnique({ where: { logbookId } })
|
|
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
|
|
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
|
|
continue
|
|
}
|
|
await prisma.logbookVesselSelectionPayload.upsert({
|
|
where: { logbookId },
|
|
create: { logbookId, encryptedData, iv, tag, updatedAt: itemUpdatedAt },
|
|
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
|
|
})
|
|
} catch (err: unknown) {
|
|
if (isMissingPrismaTable(err)) {
|
|
results.push({ payloadId, status: 'error', error: VESSEL_POOL_MIGRATION_HINT })
|
|
continue
|
|
}
|
|
throw err
|
|
}
|
|
}
|
|
|
|
recordCollaboratorChange(
|
|
logbook.userId,
|
|
logbookId,
|
|
isOwner,
|
|
collaboration,
|
|
action,
|
|
type
|
|
)
|
|
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' })
|
|
}
|
|
}
|
|
|
|
for (const { ownerId, logbookId, count } of ownerNotifications.values()) {
|
|
void notifyOwnerOfCollaboratorChanges(logbookId, ownerId, req.userId, count)
|
|
}
|
|
|
|
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 collaboration = await prisma.collaboration.findUnique({
|
|
where: {
|
|
logbookId_userId: {
|
|
logbookId,
|
|
userId: req.userId
|
|
}
|
|
}
|
|
})
|
|
|
|
if (!isOwner && !collaboration) {
|
|
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 } })
|
|
const { findLogbookCrewSelectionSafe, findLogbookVesselSelectionSafe } =
|
|
await import('../utils/crewPoolSchema.js')
|
|
const logbookCrewSelection = await findLogbookCrewSelectionSafe(logbookId)
|
|
const logbookVesselSelection = await findLogbookVesselSelectionSafe(logbookId)
|
|
|
|
return res.json({
|
|
yacht,
|
|
deviation,
|
|
crews,
|
|
logbookCrewSelection,
|
|
logbookVesselSelection,
|
|
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
|