Add account-level crew pool with per-logbook and per-day selection.

Move skipper and crew master data to the user profile pool, replace the logbook crew tab with selection from that pool, inherit crew on new travel days, and sync via new PersonPayload and LogbookCrewSelection models. Includes migration from legacy crew records, tour/demo updates, and i18n.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-01 19:05:50 +02:00
co-authored by Cursor
parent 4c6c2779f2
commit 3504ec97cc
33 changed files with 1946 additions and 73 deletions
+26
View File
@@ -23,6 +23,7 @@ model User {
pushSubscriptions PushSubscription[]
notificationPrefs UserNotificationPrefs?
appearancePrefs UserAppearancePrefs?
personPool PersonPayload[]
}
model PushSubscription {
@@ -86,6 +87,7 @@ model Logbook {
yachts YachtPayload[]
crews CrewPayload[]
logbookCrewSelection LogbookCrewSelectionPayload?
deviations DeviationPayload[]
entries EntryPayload[]
photos PhotoPayload[]
@@ -148,6 +150,30 @@ model CrewPayload {
@@unique([logbookId, payloadId])
}
model PersonPayload {
id String @id @default(uuid())
userId String
payloadId String
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, payloadId])
@@index([userId])
}
model LogbookCrewSelectionPayload {
id String @id @default(uuid())
logbookId String @unique
encryptedData String
iv String
tag String
updatedAt DateTime @updatedAt
logbook Logbook @relation(fields: [logbookId], references: [id], onDelete: Cascade)
}
model DeviationPayload {
id String @id @default(uuid())
logbookId String @unique
+69
View File
@@ -504,6 +504,75 @@ router.put('/appearance-prefs', requireUser, async (req: any, res) => {
}
})
router.get('/person-pool', requireUser, async (req: any, res) => {
try {
const persons = await prisma.personPayload.findMany({
where: { userId: req.userId }
})
return res.json({ persons })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/person-pool-get')
}
})
router.post('/person-pool/push', requireUser, 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: Array<{ payloadId: string; status: string; error?: string; reason?: string }> = []
for (const item of items) {
const { action, payloadId, data, updatedAt } = item
const itemUpdatedAt = new Date(updatedAt)
try {
if (action === 'delete') {
await prisma.personPayload.deleteMany({
where: { userId: req.userId, payloadId }
})
results.push({ payloadId, status: 'success' })
continue
}
const parsed = JSON.parse(data)
const encryptedData = parsed.encryptedData || parsed.ciphertext
const { iv, tag } = parsed
const existing = await prisma.personPayload.findUnique({
where: { userId_payloadId: { userId: req.userId, payloadId } }
})
if (existing && new Date(existing.updatedAt) > itemUpdatedAt) {
results.push({ payloadId, status: 'conflict', reason: 'Server version is newer' })
continue
}
await prisma.personPayload.upsert({
where: { userId_payloadId: { userId: req.userId, payloadId } },
create: {
userId: req.userId,
payloadId,
encryptedData,
iv,
tag,
updatedAt: itemUpdatedAt
},
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
results.push({ payloadId, status: 'success' })
} catch (err: any) {
results.push({ payloadId, status: 'error', error: err.message || 'Operation failed' })
}
}
return res.json({ results })
} catch (error: unknown) {
return sendInternalError(res, error, 'auth/person-pool-push')
}
})
router.get('/profile', requireUser, async (req: any, res) => {
try {
const user = await prisma.user.findUnique({
+4
View File
@@ -77,6 +77,9 @@ router.get('/share-pull', async (req: any, res) => {
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 logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
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 } })
@@ -86,6 +89,7 @@ router.get('/share-pull', async (req: any, res) => {
yacht,
deviation,
crews,
logbookCrewSelection,
entries,
photos,
gpsTracks
+19
View File
@@ -145,6 +145,8 @@ router.post('/push', async (req: any, res) => {
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 {
results.push({ payloadId, status: 'error', error: `Unsupported delete type: ${type}` })
continue
@@ -245,6 +247,19 @@ router.post('/push', async (req: any, res) => {
update: { encryptedData, iv, tag, updatedAt: itemUpdatedAt }
})
}
} else if (type === 'logbookCrew') {
{
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 }
})
}
}
recordCollaboratorChange(
@@ -310,11 +325,15 @@ router.get('/pull', async (req: any, res) => {
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 logbookCrewSelection = await prisma.logbookCrewSelectionPayload.findUnique({
where: { logbookId }
})
return res.json({
yacht,
deviation,
crews,
logbookCrewSelection,
entries,
photos,
gpsTracks