feat(reminders): Phase 4 partial — schema + service + validators

Migration 0072 — reminders/interests expansion:
- interests.reminder_note: optional cadence note for the existing
  reminderEnabled+reminderDays flow. Surfaces in notification body
  + inbox row.
- reminders.yacht_id (+ FK + relation): fourth entity link so
  yacht-scoped tasks have a typed home alongside client/interest/berth.
- reminders.fired_at: worker idempotency. Partial index
  idx_reminders_due_unfired drives the scan.

Service + validator updates:
- createReminderSchema / updateReminderSchema accept yachtId.
- assertReminderFksInPort validates yacht ownership against the
  caller's port — defense-in-depth, same shape as other entity FKs.
- createReminder / updateReminder thread yachtId through.

Worker scheduler + CreateReminderDialog yachtId UI deferred. The
existing reminders/reminder-form.tsx already covers the dialog
contract — Phase 4b extends it with yachtId + the per-user
digest_time_of_day picker.

Tests: 1374/1374 passing. tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 15:03:12 +02:00
parent 918c23fc0b
commit fb4a09e2ec
6 changed files with 86 additions and 1 deletions

View File

@@ -3,6 +3,7 @@ import { and, eq, lte, gte, desc, asc, inArray, sql } from 'drizzle-orm';
import { db } from '@/lib/db';
import { reminders, interests, clients } from '@/lib/db/schema';
import { berths } from '@/lib/db/schema/berths';
import { yachts } from '@/lib/db/schema/yachts';
import { createAuditLog, type AuditMeta } from '@/lib/audit';
import { NotFoundError, ValidationError } from '@/lib/errors';
import { emitToRoom } from '@/lib/socket/server';
@@ -118,7 +119,12 @@ export async function getUpcomingReminders(portId: string, days: number = 14) {
*/
async function assertReminderFksInPort(
portId: string,
fks: { clientId?: string | null; interestId?: string | null; berthId?: string | null },
fks: {
clientId?: string | null;
interestId?: string | null;
berthId?: string | null;
yachtId?: string | null;
},
): Promise<void> {
const checks: Array<Promise<void>> = [];
if (fks.clientId) {
@@ -150,6 +156,15 @@ async function assertReminderFksInPort(
}),
);
}
if (fks.yachtId) {
checks.push(
db.query.yachts
.findFirst({ where: and(eq(yachts.id, fks.yachtId), eq(yachts.portId, portId)) })
.then((row) => {
if (!row) throw new ValidationError('yachtId not found in this port');
}),
);
}
await Promise.all(checks);
}
@@ -169,6 +184,7 @@ export async function createReminder(portId: string, data: CreateReminderInput,
clientId: data.clientId,
interestId: data.interestId,
berthId: data.berthId,
yachtId: data.yachtId,
});
const [reminder] = await db
@@ -184,6 +200,7 @@ export async function createReminder(portId: string, data: CreateReminderInput,
clientId: data.clientId ?? null,
interestId: data.interestId ?? null,
berthId: data.berthId ?? null,
yachtId: data.yachtId ?? null,
})
.returning();
@@ -237,12 +254,14 @@ export async function updateReminder(
if (data.clientId !== undefined) updates.clientId = data.clientId;
if (data.interestId !== undefined) updates.interestId = data.interestId;
if (data.berthId !== undefined) updates.berthId = data.berthId;
if (data.yachtId !== undefined) updates.yachtId = data.yachtId;
// Re-validate any subject-FK changes against the caller's port.
await assertReminderFksInPort(portId, {
clientId: data.clientId,
interestId: data.interestId,
berthId: data.berthId,
yachtId: data.yachtId,
});
const [updated] = await db