feat(tenancies-renew-transfer): tenure-aware renewal + transfer actions
- renewTenancy service:
- permanent / fee_simple / strata_lot → mutate-in-place (startDate
moves forward, endDate may extend or null out)
- fixed_term / seasonal → end the current row at its existing endDate
+ mint a successor with previousTenancyId chain. newEndDate required.
- transferTenancy service: end-and-spawn — end current row at
transferDate, mint fresh active row with transferredFromTenancyId
pointing back. New client + yacht cross-validated against port +
ownership constraint (assertClientOwnsOrRepresentsYacht).
- POST /api/v1/tenancies/[id]/renew + /transfer routes gated on
tenancies.manage + module-enabled.
- TenancyRenewDialog (tenure-aware copy explains in-place vs successor),
TenancyTransferDialog (ClientPicker + YachtPicker with owner-scoped
filter). Both mounted on tenancy-detail.tsx alongside Edit + End.
- Validators: renewTenancySchema + transferTenancySchema in
src/lib/validators/tenancies.ts.
Verified: tsc clean, 1493/1493 vitest.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -18,6 +18,8 @@ import type {
|
||||
EndTenancyInput,
|
||||
CancelInput,
|
||||
ListTenanciesInput,
|
||||
RenewTenancyInput,
|
||||
TransferTenancyInput,
|
||||
UpdateTenancyInput,
|
||||
} from '@/lib/validators/tenancies';
|
||||
|
||||
@@ -404,6 +406,218 @@ export async function updateTenancy(
|
||||
return updated;
|
||||
}
|
||||
|
||||
// ─── Renew (tenure-aware) ────────────────────────────────────────────────────
|
||||
|
||||
const PERMANENT_TENURES = new Set(['permanent', 'fee_simple', 'strata_lot']);
|
||||
|
||||
/**
|
||||
* Renew an active tenancy. Per docs/tenancies-design.md:
|
||||
* - permanent / fee_simple / strata_lot: mutate the existing row in
|
||||
* place (one record forever) — startDate moves forward, endDate may
|
||||
* extend or null out, notes append.
|
||||
* - fixed_term / seasonal: end the current row at its existing
|
||||
* endDate (or now if missing), then mint a successor row with
|
||||
* previousTenancyId pointing back. Successor inherits berth /
|
||||
* client / yacht / interestId / contractFileId; rep can re-edit
|
||||
* via the standard update action.
|
||||
*/
|
||||
export async function renewTenancy(
|
||||
tenancyId: string,
|
||||
portId: string,
|
||||
data: RenewTenancyInput,
|
||||
meta: AuditMeta,
|
||||
): Promise<BerthTenancy> {
|
||||
const existing = await loadScoped(tenancyId, portId);
|
||||
if (existing.status !== 'active') {
|
||||
throw new ValidationError(
|
||||
`Cannot renew a ${existing.status} tenancy — renewals require an active record.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (PERMANENT_TENURES.has(existing.tenureType)) {
|
||||
// Mutate-in-place path. End date can extend (or be removed entirely).
|
||||
const [updated] = await db
|
||||
.update(berthTenancies)
|
||||
.set({
|
||||
startDate: data.newStartDate,
|
||||
endDate: data.newEndDate ?? null,
|
||||
notes: data.notes
|
||||
? `${existing.notes ? `${existing.notes}\n` : ''}Renewal: ${data.notes}`
|
||||
: existing.notes,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)))
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'update',
|
||||
entityType: 'berth_tenancy',
|
||||
entityId: tenancyId,
|
||||
oldValue: { startDate: existing.startDate, endDate: existing.endDate },
|
||||
newValue: { startDate: updated!.startDate, endDate: updated!.endDate, kind: 'renew' },
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth_tenancy:activated', {
|
||||
tenancyId,
|
||||
berthId: updated!.berthId,
|
||||
});
|
||||
return updated!;
|
||||
}
|
||||
|
||||
// Mint-successor path (fixed_term / seasonal). End the current row
|
||||
// first so the partial unique index on (berthId, status='active')
|
||||
// doesn't conflict when the new row activates.
|
||||
if (!data.newEndDate) {
|
||||
throw new ValidationError(
|
||||
`Renewals on ${existing.tenureType} tenancies require a new end date.`,
|
||||
);
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const closeDate = existing.endDate ?? new Date();
|
||||
await tx
|
||||
.update(berthTenancies)
|
||||
.set({ status: 'ended', endDate: closeDate, updatedAt: new Date() })
|
||||
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)));
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(berthTenancies)
|
||||
.values({
|
||||
portId,
|
||||
berthId: existing.berthId,
|
||||
clientId: existing.clientId,
|
||||
yachtId: existing.yachtId,
|
||||
interestId: existing.interestId,
|
||||
status: 'active',
|
||||
startDate: data.newStartDate,
|
||||
endDate: data.newEndDate,
|
||||
tenureType: existing.tenureType,
|
||||
contractFileId: existing.contractFileId,
|
||||
previousTenancyId: tenancyId,
|
||||
notes: data.notes
|
||||
? `Renewal of #${tenancyId.slice(0, 8)}: ${data.notes}`
|
||||
: `Renewal of #${tenancyId.slice(0, 8)}`,
|
||||
createdBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'berth_tenancy',
|
||||
entityId: inserted!.id,
|
||||
newValue: {
|
||||
previousTenancyId: tenancyId,
|
||||
kind: 'renew_successor',
|
||||
startDate: inserted!.startDate,
|
||||
endDate: inserted!.endDate,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth_tenancy:created', {
|
||||
tenancyId: inserted!.id,
|
||||
berthId: inserted!.berthId,
|
||||
});
|
||||
return inserted!;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Transfer (client/yacht swap) ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Hand a berth's active tenancy to a new client / yacht pair. The
|
||||
* current row is ended at `transferDate`, then a fresh active row is
|
||||
* minted with `transferredFromTenancyId` pointing at the predecessor.
|
||||
* Berth + tenure type + contractFileId carry over; interestId resets
|
||||
* to null (the original deal is closed).
|
||||
*/
|
||||
export async function transferTenancy(
|
||||
tenancyId: string,
|
||||
portId: string,
|
||||
data: TransferTenancyInput,
|
||||
meta: AuditMeta,
|
||||
): Promise<BerthTenancy> {
|
||||
const existing = await loadScoped(tenancyId, portId);
|
||||
if (existing.status !== 'active') {
|
||||
throw new ValidationError(
|
||||
`Cannot transfer a ${existing.status} tenancy — transfers require an active record.`,
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-validate new client + yacht live in this port.
|
||||
const client = await db.query.clients.findFirst({
|
||||
where: and(eq(clients.id, data.newClientId), eq(clients.portId, portId)),
|
||||
});
|
||||
if (!client) throw new ValidationError('new client not found in this port');
|
||||
const yacht = await db.query.yachts.findFirst({
|
||||
where: and(eq(yachts.id, data.newYachtId), eq(yachts.portId, portId)),
|
||||
});
|
||||
if (!yacht) throw new ValidationError('new yacht not found in this port');
|
||||
await assertClientOwnsOrRepresentsYacht(
|
||||
{ currentOwnerType: yacht.currentOwnerType, currentOwnerId: yacht.currentOwnerId },
|
||||
data.newClientId,
|
||||
);
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(berthTenancies)
|
||||
.set({ status: 'ended', endDate: data.transferDate, updatedAt: new Date() })
|
||||
.where(and(eq(berthTenancies.id, tenancyId), eq(berthTenancies.portId, portId)));
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(berthTenancies)
|
||||
.values({
|
||||
portId,
|
||||
berthId: existing.berthId,
|
||||
clientId: data.newClientId,
|
||||
yachtId: data.newYachtId,
|
||||
interestId: null,
|
||||
status: 'active',
|
||||
startDate: data.transferDate,
|
||||
endDate: existing.endDate,
|
||||
tenureType: existing.tenureType,
|
||||
contractFileId: existing.contractFileId,
|
||||
transferredFromTenancyId: tenancyId,
|
||||
notes: data.notes
|
||||
? `Transferred from #${tenancyId.slice(0, 8)}: ${data.notes}`
|
||||
: `Transferred from #${tenancyId.slice(0, 8)}`,
|
||||
createdBy: meta.userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
void createAuditLog({
|
||||
userId: meta.userId,
|
||||
portId,
|
||||
action: 'create',
|
||||
entityType: 'berth_tenancy',
|
||||
entityId: inserted!.id,
|
||||
newValue: {
|
||||
transferredFromTenancyId: tenancyId,
|
||||
kind: 'transfer',
|
||||
previousClientId: existing.clientId,
|
||||
newClientId: data.newClientId,
|
||||
previousYachtId: existing.yachtId,
|
||||
newYachtId: data.newYachtId,
|
||||
},
|
||||
ipAddress: meta.ipAddress,
|
||||
userAgent: meta.userAgent,
|
||||
});
|
||||
|
||||
emitToRoom(`port:${portId}`, 'berth_tenancy:created', {
|
||||
tenancyId: inserted!.id,
|
||||
berthId: inserted!.berthId,
|
||||
});
|
||||
return inserted!;
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Get ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function getById(id: string, portId: string): Promise<BerthTenancy> {
|
||||
|
||||
@@ -42,6 +42,31 @@ export const updateTenancySchema = z
|
||||
.refine((d) => Object.keys(d).length > 0, { message: 'At least one field must be provided' });
|
||||
export type UpdateTenancyInput = z.infer<typeof updateTenancySchema>;
|
||||
|
||||
/** Renewal input. For permanent-class tenancies the existing row is
|
||||
* mutated in place; for fixed_term / seasonal the existing row is
|
||||
* ended at `previousEndDate` and a fresh row is minted with
|
||||
* previousTenancyId pointing back. */
|
||||
export const renewTenancySchema = z.object({
|
||||
/** New start date for the renewal window (or the mutated row when permanent). */
|
||||
newStartDate: z.coerce.date(),
|
||||
/** New end date — required for fixed_term / seasonal; optional when permanent. */
|
||||
newEndDate: z.coerce.date().optional(),
|
||||
/** Optional notes appended to the successor / mutated row. */
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type RenewTenancyInput = z.infer<typeof renewTenancySchema>;
|
||||
|
||||
/** Transfer input — hand a berth's active tenancy to a new client/yacht
|
||||
* pair. The current row is ended, a fresh active row is minted with
|
||||
* transferredFromTenancyId pointing at the predecessor. */
|
||||
export const transferTenancySchema = z.object({
|
||||
newClientId: z.string().min(1),
|
||||
newYachtId: z.string().min(1),
|
||||
transferDate: z.coerce.date(),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
export type TransferTenancyInput = z.infer<typeof transferTenancySchema>;
|
||||
|
||||
export const listTenanciesSchema = baseListQuerySchema.extend({
|
||||
status: z.enum(TENANCY_STATUSES).optional(),
|
||||
berthId: z.string().optional(),
|
||||
|
||||
Reference in New Issue
Block a user