feat(documents): Phase A schema + service skeletons
Adds Phase A data model deltas to documents/templates and the new document_watchers table. Introduces createFromWizard/createFromUpload stubs, getDocumentDetail aggregator, cancelDocument flow, signed-doc email composer, reservation agreement context, and notifyDocumentEvent fan-out. Validator update accepts new template formats with html-only bodyHtml requirement. EOI cadence backfilled to 1 day to preserve current effective behaviour. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
136
src/lib/services/reservation-agreement-context.ts
Normal file
136
src/lib/services/reservation-agreement-context.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
|
||||
import { db } from '@/lib/db';
|
||||
import { berths } from '@/lib/db/schema/berths';
|
||||
import { berthReservations } from '@/lib/db/schema/reservations';
|
||||
import { clients } from '@/lib/db/schema/clients';
|
||||
import { ports } from '@/lib/db/schema/ports';
|
||||
import { yachts } from '@/lib/db/schema/yachts';
|
||||
import { NotFoundError } from '@/lib/errors';
|
||||
|
||||
export type ReservationAgreementContext = {
|
||||
client: {
|
||||
id: string;
|
||||
fullName: string;
|
||||
nationality: string | null;
|
||||
};
|
||||
yacht: {
|
||||
id: string;
|
||||
name: string;
|
||||
lengthFt: string | null;
|
||||
flag: string | null;
|
||||
};
|
||||
berth: {
|
||||
id: string;
|
||||
mooringNumber: string;
|
||||
area: string | null;
|
||||
lengthFt: string | null;
|
||||
priceCurrency: string;
|
||||
};
|
||||
reservation: {
|
||||
id: string;
|
||||
status: string;
|
||||
startDate: Date;
|
||||
endDate: Date | null;
|
||||
tenureType: string;
|
||||
termSummary: string;
|
||||
signedDate: string | null;
|
||||
};
|
||||
port: {
|
||||
name: string;
|
||||
defaultCurrency: string;
|
||||
};
|
||||
date: {
|
||||
today: string;
|
||||
year: string;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the merge-context shape used when generating a reservation agreement
|
||||
* document. Mirrors `buildEoiContext` for consistency: pure read-only,
|
||||
* tenant-scoped via `portId`, throws on missing rows.
|
||||
*
|
||||
* `termSummary` is a human-readable rendering of `tenureType` + dates that
|
||||
* templates can use as `{{reservation.termSummary}}` without needing date
|
||||
* formatting helpers in the template language.
|
||||
*/
|
||||
export async function buildReservationAgreementContext(
|
||||
reservationId: string,
|
||||
portId: string,
|
||||
): Promise<ReservationAgreementContext> {
|
||||
const reservation = await db.query.berthReservations.findFirst({
|
||||
where: and(eq(berthReservations.id, reservationId), eq(berthReservations.portId, portId)),
|
||||
});
|
||||
if (!reservation) throw new NotFoundError('Reservation');
|
||||
|
||||
const [client, yacht, berth, port] = await Promise.all([
|
||||
db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, reservation.clientId), eq(clients.portId, portId)),
|
||||
}),
|
||||
db.query.yachts.findFirst({
|
||||
where: and(eq(yachts.id, reservation.yachtId), eq(yachts.portId, portId)),
|
||||
}),
|
||||
db.query.berths.findFirst({
|
||||
where: and(eq(berths.id, reservation.berthId), eq(berths.portId, portId)),
|
||||
}),
|
||||
db.query.ports.findFirst({ where: eq(ports.id, portId) }),
|
||||
]);
|
||||
if (!client) throw new NotFoundError('Client');
|
||||
if (!yacht) throw new NotFoundError('Yacht');
|
||||
if (!berth) throw new NotFoundError('Berth');
|
||||
if (!port) throw new NotFoundError('Port');
|
||||
|
||||
const start = reservation.startDate.toISOString().slice(0, 10);
|
||||
const end = reservation.endDate ? reservation.endDate.toISOString().slice(0, 10) : null;
|
||||
|
||||
let termSummary: string;
|
||||
if (reservation.tenureType === 'permanent') {
|
||||
termSummary = `Permanent berth, commencing ${start}`;
|
||||
} else if (reservation.tenureType === 'fixed_term' && end) {
|
||||
termSummary = `Fixed term: ${start} to ${end}`;
|
||||
} else if (reservation.tenureType === 'seasonal' && end) {
|
||||
termSummary = `Seasonal: ${start} to ${end}`;
|
||||
} else {
|
||||
termSummary = `${reservation.tenureType} from ${start}`;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return {
|
||||
client: {
|
||||
id: client.id,
|
||||
fullName: client.fullName,
|
||||
nationality: client.nationality,
|
||||
},
|
||||
yacht: {
|
||||
id: yacht.id,
|
||||
name: yacht.name,
|
||||
lengthFt: yacht.lengthFt,
|
||||
flag: yacht.flag,
|
||||
},
|
||||
berth: {
|
||||
id: berth.id,
|
||||
mooringNumber: berth.mooringNumber,
|
||||
area: berth.area,
|
||||
lengthFt: berth.lengthFt,
|
||||
priceCurrency: berth.priceCurrency,
|
||||
},
|
||||
reservation: {
|
||||
id: reservation.id,
|
||||
status: reservation.status,
|
||||
startDate: reservation.startDate,
|
||||
endDate: reservation.endDate,
|
||||
tenureType: reservation.tenureType,
|
||||
termSummary,
|
||||
signedDate: null,
|
||||
},
|
||||
port: {
|
||||
name: port.name,
|
||||
defaultCurrency: port.defaultCurrency,
|
||||
},
|
||||
date: {
|
||||
today: now.toISOString().slice(0, 10),
|
||||
year: String(now.getFullYear()),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user