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:
26
src/app/api/v1/tenancies/[id]/renew/route.ts
Normal file
26
src/app/api/v1/tenancies/[id]/renew/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { renewTenancy } from '@/lib/services/berth-tenancies.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import { renewTenancySchema } from '@/lib/validators/tenancies';
|
||||||
|
|
||||||
|
const renewHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
|
const body = await parseBody(req, renewTenancySchema);
|
||||||
|
const result = await renewTenancy(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('tenancies', 'manage', renewHandler));
|
||||||
26
src/app/api/v1/tenancies/[id]/transfer/route.ts
Normal file
26
src/app/api/v1/tenancies/[id]/transfer/route.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
|
||||||
|
import { withAuth, withPermission, type RouteHandler } from '@/lib/api/helpers';
|
||||||
|
import { parseBody } from '@/lib/api/route-helpers';
|
||||||
|
import { errorResponse } from '@/lib/errors';
|
||||||
|
import { transferTenancy } from '@/lib/services/berth-tenancies.service';
|
||||||
|
import { assertTenanciesModuleEnabled } from '@/lib/services/tenancies-module.service';
|
||||||
|
import { transferTenancySchema } from '@/lib/validators/tenancies';
|
||||||
|
|
||||||
|
const transferHandler: RouteHandler = async (req, ctx, params) => {
|
||||||
|
try {
|
||||||
|
await assertTenanciesModuleEnabled(ctx.portId);
|
||||||
|
const body = await parseBody(req, transferTenancySchema);
|
||||||
|
const result = await transferTenancy(params.id!, ctx.portId, body, {
|
||||||
|
userId: ctx.userId,
|
||||||
|
portId: ctx.portId,
|
||||||
|
ipAddress: ctx.ipAddress,
|
||||||
|
userAgent: ctx.userAgent,
|
||||||
|
});
|
||||||
|
return NextResponse.json({ data: result });
|
||||||
|
} catch (error) {
|
||||||
|
return errorResponse(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const POST = withAuth(withPermission('tenancies', 'manage', transferHandler));
|
||||||
@@ -4,7 +4,17 @@ import { useState } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { Route } from 'next';
|
import type { Route } from 'next';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ArrowLeft, Bell, Download, FileSignature, Mail, Pencil, StopCircle } from 'lucide-react';
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
|
Bell,
|
||||||
|
Download,
|
||||||
|
FileSignature,
|
||||||
|
Mail,
|
||||||
|
Pencil,
|
||||||
|
RefreshCw,
|
||||||
|
StopCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -25,6 +35,8 @@ import { apiFetch } from '@/lib/api/client';
|
|||||||
import { toastError } from '@/lib/api/toast-error';
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
import { ClientLink, YachtLink, BerthLink } from '@/components/tenancies/tenancy-list';
|
import { ClientLink, YachtLink, BerthLink } from '@/components/tenancies/tenancy-list';
|
||||||
import { TenancyEditDialog } from '@/components/tenancies/tenancy-edit-dialog';
|
import { TenancyEditDialog } from '@/components/tenancies/tenancy-edit-dialog';
|
||||||
|
import { TenancyRenewDialog } from '@/components/tenancies/tenancy-renew-dialog';
|
||||||
|
import { TenancyTransferDialog } from '@/components/tenancies/tenancy-transfer-dialog';
|
||||||
|
|
||||||
interface TenancyDoc {
|
interface TenancyDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -121,6 +133,8 @@ interface TenancyDetailProps {
|
|||||||
export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
||||||
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
const [endDialogOpen, setEndDialogOpen] = useState(false);
|
||||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||||
|
const [renewDialogOpen, setRenewDialogOpen] = useState(false);
|
||||||
|
const [transferDialogOpen, setTransferDialogOpen] = useState(false);
|
||||||
const tenancy = useQuery<{ data: TenancyData }>({
|
const tenancy = useQuery<{ data: TenancyData }>({
|
||||||
queryKey: ['tenancy', tenancyId],
|
queryKey: ['tenancy', tenancyId],
|
||||||
queryFn: () => apiFetch(`/api/v1/tenancies/${tenancyId}`),
|
queryFn: () => apiFetch(`/api/v1/tenancies/${tenancyId}`),
|
||||||
@@ -292,10 +306,20 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
|||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
{res.status === 'active' && (
|
{res.status === 'active' && (
|
||||||
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
|
<>
|
||||||
<StopCircle className="mr-1.5 h-4 w-4" aria-hidden />
|
<Button variant="outline" size="sm" onClick={() => setRenewDialogOpen(true)}>
|
||||||
End tenancy
|
<RefreshCw className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
</Button>
|
Renew
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setTransferDialogOpen(true)}>
|
||||||
|
<ArrowRightLeft className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setEndDialogOpen(true)}>
|
||||||
|
<StopCircle className="mr-1.5 h-4 w-4" aria-hidden />
|
||||||
|
End tenancy
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
<Button asChild variant="outline">
|
<Button asChild variant="outline">
|
||||||
<Link href={`/${portSlug}/berths`}>
|
<Link href={`/${portSlug}/berths`}>
|
||||||
@@ -370,6 +394,18 @@ export function TenancyDetail({ tenancyId, portSlug }: TenancyDetailProps) {
|
|||||||
initialTenureType={res.tenureType}
|
initialTenureType={res.tenureType}
|
||||||
initialNotes={res.notes}
|
initialNotes={res.notes}
|
||||||
/>
|
/>
|
||||||
|
<TenancyRenewDialog
|
||||||
|
tenancyId={tenancyId}
|
||||||
|
open={renewDialogOpen}
|
||||||
|
onOpenChange={setRenewDialogOpen}
|
||||||
|
tenureType={res.tenureType}
|
||||||
|
currentEndDate={res.endDate}
|
||||||
|
/>
|
||||||
|
<TenancyTransferDialog
|
||||||
|
tenancyId={tenancyId}
|
||||||
|
open={transferDialogOpen}
|
||||||
|
onOpenChange={setTransferDialogOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
125
src/components/tenancies/tenancy-renew-dialog.tsx
Normal file
125
src/components/tenancies/tenancy-renew-dialog.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
const PERMANENT = new Set(['permanent', 'fee_simple', 'strata_lot']);
|
||||||
|
|
||||||
|
interface TenancyRenewDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
tenancyId: string;
|
||||||
|
tenureType: string;
|
||||||
|
currentEndDate: string | Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toIso(d: string | Date | null | undefined): string {
|
||||||
|
if (!d) return '';
|
||||||
|
return (d instanceof Date ? d : new Date(d)).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenancyRenewDialog(props: TenancyRenewDialogProps) {
|
||||||
|
return props.open ? <Inner {...props} key={String(props.open)} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
tenancyId,
|
||||||
|
tenureType,
|
||||||
|
currentEndDate,
|
||||||
|
}: TenancyRenewDialogProps) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const today = new Date();
|
||||||
|
const isPermanent = PERMANENT.has(tenureType);
|
||||||
|
const [newStartDate, setNewStartDate] = useState(toIso(currentEndDate ?? today));
|
||||||
|
const [newEndDate, setNewEndDate] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
return apiFetch(`/api/v1/tenancies/${tenancyId}/renew`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
newStartDate,
|
||||||
|
...(newEndDate ? { newEndDate } : {}),
|
||||||
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tenancy', tenancyId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['tenancies'] });
|
||||||
|
toast.success(isPermanent ? 'Tenancy renewed in place' : 'Renewal created');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Renew tenancy</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{isPermanent
|
||||||
|
? 'Permanent tenancies are renewed in place — start and end dates update on the existing row.'
|
||||||
|
: 'A new tenancy row will be minted with previousTenancyId pointing to this one. The current row is ended at its existing end date.'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="renew-start">New start date</Label>
|
||||||
|
<DatePicker id="renew-start" value={newStartDate} onChange={setNewStartDate} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="renew-end">
|
||||||
|
New end date
|
||||||
|
{isPermanent ? (
|
||||||
|
<span className="ml-1 text-xs font-normal text-muted-foreground">(optional)</span>
|
||||||
|
) : (
|
||||||
|
<span className="ml-1 text-xs font-normal text-destructive">(required)</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
<DatePicker id="renew-end" value={newEndDate} onChange={setNewEndDate} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="renew-notes">Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="renew-notes"
|
||||||
|
rows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />}
|
||||||
|
Renew
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
116
src/components/tenancies/tenancy-transfer-dialog.tsx
Normal file
116
src/components/tenancies/tenancy-transfer-dialog.tsx
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { DatePicker } from '@/components/ui/date-picker';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { ClientPicker } from '@/components/shared/client-picker';
|
||||||
|
import { YachtPicker } from '@/components/yachts/yacht-picker';
|
||||||
|
import { apiFetch } from '@/lib/api/client';
|
||||||
|
import { toastError } from '@/lib/api/toast-error';
|
||||||
|
|
||||||
|
interface TenancyTransferDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
tenancyId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TenancyTransferDialog(props: TenancyTransferDialogProps) {
|
||||||
|
return props.open ? <Inner {...props} key={String(props.open)} /> : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Inner({ open, onOpenChange, tenancyId }: TenancyTransferDialogProps) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [newClientId, setNewClientId] = useState<string | null>(null);
|
||||||
|
const [newYachtId, setNewYachtId] = useState<string | null>(null);
|
||||||
|
const [transferDate, setTransferDate] = useState(new Date().toISOString().slice(0, 10));
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
if (!newClientId) throw new Error('Pick a new client');
|
||||||
|
if (!newYachtId) throw new Error('Pick a new yacht');
|
||||||
|
return apiFetch(`/api/v1/tenancies/${tenancyId}/transfer`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
newClientId,
|
||||||
|
newYachtId,
|
||||||
|
transferDate,
|
||||||
|
...(notes.trim() ? { notes: notes.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['tenancy', tenancyId] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['tenancies'] });
|
||||||
|
toast.success('Tenancy transferred');
|
||||||
|
onOpenChange(false);
|
||||||
|
},
|
||||||
|
onError: (err) => toastError(err),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Transfer tenancy</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Hand this berth to a new client + yacht. The current tenancy is ended at the transfer
|
||||||
|
date; a new active row is created with transferredFromTenancyId pointing back.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>New client</Label>
|
||||||
|
<ClientPicker value={newClientId} onChange={setNewClientId} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>New yacht</Label>
|
||||||
|
<YachtPicker
|
||||||
|
value={newYachtId}
|
||||||
|
onChange={setNewYachtId}
|
||||||
|
ownerFilter={newClientId ? { type: 'client', id: newClientId } : undefined}
|
||||||
|
disabled={!newClientId}
|
||||||
|
placeholder={newClientId ? 'Select yacht...' : 'Pick a client first'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="transfer-date">Transfer date</Label>
|
||||||
|
<DatePicker id="transfer-date" value={transferDate} onChange={setTransferDate} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="transfer-notes">Notes (optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="transfer-notes"
|
||||||
|
rows={2}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" aria-hidden />}
|
||||||
|
Transfer
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import type {
|
|||||||
EndTenancyInput,
|
EndTenancyInput,
|
||||||
CancelInput,
|
CancelInput,
|
||||||
ListTenanciesInput,
|
ListTenanciesInput,
|
||||||
|
RenewTenancyInput,
|
||||||
|
TransferTenancyInput,
|
||||||
UpdateTenancyInput,
|
UpdateTenancyInput,
|
||||||
} from '@/lib/validators/tenancies';
|
} from '@/lib/validators/tenancies';
|
||||||
|
|
||||||
@@ -404,6 +406,218 @@ export async function updateTenancy(
|
|||||||
return updated;
|
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 ─────────────────────────────────────────────────────────────────────
|
// ─── Get ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function getById(id: string, portId: string): Promise<BerthTenancy> {
|
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' });
|
.refine((d) => Object.keys(d).length > 0, { message: 'At least one field must be provided' });
|
||||||
export type UpdateTenancyInput = z.infer<typeof updateTenancySchema>;
|
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({
|
export const listTenanciesSchema = baseListQuerySchema.extend({
|
||||||
status: z.enum(TENANCY_STATUSES).optional(),
|
status: z.enum(TENANCY_STATUSES).optional(),
|
||||||
berthId: z.string().optional(),
|
berthId: z.string().optional(),
|
||||||
|
|||||||
Reference in New Issue
Block a user