diff --git a/app/components/WorkflowChip.tsx b/app/components/WorkflowChip.tsx index 04adac309..f9a1c71c8 100644 --- a/app/components/WorkflowChip.tsx +++ b/app/components/WorkflowChip.tsx @@ -1,7 +1,6 @@ type WorkflowState = | "agreement" | "payment" - | "apprentice-review" | "published" | "cancelled" | "draft"; @@ -9,7 +8,6 @@ type WorkflowState = const STATE_LABELS: Record = { agreement: "Agreement", payment: "Payment", - "apprentice-review": "Apprentice review", published: "Published", cancelled: "Cancelled", draft: "Draft", @@ -18,7 +16,6 @@ const STATE_LABELS: Record = { const STATE_TONES: Record = { agreement: { bg: "bg-ih-watch-bg", text: "text-ih-watch-fg" }, payment: { bg: "bg-ih-info-bg", text: "text-ih-info-fg" }, - "apprentice-review": { bg: "bg-ih-watch-bg", text: "text-ih-watch-fg" }, published: { bg: "bg-ih-ok-bg", text: "text-ih-ok-fg" }, cancelled: { bg: "bg-ih-bad-bg", text: "text-ih-bad-fg" }, draft: { bg: "bg-ih-bg-muted", text: "text-ih-fg-3" }, diff --git a/app/components/modals/InviteSeatModal.tsx b/app/components/modals/InviteSeatModal.tsx index 85978ecad..2df751f9d 100644 --- a/app/components/modals/InviteSeatModal.tsx +++ b/app/components/modals/InviteSeatModal.tsx @@ -1,40 +1,58 @@ import { useState, useEffect } from "react"; import { useFetcher } from "react-router"; +import { getCapabilities, TOGGLEABLE, type Capability, type CapabilitySet, type PermissionOverrides } from "../../../server/lib/auth/capabilities"; -type Mode = "permanent" | "guest"; -type Role = "lead" | "specialist" | "apprentice" | "office"; +type Role = "owner" | "manager" | "inspector" | "agent"; const ROLE_DESC: Record = { - lead: "Full access to inspections, templates, and team management.", - specialist: "Access to assigned sections only.", - apprentice: "Supervised access — requires mentor approval before publishing.", - office: "Dashboard, scheduling, and billing. No inspection editing.", + owner: "Full access, including billing and ownership transfer.", + manager: "Full access to inspections, templates, and team management.", + inspector: "Create and edit inspections they're assigned to.", + agent: "Read-only buyer-agent view.", }; -const DURATIONS = [ - { seconds: 86400, label: "1 day", price: "$1.49" }, - { seconds: 259200, label: "3 days", price: "$4.47" }, - { seconds: 604800, label: "7 days", price: "$10.43" }, -] as const; +/** Advanced-permissions toggle labels, in TOGGLEABLE order. */ +export const CAP_LABELS: Record = { + publish: "Publish reports", + scheduleOthers: "Schedule for others", + financial: "Financial data", + manageContacts: "Manage contacts", +}; + +/** + * Reduce the edited capability set to only the toggles that differ from the + * role's template default (happy-dom has no render harness, so the submit + * logic lives here and is unit-tested directly — see invite-overrides.spec). + */ +export function computeOverrideDiff(role: Role, caps: CapabilitySet): PermissionOverrides { + const template = getCapabilities(role, null); + const diff: PermissionOverrides = {}; + for (const cap of TOGGLEABLE) { + if (caps[cap] !== template[cap]) diff[cap] = caps[cap]; + } + return diff; +} interface InviteSeatModalProps { open: boolean; onClose: () => void; - leads?: Array<{ id: string; email: string }>; - sections?: Array<{ id: string; name: string }>; } -export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: InviteSeatModalProps) { - const [mode, setMode] = useState("permanent"); +export function InviteSeatModal({ open, onClose }: InviteSeatModalProps) { const [email, setEmail] = useState(""); const [notify, setNotify] = useState(true); - const [role, setRole] = useState("lead"); - const [mentorId, setMentorId] = useState(""); - const [sectionIds, setSectionIds] = useState([]); - const [durationSeconds, setDurationSeconds] = useState(86400); - const [generatedUrl, setGeneratedUrl] = useState(""); + const [role, setRole] = useState("inspector"); + const [advancedOpen, setAdvancedOpen] = useState(false); + // Effective capability set the toggles render. Re-derived from the role + // template whenever the role changes (so the disclosure always shows the + // selected role's defaults until the inviter edits them). + const [caps, setCaps] = useState(() => getCapabilities("inspector", null)); const [error, setError] = useState(""); + useEffect(() => { + setCaps(getCapabilities(role, null)); + }, [role]); + const inviteFetcher = useFetcher<{ ok: boolean; intent?: string | null; error: string | null; url: string | null }>(); const submitting = inviteFetcher.state !== "idle"; @@ -47,36 +65,22 @@ export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: In } if (d.intent === "invite") { onClose(); - } else if (d.intent === "guest-invite" && d.url) { - setGeneratedUrl(d.url); } }, [inviteFetcher.data, onClose]); if (!open) return null; - function toggleSection(id: string) { - setSectionIds((prev) => prev.includes(id) ? prev.filter((s) => s !== id) : [...prev, id]); - } - function submitPermanent() { if (submitting) return; setError(""); + // Only send capabilities that differ from the role template; the server + // re-diffs and stores null when nothing differs. + const diff = computeOverrideDiff(role, caps); const fd = new FormData(); fd.append("intent", "invite"); fd.append("email", email); fd.append("role", role); - if (mentorId) fd.append("mentorId", mentorId); - if (sectionIds.length > 0) fd.append("assignedSectionIds", JSON.stringify(sectionIds)); - inviteFetcher.submit(fd, { method: "POST", action: "/resources/team-members" }); - } - - function submitGuest() { - if (submitting) return; - setError(""); - const fd = new FormData(); - fd.append("intent", "guest-invite"); - fd.append("role", role); - fd.append("durationSeconds", String(durationSeconds)); + if (Object.keys(diff).length > 0) fd.append("permissionOverrides", JSON.stringify(diff)); inviteFetcher.submit(fd, { method: "POST", action: "/resources/team-members" }); } @@ -85,17 +89,9 @@ export function InviteSeatModal({ open, onClose, leads = [], sections = [] }: In
e.stopPropagation()}>

Invite

-
- {(["permanent", "guest"] as const).map((m) => ( - - ))} -
- {mode === "permanent" && (
- )}

{ROLE_DESC[role]}

- {role === "apprentice" && ( - - )} - - {role === "specialist" && ( -
- Assigned sections -
- {sections.length === 0 ? ( -

No template sections loaded yet.

- ) : sections.map((s) => ( -
- {mode === "permanent" && ( - )} - {mode === "guest" && !generatedUrl && ( - - )}
diff --git a/app/components/team/RosterPopover.tsx b/app/components/team/RosterPopover.tsx index df6d8e071..bed915ab9 100644 --- a/app/components/team/RosterPopover.tsx +++ b/app/components/team/RosterPopover.tsx @@ -12,10 +12,9 @@ interface RosterPopoverProps { roster: RosterMember[]; onClose: () => void; onInvitePermanent?: () => void; - onInviteGuest?: () => void; } -export function RosterPopover({ open, roster, onClose, onInvitePermanent, onInviteGuest }: RosterPopoverProps) { +export function RosterPopover({ open, roster, onClose, onInvitePermanent }: RosterPopoverProps) { const ref = useRef(null); useEffect(() => { @@ -59,7 +58,6 @@ export function RosterPopover({ open, roster, onClose, onInvitePermanent, onInvi
-
diff --git a/app/components/team/TeamBanner.tsx b/app/components/team/TeamBanner.tsx index 66298b732..55084a3ce 100644 --- a/app/components/team/TeamBanner.tsx +++ b/app/components/team/TeamBanner.tsx @@ -18,7 +18,7 @@ export function TeamBanner({ show, members, onManage }: TeamBannerProps) { Team mode
{members.map((m) => ( -
+
{(m.name || m.id || "?").slice(0, 2).toUpperCase()}
))} diff --git a/app/lib/api-client.server.ts b/app/lib/api-client.server.ts index f2ad30e3f..f930ee7f0 100644 --- a/app/lib/api-client.server.ts +++ b/app/lib/api-client.server.ts @@ -23,7 +23,6 @@ import type { EventsApi, EmailTemplatesApi, EvidenceApi, - GuestApi, IdentityApi, InspectionPrefsApi, InspectionRequestsApi, @@ -127,7 +126,6 @@ export interface Api { events: ReturnType>; emailTemplates: ReturnType>; evidence: ReturnType>; - guest: ReturnType>; identity: ReturnType>; inspectionPrefs: ReturnType>; inspectionRequests: ReturnType>; @@ -191,7 +189,6 @@ const MOUNT: Record = { events: "/api", emailTemplates: "/api/admin", evidence: "/api/admin", - guest: "/api/guest", identity: "/api/identities", inspectionPrefs: "/api/tenant/inspection-prefs", inspectionRequests: "/api/inspection-requests", @@ -273,7 +270,6 @@ export function createApi(context: AppLoadContext, opts: CreateApiOptions = {}): events: mk(MOUNT.events), emailTemplates: mk(MOUNT.emailTemplates), evidence: mk(MOUNT.evidence), - guest: mk(MOUNT.guest), identity: mk(MOUNT.identity), inspectionPrefs: mk(MOUNT.inspectionPrefs), inspectionRequests: mk(MOUNT.inspectionRequests), diff --git a/app/lib/forms/auth.schema.ts b/app/lib/forms/auth.schema.ts index 21cf6b1f8..1077e70bc 100644 --- a/app/lib/forms/auth.schema.ts +++ b/app/lib/forms/auth.schema.ts @@ -61,19 +61,6 @@ export const joinSchema = z.object({ export type JoinInput = z.infer; -/** - * Guest-collaborator accept (`/guest-join`). Token comes from the URL. The - * guest creates a real (role-scoped, time-limited) account, so the form - * collects name + email + password — matching the API's POST /api/guest/claim. - */ -export const guestJoinSchema = z.object({ - name: z.string().min(1, "Name is required").max(100, "Name is too long"), - email: z.string().email("Enter a valid email address"), - password: z.string().min(8, "Password must be at least 8 characters").max(128, "Password is too long"), -}); - -export type GuestJoinInput = z.infer; - /** * Partner-agent invite accept (`/agent-invite/accept`). Token + email come from * the invite (email is read-only), so only name + password are validated. diff --git a/app/routes.ts b/app/routes.ts index c0d528446..cdfd86df7 100644 --- a/app/routes.ts +++ b/app/routes.ts @@ -62,7 +62,6 @@ export default [ route("setup", "routes/setup.tsx"), route("inspections/:id/form", "routes/form-renderer.tsx"), route("join/:token", "routes/join.tsx"), - route("guest-join/:token", "routes/guest-join.tsx"), route("conflict-resolver/:id", "routes/conflict-resolver.tsx"), route("version-diff/:id", "routes/version-diff.tsx"), // Standalone public — no layout (iframe-friendly) @@ -108,7 +107,6 @@ export default [ route("templates", "routes/templates.tsx"), route("team", "routes/team.tsx"), route("metrics", "routes/metrics.tsx"), - route("apprentice-review", "routes/apprentice-review.tsx"), route("reports", "routes/reports-redirect.tsx"), layout("routes/settings-layout.tsx", [ route("settings", "routes/settings-hub.tsx"), diff --git a/app/routes/apprentice-review.tsx b/app/routes/apprentice-review.tsx deleted file mode 100644 index c1b4fa799..000000000 --- a/app/routes/apprentice-review.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { useState } from "react"; -import { useLoaderData } from "react-router"; -import type { Route } from "./+types/apprentice-review"; -import { requireToken } from "~/lib/session.server"; -import { createApi } from "~/lib/api-client.server"; -import { PageHeader } from "@core/shared-ui"; - -export function meta() { - return [{ title: "Apprentice Review - OpenInspection" }]; -} - -interface ReviewItem { - id: string; - apprenticeName: string; - inspectionId: string; - inspectionAddress: string | null; - itemId: string; - field: string; - proposedValue: string | null; - submittedAt: string; - decision: string | null; -} - -export async function loader({ request, context }: Route.LoaderArgs) { - const token = await requireToken(context, request); - try { - const api = createApi(context, { token }); - const res = await api.team["apprentice-reviews"].$get(); - const body = res.ok ? ((await res.json()) as Record) : { data: [] }; - return { items: (body.data ?? []) as ReviewItem[] }; - } catch { - return { items: [] as ReviewItem[] }; - } -} - -function initials(name: string | null): string { - if (!name) return "?"; - const parts = name.trim().split(/\s+/); - if (parts.length === 1) return (parts[0]?.[0] ?? "?").toUpperCase(); - return ((parts[0]?.[0] ?? "") + (parts[parts.length - 1]?.[0] ?? "")).toUpperCase(); -} - -function shortAddress(addr: string | null): string { - if (!addr) return "No address"; - return addr.length > 30 ? addr.slice(0, 30) + "..." : addr; -} - -export default function ApprenticeReviewPage() { - const { items } = useLoaderData(); - const [activeId, setActiveId] = useState(items[0]?.id ?? null); - - const pendingCount = items.filter((i) => !i.decision).length; - const active = items.find((i) => i.id === activeId) ?? null; - - return ( -
- - - {/* Status banner */} -
- - {pendingCount === 0 ? : } - -
-

- {pendingCount === 0 ? "All caught up" : `${pendingCount} apprentice ${pendingCount === 1 ? "rating" : "ratings"} awaiting review`} -

-

- Items flow through here before they appear in the published report. -

-
-
- - {items.length === 0 ? ( -
-

Nothing to review

-

Apprentice ratings appear here when they are submitted.

-
- ) : ( -
- {/* Queue list */} - - - {/* Review pane */} - {active ? ( -
-
-
- - {initials(active.apprenticeName)} - - {active.apprenticeName} - submitted {active.submittedAt} -
-

{active.itemId}

-

Field: {active.field}

-
- -
-
-

Apprentice proposed

-
-                    {active.proposedValue || "—"}
-                  
-
- - {active.decision && ( -
- Decision recorded: {active.decision} -
- )} -
- - {!active.decision && ( -
-

- Approve to publish as-is. Reject sends back to the apprentice. -

- - -
- )} -
- ) : ( -
-

Select an item from the queue.

-
- )} -
- )} -
- ); -} - -function CheckIcon() { - return ( - - - - ); -} - -function InfoIcon() { - return ( - - - - ); -} - -function CheckSmallIcon() { - return ( - - - - ); -} diff --git a/app/routes/dashboard.tsx b/app/routes/dashboard.tsx index 6dc99a261..69ab78297 100644 --- a/app/routes/dashboard.tsx +++ b/app/routes/dashboard.tsx @@ -250,7 +250,7 @@ export async function loader({ request, context }: Route.LoaderArgs) { svcOptions = (sj.data ?? []).map((s) => ({ id: s.id, name: s.name, price: s.price })); } // B-21 team step — non-admins get 403 → null → []; team step hidden for them. - const schedulingRoles = new Set(["owner", "admin", "inspector", "lead"]); + const schedulingRoles = new Set(["owner", "manager", "inspector"]); let teamMembers: WizardTeamMember[] = []; if (membersRes?.ok) { const mb = (await membersRes.json()) as { data?: Array<{ id: string; email: string; role: string; name?: string | null }> }; diff --git a/app/routes/guest-join.tsx b/app/routes/guest-join.tsx deleted file mode 100644 index 0e77e91d1..000000000 --- a/app/routes/guest-join.tsx +++ /dev/null @@ -1,211 +0,0 @@ -import { Form, useActionData, useLoaderData, useNavigation, redirect } from "react-router"; -import { useForm } from "@conform-to/react"; -import { parseWithZod } from "@conform-to/zod/v4"; -import type { Route } from "./+types/guest-join"; -import { createApi } from "~/lib/api-client.server"; -import { createSessionWithToken } from "~/lib/session.server"; -import { guestJoinSchema } from "~/lib/forms/auth.schema"; -import { readLegalLinks } from "~/lib/legal-links.server"; -import { LegalCheckbox } from "~/components/LegalCheckbox"; - -export function meta() { - return [{ title: "Join as Guest - OpenInspection" }]; -} - -export async function loader({ request, context }: Route.LoaderArgs) { - const url = new URL(request.url); - const token = url.searchParams.get("token") || ""; - - const legal = readLegalLinks(context); - - if (!token) { - return { valid: false, error: "Missing invite token", invite: null, legal }; - } - - try { - - const api = createApi(context); - const res = await api.guest["invite-info"].$get({ query: { token } }); - if (!res.ok) { - return { valid: false, error: "Invalid or expired guest link", invite: null, legal }; - } - const body = await res.json(); - const d = ((body as Record).data ?? {}) as Record; - return { - valid: true, - error: null, - invite: (Object.keys(d).length > 0 ? d : null) as { workspaceName: string; role: string; expiresAt: number } | null, - legal, - }; - } catch { - return { valid: false, error: "Service unavailable", invite: null, legal }; - } -} - -export async function action({ request, context }: Route.ActionArgs) { - const formData = await request.formData(); - // Token rides along as a hidden field (sourced from the URL), NOT a schema - // field — guests only set a display name (passwordless). - const token = String(formData.get("token") || ""); - const submission = parseWithZod(formData, { schema: guestJoinSchema }); - if (submission.status !== "success") { - return submission.reply(); - } - const { name, email, password } = submission.value; - - try { - - const api = createApi(context); - const res = await api.guest.claim.$post({ - json: { token, name, email, password, termsAccepted: formData.get("termsAccepted") === "on" }, - }); - - if (!res.ok) { - const body = await res.json().catch(() => ({})); - const message = - (body as Record>)?.error?.message ?? - "Could not join. The link may have expired."; - return submission.reply({ formErrors: [message] }); - } - - const setCookieHeader = res.headers.get("set-cookie") || ""; - const tokenMatch = setCookieHeader.match( - /(?:inspector_token|__Host-inspector_token)=([^;]+)/, - ); - const jwt = tokenMatch?.[1]; - - if (jwt) { - - - return createSessionWithToken(context, jwt, "/dashboard"); - } - - return redirect("/dashboard"); - } catch { - return submission.reply({ formErrors: ["Network error — is the API server running?"] }); - } -} - -export default function GuestJoinPage() { - const { valid, error: loaderError, invite, legal } = useLoaderData(); - const lastResult = useActionData(); - const navigation = useNavigation(); - const isSubmitting = navigation.state === "submitting"; - - const [form, fields] = useForm({ - lastResult, - onValidate({ formData }) { - return parseWithZod(formData, { schema: guestJoinSchema }); - }, - shouldValidate: "onBlur", - shouldRevalidate: "onInput", - }); - - if (!valid) { - return ( -
-
-

- Link Unavailable -

-

{loaderError}

-
-
- ); - } - - return ( -
-
-
- - - OpenInspection - -
- -

- Join as a guest -

-

- {invite - ? `You've been invited to join ${invite.workspaceName} as a ${invite.role}. Create your account below.` - : "You have been invited to collaborate. Create your account below."} -

- -
- -
- - - {fields.name.errors && ( -

{fields.name.errors[0]}

- )} -
- -
- - - {fields.email.errors && ( -

{fields.email.errors[0]}

- )} -
- -
- - - {fields.password.errors && ( -

{fields.password.errors[0]}

- )} -
- - {form.errors && ( -
- {form.errors[0]} -
- )} - - {legal && } - - - -
-
- ); -} diff --git a/app/routes/resources/team-members.tsx b/app/routes/resources/team-members.tsx index 6aa4d02ec..1abe3de77 100644 --- a/app/routes/resources/team-members.tsx +++ b/app/routes/resources/team-members.tsx @@ -2,7 +2,7 @@ * C-12 — BFF resource route for TeamStrip / InviteSeatModal components. * * loader: GET /api/team/members — returns active members and pending invites - * action: invite | guest-invite — proxies POST /api/team/invite and /api/team/guests + * action: invite — proxies POST /api/team/invite */ import type { Route } from "./+types/team-members"; import { getToken, requireToken } from "~/lib/session.server"; @@ -37,15 +37,26 @@ export async function action({ request, context }: Route.ActionArgs) { if (intent === "invite") { const email = fd.get("email") as string | null; const role = (fd.get("role") ?? "inspector") as string; - const mentorId = (fd.get("mentorId") as string | null) || undefined; - const sectionIdsRaw = fd.get("assignedSectionIds") as string | null; - const assignedSectionIds = sectionIdsRaw ? (JSON.parse(sectionIdsRaw) as string[]) : undefined; if (!email) return { ok: false, intent, error: "Email is required", url: null }; + // Advanced-permissions disclosure ships a JSON map of the capability + // diffs vs the role template. Absent/empty → pure role template. + let permissionOverrides: Record | undefined; + const rawOverrides = fd.get("permissionOverrides"); + if (typeof rawOverrides === "string" && rawOverrides.trim()) { + try { + const parsed = JSON.parse(rawOverrides) as Record; + if (parsed && Object.keys(parsed).length > 0) permissionOverrides = parsed; + } catch { + // Ignore malformed override payloads — the server re-derives from + // the role template, so dropping them fails safe. + } + } + try { const res = await api.team.invite.$post({ - json: { email, role, mentorId, assignedSectionIds } as Parameters[0]["json"], + json: { email, role, permissionOverrides } as Parameters[0]["json"], }); if (!res.ok) { const body = await res.json().catch(() => ({})) as { error?: string }; @@ -57,21 +68,5 @@ export async function action({ request, context }: Route.ActionArgs) { } } - if (intent === "guest-invite") { - const role = (fd.get("role") ?? "lead") as string; - const durationSeconds = Number(fd.get("durationSeconds") ?? 86400); - - try { - const res = await api.team.guests.$post({ - json: { role, durationSeconds } as Parameters[0]["json"], - }); - if (!res.ok) return { ok: false, intent, error: `HTTP ${res.status}`, url: null }; - const body = await res.json() as { data?: { url?: string } }; - return { ok: true, intent, error: null, url: body?.data?.url ?? null }; - } catch (e) { - return { ok: false, intent, error: e instanceof Error ? e.message : "Failed", url: null }; - } - } - return { ok: false, intent, error: "Unknown intent", url: null }; } diff --git a/app/routes/settings-billing.tsx b/app/routes/settings-billing.tsx index 78904a57e..6721c266c 100644 --- a/app/routes/settings-billing.tsx +++ b/app/routes/settings-billing.tsx @@ -15,7 +15,6 @@ interface BillingSummary { seatsUsed?: number; maxUsers?: number | null; permanent?: number; - guests?: number; } /* ------------------------------------------------------------------ */ @@ -56,7 +55,6 @@ export default function SettingsBillingPage() { seatsUsed = 0, maxUsers, permanent = 0, - guests = 0, } = billing; return ( @@ -87,7 +85,7 @@ export default function SettingsBillingPage() {

Self-hosted · no subscription

- This deployment runs in standalone mode. No per-seat charge, no Stripe. Add as many inspectors, apprentices, and guests as you need. + This deployment runs in standalone mode. No per-seat charge, no Stripe. Add as many inspectors as you need.

@@ -117,7 +115,7 @@ export default function SettingsBillingPage() {
{/* Seat breakdown */} -
+
{hasSeatQuota ? "Seats used" : "Active members"} @@ -133,10 +131,6 @@ export default function SettingsBillingPage() {
Permanent
{permanent}
-
-
Active guests
-
{guests}
-
@@ -153,7 +147,7 @@ export default function SettingsBillingPage() {

Workspace capacity

No quotas in standalone mode — these are informational.

-
+
Active members
{seatsUsed}
@@ -162,10 +156,6 @@ export default function SettingsBillingPage() {
Permanent
{permanent}
-
-
Active guests
-
{guests}
-
@@ -189,12 +179,7 @@ export default function SettingsBillingPage() {
{permanent} permanent inspector seat{permanent !== 1 ? "s" : ""} · $29.99 each
{fmtMoney(permanent * 29.99)}
- {guests > 0 && ( -
-
{guests} active guest{guests !== 1 ? "s" : ""} · $1.49 / day each
-
billed on use
-
- )} +
Approximate seat charges this month
{fmtMoney(permanent * 29.99)}
@@ -255,7 +240,7 @@ export default function SettingsBillingPage() { )}
Add a seat
- Add a permanent inspector or generate a guest invite link in{" "} + Add an inspector in{" "} Team settings.
diff --git a/app/routes/settings-booking.tsx b/app/routes/settings-booking.tsx index 4d443a560..387d8aed0 100644 --- a/app/routes/settings-booking.tsx +++ b/app/routes/settings-booking.tsx @@ -174,13 +174,12 @@ export default function SettingsBookingPage() { const slug = ctx?.branding?.currentUserSlug; // The current user's id is not in session context — we use role+members for the picker. // Admin/owner role means members loaded successfully (403 gate for non-admins). - const isAdmin = ctx?.user?.role === "owner" || ctx?.user?.role === "admin"; + const isAdmin = ctx?.user?.role === "owner" || ctx?.user?.role === "manager"; // Show picker only to admins; restrict to the roles that can hold a - // schedule ('lead' is the canonical alias of 'inspector' — see rbac.ts - // ROLE_ALIASES; the availability API accepts both). + // schedule. const pickerMembers = isAdmin - ? data.members.filter((m) => ['owner', 'admin', 'inspector', 'lead'].includes(m.role)) + ? data.members.filter((m) => ['owner', 'manager', 'inspector'].includes(m.role)) : []; return ( diff --git a/app/routes/settings-services.tsx b/app/routes/settings-services.tsx index e2089c0ed..0adaff5d9 100644 --- a/app/routes/settings-services.tsx +++ b/app/routes/settings-services.tsx @@ -35,7 +35,7 @@ interface Member { } // Scheduling roles that may be restricted per-service. -const SCHEDULING_ROLES = new Set(["owner", "admin", "inspector", "lead"]); +const SCHEDULING_ROLES = new Set(["owner", "manager", "inspector"]); export async function loader({ request, context }: Route.LoaderArgs) { const token = await requireToken(context, request); diff --git a/app/routes/team.tsx b/app/routes/team.tsx index e9cb9f641..1bc4c5651 100644 --- a/app/routes/team.tsx +++ b/app/routes/team.tsx @@ -38,11 +38,10 @@ export async function loader({ request, context }: Route.LoaderArgs) { const ROLE_TONES: Record = { owner: "primary", - admin: "info", + manager: "info", inspector: "neutral", lead: "info", specialist: "sat", - apprentice: "monitor", agent: "warning", office: "gen", }; @@ -50,8 +49,6 @@ const ROLE_TONES: Record m.role === "lead").map((m) => ({ id: m.id, email: m.email })); - const filtered = members.filter((m) => { - if (activeTab === "active") return m.status !== "pending" && m.role !== "apprentice"; + if (activeTab === "active") return m.status !== "pending"; if (activeTab === "pending") return m.status === "pending"; - if (activeTab === "apprentices") return m.role === "apprentice"; - if (activeTab === "guests") return m.role === "guest"; return true; }); @@ -89,7 +82,7 @@ export default function TeamPage() { } /> - setInviteOpen(false)} leads={leads} /> + setInviteOpen(false)} /> @@ -150,10 +143,10 @@ export default function TeamPage() {

Roles

{[ - { role: "Lead inspector", desc: "Full edit, can publish, approves apprentice ratings." }, - { role: "Specialist", desc: "Full edit within their assigned sections." }, - { role: "Apprentice", desc: "Edits route through the lead's review queue before publish." }, - { role: "Office staff", desc: "Read-only access to inspections and scheduling." }, + { role: "Owner", desc: "Account holder. Full access, including billing." }, + { role: "Manager", desc: "Back office: team, settings, scheduling, and all inspections." }, + { role: "Inspector", desc: "Conducts inspections; edits and publishes reports." }, + { role: "Agent", desc: "External agent. Read-only access to their own orders." }, ].map((r) => (

{r.role}

diff --git a/docs/getting-started.md b/docs/getting-started.md index 7174a5b67..f169ebdd5 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -113,6 +113,7 @@ Browser → single Worker (Hono entry): | Doc | Topic | |---|---| +| [`migrating-roles.md`](migrating-roles.md) | Mapping Spectora / ISN roles to OpenInspection's 4 roles + toggles | | [`01_architecture.md`](developers/01_architecture.md) | Single-worker architecture, request flow | | [`02_deploy.md`](developers/02_deploy.md) | Production deployment on Cloudflare | | [`03_api_reference.md`](developers/03_api_reference.md) | API endpoints and auth patterns | diff --git a/docs/migrating-roles.md b/docs/migrating-roles.md new file mode 100644 index 000000000..a645e6923 --- /dev/null +++ b/docs/migrating-roles.md @@ -0,0 +1,52 @@ +# Migrating Roles from Spectora / ISN to OpenInspection + +## How OpenInspection Roles Work + +OpenInspection uses **4 roles** — Owner, Manager, Inspector, Agent — each acting as a fixed permission template. In addition, there are **4 optional advanced toggles** you can flip per person: + +- **Publish reports** — can the person publish a completed report to the client? +- **Schedule for others** — can the person book or reassign inspections for other inspectors? +- **Financial data** — can the person see pricing, invoices, and payment details? +- **Manage contacts** — can the person add, edit, or delete clients and agents? + +Pick a role and you are ~90% configured; the toggles cover the remaining edge cases. Large or commercial jobs that require multiple inspectors are handled by **assigning multiple inspectors to one inspection** (the assignment axis), not by creating special roles. + +--- + +## The 4 Roles + +| Role | Who it is for | +|---|---| +| **Owner** | Account holder — billing + everything. | +| **Manager** | Back-office: team management, settings, scheduling, all inspections. | +| **Inspector** | Conducts inspections, edits and publishes reports. | +| **Agent** | External real-estate agent — read-only access to their own orders. | + +--- + +## Coming from Spectora + +| Spectora setup | OpenInspection role | Advanced toggles | +|---|---|---| +| Inspector | Inspector | — | +| Inspector + Trainee | Inspector | Publish reports = **off** (requires senior review before delivery) | +| Inspector + Access Financial Data | Inspector | Financial data = **on** | +| Inspector + Schedule All | Inspector | Schedule for others = **on** | +| Support Staff (with or without Admin) | Manager | Financial data = **off** if it was off in Spectora | +| Organization Manager | Owner | — | + +--- + +## Coming from ISN + +| ISN setup | OpenInspection role | Advanced toggles | +|---|---|---| +| Inspector | Inspector | Financial data = **on** if "view fees" was enabled in ISN | +| Standard User / Office Administrator | Manager | Trim with the toggles as needed | +| Account owner | Owner | — | + +--- + +## A Note on Guest / Temporary Access + +Neither Spectora nor ISN has a dedicated "guest" or temporary-login role, so there is nothing to migrate in that category. To bring on temporary help, add the person as an Inspector and remove them when the work is done. diff --git a/eslint.config.js b/eslint.config.js index ae4f27057..7609e4e08 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -31,10 +31,87 @@ export default tseslint.config( // - a753af5 (login 2fa form) // Rule: x-cloak ONLY on the outermost x-data element. For nested // hide-on-load, use style="display:none" + x-show. - 'no-restricted-syntax': ['warn', { - selector: "JSXAttribute[name.name='x-cloak']", - message: 'Avoid x-cloak on nested JSX elements — Alpine does not auto-remove it, so [x-cloak]{display:none} stays sticky. Use style="display:none" + x-show, or place x-cloak only on the outermost x-data element. See main-layout.tsx comment.', - }], + 'no-restricted-syntax': ['warn', + { + selector: "JSXAttribute[name.name='x-cloak']", + message: 'Avoid x-cloak on nested JSX elements — Alpine does not auto-remove it, so [x-cloak]{display:none} stays sticky. Use style="display:none" + x-show, or place x-cloak only on the outermost x-data element. See main-layout.tsx comment.', + }, + // Role taxonomy guard — all RBAC role string literals must derive from + // ROLES / Role in server/lib/auth/roles.ts (the single source of truth). + // This prevents typos and stale literals surviving a role rename. + // Exempt: roles.ts itself, test files, schema/data/seed files + // (see override block below). Includes 'manager' now so the future + // admin→manager rename is already guarded on day one. + // requireRole(...roles: Role[]) is excluded via :not() because TypeScript + // already enforces Role at the call site — a typo there is a compile error. + { + selector: "Literal[value=/^(owner|admin|manager|inspector|agent)$/]:not(CallExpression[callee.name='requireRole'] > Literal)", + message: 'Use ROLES / Role from server/lib/auth/roles.ts — no bare role string literals.', + }, + ], + }, + }, + { + // Exempt files where the role-string matches are NOT bare RBAC literals + // that need fixing. Each category is explained below. The rule fires only + // on NEW code paths outside these globs, keeping the guard forward-looking. + // + // server/lib/auth/roles.ts — source of truth; defines the literals + // server/lib/db/schema/** — drizzle column defs; also has non-user-role + // enums (signer/contact roles) which use 'agent' + // server/data/** — seed/fixture data; literals are authoritative + // server/lib/middleware/rbac.ts — requireRole(...roles:Role[]) definition; + // the Role type already enforces call sites + // server/lib/auth/jwt-claims.ts — uses 'agent' as a JWT kind discriminant + // server/lib/public-access.ts — PortalRole ('client'|'co_client'|'agent') is + // a non-RBAC signer role (≠ users.role) + // server/durable-objects/** — presence role ('inspector'|'observer') ≠ RBAC + // server/lib/email-templates/** — email category ('agent'|'client') ≠ RBAC + // server/lib/integration/** — bootstrap insert; drizzle column enum enforces + // server/portal/** — credential upsert; drizzle column enum enforces + // server/api/** — existing sites: OpenAPI tags/scopes strings + // ('admin' there is a doc label, not a role), plus + // Drizzle typed inserts (column enum enforces), JWT + // payload role fields (typed as UserRole), and + // non-RBAC signer/contact role strings. requireRole + // args are already excluded by :not() in the selector. + // server/services/** — Drizzle insert/query role literals are typed by + // { enum: ROLES }; non-RBAC contact-type strings + // ('agent'|'client') are a distinct taxonomy + // server/lib/** — dashboard-column ids, route-metadata scopes, + // validation schemas for non-RBAC signer/automation roles. + // RBAC-specific helpers (can-edit, report-section-numbering) + // were already fixed to use ROLE.* constants. + // server/index.ts — JWT context population; typed as UserRole + // app/** — UI role strings are typed via the session context + // (Role type flows from the loader); display/conditional + // logic uses the session role value directly + files: [ + 'server/lib/auth/roles.ts', + 'server/lib/db/schema/**/*.ts', + 'server/data/**/*.ts', + 'server/lib/middleware/rbac.ts', + 'server/lib/auth/jwt-claims.ts', + 'server/lib/public-access.ts', + 'server/durable-objects/**/*.ts', + 'server/lib/email-templates/**/*.ts', + 'server/lib/integration/**/*.ts', + 'server/portal/**/*.ts', + 'server/api/**/*.ts', + 'server/services/**/*.ts', + 'server/lib/**/*.ts', + 'server/index.ts', + 'app/**/*.ts', + 'app/**/*.tsx', + ], + rules: { + // Turn off ONLY the role-literal restriction for these files; all other rules still apply. + 'no-restricted-syntax': ['warn', + { + selector: "JSXAttribute[name.name='x-cloak']", + message: 'Avoid x-cloak on nested JSX elements — Alpine does not auto-remove it, so [x-cloak]{display:none} stays sticky. Use style="display:none" + x-show, or place x-cloak only on the outermost x-data element. See main-layout.tsx comment.', + }, + ], }, }, { diff --git a/migrations/0002_classy_ben_grimm.sql b/migrations/0002_classy_ben_grimm.sql new file mode 100644 index 000000000..52add6d75 --- /dev/null +++ b/migrations/0002_classy_ben_grimm.sql @@ -0,0 +1,2 @@ +ALTER TABLE `users` ADD `permission_overrides` text;--> statement-breakpoint +ALTER TABLE `tenant_invites` ADD `permission_overrides` text; diff --git a/migrations/0003_role_value_remap.sql b/migrations/0003_role_value_remap.sql new file mode 100644 index 000000000..8508e0cfa --- /dev/null +++ b/migrations/0003_role_value_remap.sql @@ -0,0 +1,11 @@ +-- 0003_role_value_remap.sql +-- Role taxonomy collapse + rename (2026-06-13). Idempotent / defensive: no-op when absent. +UPDATE users SET role = 'manager' WHERE role = 'admin'; +UPDATE tenant_invites SET role = 'manager' WHERE role = 'admin'; +UPDATE users SET role = 'manager' WHERE role = 'office'; +UPDATE tenant_invites SET role = 'manager' WHERE role = 'office'; +UPDATE users SET role = 'inspector' WHERE role IN ('lead', 'specialist', 'apprentice'); +UPDATE tenant_invites SET role = 'inspector' WHERE role IN ('lead', 'specialist', 'apprentice'); +UPDATE users SET permission_overrides = '{"publish":false}' + WHERE role = 'inspector' AND permission_overrides IS NULL + AND id IN (SELECT id FROM users WHERE mentor_id IS NOT NULL); diff --git a/migrations/meta/0002_snapshot.json b/migrations/meta/0002_snapshot.json new file mode 100644 index 000000000..03fb8df05 --- /dev/null +++ b/migrations/meta/0002_snapshot.json @@ -0,0 +1,7771 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "6eebc305-7df8-49c2-810b-9fc40fb64d0a", + "prevId": "bb1a624a-bb02-4991-9531-07b94c5ea6b4", + "tables": { + "agreement_requests": { + "name": "agreement_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signature_base64": { + "name": "inspector_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_signed_at": { + "name": "inspector_signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_user_id": { + "name": "inspector_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_snapshot": { + "name": "content_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completion_policy": { + "name": "completion_policy", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'all'" + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "purged_at": { + "name": "purged_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "agreement_requests_token_unique": { + "name": "agreement_requests_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_agreement_requests_verify_token": { + "name": "idx_agreement_requests_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + }, + "idx_agreement_requests_tenant": { + "name": "idx_agreement_requests_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agreement_requests_inspection": { + "name": "idx_agreement_requests_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_agreement_requests_token_hash": { + "name": "idx_agreement_requests_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "agreement_requests_tenant_id_tenants_id_fk": { + "name": "agreement_requests_tenant_id_tenants_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspection_id_inspections_id_fk": { + "name": "agreement_requests_inspection_id_inspections_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_agreement_id_agreements_id_fk": { + "name": "agreement_requests_agreement_id_agreements_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agreement_requests_inspector_user_id_users_id_fk": { + "name": "agreement_requests_inspector_user_id_users_id_fk", + "tableFrom": "agreement_requests", + "tableTo": "users", + "columnsFrom": [ + "inspector_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreement_signers": { + "name": "agreement_signers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "viewed_at": { + "name": "viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_of": { + "name": "on_behalf_of", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "on_behalf_disclaimer": { + "name": "on_behalf_disclaimer", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_reminded_at": { + "name": "last_reminded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreement_signers_tenant_request": { + "name": "idx_agreement_signers_tenant_request", + "columns": [ + "tenant_id", + "request_id" + ], + "isUnique": false + }, + "idx_agreement_signers_request_email": { + "name": "idx_agreement_signers_request_email", + "columns": [ + "request_id", + "email" + ], + "isUnique": true + }, + "idx_agreement_signers_token_hash": { + "name": "idx_agreement_signers_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agreements": { + "name": "agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agreements_tenant": { + "name": "idx_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agreements_tenant_id_tenants_id_fk": { + "name": "agreements_tenant_id_tenants_id_fk", + "tableFrom": "agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automation_logs": { + "name": "automation_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "channel": { + "name": "channel", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'email'" + }, + "send_at": { + "name": "send_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delivered_at": { + "name": "delivered_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_automation_logs_pending": { + "name": "idx_automation_logs_pending", + "columns": [ + "tenant_id", + "status", + "send_at" + ], + "isUnique": false + }, + "idx_automation_logs_insp": { + "name": "idx_automation_logs_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_automation_logs_event": { + "name": "uq_automation_logs_event", + "columns": [ + "automation_id", + "inspection_id", + "event_id" + ], + "isUnique": true, + "where": "event_id IS NOT NULL" + } + }, + "foreignKeys": { + "automation_logs_tenant_id_tenants_id_fk": { + "name": "automation_logs_tenant_id_tenants_id_fk", + "tableFrom": "automation_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "automations": { + "name": "automations", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient": { + "name": "recipient", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "delay_minutes": { + "name": "delay_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "subject_template": { + "name": "subject_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body_template": { + "name": "body_template", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "conditions": { + "name": "conditions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "channels": { + "name": "channels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[\"email\"]'" + }, + "sms_body": { + "name": "sms_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_automations_tenant": { + "name": "idx_automations_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "automations_tenant_id_tenants_id_fk": { + "name": "automations_tenant_id_tenants_id_fk", + "tableFrom": "automations", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability": { + "name": "availability", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "day_of_week": { + "name": "day_of_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_availability_inspector": { + "name": "idx_availability_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_availability_window_unique": { + "name": "idx_availability_window_unique", + "columns": [ + "inspector_id", + "day_of_week", + "start_time" + ], + "isUnique": true + } + }, + "foreignKeys": { + "availability_tenant_id_tenants_id_fk": { + "name": "availability_tenant_id_tenants_id_fk", + "tableFrom": "availability", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_inspector_id_users_id_fk": { + "name": "availability_inspector_id_users_id_fk", + "tableFrom": "availability", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "availability_overrides": { + "name": "availability_overrides", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_available": { + "name": "is_available", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "start_time": { + "name": "start_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "end_time": { + "name": "end_time", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_avail_overrides_insp": { + "name": "idx_avail_overrides_insp", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_avail_overrides_block_unique": { + "name": "idx_avail_overrides_block_unique", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": true, + "where": "is_available = 0" + } + }, + "foreignKeys": { + "availability_overrides_tenant_id_tenants_id_fk": { + "name": "availability_overrides_tenant_id_tenants_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "availability_overrides_inspector_id_users_id_fk": { + "name": "availability_overrides_inspector_id_users_id_fk", + "tableFrom": "availability_overrides", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comment_usage": { + "name": "comment_usage", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "use_count": { + "name": "use_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_comment_usage_user_last_used": { + "name": "idx_comment_usage_user_last_used", + "columns": [ + "tenant_id", + "user_id", + "last_used_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comment_usage_comment_id_comments_id_fk": { + "name": "comment_usage_comment_id_comments_id_fk", + "tableFrom": "comment_usage", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "comment_usage_tenant_id_user_id_comment_id_pk": { + "columns": [ + "tenant_id", + "user_id", + "comment_id" + ], + "name": "comment_usage_tenant_id_user_id_comment_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "comments": { + "name": "comments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_bucket": { + "name": "rating_bucket", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section": { + "name": "section", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "section_ids": { + "name": "section_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_labels": { + "name": "item_labels", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "trigger_code": { + "name": "trigger_code", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "search_keywords": { + "name": "search_keywords", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "item_label": { + "name": "item_label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "severity": { + "name": "severity", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repair_summary": { + "name": "repair_summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_min_cents": { + "name": "estimate_min_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "estimate_max_cents": { + "name": "estimate_max_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recommended_contractor_type_id": { + "name": "recommended_contractor_type_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_comments_tenant": { + "name": "idx_comments_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_comments_rating_bucket": { + "name": "idx_comments_rating_bucket", + "columns": [ + "tenant_id", + "rating_bucket" + ], + "isUnique": false + }, + "idx_comments_library_id": { + "name": "idx_comments_library_id", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "comments_tenant_id_tenants_id_fk": { + "name": "comments_tenant_id_tenants_id_fk", + "tableFrom": "comments", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "commercial_subtypes": { + "name": "commercial_subtypes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "based_on": { + "name": "based_on", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disabled": { + "name": "disabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_commercial_subtypes_tenant_name": { + "name": "idx_commercial_subtypes_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "commercial_subtypes_tenant_id_tenants_id_fk": { + "name": "commercial_subtypes_tenant_id_tenants_id_fk", + "tableFrom": "commercial_subtypes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "concierge_confirm_tokens": { + "name": "concierge_confirm_tokens", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_concierge_tokens_expiry": { + "name": "idx_concierge_tokens_expiry", + "columns": [ + "expires_at" + ], + "isUnique": false + }, + "idx_concierge_confirm_token_hash": { + "name": "idx_concierge_confirm_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "concierge_confirm_tokens_inspection_id_inspections_id_fk": { + "name": "concierge_confirm_tokens_inspection_id_inspections_id_fk", + "tableFrom": "concierge_confirm_tokens", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contacts": { + "name": "contacts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agency": { + "name": "agency", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_contacts_type": { + "name": "idx_contacts_type", + "columns": [ + "tenant_id", + "type" + ], + "isUnique": false + }, + "idx_contacts_tenant": { + "name": "idx_contacts_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_contacts_tenant_email": { + "name": "uq_contacts_tenant_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "email IS NOT NULL AND archived_at IS NULL" + } + }, + "foreignKeys": { + "contacts_tenant_id_tenants_id_fk": { + "name": "contacts_tenant_id_tenants_id_fk", + "tableFrom": "contacts", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "contractor_types": { + "name": "contractor_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_contractor_types_tenant": { + "name": "idx_contractor_types_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "customer_messages": { + "name": "customer_messages", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_role": { + "name": "from_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "attachments": { + "name": "attachments", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_msg_inspection": { + "name": "idx_msg_inspection", + "columns": [ + "inspection_id", + "created_at" + ], + "isUnique": false + }, + "idx_msg_unread": { + "name": "idx_msg_unread", + "columns": [ + "tenant_id", + "inspection_id", + "from_role" + ], + "isUnique": false, + "where": "\"customer_messages\".\"read_at\" IS NULL" + } + }, + "foreignKeys": { + "customer_messages_tenant_id_tenants_id_fk": { + "name": "customer_messages_tenant_id_tenants_id_fk", + "tableFrom": "customer_messages", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "customer_messages_inspection_id_inspections_id_fk": { + "name": "customer_messages_inspection_id_inspections_id_fk", + "tableFrom": "customer_messages", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "discount_codes": { + "name": "discount_codes", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "max_uses": { + "name": "max_uses", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "uses_count": { + "name": "uses_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_discount_codes_tenant": { + "name": "idx_discount_codes_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "discount_codes_code_tenant": { + "name": "discount_codes_code_tenant", + "columns": [ + "upper(code)", + "tenant_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "discount_codes_tenant_id_tenants_id_fk": { + "name": "discount_codes_tenant_id_tenants_id_fk", + "tableFrom": "discount_codes", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "erasure_log": { + "name": "erasure_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject_email": { + "name": "subject_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "requested_by": { + "name": "requested_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "identity_basis": { + "name": "identity_basis", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "decisions_json": { + "name": "decisions_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retained_count": { + "name": "retained_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "anonymized_count": { + "name": "anonymized_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "deleted_count": { + "name": "deleted_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "response_note": { + "name": "response_note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_erasure_log_tenant": { + "name": "idx_erasure_log_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "esign_audit_logs": { + "name": "esign_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload_json": { + "name": "payload_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_esign_audit_logs_request": { + "name": "idx_esign_audit_logs_request", + "columns": [ + "tenant_id", + "request_id", + "created_at" + ], + "isUnique": false + }, + "idx_esign_audit_logs_event_dedup": { + "name": "idx_esign_audit_logs_event_dedup", + "columns": [ + "tenant_id", + "request_id", + "event" + ], + "isUnique": true, + "where": "event NOT LIKE 'signer.%'" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "event_types": { + "name": "event_types", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "default_duration_min": { + "name": "default_duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 30 + }, + "default_price_cents": { + "name": "default_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'#6366f1'" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "event_types_tenant_slug_idx": { + "name": "event_types_tenant_slug_idx", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": { + "event_types_tenant_id_tenants_id_fk": { + "name": "event_types_tenant_id_tenants_id_fk", + "tableFrom": "event_types", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_access_tokens": { + "name": "inspection_access_tokens", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_email": { + "name": "recipient_email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'client'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_iat_token": { + "name": "idx_iat_token", + "columns": [ + "token" + ], + "isUnique": true + }, + "idx_iat_inspection": { + "name": "idx_iat_inspection", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "idx_iat_recipient": { + "name": "idx_iat_recipient", + "columns": [ + "inspection_id", + "recipient_email" + ], + "isUnique": true + }, + "idx_iat_token_hash": { + "name": "idx_iat_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_access_tokens_tenant_id_tenants_id_fk": { + "name": "inspection_access_tokens_tenant_id_tenants_id_fk", + "tableFrom": "inspection_access_tokens", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_agreements": { + "name": "inspection_agreements", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signature_base64": { + "name": "signature_base64", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "signed_at": { + "name": "signed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_agreements_tenant": { + "name": "idx_insp_agreements_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_agreements_insp": { + "name": "idx_insp_agreements_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_agreements_tenant_id_tenants_id_fk": { + "name": "inspection_agreements_tenant_id_tenants_id_fk", + "tableFrom": "inspection_agreements", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_agreements_inspection_id_inspections_id_fk": { + "name": "inspection_agreements_inspection_id_inspections_id_fk", + "tableFrom": "inspection_agreements", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_conflicts": { + "name": "inspection_conflicts", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "section_id": { + "name": "section_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "field": { + "name": "field", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base": { + "name": "base", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "local": { + "name": "local", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote": { + "name": "remote", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "resolved_at": { + "name": "resolved_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_conflicts_inspection": { + "name": "idx_inspection_conflicts_inspection", + "columns": [ + "inspection_id", + "resolved_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_events": { + "name": "inspection_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "event_type_id": { + "name": "event_type_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_min": { + "name": "duration_min", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'scheduled'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "completed_at": { + "name": "completed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "results_received_at": { + "name": "results_received_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gcal_event_id": { + "name": "gcal_event_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "inspection_events_scheduled_idx": { + "name": "inspection_events_scheduled_idx", + "columns": [ + "tenant_id", + "scheduled_at" + ], + "isUnique": false + }, + "inspection_events_inspection_idx": { + "name": "inspection_events_inspection_idx", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_events_tenant_id_tenants_id_fk": { + "name": "inspection_events_tenant_id_tenants_id_fk", + "tableFrom": "inspection_events", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspection_id_inspections_id_fk": { + "name": "inspection_events_inspection_id_inspections_id_fk", + "tableFrom": "inspection_events", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_events_event_type_id_event_types_id_fk": { + "name": "inspection_events_event_type_id_event_types_id_fk", + "tableFrom": "inspection_events", + "tableTo": "event_types", + "columnsFrom": [ + "event_type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_events_inspector_id_users_id_fk": { + "name": "inspection_events_inspector_id_users_id_fk", + "tableFrom": "inspection_events", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_inspectors": { + "name": "inspection_inspectors", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'lead'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_inspectors_tenant_user": { + "name": "idx_insp_inspectors_tenant_user", + "columns": [ + "tenant_id", + "user_id" + ], + "isUnique": false + }, + "idx_insp_inspectors_user": { + "name": "idx_insp_inspectors_user", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_inspectors_inspection_id_user_id_pk": { + "columns": [ + "inspection_id", + "user_id" + ], + "name": "inspection_inspectors_inspection_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_item_tag_links": { + "name": "inspection_item_tag_links", + "columns": { + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tag_links_tenant": { + "name": "idx_tag_links_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_tag_links_tag": { + "name": "idx_tag_links_tag", + "columns": [ + "tag_id" + ], + "isUnique": false + }, + "idx_tag_links_inspection_item": { + "name": "idx_tag_links_inspection_item", + "columns": [ + "inspection_id", + "item_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "inspection_item_tag_links_inspection_id_item_id_tag_id_pk": { + "columns": [ + "inspection_id", + "item_id", + "tag_id" + ], + "name": "inspection_item_tag_links_inspection_id_item_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_media_pool": { + "name": "inspection_media_pool", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "exif_data": { + "name": "exif_data", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "annotations": { + "name": "annotations", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_media_pool_tenant": { + "name": "idx_media_pool_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_media_pool_inspection": { + "name": "idx_media_pool_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_requests": { + "name": "inspection_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "property_city": { + "name": "property_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_state": { + "name": "property_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_zip": { + "name": "property_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scheduled_at": { + "name": "scheduled_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "total_amount_cents": { + "name": "total_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_inspection_requests_tenant": { + "name": "idx_inspection_requests_tenant", + "columns": [ + "tenant_id", + "status", + "scheduled_at" + ], + "isUnique": false + }, + "idx_inspection_requests_email": { + "name": "idx_inspection_requests_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_requests_tenant_id_tenants_id_fk": { + "name": "inspection_requests_tenant_id_tenants_id_fk", + "tableFrom": "inspection_requests", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_results": { + "name": "inspection_results", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_synced_at": { + "name": "last_synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rating_system_snapshot": { + "name": "rating_system_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_results_tenant": { + "name": "idx_results_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_results_inspection": { + "name": "idx_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "uq_results_inspection": { + "name": "uq_results_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "inspection_results_tenant_id_tenants_id_fk": { + "name": "inspection_results_tenant_id_tenants_id_fk", + "tableFrom": "inspection_results", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_results_inspection_id_inspections_id_fk": { + "name": "inspection_results_inspection_id_inspections_id_fk", + "tableFrom": "inspection_results", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_services": { + "name": "inspection_services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_override_cents": { + "name": "price_override_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name_snapshot": { + "name": "name_snapshot", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "price_snapshot_cents": { + "name": "price_snapshot_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_insp_services_tenant": { + "name": "idx_insp_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_insp_services_insp": { + "name": "idx_insp_services_insp", + "columns": [ + "inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspection_services_tenant_id_tenants_id_fk": { + "name": "inspection_services_tenant_id_tenants_id_fk", + "tableFrom": "inspection_services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspection_services_inspection_id_inspections_id_fk": { + "name": "inspection_services_inspection_id_inspections_id_fk", + "tableFrom": "inspection_services", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "inspection_services_service_id_services_id_fk": { + "name": "inspection_services_service_id_services_id_fk", + "tableFrom": "inspection_services", + "tableTo": "services", + "columnsFrom": [ + "service_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspection_units": { + "name": "inspection_units", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_unit_id": { + "name": "parent_unit_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unit'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "inspection_units_tenant_inspection_idx": { + "name": "inspection_units_tenant_inspection_idx", + "columns": [ + "tenant_id", + "inspection_id" + ], + "isUnique": false + }, + "inspection_units_parent_idx": { + "name": "inspection_units_parent_idx", + "columns": [ + "parent_unit_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "inspections": { + "name": "inspections", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_id": { + "name": "inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_address": { + "name": "property_address", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "address_place_id": { + "name": "address_place_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_street": { + "name": "address_street", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_city": { + "name": "address_city", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_state": { + "name": "address_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_zip": { + "name": "address_zip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_county": { + "name": "address_county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lat": { + "name": "address_lat", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_lng": { + "name": "address_lng", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "address_geocoded_at": { + "name": "address_geocoded_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_contact_id": { + "name": "client_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_phone": { + "name": "client_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'draft'" + }, + "payment_status": { + "name": "payment_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unpaid'" + }, + "referred_by_agent_id": { + "name": "referred_by_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_reason": { + "name": "cancel_reason", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_notes": { + "name": "cancel_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_required": { + "name": "payment_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "agreement_required": { + "name": "agreement_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "auto_sign_on_publish": { + "name": "auto_sign_on_publish", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "discount_code_id": { + "name": "discount_code_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "discount_amount_cents": { + "name": "discount_amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "closing_date": { + "name": "closing_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "referral_source": { + "name": "referral_source", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "internal_notes": { + "name": "internal_notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "year_built": { + "name": "year_built", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sqft": { + "name": "sqft", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "foundation_type": { + "name": "foundation_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bedrooms": { + "name": "bedrooms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bathrooms": { + "name": "bathrooms", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lot_size": { + "name": "lot_size", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_facts": { + "name": "property_facts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_photo_id": { + "name": "cover_photo_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "county": { + "name": "county", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selling_agent_id": { + "name": "selling_agent_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "disable_automations": { + "name": "disable_automations", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "message_token": { + "name": "message_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot": { + "name": "template_snapshot", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_snapshot_version": { + "name": "template_snapshot_version", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 1 + }, + "report_theme_override": { + "name": "report_theme_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "require_defect_fields_override": { + "name": "require_defect_fields_override", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_status": { + "name": "concierge_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "team_mode": { + "name": "team_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "lead_inspector_id": { + "name": "lead_inspector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "helper_inspector_ids": { + "name": "helper_inspector_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "data_version": { + "name": "data_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "source_inspection_id": { + "name": "source_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "root_inspection_id": { + "name": "root_inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reinspection_round": { + "name": "reinspection_round", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_inspections_msg_token": { + "name": "idx_inspections_msg_token", + "columns": [ + "message_token" + ], + "isUnique": true + }, + "idx_inspections_tenant": { + "name": "idx_inspections_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_inspections_request": { + "name": "idx_inspections_request", + "columns": [ + "request_id" + ], + "isUnique": false + }, + "idx_inspections_inspector": { + "name": "idx_inspections_inspector", + "columns": [ + "inspector_id" + ], + "isUnique": false + }, + "idx_inspections_agent": { + "name": "idx_inspections_agent", + "columns": [ + "referred_by_agent_id" + ], + "isUnique": false + }, + "idx_inspections_tenant_status": { + "name": "idx_inspections_tenant_status", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_inspections_tenant_date": { + "name": "idx_inspections_tenant_date", + "columns": [ + "tenant_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_tenant_client_email": { + "name": "idx_inspections_tenant_client_email", + "columns": [ + "tenant_id", + "client_email" + ], + "isUnique": false + }, + "idx_inspections_inspector_date": { + "name": "idx_inspections_inspector_date", + "columns": [ + "inspector_id", + "date" + ], + "isUnique": false + }, + "idx_inspections_root": { + "name": "idx_inspections_root", + "columns": [ + "root_inspection_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "inspections_tenant_id_tenants_id_fk": { + "name": "inspections_tenant_id_tenants_id_fk", + "tableFrom": "inspections", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_inspector_id_users_id_fk": { + "name": "inspections_inspector_id_users_id_fk", + "tableFrom": "inspections", + "tableTo": "users", + "columnsFrom": [ + "inspector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_template_id_templates_id_fk": { + "name": "inspections_template_id_templates_id_fk", + "tableFrom": "inspections", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_discount_code_id_discount_codes_id_fk": { + "name": "inspections_discount_code_id_discount_codes_id_fk", + "tableFrom": "inspections", + "tableTo": "discount_codes", + "columnsFrom": [ + "discount_code_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_selling_agent_id_contacts_id_fk": { + "name": "inspections_selling_agent_id_contacts_id_fk", + "tableFrom": "inspections", + "tableTo": "contacts", + "columnsFrom": [ + "selling_agent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "inspections_request_id_inspection_requests_id_fk": { + "name": "inspections_request_id_inspection_requests_id_fk", + "tableFrom": "inspections", + "tableTo": "inspection_requests", + "columnsFrom": [ + "request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invoices": { + "name": "invoices", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_name": { + "name": "client_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_email": { + "name": "client_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "line_items": { + "name": "line_items", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "due_date": { + "name": "due_date", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sent_at": { + "name": "sent_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "paid_at": { + "name": "paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "payment_method": { + "name": "payment_method", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "partial_paid_at": { + "name": "partial_paid_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "qbo_sync_status": { + "name": "qbo_sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_invoices_tenant": { + "name": "idx_invoices_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_invoices_inspection": { + "name": "idx_invoices_inspection", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_invoices_contact": { + "name": "idx_invoices_contact", + "columns": [ + "tenant_id", + "contact_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "invoices_tenant_id_tenants_id_fk": { + "name": "invoices_tenant_id_tenants_id_fk", + "tableFrom": "invoices", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_inspection_id_inspections_id_fk": { + "name": "invoices_inspection_id_inspections_id_fk", + "tableFrom": "invoices", + "tableTo": "inspections", + "columnsFrom": [ + "inspection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "invoices_contact_id_contacts_id_fk": { + "name": "invoices_contact_id_contacts_id_fk", + "tableFrom": "invoices", + "tableTo": "contacts", + "columnsFrom": [ + "contact_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_libraries": { + "name": "marketplace_libraries", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_libraries_kind_featured": { + "name": "idx_marketplace_libraries_kind_featured", + "columns": [ + "kind", + "featured" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "marketplace_templates": { + "name": "marketplace_templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "semver": { + "name": "semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'system'" + }, + "changelog": { + "name": "changelog", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "download_count": { + "name": "download_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "observer_links": { + "name": "observer_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_viewed_at": { + "name": "last_viewed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "token_enc": { + "name": "token_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "observer_links_token_unique": { + "name": "observer_links_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "observer_links_inspection_idx": { + "name": "observer_links_inspection_idx", + "columns": [ + "inspection_id" + ], + "isUnique": false + }, + "idx_observer_links_token_hash": { + "name": "idx_observer_links_token_hash", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_connections": { + "name": "qbo_connections", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "realm_id": { + "name": "realm_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "company_name": { + "name": "company_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_enc": { + "name": "access_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_enc": { + "name": "refresh_token_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token_expires_at": { + "name": "token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sync_enabled": { + "name": "sync_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_item_id": { + "name": "default_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'1'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_entity_map": { + "name": "qbo_entity_map", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_type": { + "name": "qbo_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_id": { + "name": "qbo_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "qbo_sync_token": { + "name": "qbo_sync_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "synced_at": { + "name": "synced_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_qbo_entity_map_qbo": { + "name": "idx_qbo_entity_map_qbo", + "columns": [ + "tenant_id", + "qbo_type", + "qbo_id" + ], + "isUnique": true + }, + "idx_qbo_entity_map_oi": { + "name": "idx_qbo_entity_map_oi", + "columns": [ + "tenant_id", + "oi_type", + "oi_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "qbo_sync_errors": { + "name": "qbo_sync_errors", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_type": { + "name": "oi_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "oi_id": { + "name": "oi_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "error_msg": { + "name": "error_msg", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rating_systems": { + "name": "rating_systems", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "levels": { + "name": "levels", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_default": { + "name": "is_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_rating_systems_tenant_slug": { + "name": "idx_rating_systems_tenant_slug", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_rating_systems_tenant": { + "name": "idx_rating_systems_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "rating_systems_tenant_id_tenants_id_fk": { + "name": "rating_systems_tenant_id_tenants_id_fk", + "tableFrom": "rating_systems", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_pdfs": { + "name": "report_pdfs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "r2_key": { + "name": "r2_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rendered_at": { + "name": "rendered_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'ready'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "uq_report_pdfs_inspection_type": { + "name": "uq_report_pdfs_inspection_type", + "columns": [ + "inspection_id", + "type", + "version_number" + ], + "isUnique": true + }, + "idx_report_pdfs_tenant": { + "name": "idx_report_pdfs_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_report_pdfs_status": { + "name": "idx_report_pdfs_status", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "report_pdfs_tenant_id_tenants_id_fk": { + "name": "report_pdfs_tenant_id_tenants_id_fk", + "tableFrom": "report_pdfs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "report_versions": { + "name": "report_versions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspection_id": { + "name": "inspection_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version_number": { + "name": "version_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "snapshot_json": { + "name": "snapshot_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "prev_hash": { + "name": "prev_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "signature": { + "name": "signature", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "key_fingerprint": { + "name": "key_fingerprint", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_amendment": { + "name": "is_amendment", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "verification_token": { + "name": "verification_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_by": { + "name": "published_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "report_versions_inspection_idx": { + "name": "report_versions_inspection_idx", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": false + }, + "report_versions_inspection_version_unique": { + "name": "report_versions_inspection_version_unique", + "columns": [ + "inspection_id", + "version_number" + ], + "isUnique": true + }, + "idx_report_versions_verify_token": { + "name": "idx_report_versions_verify_token", + "columns": [ + "verification_token" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "service_inspectors": { + "name": "service_inspectors", + "columns": { + "service_id": { + "name": "service_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_service_inspectors_tenant": { + "name": "idx_service_inspectors_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "service_inspectors_service_id_user_id_pk": { + "columns": [ + "service_id", + "user_id" + ], + "name": "service_inspectors_service_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "services": { + "name": "services", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "price_cents": { + "name": "price_cents", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration_minutes": { + "name": "duration_minutes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "agreement_id": { + "name": "agreement_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "active": { + "name": "active", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_services_tenant": { + "name": "idx_services_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "services_tenant_id_tenants_id_fk": { + "name": "services_tenant_id_tenants_id_fk", + "tableFrom": "services", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_template_id_templates_id_fk": { + "name": "services_template_id_templates_id_fk", + "tableFrom": "services", + "tableTo": "templates", + "columnsFrom": [ + "template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "services_agreement_id_agreements_id_fk": { + "name": "services_agreement_id_agreements_id_fk", + "tableFrom": "services", + "tableTo": "agreements", + "columnsFrom": [ + "agreement_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "signing_keys": { + "name": "signing_keys", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_enc": { + "name": "private_key_enc", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "private_key_iv": { + "name": "private_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "algorithm": { + "name": "algorithm", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'Ed25519'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rotated_at": { + "name": "rotated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "signing_keys_tenant_id_tenants_id_fk": { + "name": "signing_keys_tenant_id_tenants_id_fk", + "tableFrom": "signing_keys", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_consent_log": { + "name": "sms_consent_log", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "contact_id": { + "name": "contact_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "recipient_type": { + "name": "recipient_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "disclosure_version": { + "name": "disclosure_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "captured_via": { + "name": "captured_via", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip": { + "name": "ip", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_sms_consent_contact": { + "name": "idx_sms_consent_contact", + "columns": [ + "tenant_id", + "contact_id", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sms_disclosure_versions": { + "name": "sms_disclosure_versions", + "columns": { + "version": { + "name": "version", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "text": { + "name": "text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "published_at": { + "name": "published_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tags": { + "name": "tags", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "is_seed": { + "name": "is_seed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_tags_tenant_name": { + "name": "idx_tags_tenant_name", + "columns": [ + "tenant_id", + "name" + ], + "isUnique": true + }, + "idx_tags_tenant": { + "name": "idx_tags_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "templates": { + "name": "templates", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "schema": { + "name": "schema", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "rating_system_id": { + "name": "rating_system_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "property_type": { + "name": "property_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "commercial_subtype": { + "name": "commercial_subtype", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "featured": { + "name": "featured", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "idx_templates_tenant": { + "name": "idx_templates_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_templates_rating_system": { + "name": "idx_templates_rating_system", + "columns": [ + "rating_system_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "templates_tenant_id_tenants_id_fk": { + "name": "templates_tenant_id_tenants_id_fk", + "tableFrom": "templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_library_imports": { + "name": "tenant_library_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "uq_tenant_library_import": { + "name": "uq_tenant_library_import", + "columns": [ + "tenant_id", + "library_id" + ], + "isUnique": true + }, + "idx_tenant_library_imports_tenant": { + "name": "idx_tenant_library_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_import_history": { + "name": "tenant_marketplace_import_history", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "library_id": { + "name": "library_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "template_id": { + "name": "template_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source_version": { + "name": "source_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "target_version": { + "name": "target_version", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_affected": { + "name": "rows_affected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_marketplace_history_tenant": { + "name": "idx_marketplace_history_tenant", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_marketplace_history_template": { + "name": "idx_marketplace_history_template", + "columns": [ + "template_id" + ], + "isUnique": false + }, + "idx_marketplace_history_library": { + "name": "idx_marketplace_history_library", + "columns": [ + "library_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_marketplace_imports": { + "name": "tenant_marketplace_imports", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "marketplace_template_id": { + "name": "marketplace_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_semver": { + "name": "imported_semver", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "local_template_id": { + "name": "local_template_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "imported_at": { + "name": "imported_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_mkt_imports_tmpl": { + "name": "idx_mkt_imports_tmpl", + "columns": [ + "marketplace_template_id" + ], + "isUnique": false + }, + "idx_mkt_imports_tenant": { + "name": "idx_mkt_imports_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk": { + "name": "tenant_marketplace_imports_marketplace_template_id_marketplace_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "marketplace_templates", + "columnsFrom": [ + "marketplace_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "tenant_marketplace_imports_local_template_id_templates_id_fk": { + "name": "tenant_marketplace_imports_local_template_id_templates_id_fk", + "tableFrom": "tenant_marketplace_imports", + "tableTo": "templates", + "columnsFrom": [ + "local_template_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "usage_counters": { + "name": "usage_counters", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "period_key": { + "name": "period_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_usage_counters_tenant": { + "name": "idx_usage_counters_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "usage_counters_tenant_id_metric_period_key_pk": { + "columns": [ + "tenant_id", + "metric", + "period_key" + ], + "name": "usage_counters_tenant_id_metric_period_key_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_identity_links": { + "name": "user_identity_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "primary_user_id": { + "name": "primary_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_user_id": { + "name": "linked_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_tenant_id": { + "name": "linked_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_role": { + "name": "linked_role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "linked_display_name": { + "name": "linked_display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(datetime('now'))" + } + }, + "indexes": { + "user_identity_links_primary_idx": { + "name": "user_identity_links_primary_idx", + "columns": [ + "primary_user_id" + ], + "isUnique": false + }, + "user_identity_links_primary_linked_unique": { + "name": "user_identity_links_primary_linked_unique", + "columns": [ + "primary_user_id", + "linked_user_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_invites": { + "name": "agent_invites", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_contact_id": { + "name": "inspector_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_invites_email": { + "name": "idx_agent_invites_email", + "columns": [ + "email" + ], + "isUnique": false + }, + "idx_agent_invites_tenant": { + "name": "idx_agent_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_agent_invites_expiration": { + "name": "idx_agent_invites_expiration", + "columns": [ + "expires_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_invites_tenant_id_tenants_id_fk": { + "name": "agent_invites_tenant_id_tenants_id_fk", + "tableFrom": "agent_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_invites_invited_by_user_id_users_id_fk": { + "name": "agent_invites_invited_by_user_id_users_id_fk", + "tableFrom": "agent_invites", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "agent_tenant_links": { + "name": "agent_tenant_links", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "agent_user_id": { + "name": "agent_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "inspector_contact_id": { + "name": "inspector_contact_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_agent_tenant_unique": { + "name": "idx_agent_tenant_unique", + "columns": [ + "agent_user_id", + "tenant_id" + ], + "isUnique": true + }, + "idx_agent_tenant_by_tenant": { + "name": "idx_agent_tenant_by_tenant", + "columns": [ + "tenant_id", + "status" + ], + "isUnique": false + }, + "idx_agent_tenant_by_agent": { + "name": "idx_agent_tenant_by_agent", + "columns": [ + "agent_user_id", + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "agent_tenant_links_agent_user_id_users_id_fk": { + "name": "agent_tenant_links_agent_user_id_users_id_fk", + "tableFrom": "agent_tenant_links", + "tableTo": "users", + "columnsFrom": [ + "agent_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "agent_tenant_links_tenant_id_tenants_id_fk": { + "name": "agent_tenant_links_tenant_id_tenants_id_fk", + "tableFrom": "agent_tenant_links", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audit_logs": { + "name": "audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inspector_slug": { + "name": "inspector_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_audit_tenant_created": { + "name": "idx_audit_tenant_created", + "columns": [ + "tenant_id", + "created_at" + ], + "isUnique": false + }, + "idx_audit_entity": { + "name": "idx_audit_entity", + "columns": [ + "entity_type", + "entity_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "audit_logs_tenant_id_tenants_id_fk": { + "name": "audit_logs_tenant_id_tenants_id_fk", + "tableFrom": "audit_logs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "email_templates": { + "name": "email_templates", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "blocks": { + "name": "blocks", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "email_templates_tenant_id_tenants_id_fk": { + "name": "email_templates_tenant_id_tenants_id_fk", + "tableFrom": "email_templates", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "email_templates_tenant_id_trigger_pk": { + "columns": [ + "tenant_id", + "trigger" + ], + "name": "email_templates_tenant_id_trigger_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notifications": { + "name": "notifications", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "read_at": { + "name": "read_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "archived_at": { + "name": "archived_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_notifications_tenant_user_created": { + "name": "idx_notifications_tenant_user_created", + "columns": [ + "tenant_id", + "user_id", + "created_at" + ], + "isUnique": false + }, + "idx_notifications_tenant_user_unread": { + "name": "idx_notifications_tenant_user_unread", + "columns": [ + "tenant_id", + "user_id", + "read_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "notifications_tenant_id_tenants_id_fk": { + "name": "notifications_tenant_id_tenants_id_fk", + "tableFrom": "notifications", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "parked_cmd_events": { + "name": "parked_cmd_events", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "envelope": { + "name": "envelope", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "received_at": { + "name": "received_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_parked_cmd_events_received_at": { + "name": "idx_parked_cmd_events_received_at", + "columns": [ + "received_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "processed_cmd_events": { + "name": "processed_cmd_events", + "columns": { + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "cmd_type": { + "name": "cmd_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "processed_at": { + "name": "processed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "slug_reservations": { + "name": "slug_reservations", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sync_outbox": { + "name": "sync_outbox", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "payload": { + "name": "payload", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_tried_at": { + "name": "last_tried_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_sync_outbox_status_created": { + "name": "idx_sync_outbox_status_created", + "columns": [ + "status", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_configs": { + "name": "tenant_configs", + "columns": { + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "site_name": { + "name": "site_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "primary_color": { + "name": "primary_color", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "support_email": { + "name": "support_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sender_email": { + "name": "sender_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reply_to": { + "name": "reply_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email_mode": { + "name": "email_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sms_mode": { + "name": "sms_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'platform'" + }, + "sender_display_name": { + "name": "sender_display_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "use_inspector_from_name": { + "name": "use_inspector_from_name", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "billing_url": { + "name": "billing_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "review_url": { + "name": "review_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "company_phone": { + "name": "company_phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "integration_config": { + "name": "integration_config", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "encrypted_secrets": { + "name": "encrypted_secrets", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dek_enc": { + "name": "dek_enc", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ics_token": { + "name": "ics_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "widget_allowed_origins": { + "name": "widget_allowed_origins", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "report_theme": { + "name": "report_theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'modern'" + }, + "attention_thresholds": { + "name": "attention_thresholds", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{\"agreement_unsigned_h\":72,\"invoice_overdue_h\":72,\"report_unpublished_h\":72}'" + }, + "inspection_prefs": { + "name": "inspection_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "show_estimates": { + "name": "show_estimates", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_repair_list": { + "name": "enable_repair_list", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_customer_repair_export": { + "name": "enable_customer_repair_export", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "block_unpaid": { + "name": "block_unpaid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "block_unsigned_agreement": { + "name": "block_unsigned_agreement", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "custom_referral_sources": { + "name": "custom_referral_sources", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "dashboard_column_prefs": { + "name": "dashboard_column_prefs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "concierge_review_required": { + "name": "concierge_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "allow_inspector_choice": { + "name": "allow_inspector_choice", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "enable_pdf_pipeline": { + "name": "enable_pdf_pipeline", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "team_mode_default": { + "name": "team_mode_default", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "apprentice_review_required": { + "name": "apprentice_review_required", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "guest_invites_enabled": { + "name": "guest_invites_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "require_defect_fields": { + "name": "require_defect_fields", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "agreement_retention_years": { + "name": "agreement_retention_years", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 6 + }, + "reinspection_statuses": { + "name": "reinspection_statuses", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "tenant_configs_tenant_id_tenants_id_fk": { + "name": "tenant_configs_tenant_id_tenants_id_fk", + "tableFrom": "tenant_configs", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_destruction_records": { + "name": "tenant_destruction_records", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tenant_slug": { + "name": "tenant_slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "rows_deleted": { + "name": "rows_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_objects": { + "name": "r2_objects", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "r2_bytes": { + "name": "r2_bytes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "kv_keys": { + "name": "kv_keys", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "destroyed_at": { + "name": "destroyed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "idx_destruction_tenant": { + "name": "idx_destruction_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_destruction_destroyed_at": { + "name": "idx_destruction_destroyed_at", + "columns": [ + "destroyed_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenant_invites": { + "name": "tenant_invites", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'inspector'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_invites_tenant": { + "name": "idx_invites_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "uq_tenant_invites_pending_email": { + "name": "uq_tenant_invites_pending_email", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "status = 'pending'" + } + }, + "foreignKeys": { + "tenant_invites_tenant_id_tenants_id_fk": { + "name": "tenant_invites_tenant_id_tenants_id_fk", + "tableFrom": "tenant_invites", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tenants": { + "name": "tenants", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tier": { + "name": "tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'free'" + }, + "stripe_connect_account_id": { + "name": "stripe_connect_account_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "max_users": { + "name": "max_users", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 5 + }, + "deployment_mode": { + "name": "deployment_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'shared'" + }, + "nachi_number": { + "name": "nachi_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "applied_cmd_seq": { + "name": "applied_cmd_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "applied_cred_seq": { + "name": "applied_cred_seq", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tenants_slug_unique": { + "name": "tenants_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users": { + "name": "users", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "tenant_id": { + "name": "tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "license_number": { + "name": "license_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_signature_base64": { + "name": "default_signature_base64", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "service_areas": { + "name": "service_areas", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'manager'" + }, + "google_refresh_token": { + "name": "google_refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "google_calendar_id": { + "name": "google_calendar_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "onboarding_state": { + "name": "onboarding_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "totp_secret": { + "name": "totp_secret", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_enabled": { + "name": "totp_enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "totp_recovery_codes": { + "name": "totp_recovery_codes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "totp_verified_at": { + "name": "totp_verified_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notify_on_referral": { + "name": "notify_on_referral", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_report": { + "name": "notify_on_report", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "notify_on_paid": { + "name": "notify_on_paid", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "mentor_id": { + "name": "mentor_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "assigned_section_ids": { + "name": "assigned_section_ids", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "terms_accepted": { + "name": "terms_accepted", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "permission_overrides": { + "name": "permission_overrides", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "idx_users_deleted_at": { + "name": "idx_users_deleted_at", + "columns": [ + "deleted_at" + ], + "isUnique": false + }, + "users_tenant_email_unique": { + "name": "users_tenant_email_unique", + "columns": [ + "tenant_id", + "email" + ], + "isUnique": true, + "where": "deleted_at IS NULL" + }, + "idx_users_tenant": { + "name": "idx_users_tenant", + "columns": [ + "tenant_id" + ], + "isUnique": false + }, + "idx_users_slug_per_tenant": { + "name": "idx_users_slug_per_tenant", + "columns": [ + "tenant_id", + "slug" + ], + "isUnique": true + }, + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": { + "users_tenant_id_tenants_id_fk": { + "name": "users_tenant_id_tenants_id_fk", + "tableFrom": "users", + "tableTo": "tenants", + "columnsFrom": [ + "tenant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": { + "discount_codes_code_tenant": { + "columns": { + "upper(code)": { + "isExpression": true + } + } + } + } + } +} \ No newline at end of file diff --git a/migrations/meta/_journal.json b/migrations/meta/_journal.json index 293972d85..910bcd7f5 100644 --- a/migrations/meta/_journal.json +++ b/migrations/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1781358469141, "tag": "0001_living_the_fury", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1781395951852, + "tag": "0002_classy_ben_grimm", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/api-types/index.ts b/packages/api-types/index.ts index cf98e4a52..fcf39a12e 100644 --- a/packages/api-types/index.ts +++ b/packages/api-types/index.ts @@ -26,7 +26,6 @@ export type { CoreAuthApi } from '../../server/api/auth'; export type { DataApi } from '../../server/api/data'; export type { EventsApi } from '../../server/api/events'; export type { EvidenceApi } from '../../server/api/evidence'; -export type { GuestApi } from '../../server/api/guest'; export type { IdentityApi } from '../../server/api/identity'; export type { InspectionPrefsApi } from '../../server/api/inspection-prefs'; export type { InspectionRequestsApi } from '../../server/api/inspection-requests'; diff --git a/server/api/admin.ts b/server/api/admin.ts index 247cd2ee0..36933fc49 100644 --- a/server/api/admin.ts +++ b/server/api/admin.ts @@ -58,7 +58,7 @@ const exportDataRoute = createRoute(withMcpMetadata({ path: '/export', tags: ["admin"], summary: "Export tenant for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { @@ -82,7 +82,7 @@ const inviteMemberRoute = createRoute(withMcpMetadata({ path: '/invite', tags: ["admin"], summary: "Invite tenant for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { @@ -115,7 +115,7 @@ const importDataRoute = createRoute(withMcpMetadata({ path: '/import', tags: ["admin"], summary: "Import tenant for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { @@ -153,7 +153,7 @@ const listMembersRoute = createRoute(withMcpMetadata({ path: '/members', tags: ["admin"], summary: "List tenant members for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { @@ -177,7 +177,7 @@ const listAgreementsRoute = createRoute(withMcpMetadata({ path: '/agreements', tags: ["admin"], summary: "List tenant agreements for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { @@ -198,7 +198,7 @@ const createAgreementRoute = createRoute(withMcpMetadata({ path: '/agreements', tags: ["admin"], summary: "Create tenant agreements for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { @@ -228,7 +228,7 @@ const updateAgreementRoute = createRoute(withMcpMetadata({ path: '/agreements/{id}', tags: ["admin"], summary: "Update tenant agreement for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { @@ -259,7 +259,7 @@ const deleteAgreementRoute = createRoute(withMcpMetadata({ path: '/agreements/{id}', tags: ["admin"], summary: "Delete tenant agreement for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, @@ -286,7 +286,7 @@ const getAuditLogsRoute = createRoute(withMcpMetadata({ path: '/audit-logs', tags: ["admin"], summary: "List tenant audit logs", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { query: z.object({ limit: z.string().optional().describe('TODO describe limit field for the OpenInspection MCP integration'), @@ -325,7 +325,7 @@ const postAuditLogRoute = createRoute(withMcpMetadata({ path: '/audit-logs', tags: ["admin"], summary: 'Record an inspector-driven audit event', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { body: { content: { @@ -359,7 +359,7 @@ const eraseDataRoute = createRoute(withMcpMetadata({ path: '/data', tags: ["admin"], summary: "Delete tenant data for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { @@ -401,7 +401,7 @@ const getConfigRoute = createRoute(withMcpMetadata({ path: '/config', tags: ["admin"], summary: 'Get integration config and masked secrets', - middleware: [requireRole(['owner'])], + middleware: [requireRole('owner')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ integrationConfig: IntegrationConfigSchema.describe('TODO describe integrationConfig field for the OpenInspection MCP integration'), secrets: z.record(z.string(), z.string()).describe('TODO describe secrets field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }).openapi('ConfigResponse') } }, @@ -418,7 +418,7 @@ const updateIntegrationConfigRoute = createRoute(withMcpMetadata({ path: '/config', tags: ["admin"], summary: 'Save non-sensitive integration config (plaintext)', - middleware: [requireRole(['owner'])], + middleware: [requireRole('owner')], request: { body: { content: { 'application/json': { schema: IntegrationConfigSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration') }).describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Saved' }, @@ -435,7 +435,7 @@ const sendAgreementRoute = createRoute(withMcpMetadata({ path: '/agreements/send', tags: ["admin", "agreements"], summary: 'Send an agreement signing request to a client', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: SendAgreementSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { @@ -467,7 +467,7 @@ const listSigningRequestsRoute = createRoute(withMcpMetadata({ path: '/agreements/requests', tags: ["admin"], summary: 'List signing requests for tenant', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.array(z.unknown()).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, description: 'OK' }, }, @@ -480,7 +480,7 @@ const getSigningRequestDetailRoute = createRoute(withMcpMetadata({ path: '/agreements/requests/{id}', tags: ["admin"], summary: 'Get a signing request with full audit trail', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.unknown().describe('TODO describe data field for the OpenInspection MCP integration') }).describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'OK' }, @@ -495,7 +495,7 @@ const downloadAuditTrailRoute = createRoute(withMcpMetadata({ path: '/agreements/requests/{id}/audit-trail', tags: ["admin"], summary: 'Download audit trail JSON for legal evidence', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.unknown().describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Audit JSON download' }, @@ -527,7 +527,7 @@ const listSignersRoute = createRoute(withMcpMetadata({ path: '/agreements/requests/{requestId}/signers', tags: ["admin", "agreements"], summary: 'List signers of an agreement envelope (no token material)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ requestId: z.string().describe('The agreement envelope (agreement_requests) id whose signers to list') }) }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.array(SignerRowSchema) }) } }, description: 'OK' }, @@ -542,7 +542,7 @@ const remindSignerRoute = createRoute(withMcpMetadata({ path: '/agreements/requests/{requestId}/signers/{signerId}/remind', tags: ["admin", "agreements"], summary: 'Re-send the agreement request to a single signer', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ requestId: z.string().describe('The agreement envelope id that owns the signer being reminded'), signerId: z.string().describe('The agreement_signers id to re-send the request to') }) }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.object({ remindedAt: z.number() }) }) } }, description: 'Reminder sent' }, @@ -559,7 +559,7 @@ const getSignerLinkRoute = createRoute(withMcpMetadata({ path: '/agreements/requests/{requestId}/signers/{signerId}/link', tags: ["admin", "agreements"], summary: 'Get a single signer\'s persistent public link (copy-link)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ requestId: z.string().describe('The agreement envelope id that owns the signer whose link is requested'), signerId: z.string().describe('The agreement_signers id whose persistent public link to return') }) }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.object({ url: z.string() }) }) } }, description: 'OK' }, @@ -591,7 +591,7 @@ const listCommentsRoute = createRoute(withMcpMetadata({ summary: 'List comment library entries', // Inspectors need read access so the inspection-edit picker (T7+1) can // populate. Create/delete remain admin-only further below. - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { query: ListCommentsQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { @@ -610,7 +610,7 @@ const createCommentRoute = createRoute(withMcpMetadata({ path: '/comments', tags: ["admin"], summary: 'Create a comment library entry', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: CommentSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 201: { @@ -629,7 +629,7 @@ const deleteCommentRoute = createRoute(withMcpMetadata({ path: '/comments/{id}', tags: ["admin"], summary: 'Delete a comment library entry', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -649,7 +649,7 @@ const updateCommentRoute = createRoute(withMcpMetadata({ path: '/comments/{id}', tags: ["admin"], summary: 'Update a comment library entry', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateCommentSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -675,7 +675,7 @@ const touchCommentRoute = createRoute(withMcpMetadata({ path: '/comments/{id}/touch', tags: ['admin'], summary: "Record an inspector's use of a snippet (per-user counter)", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Comment library entry identifier') }) }, responses: { 200: { @@ -696,7 +696,7 @@ const getWidgetOriginsRoute = createRoute(withMcpMetadata({ path: '/widget/origins', tags: ["admin"], summary: 'Get current widget allowed-origin list', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ origins: z.array(z.string()).describe('TODO describe origins field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, @@ -713,7 +713,7 @@ const setWidgetOriginsRoute = createRoute(withMcpMetadata({ path: '/widget/origins', tags: ["admin"], summary: 'Replace widget allowed-origin list', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: z.object({ origins: z.array(z.string().min(1)).max(50).describe('TODO describe origins field for the OpenInspection MCP integration') }).describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { @@ -733,7 +733,7 @@ const getStripeConnectRoute = createRoute(withMcpMetadata({ path: '/stripe-connect', tags: ["admin"], summary: 'Get the tenant Stripe Connect account ID', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ accountId: z.string().nullable().describe('TODO describe accountId field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, @@ -750,7 +750,7 @@ const setStripeConnectRoute = createRoute(withMcpMetadata({ path: '/stripe-connect', tags: ["admin"], summary: 'Set the tenant Stripe Connect account ID', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: StripeConnectAccountSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { @@ -768,7 +768,7 @@ const deleteStripeConnectRoute = createRoute(withMcpMetadata({ path: '/stripe-connect', tags: ["admin"], summary: 'Disconnect the tenant Stripe Connect account', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ accountId: z.null().describe('TODO describe accountId field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, @@ -787,7 +787,7 @@ const getEarningsSummaryRoute = createRoute(withMcpMetadata({ path: '/earnings-summary', tags: ["admin"], summary: 'Get aggregated invoice earnings (paid/pending/count)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { @@ -817,7 +817,7 @@ const icsTokenRoute = createRoute(withMcpMetadata({ path: '/ics-token', tags: ["admin", "calendar"], summary: "List tenant ics token", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { @@ -847,7 +847,7 @@ const getAttentionThresholdsRoute = createRoute(withMcpMetadata({ path: '/attention-thresholds', tags: ["admin"], summary: "List tenant attention thresholds", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, responses: { 200: { content: { 'application/json': { schema: AttentionThresholdsResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, @@ -864,7 +864,7 @@ const updateAttentionThresholdsRoute = createRoute(withMcpMetadata({ path: '/attention-thresholds', tags: ["admin"], summary: "Patch tenant attention threshold", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: AttentionThresholdsSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { @@ -891,7 +891,7 @@ const getDashboardColumnsRoute = createRoute(withMcpMetadata({ path: '/dashboard-columns', tags: ["admin"], summary: 'Get tenant default dashboard column prefs', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: DashboardColumnPrefsResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, @@ -908,7 +908,7 @@ const updateDashboardColumnsRoute = createRoute(withMcpMetadata({ path: '/dashboard-columns', tags: ["admin"], summary: 'Update tenant default dashboard column prefs', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: DashboardColumnPrefsSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { @@ -942,7 +942,7 @@ const tenantConfigGetRoute = createRoute(withMcpMetadata({ path: '/tenant-config', tags: ["admin"], summary: 'Get tenant configuration flags', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: TenantConfigGetResponseSchema.describe('Tenant configuration flags') } }, @@ -980,7 +980,7 @@ const tenantConfigPatchRoute = createRoute(withMcpMetadata({ path: '/tenant-config', tags: ["admin"], summary: 'Patch a small allowlist of tenant_configs columns', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: TenantConfigPatchSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { @@ -1004,7 +1004,7 @@ const migrateFindingKeysRoute = createRoute(withMcpMetadata({ path: '/migrate-finding-keys', tags: ['admin'], summary: 'One-time migration: rewrite legacy finding keys to composite format', - middleware: [requireRole(['owner'])] as const, + middleware: [requireRole('owner')] as const, responses: { 200: { content: { @@ -1063,7 +1063,7 @@ const brSmokeRoute = createRoute(withMcpMetadata({ path: '/system/br-smoke', tags: ['admin'], summary: 'Probe Cloudflare Browser Run binding (env.BROWSER) liveness', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { query: BrSmokeQuerySchema }, responses: { 200: { @@ -1108,7 +1108,7 @@ const erasureLogRoute = createRoute(withMcpMetadata({ path: '/compliance/erasure-log', tags: ['admin'], summary: 'Recent GDPR erasure (DSAR) decision records for the tenant', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, responses: { 200: { content: { 'application/json': { schema: ErasureLogResponseSchema } }, @@ -1147,7 +1147,7 @@ const togglePdfPipelineRoute = createRoute(withMcpMetadata({ path: '/pdf-pipeline', tags: ['admin'], summary: 'Toggle the per-tenant Browser-Run PDF rendering pipeline', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: PdfPipelineToggleSchema } } }, }, @@ -1166,7 +1166,7 @@ const inspectorSignRoute = createRoute(withMcpMetadata({ method: 'post', path: '/agreement-requests/{id}/inspector-sign', tags: ['admin'], summary: 'Inspector pre-signs an agreement before sending to client', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ id: z.string().describe('Agreement request (envelope) identifier') }), body: { content: { 'application/json': { schema: InspectorSignSchema } } }, @@ -1206,7 +1206,7 @@ const listEventTypesRoute = createRoute(withMcpMetadata({ path: '/event-types', tags: ['admin'], summary: 'List scheduling event types', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(z.array(EventTypeRowSchema)) } }, description: 'Event types' }, 401: { description: 'Unauthorized' }, 403: { description: 'Forbidden' }, @@ -1221,7 +1221,7 @@ const createEventTypeRoute = createRoute(withMcpMetadata({ path: '/event-types', tags: ['admin'], summary: 'Create a scheduling event type', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: EventTypeCreateSchema } } } }, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(EventTypeRowSchema) } }, description: 'Created event type' }, @@ -1237,7 +1237,7 @@ const updateEventTypeRoute = createRoute(withMcpMetadata({ path: '/event-types/{id}', tags: ['admin'], summary: 'Update a scheduling event type', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: EventTypeIdParam, body: { content: { 'application/json': { schema: EventTypeUpdateSchema } } } }, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(EventTypeRowSchema) } }, description: 'Updated event type' }, @@ -1253,7 +1253,7 @@ const deleteEventTypeRoute = createRoute(withMcpMetadata({ path: '/event-types/{id}', tags: ['admin'], summary: 'Delete or deactivate a scheduling event type', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: EventTypeIdParam }, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(z.object({ ok: z.literal(true) })) } }, description: 'Deleted/deactivated' }, @@ -1294,7 +1294,7 @@ const getCommunicationRoute = createRoute(withMcpMetadata({ path: '/communication', tags: ['admin'], summary: 'Get tenant communication settings', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(CommunicationResponseSchema) } }, description: 'Communication config' }, 401: { description: 'Unauthorized' }, 403: { description: 'Forbidden' }, @@ -1309,7 +1309,7 @@ const patchCommunicationRoute = createRoute(withMcpMetadata({ path: '/communication', tags: ['admin'], summary: 'Update tenant communication settings', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: CommunicationPatchSchema } } } }, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(z.object({ ok: z.literal(true) })) } }, description: 'Saved' }, diff --git a/server/api/admin/branding.ts b/server/api/admin/branding.ts index 77e4ed326..d02310db6 100644 --- a/server/api/admin/branding.ts +++ b/server/api/admin/branding.ts @@ -16,7 +16,7 @@ const getBrandingRoute = createRoute(withMcpMetadata({ path: '/branding', tags: ["admin"], summary: "List tenant branding for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { @@ -40,7 +40,7 @@ const updateBrandingRoute = createRoute(withMcpMetadata({ path: '/branding', tags: ["admin"], summary: "Create tenant branding for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { @@ -73,7 +73,7 @@ const uploadLogoRoute = createRoute(withMcpMetadata({ path: '/branding/logo', tags: ["admin"], summary: "Create tenant branding logo", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { diff --git a/server/api/agent.ts b/server/api/agent.ts index fb705e0d4..cda4a24f6 100644 --- a/server/api/agent.ts +++ b/server/api/agent.ts @@ -7,6 +7,7 @@ import { requireRole } from '../lib/middleware/rbac'; import { inspections } from '../lib/db/schema/inspection'; import { contacts } from '../lib/db/schema/contact'; import { Errors } from '../lib/errors'; +import { ROLE } from '../lib/auth/roles'; import { AgentReportsQuerySchema, AgentReportsResponseSchema, @@ -235,7 +236,7 @@ const inspectorsRoute = createRoute(withMcpMetadata({ export const agentRoutes = createApiRouter() .openapi(getReportsRoute, async (c) => { // Move RBAC check inside to fix OpenAPIHono type inference issues with context - await requireRole(['office_staff', 'admin'])(c, async () => {}); + await requireRole('manager')(c, async () => {}); const tenantId = c.get('tenantId'); const user = c.get('user'); @@ -245,7 +246,7 @@ export const agentRoutes = createApiRouter() // Admins/owners can pass ?agentId= to view any agent's reports const agentId = - userRole === 'admin' + userRole === ROLE.MANAGER ? (queryAgentId ?? user.sub) : user.sub; @@ -263,13 +264,13 @@ export const agentRoutes = createApiRouter() }, 200); }) .openapi(myRecommendationsRoute, async (c) => { - await requireRole(['agent'])(c, async () => {}); + await requireRole('agent')(c, async () => {}); const user = c.get('user'); const groups = await c.var.services.agent.listRecommendationsForAgent(user.sub); return c.json({ success: true, data: groups }, 200); }) .openapi(getLeaderboardRoute, async (c) => { - await requireRole(['owner', 'admin', 'inspector', 'agent'])(c, async () => {}); + await requireRole('owner', 'manager', 'inspector', 'agent')(c, async () => {}); const tenantId = c.get('tenantId'); const db = drizzle(c.env.DB); @@ -299,7 +300,7 @@ export const agentRoutes = createApiRouter() }, 200); }) .openapi(updateProfileRoute, async (c) => { - await requireRole(['agent'])(c, async () => {}); + await requireRole('agent')(c, async () => {}); const user = c.get('user'); if (!user?.sub) throw Errors.Unauthorized(); @@ -315,7 +316,7 @@ export const agentRoutes = createApiRouter() return c.json({ success: true as const, data: { ok: true as const } }, 200); }) .openapi(conciergeBookRoute, async (c) => { - await requireRole(['agent'])(c, async () => {}); + await requireRole('agent')(c, async () => {}); const agentUserId = c.get('agentUserId'); if (!agentUserId) throw Errors.Unauthorized('Agent identity missing from token'); @@ -336,13 +337,13 @@ export const agentRoutes = createApiRouter() return c.json({ success: true as const, data: result }, 200); }) .openapi(referralsRoute, async (c) => { - await requireRole(['agent'])(c, async () => {}); + await requireRole('agent')(c, async () => {}); const user = c.get('user'); const data = await c.var.services.agent.listReferrals(user.sub, { limit: 100 }); return c.json({ success: true as const, data }, 200); }) .openapi(inspectorsRoute, async (c) => { - await requireRole(['agent'])(c, async () => {}); + await requireRole('agent')(c, async () => {}); const user = c.get('user'); const data = await c.var.services.agent.listInspectors(user.sub); return c.json({ success: true as const, data }, 200); diff --git a/server/api/agents.ts b/server/api/agents.ts index 45925650d..04c381d89 100644 --- a/server/api/agents.ts +++ b/server/api/agents.ts @@ -213,7 +213,7 @@ export const agentsRoutes = createApiRouter() .openapi(inviteRoute, async (c) => { // RBAC moved inside to keep OpenAPIHono context typing happy. Owners, admins, // and rank-and-file inspectors can all invite agents. - await requireRole(['owner', 'admin', 'inspector'])(c, async () => {}); + await requireRole('owner', 'manager', 'inspector')(c, async () => {}); const tenantId = c.get('tenantId'); const user = c.get('user'); @@ -272,7 +272,7 @@ export const agentsRoutes = createApiRouter() }, 200); }) .openapi(listLinksRoute, async (c) => { - await requireRole(['owner', 'admin', 'inspector'])(c, async () => {}); + await requireRole('owner', 'manager', 'inspector')(c, async () => {}); const tenantId = c.get('tenantId'); if (!tenantId) throw Errors.Unauthorized(); const db = drizzle(c.env.DB); @@ -307,7 +307,7 @@ export const agentsRoutes = createApiRouter() return c.json({ success: true as const, data: { links } }, 200); }) .openapi(revokeRoute, async (c) => { - await requireRole(['owner', 'admin', 'inspector'])(c, async () => {}); + await requireRole('owner', 'manager', 'inspector')(c, async () => {}); const tenantId = c.get('tenantId'); if (!tenantId) throw Errors.Unauthorized(); const { linkId } = c.req.valid('param'); diff --git a/server/api/ai.ts b/server/api/ai.ts index 12d8ab8b1..78aa09457 100644 --- a/server/api/ai.ts +++ b/server/api/ai.ts @@ -23,7 +23,7 @@ const commentAssistRoute = createRoute(withMcpMetadata({ path: '/comment-assist', tags: ["ai"], summary: "Create ai comment assist", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { @@ -56,7 +56,7 @@ const autoSummaryRoute = createRoute(withMcpMetadata({ path: '/auto-summary', tags: ["ai"], summary: "Create ai auto summary", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { @@ -95,7 +95,7 @@ const commentEditRoute = createRoute(withMcpMetadata({ path: '/comment/edit', tags: ["ai"], summary: 'Rewrite a canned comment with AI assistance', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: CommentEditSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, }, @@ -114,7 +114,7 @@ const suggestCommentRoute = createRoute(withMcpMetadata({ path: '/suggest-comment', tags: ["ai"], summary: 'Suggest professional comments for a form item', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: SuggestCommentSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, diff --git a/server/api/auth.ts b/server/api/auth.ts index 98a52e095..f1881702e 100644 --- a/server/api/auth.ts +++ b/server/api/auth.ts @@ -260,7 +260,7 @@ const skipSetupRoute = createRoute(withMcpMetadata({ summary: 'Skip the onboarding wizard', description: 'Marks the in-app onboarding wizard as skipped for the current user. Does not affect tenant-level setup or any system configuration.', tags: ['auth'], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { @@ -279,7 +279,7 @@ const dismissChecklistRoute = createRoute(withMcpMetadata({ summary: 'Dismiss the onboarding checklist', description: 'Marks the dashboard onboarding checklist as dismissed for the current user. Idempotent — safe to call multiple times.', tags: ['auth'], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { @@ -303,7 +303,7 @@ const markOnboardingFlagRoute = createRoute(withMcpMetadata({ summary: 'Mark a one-time onboarding flag as seen', description: 'Sets a boolean flag in the current user\'s onboardingState. Allowlisted flags only: checklistDismissed, spectoraMappingSeen. Idempotent.', tags: ['auth'], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { diff --git a/server/api/automations.ts b/server/api/automations.ts index 398113b42..c879d0891 100644 --- a/server/api/automations.ts +++ b/server/api/automations.ts @@ -11,7 +11,7 @@ import { withMcpMetadata } from "../lib/route-metadata-standards"; // GET /api/automations const listRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["automations"], - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: AutomationListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'List' } }, operationId: "listAutomations", summary: "List automations for current tenant", @@ -21,7 +21,7 @@ const listRoute = createRoute(withMcpMetadata({ // POST /api/automations const createAutomationRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["automations"], - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: CreateAutomationSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 201: { content: { 'application/json': { schema: createApiResponseSchema(AutomationSchema) } }, description: 'Created' } }, operationId: "createAutomation", @@ -33,7 +33,7 @@ const createAutomationRoute = createRoute(withMcpMetadata({ // MUST be registered BEFORE /logs/{inspectionId} to avoid path-param shadowing const getRecentLogsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/logs/recent', tags: ["automations"], - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { query: z.object({ limit: z.coerce.number().int().min(1).max(200).optional().describe('TODO describe limit field for the OpenInspection MCP integration') }).describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: AutomationLogListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Recent automation logs' } }, operationId: "listAutomationLogsRecent", @@ -44,7 +44,7 @@ const getRecentLogsRoute = createRoute(withMcpMetadata({ // GET /api/automations/logs/:inspectionId — BEFORE /:id to avoid shadowing const getLogsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/logs/{inspectionId}', tags: ["automations"], - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ inspectionId: z.string().describe('TODO describe inspectionId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: AutomationLogListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Logs' } }, operationId: "getAutomationLog", @@ -55,7 +55,7 @@ const getLogsRoute = createRoute(withMcpMetadata({ // PATCH /api/automations/:id const updateRoute = createRoute(withMcpMetadata({ method: 'patch', path: '/{id}', tags: ["automations"], - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateAutomationSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -69,7 +69,7 @@ const updateRoute = createRoute(withMcpMetadata({ // DELETE /api/automations/:id const deleteRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["automations"], - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Deleted' } }, operationId: "deleteAutomation", diff --git a/server/api/availability.ts b/server/api/availability.ts index 7aaad41dd..0530962d3 100644 --- a/server/api/availability.ts +++ b/server/api/availability.ts @@ -6,6 +6,7 @@ import { availability, availabilityOverrides } from '../lib/db/schema'; import { safeISODate } from '../lib/date'; import { Errors } from '../lib/errors'; import { requireRole } from '../lib/middleware/rbac'; +import { ROLE } from '../lib/auth/roles'; import { AvailabilitySchema, OverrideSchema, @@ -27,7 +28,7 @@ const listAvailabilityRoute = createRoute(withMcpMetadata({ tags: ['bookings'], summary: 'List recurring weekly availability', description: 'Returns the recurring weekly availability slots for an inspector. Defaults to the caller; admins can query any inspector via the inspectorId query parameter.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: z.object({ inspectorId: z.string().uuid().optional().describe('Inspector UUID to query; defaults to the caller when omitted.'), @@ -56,7 +57,7 @@ const updateScheduleRoute = createRoute(withMcpMetadata({ tags: ['bookings'], summary: 'Replace weekly availability schedule', description: 'Replaces the inspector\'s recurring weekly schedule wholesale with the supplied slots. Admins can edit any inspector; others may only edit their own.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { @@ -88,7 +89,7 @@ const listOverridesRoute = createRoute(withMcpMetadata({ tags: ['bookings'], summary: 'List availability override entries', description: 'Returns availability override entries (blocked dates and custom slots) for an inspector. Used by the calendar UI to render day-level adjustments.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: z.object({ inspectorId: z.string().uuid().optional().describe('Inspector UUID to query; defaults to the caller when omitted.'), @@ -116,7 +117,7 @@ const createOverrideRoute = createRoute(withMcpMetadata({ tags: ['bookings'], summary: 'Create an availability override', description: 'Adds a single availability override (block a date, add an unusual slot). Admins may create overrides for any inspector; others only for themselves.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { @@ -148,7 +149,7 @@ const deleteOverrideRoute = createRoute(withMcpMetadata({ tags: ['bookings'], summary: 'Delete an availability override', description: 'Removes the specified availability override entry, restoring the default recurring schedule for that date.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('UUID of the availability override entry to delete.') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, @@ -172,7 +173,7 @@ export const availabilityRoutes = createApiRouter() const userRole = c.get('userRole'); const { inspectorId: queryId } = c.req.valid('query'); - if (queryId && queryId !== user.sub && !['admin', 'owner'].includes(userRole)) { + if (queryId && queryId !== user.sub && ![ROLE.MANAGER, ROLE.OWNER].includes(userRole)) { throw Errors.Forbidden('Can only view your own availability'); } @@ -199,7 +200,7 @@ export const availabilityRoutes = createApiRouter() const inspectorId = body.inspectorId || user.sub; - if (inspectorId !== user.sub && !['admin', 'owner'].includes(userRole)) { + if (inspectorId !== user.sub && ![ROLE.MANAGER, ROLE.OWNER].includes(userRole)) { throw Errors.Forbidden('Can only manage your own availability'); } @@ -215,7 +216,7 @@ export const availabilityRoutes = createApiRouter() const userRole = c.get('userRole'); const { inspectorId: queryId } = c.req.valid('query'); - if (queryId && queryId !== user.sub && !['admin', 'owner'].includes(userRole)) { + if (queryId && queryId !== user.sub && ![ROLE.MANAGER, ROLE.OWNER].includes(userRole)) { throw Errors.Forbidden('Can only view your own availability'); } @@ -240,7 +241,7 @@ export const availabilityRoutes = createApiRouter() const body = c.req.valid('json'); const inspectorId = body.inspectorId || user.sub; - if (inspectorId !== user.sub && !['admin', 'owner'].includes(userRole)) { + if (inspectorId !== user.sub && ![ROLE.MANAGER, ROLE.OWNER].includes(userRole)) { throw Errors.Forbidden('Can only manage your own availability'); } diff --git a/server/api/billing.ts b/server/api/billing.ts index d0bdcacda..d616de392 100644 --- a/server/api/billing.ts +++ b/server/api/billing.ts @@ -44,11 +44,10 @@ export const billingRoutes = createApiRouter() if (!tenant) throw Errors.NotFound('Tenant not found'); const rows = await db.select({ - id: usersTbl.id, - expiresAt: usersTbl.expiresAt, + id: usersTbl.id, }).from(usersTbl).where(eq(usersTbl.tenantId, tenantId)).all(); - const summary = summariseSeats(rows, tenant, Math.floor(Date.now() / 1000)); + const summary = summariseSeats(rows, tenant); // Portal Customer Portal redirect URL — surfaced for the "Manage // billing" CTA on the page. Omitted when the portal isn't wired diff --git a/server/api/calendar-events.ts b/server/api/calendar-events.ts index 16f6c3756..f556197c6 100644 --- a/server/api/calendar-events.ts +++ b/server/api/calendar-events.ts @@ -51,7 +51,7 @@ const eventsRoute = createRoute(withMcpMetadata({ tags: ['calendar'], summary: 'Get calendar events for FullCalendar', description: 'Returns combined calendar events (local inspections + Google Calendar busy blocks) in FullCalendar-compatible format. Used by the dashboard month/week views.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: z.object({ // Accept either YYYY-MM-DD (FullCalendar dayGridMonth view) or full ISO 8601 diff --git a/server/api/contacts.ts b/server/api/contacts.ts index 63463f91e..ee5f0a4be 100644 --- a/server/api/contacts.ts +++ b/server/api/contacts.ts @@ -1,6 +1,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { createApiRouter } from '../lib/openapi-router'; import { requireRole } from '../lib/middleware/rbac'; +import { requireCapability } from '../lib/middleware/require-capability'; import { CreateContactSchema, UpdateContactSchema, ContactResponseSchema, ContactListQuerySchema, @@ -11,7 +12,7 @@ import { withMcpMetadata } from "../lib/route-metadata-standards"; const listContactsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["contacts"], summary: "List contacts for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { query: ContactListQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { @@ -27,7 +28,7 @@ const listContactsRoute = createRoute(withMcpMetadata({ const getContactDetailRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}', tags: ["contacts"], summary: "Contact detail: record + inspection history + stats", - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ id: z.string().min(1).describe('Contact identifier') }) }, responses: { 200: { @@ -44,7 +45,8 @@ const getContactDetailRoute = createRoute(withMcpMetadata({ const createContactRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["contacts"], summary: "Create contact for current tenant", - middleware: [requireRole(['owner', 'admin'])], + // Task 10 — manageContacts capability gates contact CREATE/UPDATE/DELETE. + middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('manageContacts')], request: { body: { content: { 'application/json': { schema: CreateContactSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 201: { @@ -60,7 +62,8 @@ const createContactRoute = createRoute(withMcpMetadata({ const updateContactRoute = createRoute(withMcpMetadata({ method: 'put', path: '/{id}', tags: ["contacts"], summary: "Replace contact for current tenant", - middleware: [requireRole(['owner', 'admin'])], + // Task 10 — manageContacts capability gates contact CREATE/UPDATE/DELETE. + middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('manageContacts')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateContactSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -79,7 +82,8 @@ const updateContactRoute = createRoute(withMcpMetadata({ const deleteContactRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["contacts"], summary: "Delete contact for current tenant", - middleware: [requireRole(['owner', 'admin'])], + // Task 10 — manageContacts capability gates contact CREATE/UPDATE/DELETE. + middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('manageContacts')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { diff --git a/server/api/contacts/import.ts b/server/api/contacts/import.ts index 534a65304..6dd3fa90e 100644 --- a/server/api/contacts/import.ts +++ b/server/api/contacts/import.ts @@ -13,7 +13,7 @@ import { withMcpMetadata } from '../../lib/route-metadata-standards'; const importPreviewRoute = createRoute(withMcpMetadata({ method: 'post', path: '/import/preview', tags: ['contacts'], summary: 'Preview parsed CSV rows for the contact-import mapping UI', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: ContactImportPreviewSchema } } }, }, @@ -31,7 +31,7 @@ const importPreviewRoute = createRoute(withMcpMetadata({ const importRoute = createRoute(withMcpMetadata({ method: 'post', path: '/import', tags: ['contacts'], summary: 'Bulk-insert contacts from a CSV blob + mapping', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: ContactImportSchema } } }, }, diff --git a/server/api/contractor-types.ts b/server/api/contractor-types.ts index b210e3c8d..cfe3fbdda 100644 --- a/server/api/contractor-types.ts +++ b/server/api/contractor-types.ts @@ -24,7 +24,7 @@ const listContractorTypesRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["contractor-types"], summary: 'List contractor types for current tenant', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: {}, responses: { 200: { content: { 'application/json': { schema: ContractorTypeListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'List' }, @@ -37,7 +37,7 @@ const listContractorTypesRoute = createRoute(withMcpMetadata({ const createContractorTypeRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["contractor-types"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: CreateContractorTypeSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: ContractorTypeResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Created' }, @@ -51,7 +51,7 @@ const createContractorTypeRoute = createRoute(withMcpMetadata({ const updateContractorTypeRoute = createRoute(withMcpMetadata({ method: 'patch', path: '/{id}', tags: ["contractor-types"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateContractorTypeSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -68,7 +68,7 @@ const updateContractorTypeRoute = createRoute(withMcpMetadata({ const deleteContractorTypeRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["contractor-types"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ deleted: z.literal(true).describe('TODO describe deleted field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, description: 'Deleted' }, @@ -83,7 +83,7 @@ const reorderContractorTypesRoute = createRoute(withMcpMetadata({ method: 'post', path: '/reorder', tags: ["contractor-types"], summary: 'Reorder contractor types (persist the supplied id order as sortOrder)', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: ReorderContractorTypesSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ reordered: z.literal(true).describe('TODO describe reordered field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, description: 'Reordered' }, diff --git a/server/api/data.ts b/server/api/data.ts index 24cf0bb03..5281adb4f 100644 --- a/server/api/data.ts +++ b/server/api/data.ts @@ -6,7 +6,7 @@ import { Errors } from '../lib/errors'; export const dataRoutes = createApiRouter() // GET /api/data/export/inspections — CSV download - .get('/export/inspections', requireRole(['owner', 'admin']), async (c) => { + .get('/export/inspections', requireRole('owner', 'manager'), async (c) => { const tenantId = c.get('tenantId'); const svc = new DataService(c.env.DB); const csv = await svc.exportInspectionsCSV(tenantId); @@ -19,7 +19,7 @@ export const dataRoutes = createApiRouter() }); }) // GET /api/data/export/contacts — CSV download - .get('/export/contacts', requireRole(['owner', 'admin']), async (c) => { + .get('/export/contacts', requireRole('owner', 'manager'), async (c) => { const tenantId = c.get('tenantId'); const svc = new DataService(c.env.DB); const csv = await svc.exportContactsCSV(tenantId); @@ -33,7 +33,7 @@ export const dataRoutes = createApiRouter() }) // POST /api/data/import/contacts — multipart/form-data or text/csv body // Query: ?dry_run=true — parse and count rows without writing to DB - .post('/import/contacts', requireRole(['owner', 'admin']), async (c) => { + .post('/import/contacts', requireRole('owner', 'manager'), async (c) => { const tenantId = c.get('tenantId'); const dryRun = c.req.query('dry_run') === 'true'; const contentType = c.req.header('content-type') ?? ''; diff --git a/server/api/email-templates.ts b/server/api/email-templates.ts index 6088cb1f2..19d5a0de5 100644 --- a/server/api/email-templates.ts +++ b/server/api/email-templates.ts @@ -80,7 +80,7 @@ const listRoute = createRoute(withMcpMetadata({ path: '/email-templates', tags: ['admin'], summary: 'List editable email templates', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: TemplateListResponseSchema } }, @@ -97,7 +97,7 @@ const getRoute = createRoute(withMcpMetadata({ path: '/email-templates/{trigger}', tags: ['admin'], summary: 'Get email template detail', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: TriggerParamSchema, }, @@ -121,7 +121,7 @@ const putRoute = createRoute(withMcpMetadata({ path: '/email-templates/{trigger}', tags: ['admin'], summary: 'Save email template override', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: TriggerParamSchema, body: { content: { 'application/json': { schema: SaveEmailTemplateSchema } } }, @@ -150,7 +150,7 @@ const resetRoute = createRoute(withMcpMetadata({ path: '/email-templates/{trigger}/reset', tags: ['admin'], summary: 'Reset email template to defaults', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: TriggerParamSchema, }, @@ -174,7 +174,7 @@ const previewRoute = createRoute(withMcpMetadata({ path: '/email-templates/{trigger}/preview', tags: ['admin'], summary: 'Preview email template with unsaved edits', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: TriggerParamSchema, body: { content: { 'application/json': { schema: PreviewEmailTemplateSchema } } }, diff --git a/server/api/events.ts b/server/api/events.ts index a0b5447d7..0e343c9f3 100644 --- a/server/api/events.ts +++ b/server/api/events.ts @@ -29,39 +29,39 @@ const EventStatusBody = z.object({ // ---- Event types CRUD ---- export const eventsRoutes = createApiRouter() - .get('/event-types', requireRole(['owner', 'admin', 'inspector']), async (c) => { + .get('/event-types', requireRole('owner', 'manager', 'inspector'), async (c) => { const data = await c.var.services.event.listEventTypes(c.get('tenantId')); return c.json({ success: true, data }); }) - .post('/event-types', requireRole(['owner', 'admin']), async (c) => { + .post('/event-types', requireRole('owner', 'manager'), async (c) => { const parsed = TypeBody.safeParse(await c.req.json()); if (!parsed.success) throw Errors.BadRequest('Invalid event type', parsed.error.flatten().fieldErrors); const row = await c.var.services.event.createEventType(c.get('tenantId'), parsed.data); return c.json({ success: true, data: row }, 201); }) - .put('/event-types/:id', requireRole(['owner', 'admin']), async (c) => { + .put('/event-types/:id', requireRole('owner', 'manager'), async (c) => { const id = c.req.param('id') as string; const parsed = TypeBody.partial().safeParse(await c.req.json()); if (!parsed.success) throw Errors.BadRequest('Invalid event type', parsed.error.flatten().fieldErrors); await c.var.services.event.updateEventType(c.get('tenantId'), id, parsed.data); return c.json({ success: true }); }) - .delete('/event-types/:id', requireRole(['owner', 'admin']), async (c) => { + .delete('/event-types/:id', requireRole('owner', 'manager'), async (c) => { const id = c.req.param('id') as string; await c.var.services.event.deactivateEventType(c.get('tenantId'), id); return c.json({ success: true }); }) - .post('/event-types/seed', requireRole(['owner', 'admin']), async (c) => { + .post('/event-types/seed', requireRole('owner', 'manager'), async (c) => { const r = await c.var.services.event.bulkSeed(c.get('tenantId')); return c.json({ success: true, data: r }); }) // ---- Inspection events ---- - .get('/inspections/:inspectionId/events', requireRole(['owner', 'admin', 'inspector']), async (c) => { + .get('/inspections/:inspectionId/events', requireRole('owner', 'manager', 'inspector'), async (c) => { const inspectionId = c.req.param('inspectionId') as string; const data = await c.var.services.event.listInspectionEvents(c.get('tenantId'), inspectionId); return c.json({ success: true, data }); }) - .post('/inspections/:inspectionId/events', requireRole(['owner', 'admin', 'inspector']), async (c) => { + .post('/inspections/:inspectionId/events', requireRole('owner', 'manager', 'inspector'), async (c) => { const inspectionId = c.req.param('inspectionId') as string; const parsed = EventBody.safeParse(await c.req.json()); if (!parsed.success) throw Errors.BadRequest('Invalid event', parsed.error.flatten().fieldErrors); @@ -71,19 +71,19 @@ export const eventsRoutes = createApiRouter() }); return c.json({ success: true, data: row }, 201); }) - .put('/events/:id', requireRole(['owner', 'admin', 'inspector']), async (c) => { + .put('/events/:id', requireRole('owner', 'manager', 'inspector'), async (c) => { const id = c.req.param('id') as string; const parsed = EventStatusBody.safeParse(await c.req.json()); if (!parsed.success) throw Errors.BadRequest('Invalid status', parsed.error.flatten().fieldErrors); await c.var.services.event.updateEventStatus(c.get('tenantId'), id, parsed.data.status); return c.json({ success: true }); }) - .delete('/events/:id', requireRole(['owner', 'admin']), async (c) => { + .delete('/events/:id', requireRole('owner', 'manager'), async (c) => { const id = c.req.param('id') as string; await c.var.services.event.deleteEvent(c.get('tenantId'), id); return c.json({ success: true }); }) - .get('/events/upcoming', requireRole(['owner', 'admin', 'inspector']), async (c) => { + .get('/events/upcoming', requireRole('owner', 'manager', 'inspector'), async (c) => { const days = parseInt(c.req.query('days') || '7', 10); const from = Date.now(); const to = from + days * 86_400_000; diff --git a/server/api/evidence.ts b/server/api/evidence.ts index c29223c1c..7e3f7639d 100644 --- a/server/api/evidence.ts +++ b/server/api/evidence.ts @@ -94,7 +94,7 @@ const downloadAgreementRoute = createRoute(withMcpMetadata({ path: '/agreement-requests/{id}/pdf', tags: ['admin'], summary: 'Download signed agreement PDF (Worker-proxied from R2)', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ id: z.string().describe('Agreement request (envelope) identifier') }) }, responses: { 200: { content: { 'application/pdf': { schema: z.any() } }, description: 'PDF bytes' }, @@ -109,7 +109,7 @@ const downloadCertRoute = createRoute(withMcpMetadata({ path: '/agreement-requests/{id}/certificate.pdf', tags: ['admin'], summary: 'Download Certificate of Completion PDF', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ id: z.string().describe('Agreement request (envelope) identifier') }) }, responses: { 200: { content: { 'application/pdf': { schema: z.any() } }, description: 'PDF bytes' }, @@ -124,7 +124,7 @@ const downloadEvidenceRoute = createRoute(withMcpMetadata({ path: '/agreement-requests/{id}/evidence.zip', tags: ['admin'], summary: 'Download evidence pack zip', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ id: z.string().describe('Agreement request (envelope) identifier') }) }, responses: { 200: { content: { 'application/zip': { schema: z.any() } }, description: 'evidence zip' }, diff --git a/server/api/guest.ts b/server/api/guest.ts deleted file mode 100644 index 92e659952..000000000 --- a/server/api/guest.ts +++ /dev/null @@ -1,126 +0,0 @@ -/** - * Design System 0520 subsystem C phase 6 — anonymous guest claim route. - * - * `POST /api/guest/claim` runs without a JWT. The caller proves - * authorisation by presenting the random invite `token` minted by an - * admin via `POST /api/team/guests` (Phase 6 task 6.2). We look up the - * invite to discover the tenant, pull the tenant's seat quota from - * `tenants.max_users`, and delegate to `GuestInviteService.claim`. - * - * This route is JWT-exempt: the JWT middleware in `server/index.ts` adds - * `/api/guest/` to its public-path list. - */ -import { createRoute, z } from '@hono/zod-openapi'; -import { createApiRouter } from '../lib/openapi-router'; -import { Errors } from '../lib/errors'; -import { sendSuccess } from '../lib/response'; -import { withMcpMetadata } from "../lib/route-metadata-standards"; -import { getLegalLinks, buildTermsAcceptedBlob } from '../lib/legal-links'; - -const claimRoute = createRoute(withMcpMetadata({ - method: 'post', - path: '/claim', - tags: ["guest"], - summary: 'Anonymously claim a guest invite token', - request: { - body: { content: { 'application/json': { schema: z.object({ - token: z.string().min(20).max(128).describe('TODO describe token field for the OpenInspection MCP integration'), - name: z.string().min(1).max(100).describe('TODO describe name field for the OpenInspection MCP integration'), - email: z.string().email().describe('TODO describe email field for the OpenInspection MCP integration'), - password: z.string().min(8).max(128).describe('TODO describe password field for the OpenInspection MCP integration'), - // Legal-links feature — required (true) only when the operator configured - // TERMS_URL/PRIVACY_URL; enforced in the handler, optional on the wire. - termsAccepted: z.boolean().optional().describe('Acceptance of operator Terms of Service and Privacy Policy; required when the operator has configured TERMS_URL/PRIVACY_URL'), - }).describe('TODO describe schema field for the OpenInspection MCP integration') } } }, - }, - responses: { - 200: { - description: 'Claim succeeded', - content: { 'application/json': { schema: z.object({ - success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration'), - data: z.object({ userId: z.string().describe('TODO describe userId field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration'), - }) } }, - }, - 400: { description: 'Invalid input' }, - 402: { description: 'Tenant at seat cap' }, - 404: { description: 'Not found / expired / already claimed' }, - }, - operationId: "createGuestClaim", - description: "Auto-generated placeholder for createGuestClaim (POST /claim, guest domain). TODO: replace with a real description sourced from the handler." -}, { scopes: [], tier: 'extended' })); - -// C-10 ③-B — GET /api/guest/invite-info?token= — preview the workspace + role -// a guest invite grants, for the /guest-join accept page (JWT-exempt, like claim). -const inviteInfoRoute = createRoute(withMcpMetadata({ - method: 'get', - path: '/invite-info', - tags: ["guest"], - summary: 'Resolve a guest invite token for the accept page', - request: { query: z.object({ token: z.string().describe('Guest invite token from the URL.') }) }, - responses: { - 200: { - description: 'Invite preview', - content: { 'application/json': { schema: z.object({ - success: z.boolean().describe('Always true on the 200 path.'), - data: z.object({ - workspaceName: z.string().describe('Inviting workspace name.'), - role: z.string().describe('Role the invite grants (lead/specialist/apprentice/office).'), - expiresAt: z.number().describe('Invite expiry (unix epoch seconds).'), - }).describe('Guest invite preview.'), - }) } }, - }, - 404: { description: 'Not found / expired / already claimed' }, - }, - operationId: "getGuestInviteInfo", - description: "Public, no-login resolution of a guest invite token into the workspace name + granted role + expiry for the /guest-join page. 404 for unknown/expired/claimed tokens.", -}, { scopes: [], tier: 'extended' })); - -export const guestRoutes = createApiRouter() - .openapi(inviteInfoRoute, async (c) => { - const { token } = c.req.valid('query'); - const info = await c.var.services.guestInvite.getInviteInfo(token); - if (!info) throw Errors.NotFound('Invalid or expired invite token'); - return sendSuccess(c, info); - }) - .openapi(claimRoute, async (c) => { - const body = c.req.valid('json'); - - const links = getLegalLinks(c.env); - if (links && body.termsAccepted !== true) { - throw Errors.BadRequest('You must accept the terms to create an account.'); - } - - // Resolve the invite's tenant + seat cap before the service call so we - // can pass maxUsers without touching ScopedDB (which needs a JWT we - // don't have on this route). Hash-aware — the token is stored hashed. - const resolved = await c.var.services.guestInvite.resolveTenantForToken(body.token); - if (!resolved) throw Errors.NotFound('Invalid or unknown invite token'); - - const out = await c.var.services.guestInvite.claim(body.token, body, { - maxUsers: resolved.maxUsers, - enforceSeatQuota: c.var.profile.hasSeatQuota, - ...(links ? { termsAccepted: buildTermsAcceptedBlob(links, { - ip: c.req.header('CF-Connecting-IP'), - country: (c.req.raw.cf?.country as string | undefined), - }) } : {}), - }); - - switch (out.kind) { - case 'ok': - return sendSuccess(c, { userId: out.userId }); - case 'expired': - throw Errors.NotFound('Invite has expired'); - case 'claimed': - throw Errors.NotFound('Invite has already been claimed'); - case 'not_found': - throw Errors.NotFound('Invalid or unknown invite token'); - case 'over_quota': - throw Errors.SeatLimitReached({ used: resolved.maxUsers, max: resolved.maxUsers, billingPortalUrl: null }); - case 'invalid': - throw Errors.Validation({ reason: out.reason }); - } - }); - -export type GuestApi = typeof guestRoutes; - -export default guestRoutes; diff --git a/server/api/inspection-requests.ts b/server/api/inspection-requests.ts index 60f4ba5f7..e2e9324dc 100644 --- a/server/api/inspection-requests.ts +++ b/server/api/inspection-requests.ts @@ -27,7 +27,7 @@ const listRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["inspections"], summary: "List inspection requests for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: InspectionRequestListQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { @@ -44,7 +44,7 @@ const detailRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}', tags: ["inspections"], summary: "Get inspection request for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -66,7 +66,7 @@ const byInspectionRoute = createRoute(withMcpMetadata({ method: 'get', path: '/by-inspection/{inspectionId}', tags: ["inspections"], summary: 'Get parent request by inspection id', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ inspectionId: z.string().min(1).describe('TODO describe inspectionId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -90,7 +90,7 @@ const createReqRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["inspections"], summary: 'Create inspection request with N sub-inspections', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: CreateInspectionRequestSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, }, @@ -109,7 +109,7 @@ const updateReqRoute = createRoute(withMcpMetadata({ method: 'put', path: '/{id}', tags: ["inspections"], summary: "Replace inspection request for current tenant", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateInspectionRequestSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -129,7 +129,7 @@ const addSubRoute = createRoute(withMcpMetadata({ method: 'post', path: '/{id}/inspections', tags: ["inspections"], summary: 'Add a sub-inspection to an existing request', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { diff --git a/server/api/inspection-sync.ts b/server/api/inspection-sync.ts index dddca1eb3..448925d8f 100644 --- a/server/api/inspection-sync.ts +++ b/server/api/inspection-sync.ts @@ -23,7 +23,7 @@ export const syncRoutes = createApiRouter() path: '/{id}/results/merge', tags: ["inspections"], summary: 'Three-way merge sync of offline results', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: ResultsMergeRequestSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -151,7 +151,7 @@ export const syncRoutes = createApiRouter() path: '/{id}/items/{itemId}/photos/{photoIndex}', tags: ["inspections"], summary: 'Authoritative delete of a photo from a result item', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), @@ -210,7 +210,7 @@ export const syncRoutes = createApiRouter() path: '/{id}/inspector-signature', tags: ["inspections"], summary: 'Record inspector signature on an inspection (authenticated)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: InspectorSignatureSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -267,7 +267,7 @@ export const syncRoutes = createApiRouter() path: '/{id}/template/upgrade', tags: ["inspections"], summary: 'Upgrade inspection template snapshot to current master version', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ from: z.number().describe('TODO describe from field for the OpenInspection MCP integration'), to: z.number().describe('TODO describe to field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, description: 'Upgraded' } }, operationId: "upgradeInspection", diff --git a/server/api/inspections.ts b/server/api/inspections.ts index 2db288af9..011665cd2 100644 --- a/server/api/inspections.ts +++ b/server/api/inspections.ts @@ -2,6 +2,7 @@ import { createRoute, z } from '@hono/zod-openapi'; import { HonoConfig } from '../types/hono'; import { createApiRouter } from '../lib/openapi-router'; import { requireRole } from '../lib/middleware/rbac'; +import { requireCapability } from '../lib/middleware/require-capability'; import { auditFromContext } from '../lib/audit'; import { getBookingHost } from '../lib/url'; import { reportUrl as buildReportUrl, agreementSignUrl } from '../lib/public-urls'; @@ -104,7 +105,7 @@ const dashboardRoute = createRoute(withMcpMetadata({ path: '/dashboard', tags: ["inspections"], summary: 'Bucketed inspections for dashboard', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(DashboardResponseSchema) } }, @@ -125,7 +126,7 @@ const listInspectionsRoute = createRoute(withMcpMetadata({ tags: ["inspections"], summary: "List inspections for current tenant", description: 'Retrieve a paginated list of inspections with optional filtering.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: InspectionListQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration'), }, @@ -190,7 +191,7 @@ const listTemplateDuplicatesRoute = createRoute(withMcpMetadata({ tags: ["inspections", "templates"], summary: 'List duplicate marketplace imports', description: 'Returns one entry per marketplace template ID that has more than one local copy.', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -260,7 +261,7 @@ const createTemplateRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 201: { content: { @@ -306,7 +307,7 @@ const importSpectoraRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 201: { content: { @@ -342,7 +343,7 @@ const updateTemplateRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -369,7 +370,7 @@ const deleteTemplateRoute = createRoute(withMcpMetadata({ request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -393,7 +394,7 @@ const listInspectorsRoute = createRoute(withMcpMetadata({ path: '/inspectors', tags: ["inspections"], summary: "List inspection inspectors for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -436,7 +437,13 @@ const bulkUpdateRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + // Task 10 — bulk assignInspector is the canonical "schedule a DIFFERENT + // inspector" mutation, so the scheduleOthers capability gates this route. + // owner/admin always pass; an inspector only passes with an explicit + // {scheduleOthers:true} override. NOTE: this route also serves the + // updateStatus bulk action, which is correspondingly gated (acceptable — + // bulk status changes are an admin-grade operation). + middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('scheduleOthers')], responses: { 200: { content: { @@ -459,7 +466,7 @@ const getCountsRoute = createRoute(withMcpMetadata({ path: '/counts', tags: ["inspections"], summary: 'Get inspection tab counts', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(InspectionCountsSchema) } }, @@ -478,7 +485,7 @@ const scheduleConflictsRoute = createRoute(withMcpMetadata({ path: '/schedule-conflicts', tags: ['inspections'], summary: 'Detect same-day-hour assignment conflicts for an inspector', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: z.object({ inspectorId: z.string().min(1).optional().describe('Inspector user id to check; defaults to the caller (solo wizard flow assigns the creator).'), @@ -558,7 +565,7 @@ const deleteInspectionRoute = createRoute(withMcpMetadata({ id: z.string().uuid().openapi({ example: '550e8400-e29b-41d4-a716-446655440000' }).describe('TODO describe id field for the OpenInspection MCP integration'), }).describe('TODO describe params field for the OpenInspection MCP integration'), }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -594,7 +601,7 @@ const updateInspectionRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -623,7 +630,7 @@ const getPropertyFactsRoute = createRoute(withMcpMetadata({ request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { 'application/json': { schema: PropertyFactsResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, @@ -649,7 +656,7 @@ const updatePropertyFactsRoute = createRoute(withMcpMetadata({ params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: PropertyFactsSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { 'application/json': { schema: PropertyFactsResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, @@ -684,7 +691,7 @@ const autofillPropertyFactsRoute = createRoute(withMcpMetadata({ params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: PropertyFactsAutofillRequestSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, }, - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: PropertyFactsAutofillResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, @@ -739,7 +746,7 @@ const updateResultsRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -773,7 +780,7 @@ const updateTemplateSnapshotRoute = createRoute(withMcpMetadata({ tags: ["inspections"], summary: 'Replace the per-inspection template snapshot', description: 'Replaces the templateSnapshot JSON wholesale. Validated against TemplateSchemaV2. Used by the inspection editor for inline structural edits (rating system swap, add/remove section/item).', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('Inspection ID') }), body: { content: { 'application/json': { schema: PatchTemplateSnapshotBodySchema } } }, @@ -809,7 +816,7 @@ const switchRatingSystemRoute = createRoute(withMcpMetadata({ tags: ["inspections"], summary: 'Switch the rating system on the per-inspection snapshot', description: 'Swaps the per-inspection ratingSystem to the target system. mode="remap" maps existing item ratings by severity bucket; mode="clear" wipes them. Notes/photos/canned comments preserved. Clears the inspection_results.ratingSystemSnapshot freeze so the new system applies end-to-end.', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('Inspection ID') }), body: { content: { 'application/json': { schema: SwitchRatingSystemSchema } } }, @@ -831,7 +838,7 @@ const aggregateRecommendationsRoute = createRoute(withMcpMetadata({ path: '/{id}/recommendations', tags: ["inspections"], summary: 'Aggregate all attached recommendations + totals for repair list', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: AggregatedRecommendationsResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Aggregated recommendations' }, @@ -859,7 +866,7 @@ const createInspectionRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 201: { content: { @@ -887,7 +894,7 @@ const cloneInspectionRoute = createRoute(withMcpMetadata({ request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 201: { content: { @@ -932,7 +939,7 @@ const uploadPhotoRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -966,7 +973,7 @@ const servePhotoRoute = createRoute(withMcpMetadata({ path: '/{id}/photo', tags: ["inspections"], summary: 'Serve an inspection photo (tenant-scoped)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('Inspection id that scopes the photo.') }), query: z.object({ @@ -997,7 +1004,7 @@ const mediaCenterRoute = createRoute(withMcpMetadata({ path: '/{id}/media', tags: ["inspections"], summary: 'Media Center — all attached + pool photos', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -1014,7 +1021,7 @@ const mediaUploadRoute = createRoute(withMcpMetadata({ path: '/{id}/media/upload', tags: ["inspections"], summary: 'Upload a photo to the inspection media pool (loose, unattached)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { @@ -1047,7 +1054,7 @@ const mediaAttachRoute = createRoute(withMcpMetadata({ path: '/{id}/media/attach', tags: ["inspections"], summary: 'Attach a pool photo to an inspection item', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: MediaAttachRequestSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -1067,7 +1074,7 @@ const mediaPoolDeleteRoute = createRoute(withMcpMetadata({ path: '/{id}/media/pool/{poolId}', tags: ["inspections"], summary: 'Delete a pool photo (cancel an upload)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), poolId: z.string().min(1).describe('TODO describe poolId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, @@ -1089,7 +1096,7 @@ const updateMediaAnnotationsRoute = createRoute(withMcpMetadata({ path: '/{id}/media/{mediaId}/annotations', tags: ["inspections"], summary: 'Save PhotoStudio annotation overlay + caption', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), mediaId: z.string().min(1).describe('TODO describe mediaId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { @@ -1160,7 +1167,7 @@ const completeInspectionRoute = createRoute(withMcpMetadata({ request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { @@ -1181,7 +1188,7 @@ const sendReportPdfRoute = createRoute(withMcpMetadata({ path: '/{id}/send-report-pdf', tags: ["inspections"], summary: 'Re-send the inspection report as a PDF email attachment', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { @@ -1330,7 +1337,7 @@ const getRepairListRoute = createRoute(withMcpMetadata({ path: '/{id}/repair-list', tags: ["inspections"], summary: 'Get aggregated repair list (defects-only punch list)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -1365,7 +1372,7 @@ const recipientsRoute = createRoute(withMcpMetadata({ path: '/{id}/recipients', tags: ["inspections"], summary: 'List the recipients eligible for the Publish modal', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -1387,7 +1394,7 @@ const peopleRoute = createRoute(withMcpMetadata({ path: '/{id}/people', tags: ["inspections"], summary: 'People card payload (inspector, client, agents)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -1413,7 +1420,7 @@ const hubRoute = createRoute(withMcpMetadata({ path: '/{id}/hub', tags: ['inspections'], summary: 'Aggregate hub payload (people, schedule, services, agreement, invoice, report status)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Inspection identifier') }) }, responses: { 200: { @@ -1440,7 +1447,7 @@ const sendAgreementRequestRoute = createRoute(withMcpMetadata({ path: '/{id}/agreement-requests', tags: ['inspections'], summary: 'Create + email an agreement signing request for an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Inspection identifier') }), body: { content: { 'application/json': { schema: SendAgreementRequestSchema } } }, @@ -1466,7 +1473,10 @@ const publishRoute = createRoute(withMcpMetadata({ path: '/{id}/publish', tags: ["inspections"], summary: "Publish inspection for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + // Task 10 — publish capability layered on top of the role gate. owner/admin + // always pass; an inspector with permission_overrides {publish:false} + // ("requires review") is 403'd here. + middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('publish')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { @@ -1502,7 +1512,7 @@ const reinspectRoute = createRoute(withMcpMetadata({ path: '/{id}/reinspect', tags: ['inspections'], summary: 'Create a re-inspection from this (published) baseline report', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('Baseline inspection id (original or a prior re-inspection; must be published).') }), body: { content: { 'application/json': { schema: CreateReinspectionSchema } } }, @@ -1526,7 +1536,7 @@ const reinspectCandidatesRoute = createRoute(withMcpMetadata({ path: '/{id}/reinspect-candidates', tags: ['inspections'], summary: 'Candidate carry-forward items for a re-inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Baseline inspection id (the published report to re-inspect).') }) }, responses: { 200: { @@ -1587,7 +1597,7 @@ const saveAnnotationRoute = createRoute(withMcpMetadata({ }, }, }, - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(z.object({ annotatedKey: z.string().describe('TODO describe annotatedKey field for the OpenInspection MCP integration') })) } }, @@ -1610,7 +1620,7 @@ const approveConciergeRoute = createRoute(withMcpMetadata({ path: '/{id}/concierge/approve', tags: ["inspections"], summary: 'Approve a concierge booking awaiting inspector review', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, @@ -1638,7 +1648,7 @@ const createFromWizardRoute = createRoute(withMcpMetadata({ path: '/wizard', tags: ["inspections"], summary: 'Create an inspection from the 4-step NewInspectionWizard', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: CreateInspectionFromWizardSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, }, @@ -1669,7 +1679,7 @@ const patchItemFieldRoute = createRoute(withMcpMetadata({ path: '/{id}/items/{itemId}', tags: ["inspections"], summary: 'Patch a single item field with optimistic-concurrency version check', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), itemId: z.string().min(1).describe('TODO describe itemId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: PatchItemFieldSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -1729,7 +1739,7 @@ const createUnitRoute = createRoute(withMcpMetadata({ path: '/{id}/units', tags: ["inspections"], summary: 'Create a unit (Building / Floor / Unit) under an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: CreateUnitSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -1747,7 +1757,7 @@ const listUnitsRoute = createRoute(withMcpMetadata({ path: '/{id}/units', tags: ["inspections"], summary: 'List units for an inspection (flat — client builds tree)', - middleware: [requireRole(['owner', 'admin', 'inspector', 'agent'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector', 'agent')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { description: 'ok' }, @@ -1761,7 +1771,7 @@ const updateUnitRoute = createRoute(withMcpMetadata({ path: '/{id}/units/{unitId}', tags: ["inspections"], summary: 'Rename or re-sort a unit', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), unitId: z.string().min(1).describe('TODO describe unitId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateUnitSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -1776,7 +1786,7 @@ const deleteUnitRoute = createRoute(withMcpMetadata({ path: '/{id}/units/{unitId}', tags: ["inspections"], summary: 'Delete a unit (cascades to children)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), unitId: z.string().min(1).describe('TODO describe unitId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { description: 'ok', content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, operationId: "deleteInspectionUnit", @@ -1788,7 +1798,7 @@ const moveUnitRoute = createRoute(withMcpMetadata({ path: '/{id}/units/{unitId}/move', tags: ["inspections"], summary: 'Reparent + reorder atomically (cycle-detected)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), unitId: z.string().min(1).describe('TODO describe unitId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: MoveUnitSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -1813,7 +1823,7 @@ const mintObserverLinkRoute = createRoute(withMcpMetadata({ path: '/{id}/observer-links', tags: ["inspections"], summary: 'Mint a no-account read-only viewer link', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: z.object({ @@ -1830,7 +1840,7 @@ const listObserverLinksRoute = createRoute(withMcpMetadata({ path: '/{id}/observer-links', tags: ["inspections"], summary: 'List active observer links for an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { description: 'ok' } }, operationId: "listInspectionObserverLinks", @@ -1842,7 +1852,7 @@ const revokeObserverLinkRoute = createRoute(withMcpMetadata({ path: '/{id}/observer-links/{linkId}', tags: ["inspections"], summary: 'Revoke an observer link', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), linkId: z.string().min(1).describe('TODO describe linkId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { description: 'ok', content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, operationId: "deleteInspectionObserverLink", @@ -1861,7 +1871,7 @@ const listVersionsRoute = createRoute(withMcpMetadata({ path: '/{id}/versions', tags: ["inspections"], summary: 'List published versions for an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector', 'agent'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector', 'agent')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { description: 'ok' } }, operationId: "listInspectionVersions", @@ -1873,7 +1883,7 @@ const getVersionRoute = createRoute(withMcpMetadata({ path: '/{id}/versions/{n}', tags: ["inspections"], summary: 'Get full snapshot for a specific version', - middleware: [requireRole(['owner', 'admin', 'inspector', 'agent'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector', 'agent')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), n: z.string().regex(/^\d+$/).describe('TODO describe n field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { description: 'ok' }, 404: { description: 'not found' } }, operationId: "getInspectionVersion", @@ -1885,7 +1895,7 @@ const diffVersionRoute = createRoute(withMcpMetadata({ path: '/{id}/versions/{n}/diff', tags: ["inspections"], summary: 'Diff version :n against ?from=', - middleware: [requireRole(['owner', 'admin', 'inspector', 'agent'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector', 'agent')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration'), n: z.string().regex(/^\d+$/).describe('TODO describe n field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), query: z.object({ from: z.string().regex(/^\d+$/).describe('TODO describe from field for the OpenInspection MCP integration') }).describe('TODO describe query field for the OpenInspection MCP integration'), @@ -1906,7 +1916,7 @@ const resultsBatchRoute = createRoute(withMcpMetadata({ path: '/{id}/results/batch', tags: ['inspections'], summary: 'Apply a batch of result patches to an inspection in one round-trip', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Inspection id whose results are patched') }), body: { content: { 'application/json': { schema: ResultsBatchSchema } } }, @@ -1930,7 +1940,7 @@ const listConflictsRoute = createRoute(withMcpMetadata({ path: '/{id}/conflicts', tags: ['inspections'], summary: 'List pending sync conflicts for an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Inspection id whose conflicts are listed') }), }, @@ -1950,7 +1960,7 @@ const resolveConflictsRoute = createRoute(withMcpMetadata({ path: '/{id}/conflicts/resolve', tags: ['inspections'], summary: 'Clear sync conflicts the inspector has adjudicated', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().min(1).describe('Inspection id whose conflicts are resolved') }), body: { content: { 'application/json': { schema: ConflictResolveSchema } } }, @@ -2630,7 +2640,7 @@ export const inspectionsRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/{id}/confirm', tags: ["inspections"], summary: "Confirm inspection for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Confirmed' } }, operationId: "confirmInspection", @@ -2644,7 +2654,7 @@ export const inspectionsRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/{id}/cancel', tags: ["inspections"], summary: "Cancel inspection for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: CancelInspectionSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -2662,7 +2672,7 @@ export const inspectionsRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/{id}/uncancel', tags: ["inspections"], summary: "Create inspection uncancel for current tenant", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Uncancelled' } }, operationId: "createInspectionUncancel", @@ -2779,7 +2789,19 @@ export const inspectionsRoutes = createApiRouter() const { id } = c.req.valid('param'); const body = c.req.valid('json'); const service = c.var.services.inspection; - const result = await service.publishInspection(id, tenantId, body); + // Build the publish options explicitly so `recipients` is omitted (not + // set to `undefined`) when absent — exactOptionalPropertyTypes rejects + // `recipients: X[] | undefined` against the service's optional param. + const publishOptions: Parameters[2] = { + theme: body.theme, + notifyClient: body.notifyClient, + notifyAgent: body.notifyAgent, + requireSignature: body.requireSignature, + requirePayment: body.requirePayment, + sendAgreementCopy: body.sendAgreementCopy, + ...(body.recipients ? { recipients: body.recipients } : {}), + }; + const result = await service.publishInspection(id, tenantId, publishOptions); // Design System 0520 subsystem D phase 9 — Republish snapshot. // After the inspection's status flips to published, persist a frozen @@ -2865,7 +2887,7 @@ export const inspectionsRoutes = createApiRouter() method: 'post', path: '/{id}/pdf/refresh', tags: ["inspections"], summary: 'Refresh PDF renders (Summary + Full)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 202: { @@ -2971,7 +2993,7 @@ export const inspectionsRoutes = createApiRouter() method: 'post', path: '/{id}/agent-token', tags: ["inspections"], summary: 'Generate shareable agent view token', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -2993,7 +3015,7 @@ export const inspectionsRoutes = createApiRouter() method: 'post', path: '/{id}/share-agent', tags: ["inspections"], summary: 'Email the report share link to the linked agent', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -3101,12 +3123,6 @@ export const inspectionsRoutes = createApiRouter() if (out.kind === 'conflict') { return c.json({ success: false as const, error: { code: 'CONFLICT', current: out.current, yours: out.yours } }, 409); } - // Design System 0520 subsystem C phase 2 — apprentice writes get queued. - // Returns 200 + { kind: 'queued', reviewId } so the editor can update - // its UI to "Pending review" without retrying. - if (out.kind === 'queued') { - return c.json({ success: true as const, data: { kind: 'queued', reviewId: out.reviewId } }, 200); - } return c.json({ success: true as const, data: { kind: 'ok', newVersion: out.newVersion, by: out.by, at: out.at } }, 200); }) .openapi(preflightRoute, async (c) => { @@ -3274,7 +3290,7 @@ export const inspectionsRoutes = createApiRouter() }, }, 410); }) - .get('/:id/full', requireRole(['owner', 'admin', 'inspector']), async (c) => { + .get('/:id/full', requireRole('owner', 'manager', 'inspector'), async (c) => { const id = c.req.param('id') as string; const tenantId = c.get('tenantId'); const svc = c.var.services.inspection; diff --git a/server/api/integrations.ts b/server/api/integrations.ts index 05c0d421a..cff958831 100644 --- a/server/api/integrations.ts +++ b/server/api/integrations.ts @@ -38,7 +38,7 @@ const stripeTestRoute = createRoute(withMcpMetadata({ path: '/stripe/test', tags: ['integrations'], summary: 'Verify the stored Stripe secret key against the live API', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: StripeTestResponseSchema } }, description: 'Key is valid; returns account name and mode' }, 502: { description: 'Stripe rejected the stored key' }, @@ -61,7 +61,7 @@ const stripeWebhookLogRoute = createRoute(withMcpMetadata({ path: '/stripe/webhook-log', tags: ['integrations'], summary: 'Recent Stripe webhook deliveries (diagnostics)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.array(WebhookLogEntrySchema) }).openapi('StripeWebhookLogResponse') } }, description: 'Up to 20 recent deliveries, newest first' }, }, @@ -76,7 +76,7 @@ const resendTestRoute = createRoute(withMcpMetadata({ path: '/resend/test', tags: ['integrations'], summary: 'Verify the stored Resend API key against the live API', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.object({ domains: z.number().describe('Verified sending domains on the account.') }) }).openapi('ResendTestResponse') } }, description: 'Key is valid' }, 502: { description: 'Resend rejected the stored key' }, @@ -91,7 +91,7 @@ const geminiTestRoute = createRoute(withMcpMetadata({ path: '/gemini/test', tags: ['integrations'], summary: 'Verify the stored Gemini API key against the live API', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), data: z.object({ ok: z.literal(true) }) }).openapi('GeminiTestResponse') } }, description: 'Key is valid' }, 502: { description: 'Google rejected the stored key' }, diff --git a/server/api/invoices.ts b/server/api/invoices.ts index f70e073fa..c3888a140 100644 --- a/server/api/invoices.ts +++ b/server/api/invoices.ts @@ -3,6 +3,7 @@ import { drizzle } from 'drizzle-orm/d1'; import { and, eq } from 'drizzle-orm'; import { createApiRouter } from '../lib/openapi-router'; import { requireRole } from '../lib/middleware/rbac'; +import { requireCapability } from '../lib/middleware/require-capability'; import { CreateInvoiceSchema, InvoiceResponseSchema, @@ -24,7 +25,11 @@ const USD = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' const listInvoicesRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["invoices"], summary: "List invoices for current tenant", - middleware: [requireRole(['owner', 'admin'])], + // Task 10 — financial capability gates the primary financial-data read. + // owner/admin always pass; layered here so an inspector granted + // {financial:true} (and added to the role list in a future change) would be + // governed by the capability rather than a bare role check. + middleware: [requireRole('owner', 'manager', 'inspector'), requireCapability('financial')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.array(InvoiceResponseSchema).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, @@ -39,7 +44,7 @@ const listInvoicesRoute = createRoute(withMcpMetadata({ const createInvoiceRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["invoices"], summary: "Create invoice for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: CreateInvoiceSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 201: { @@ -55,7 +60,7 @@ const createInvoiceRoute = createRoute(withMcpMetadata({ const markSentRoute = createRoute(withMcpMetadata({ method: 'post', path: '/{id}/mark-sent', tags: ["invoices"], summary: 'Mark invoice as sent', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration') }).describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Success' }, @@ -68,7 +73,7 @@ const markSentRoute = createRoute(withMcpMetadata({ const markPaidRoute = createRoute(withMcpMetadata({ method: 'post', path: '/{id}/mark-paid', tags: ["invoices"], summary: 'Mark invoice as paid', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('Invoice id to mark as paid.') }).describe('Path params for the mark-paid endpoint.'), body: { content: { 'application/json': { schema: MarkInvoicePaidSchema } } }, @@ -84,7 +89,7 @@ const markPaidRoute = createRoute(withMcpMetadata({ const deleteInvoiceRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["invoices"], summary: "Delete invoice for current tenant", - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration') }).describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Deleted' }, @@ -107,7 +112,7 @@ const deleteInvoiceRoute = createRoute(withMcpMetadata({ const requestPaymentRoute = createRoute(withMcpMetadata({ method: 'post', path: '/request-payment', tags: ['invoices'], summary: 'Create + email an invoice payment request for an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: RequestPaymentSchema } } } }, responses: { 200: { content: { 'application/json': { schema: RequestPaymentResponseSchema } }, description: 'Invoice marked sent and emailed' }, diff --git a/server/api/marketplace.ts b/server/api/marketplace.ts index 5123a9e1d..db3f21383 100644 --- a/server/api/marketplace.ts +++ b/server/api/marketplace.ts @@ -24,7 +24,7 @@ export const marketplaceRoutes = createApiRouter() method: 'get', path: '/', tags: ["marketplace"], summary: "List marketplaces for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: paginationQuerySchema.extend({ search: z.string().optional().describe('Free-text search over marketplace template names'), @@ -62,7 +62,7 @@ export const marketplaceRoutes = createApiRouter() method: 'post', path: '/{id}/import', tags: ["marketplace"], summary: 'Import marketplace template as tenant copy', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 201: { @@ -90,7 +90,7 @@ export const marketplaceRoutes = createApiRouter() method: 'get', path: '/libraries', tags: ["marketplace"], summary: 'List marketplace libraries (comment packs, snippet packs)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: z.object({ kind: z.enum(['comments', 'snippets']).optional().describe('TODO describe kind field for the OpenInspection MCP integration') }).describe('TODO describe query field for the OpenInspection MCP integration'), }, @@ -112,7 +112,7 @@ export const marketplaceRoutes = createApiRouter() method: 'post', path: '/{id}/update', tags: ["marketplace"], summary: 'Update tenant copy to latest marketplace version (creates new local copy)', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -164,7 +164,7 @@ export const marketplaceRoutes = createApiRouter() method: 'post', path: '/libraries/{id}/import', tags: ["marketplace"], summary: 'Import marketplace library into tenant', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 201: { content: { 'application/json': { schema: z.object({ success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ rowCount: z.number().describe('TODO describe rowCount field for the OpenInspection MCP integration'), localFirstId: z.string().describe('TODO describe localFirstId field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, description: 'Imported' }, @@ -194,7 +194,7 @@ export const marketplaceRoutes = createApiRouter() method: 'post', path: '/libraries/{id}/update', tags: ["marketplace"], summary: 'Update tenant library import to latest marketplace version (adds new rows)', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -248,7 +248,7 @@ export const marketplaceRoutes = createApiRouter() tags: ["marketplace"], summary: 'Replace tenant library import (deletes prior rows + inserts new pack)', description: "Auto-generated placeholder for replaceMarketplace (POST /libraries/{libraryId}/imports/replace, marketplace domain). TODO: replace with a real description sourced from the handler.", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: LibraryReplaceParamsSchema.describe('TODO describe params field for the OpenInspection MCP integration'), body: { @@ -316,7 +316,7 @@ export const marketplaceRoutes = createApiRouter() tags: ["marketplace"], summary: 'List per-import history events', description: "Auto-generated placeholder for listMarketplaceImportsHistory (GET /imports/history, marketplace domain). TODO: replace with a real description sourced from the handler.", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: ImportHistoryQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { diff --git a/server/api/messages.ts b/server/api/messages.ts index 08827708c..b49e2ac21 100644 --- a/server/api/messages.ts +++ b/server/api/messages.ts @@ -20,7 +20,7 @@ const listRoute = createRoute(withMcpMetadata({ method: 'get', path: '/inspections/{inspectionId}', tags: ["messages"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ inspectionId: z.string().describe('TODO describe inspectionId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ @@ -39,7 +39,7 @@ const sendRoute = createRoute(withMcpMetadata({ method: 'post', path: '/inspections/{inspectionId}', tags: ["messages"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ inspectionId: z.string().describe('TODO describe inspectionId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: z.object({ @@ -60,7 +60,7 @@ const sendRoute = createRoute(withMcpMetadata({ const unreadRoute = createRoute(withMcpMetadata({ method: 'get', path: '/unread-count', tags: ["messages"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.boolean().describe('TODO describe success field for the OpenInspection MCP integration'), @@ -118,7 +118,7 @@ const uploadRoute = createRoute(withMcpMetadata({ method: 'post', path: '/inspections/{inspectionId}/upload', tags: ["messages"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ inspectionId: z.string().describe('TODO describe inspectionId field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ diff --git a/server/api/metrics.ts b/server/api/metrics.ts index 63f6f8ce6..eb4c12c23 100644 --- a/server/api/metrics.ts +++ b/server/api/metrics.ts @@ -11,7 +11,7 @@ export const metricsRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["metrics"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { query: MetricsQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: MetricsApiResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Metrics' } }, operationId: "listMetrics", diff --git a/server/api/rating-systems.ts b/server/api/rating-systems.ts index 477e141bd..22a3f4f20 100644 --- a/server/api/rating-systems.ts +++ b/server/api/rating-systems.ts @@ -28,7 +28,7 @@ const listRatingSystemsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["ratings"], summary: 'List rating systems for the current tenant (seed + custom)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: RatingSystemListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'List' }, }, @@ -40,7 +40,7 @@ const listRatingSystemsRoute = createRoute(withMcpMetadata({ const getRatingSystemRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}', tags: ["ratings"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: RatingSystemSingleResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'One system' }, @@ -54,7 +54,7 @@ const getRatingSystemRoute = createRoute(withMcpMetadata({ const createRatingSystemRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["ratings"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: CreateRatingSystemSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: RatingSystemSingleResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Created' }, @@ -68,7 +68,7 @@ const createRatingSystemRoute = createRoute(withMcpMetadata({ const cloneRatingSystemRoute = createRoute(withMcpMetadata({ method: 'post', path: '/{id}/clone', tags: ["ratings"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: CloneRatingSystemSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -85,7 +85,7 @@ const cloneRatingSystemRoute = createRoute(withMcpMetadata({ const replaceRatingSystemRoute = createRoute(withMcpMetadata({ method: 'put', path: '/{id}', tags: ["ratings"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateRatingSystemSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -102,7 +102,7 @@ const replaceRatingSystemRoute = createRoute(withMcpMetadata({ const deleteRatingSystemRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["ratings"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { diff --git a/server/api/recommendations.ts b/server/api/recommendations.ts index 32dbe25a1..9d2af5924 100644 --- a/server/api/recommendations.ts +++ b/server/api/recommendations.ts @@ -18,7 +18,7 @@ const listRecommendationsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["recommendations"], summary: 'List recommendations (filter: category, severity)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { query: ListRecommendationsQuerySchema.describe('TODO describe query field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: RecommendationListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'List' }, @@ -31,7 +31,7 @@ const listRecommendationsRoute = createRoute(withMcpMetadata({ const getRecommendationRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}', tags: ["recommendations"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: RecommendationResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Single recommendation' }, @@ -45,7 +45,7 @@ const getRecommendationRoute = createRoute(withMcpMetadata({ const createRecommendationRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["recommendations"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: CreateRecommendationSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: RecommendationResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Created' }, @@ -59,7 +59,7 @@ const createRecommendationRoute = createRoute(withMcpMetadata({ const replaceRecommendationRoute = createRoute(withMcpMetadata({ method: 'put', path: '/{id}', tags: ["recommendations"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateRecommendationSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -76,7 +76,7 @@ const replaceRecommendationRoute = createRoute(withMcpMetadata({ const deleteRecommendationRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["recommendations"], - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true).describe('TODO describe success field for the OpenInspection MCP integration'), data: z.object({ deleted: z.literal(true).describe('TODO describe deleted field for the OpenInspection MCP integration') }).describe('TODO describe data field for the OpenInspection MCP integration') }) } }, description: 'Deleted' }, @@ -91,7 +91,7 @@ const seedDefaultsRecommendationRoute = createRoute(withMcpMetadata({ method: 'post', path: '/seed-defaults', tags: ["recommendations"], summary: 'Bulk-insert the default 80 recommendations (idempotent — skips entries with matching name+category)', - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: {}, responses: { 200: { content: { 'application/json': { schema: z.object({ diff --git a/server/api/secrets.ts b/server/api/secrets.ts index c13dc1441..91196d13d 100644 --- a/server/api/secrets.ts +++ b/server/api/secrets.ts @@ -98,7 +98,7 @@ const getSecretsRoute = createRoute(withMcpMetadata({ path: '/secrets', tags: ['admin'], summary: 'Get integration secrets (masked)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: SecretsResponseSchema } }, @@ -115,7 +115,7 @@ const putSecretsRoute = createRoute(withMcpMetadata({ path: '/secrets', tags: ['admin'], summary: 'Save tenant integration API secrets', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: SecretsInputSchema } } }, }, @@ -136,7 +136,7 @@ const postSecretsRoute = createRoute(withMcpMetadata({ path: '/secrets', tags: ['admin'], summary: 'Save integration secrets (POST alias)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: SecretsInputSchema } } }, }, diff --git a/server/api/services.ts b/server/api/services.ts index 1d665e31d..e7a47ab59 100644 --- a/server/api/services.ts +++ b/server/api/services.ts @@ -16,7 +16,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["services"], summary: "List services for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: ServiceListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'OK' } }, operationId: "listServices", description: "Auto-generated placeholder for listServices (GET /, services domain). TODO: replace with a real description sourced from the handler." @@ -29,7 +29,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/discount/validate', tags: ["services"], summary: "Validate service for current tenant", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: ValidateDiscountSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: createApiResponseSchema(ValidateDiscountResponseSchema) } }, description: 'Validation result' } }, operationId: "validateService", @@ -44,7 +44,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/discount-codes', tags: ["services"], summary: "Create service discount codes", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: CreateDiscountCodeSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 201: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Created' } }, operationId: "createServiceDiscountCodes", @@ -59,7 +59,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'get', path: '/discount-codes', tags: ["services"], summary: "List service discount codes", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'OK' } }, operationId: "listServiceDiscountCodes", description: "Auto-generated placeholder for listServiceDiscountCodes (GET /discount-codes, services domain). TODO: replace with a real description sourced from the handler." @@ -72,7 +72,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'put', path: '/discount-codes/{id}', tags: ["services"], summary: "Update service discount code", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateDiscountCodeSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -91,7 +91,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'delete', path: '/discount-codes/{id}', tags: ["services"], summary: "Delete service discount code", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Deleted' } }, operationId: "deleteServiceDiscountCode", @@ -106,7 +106,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["services"], summary: "Create service for current tenant", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: CreateServiceSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 201: { content: { 'application/json': { schema: ServiceResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Created' } }, operationId: "createService", @@ -121,7 +121,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'get', path: '/{id}/inspectors', tags: ["services"], summary: "Get qualified inspector restriction list for a service", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('Service ID') }), }, @@ -140,7 +140,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'put', path: '/{id}/inspectors', tags: ["services"], summary: "Replace inspector restriction list for a service", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('Service ID') }), body: { content: { 'application/json': { schema: SetServiceInspectorsSchema } } }, @@ -161,7 +161,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'put', path: '/{id}', tags: ["services"], summary: "Replace service for current tenant", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateServiceSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -180,7 +180,7 @@ export const servicesRoutes = createApiRouter() .openapi(createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["services"], summary: "Delete service for current tenant", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: z.object({ id: z.string().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: SuccessResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Deleted' } }, operationId: "deleteService", diff --git a/server/api/sms.ts b/server/api/sms.ts index 3f89a3d6d..d75933543 100644 --- a/server/api/sms.ts +++ b/server/api/sms.ts @@ -11,7 +11,7 @@ * Twilio request signature, not a zod body) and are NOT part of the typed * BFF client — Twilio calls them directly. * - * Admin router (`smsAdminRoutes`, mounted /api/admin, requireRole owner/admin): + * Admin router (`smsAdminRoutes`, mounted /api/'manager', requireRole owner/'manager'): * - POST /sms/attest {inspectionId} — inspector attestation (admin) → granted. * - POST /sms/test {to} — one-off test send via resolved creds. * - GET /sms/consent?inspectionId= — latest client consent for the inspection. @@ -208,7 +208,7 @@ const attestRoute = createRoute(withMcpMetadata({ path: '/sms/attest', tags: ['admin', 'sms'], summary: 'Inspector attestation — confirm the client agreed to receive texts', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: SmsAttestSchema } } } }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true) }) } }, description: 'Consent recorded' }, @@ -222,7 +222,7 @@ const testSendRoute = createRoute(withMcpMetadata({ path: '/sms/test', tags: ['admin', 'sms'], summary: 'Send a one-off test SMS using the resolved Twilio creds', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], request: { body: { content: { 'application/json': { schema: SmsTestSendSchema } } } }, responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.boolean(), error: z.string().optional() }) } }, description: 'Send result' }, @@ -236,7 +236,7 @@ const smsConfigRoute = createRoute(withMcpMetadata({ path: '/sms/config', tags: ['admin', 'sms'], summary: 'Effective SMS sender configuration (mode + source, no secrets)', - middleware: [requireRole(['owner', 'admin'])], + middleware: [requireRole('owner', 'manager')], responses: { 200: { content: { 'application/json': { schema: z.object({ success: z.literal(true), @@ -255,7 +255,7 @@ const consentStatusRoute = createRoute(withMcpMetadata({ path: '/sms/consent', tags: ['admin', 'sms'], summary: 'Latest SMS consent status for an inspection client', - middleware: [requireRole(['owner', 'admin', 'inspector'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { query: SmsConsentQuerySchema }, responses: { 200: { content: { 'application/json': { schema: z.object({ diff --git a/server/api/tags.ts b/server/api/tags.ts index e63ec715c..cee9bab4d 100644 --- a/server/api/tags.ts +++ b/server/api/tags.ts @@ -46,7 +46,7 @@ const listTagsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/', tags: ["tags"], summary: 'List tags for the current tenant (seed + custom)', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { content: { 'application/json': { schema: TagListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'List' }, }, @@ -59,7 +59,7 @@ const createTagRoute = createRoute(withMcpMetadata({ method: 'post', path: '/', tags: ["tags"], summary: 'Create a custom tag', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { body: { content: { 'application/json': { schema: CreateTagSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { content: { 'application/json': { schema: TagSingleResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Created' }, @@ -72,7 +72,7 @@ const createTagRoute = createRoute(withMcpMetadata({ const replaceTagRoute = createRoute(withMcpMetadata({ method: 'put', path: '/{id}', tags: ["tags"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: UpdateTagSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -94,7 +94,7 @@ const listTagInspectionsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}/inspections', tags: ["tags"], summary: 'List inspections that have any item tagged with this tag', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { @@ -117,7 +117,7 @@ const listTagInspectionsRoute = createRoute(withMcpMetadata({ const deleteTagRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}', tags: ["tags"], - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: IdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: TagDeleteResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Deleted' }, @@ -180,7 +180,7 @@ const listInspectionItemTagsRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}/items/{itemId}/tags', tags: ["tags"], summary: 'List tags linked to an inspection item', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: InspectionItemTagParamsSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: TagListResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Item tags' }, @@ -194,7 +194,7 @@ const linkInspectionItemTagRoute = createRoute(withMcpMetadata({ method: 'post', path: '/{id}/items/{itemId}/tags', tags: ["tags"], summary: 'Link a tag to an inspection item', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: InspectionItemTagParamsSchema.describe('TODO describe params field for the OpenInspection MCP integration'), body: { content: { 'application/json': { schema: LinkBodySchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } }, @@ -211,7 +211,7 @@ const unlinkInspectionItemTagRoute = createRoute(withMcpMetadata({ method: 'delete', path: '/{id}/items/{itemId}/tags/{tagId}', tags: ["tags"], summary: 'Unlink a tag from an inspection item', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: InspectionItemTagWithTagParamsSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { content: { 'application/json': { schema: TagUnlinkResponseSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } }, description: 'Unlinked' }, @@ -228,7 +228,7 @@ const listInspectionTagMapRoute = createRoute(withMcpMetadata({ method: 'get', path: '/{id}/tags', tags: ["tags"], summary: 'Map of itemId → tags for an inspection', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, + middleware: [requireRole('owner', 'manager', 'inspector')] as const, request: { params: InspectionIdParamSchema.describe('TODO describe params field for the OpenInspection MCP integration') }, responses: { 200: { diff --git a/server/api/team.ts b/server/api/team.ts index 0c7bab55d..5953c50c9 100644 --- a/server/api/team.ts +++ b/server/api/team.ts @@ -2,12 +2,11 @@ import { createRoute } from '@hono/zod-openapi'; import { createApiRouter } from '../lib/openapi-router'; import { z } from '@hono/zod-openapi'; import { drizzle } from 'drizzle-orm/d1'; -import { eq, and, count, inArray } from 'drizzle-orm'; +import { eq } from 'drizzle-orm'; import { requireRole } from '../lib/middleware/rbac'; import { requireSeatAvailable } from '../features/seat-quota'; import { getBaseUrl } from '../lib/url'; -import { Errors } from '../lib/errors'; -import { tenantConfigs, users, apprenticeReviews, inspections } from '../lib/db/schema'; +import { tenantConfigs } from '../lib/db/schema'; import { InviteMemberSchema, InviteResponseSchema, @@ -25,7 +24,7 @@ const listTeamMembersRoute = createRoute(withMcpMetadata({ path: '/members', tags: ["team"], summary: 'List team members and pending invites', - middleware: [requireRole(['admin', 'owner', 'inspector', 'viewer'])], + middleware: [requireRole('manager', 'owner', 'inspector')], responses: { 200: { content: { @@ -49,7 +48,7 @@ const inviteTeamMemberRoute = createRoute(withMcpMetadata({ path: '/invite', tags: ["team"], summary: 'Invite a new team member', - middleware: [requireRole(['admin', 'owner']), requireSeatAvailable], + middleware: [requireRole('manager', 'owner'), requireSeatAvailable], request: { body: { content: { @@ -82,7 +81,7 @@ const removeTeamMemberRoute = createRoute(withMcpMetadata({ path: '/members/{id}', tags: ["team"], summary: 'Remove a team member', - middleware: [requireRole(['admin', 'owner'])], + middleware: [requireRole('manager', 'owner')], request: { params: z.object({ id: z.string().uuid().describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), }, @@ -100,74 +99,10 @@ const removeTeamMemberRoute = createRoute(withMcpMetadata({ description: "Auto-generated placeholder for deleteTeamMember (DELETE /members/{id}, team domain). TODO: replace with a real description sourced from the handler." }, { scopes: ['write'], tier: 'extended' })); -// ============================================================================ -// Design System 0520 subsystem C phase 3 — apprentice review queue routes. -// ============================================================================ -// Mentor-facing endpoints used by /apprentice-review (HTML page mounted -// separately). list returns this mentor's pending queue; decide closes -// a single row and (on approve / edit) applies the value to -// inspection_results via patchItem(force: true). - -const listApprenticeReviewsRoute = createRoute(withMcpMetadata({ - method: 'get', - path: '/apprentice-reviews', - tags: ["team"], - summary: "List the caller's pending apprentice reviews", - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, - responses: { 200: { description: 'ok' } }, - operationId: "listTeamApprenticeReviews", - description: "Auto-generated placeholder for listTeamApprenticeReviews (GET /apprentice-reviews, team domain). TODO: replace with a real description sourced from the handler." -}, { scopes: ['read'], tier: 'extended' })); - -const decideApprenticeReviewRoute = createRoute(withMcpMetadata({ - method: 'post', - path: '/apprentice-reviews/{id}/decide', - tags: ["team"], - summary: 'Approve / reject / edit an apprentice-submitted item field', - middleware: [requireRole(['owner', 'admin', 'inspector'])] as const, - request: { - params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration'), - body: { content: { 'application/json': { schema: z.object({ - action: z.enum(['approved', 'rejected', 'edited']).describe('TODO describe action field for the OpenInspection MCP integration'), - decisionValue: z.unknown().optional().describe('TODO describe decisionValue field for the OpenInspection MCP integration'), - }).describe('TODO describe schema field for the OpenInspection MCP integration') } } }, - }, - responses: { 200: { description: 'ok' }, 404: { description: 'review not found' } }, - operationId: "createTeamApprenticeReviewsDecide", - description: "Auto-generated placeholder for createTeamApprenticeReviewsDecide (POST /apprentice-reviews/{id}/decide, team domain). TODO: replace with a real description sourced from the handler." -}, { scopes: ['write'], tier: 'extended' })); - -// Subsystem C P5 — admin-only guest invite minting. Returns the one-time -// `/guest-join?token=…` URL the admin can paste into chat/email. Active -// guests count against the same seat quota as permanent members, so the -// seat-guard middleware runs first. - -const mintGuestInviteRoute = createRoute(withMcpMetadata({ - method: 'post', - path: '/guests', - tags: ["team"], - summary: 'Mint a one-time guest invite link', - middleware: [requireRole(['admin', 'owner']), requireSeatAvailable] as const, - request: { - body: { content: { 'application/json': { schema: z.object({ - role: z.enum(['lead', 'specialist', 'apprentice', 'office']).describe('TODO describe role field for the OpenInspection MCP integration'), - durationSeconds: z.number().int().positive().max(60 * 60 * 24 * 30).default(86_400).describe('TODO describe durationSeconds field for the OpenInspection MCP integration'), - }).describe('TODO describe schema field for the OpenInspection MCP integration') } } }, - }, - responses: { - 201: { description: 'Invite minted' }, - 402: { description: 'Tenant at seat cap' }, - }, - operationId: "createTeamGuests", - description: "Auto-generated placeholder for createTeamGuests (POST /guests, team domain). TODO: replace with a real description sourced from the handler." -}, { scopes: ['write'], tier: 'extended' })); - -// ─── Design System 0520 subsystem C P10.2 — defaults / apprentices / guests ── +// ─── Design System 0520 subsystem C P10.2 — team defaults ── const DefaultsSchema = z.object({ teamModeDefault: z.boolean().optional().describe('TODO describe teamModeDefault field for the OpenInspection MCP integration'), - apprenticeReviewRequired: z.boolean().optional().describe('TODO describe apprenticeReviewRequired field for the OpenInspection MCP integration'), - guestInvitesEnabled: z.boolean().optional().describe('TODO describe guestInvitesEnabled field for the OpenInspection MCP integration'), }); export const teamRoutes = createApiRouter() @@ -194,8 +129,7 @@ export const teamRoutes = createApiRouter() tenantId, email: body.email, role: body.role, - ...(body.mentorId ? { mentorId: body.mentorId } : {}), - ...(body.assignedSectionIds ? { assignedSectionIds: body.assignedSectionIds } : {}), + permissionOverrides: body.permissionOverrides ?? null, }); const inviteLink = `${getBaseUrl(c)}/join?token=${token}`; @@ -227,136 +161,36 @@ export const teamRoutes = createApiRouter() return c.json({ success: true, data: { removed: true } }, 200); }) - .openapi(listApprenticeReviewsRoute, async (c) => { - const tenantId = c.get('tenantId'); - const user = c.get('user') as { sub?: string } | undefined; - if (!user?.sub) throw Errors.Unauthorized('Missing user identity'); - - const rows = await c.var.services.apprentice.listPendingForMentor(tenantId, user.sub); - if (rows.length === 0) { - return c.json({ success: true as const, data: [] }, 200); - } - - // UI enrichment — the /apprentice-review page needs the apprentice's - // name and the inspection's property address to be usable. Two batched - // queries (one per join) keep this O(1) instead of N+1. - const db = drizzle(c.env.DB); - const typedRows = rows as Array<{ apprenticeId: string; inspectionId: string } & Record>; - const apprenticeIds: string[] = [...new Set(typedRows.map((r) => r.apprenticeId))]; - const inspectionIds: string[] = [...new Set(typedRows.map((r) => r.inspectionId))]; - - const apprenticeRows = await db - .select({ id: users.id, name: users.name }) - .from(users) - .where(and(eq(users.tenantId, tenantId), inArray(users.id, apprenticeIds))) - .all(); - const inspectionRows = await db - .select({ id: inspections.id, address: inspections.propertyAddress }) - .from(inspections) - .where(and(eq(inspections.tenantId, tenantId), inArray(inspections.id, inspectionIds))) - .all(); - - const apprenticeNameById: Record = Object.fromEntries(apprenticeRows.map((a) => [a.id, a.name])); - const inspectionAddrById: Record = Object.fromEntries(inspectionRows.map((i) => [i.id, i.address])); - - const items = typedRows.map((r) => ({ - ...r, - apprenticeName: apprenticeNameById[r.apprenticeId] ?? 'Unknown apprentice', - inspectionAddress: inspectionAddrById[r.inspectionId] ?? r.inspectionId, - })); - - return c.json({ success: true as const, data: items }, 200); - }) - .openapi(decideApprenticeReviewRoute, async (c) => { - const { id } = c.req.valid('param'); - const { action, decisionValue } = c.req.valid('json'); - const tenantId = c.get('tenantId'); - const user = c.get('user') as { sub?: string } | undefined; - if (!user?.sub) throw Errors.Unauthorized('Missing user identity'); - - const out = await c.var.services.apprentice.decide(tenantId, id, action, decisionValue); - if (out.kind === 'not_found') throw Errors.NotFound('Review not found'); - - // If approved or edited, apply the value to inspection_results via the - // canonical patchItem path with force: true (bypasses version check - // since mentor's decision is authoritative). - if (action === 'approved' || action === 'edited') { - const review = await c.var.services.apprentice.getById(tenantId, id); - if (review) { - const sourceJson = action === 'edited' ? review.decisionValue : review.proposedValue; - let finalValue: unknown = null; - try { finalValue = sourceJson ? JSON.parse(sourceJson) : null; } catch { /* keep null */ } - await c.var.services.inspection.patchItem( - review.inspectionId, - tenantId, - review.itemId, - review.field as 'rating' | 'notes' | 'value', - finalValue, - 0, - review.apprenticeId, - { force: true }, - ); - } - } - - return c.json({ success: true as const, data: { reviewId: id, action } }, 200); - }) - .openapi(mintGuestInviteRoute, async (c) => { - const tenantId = c.get('tenantId'); - const user = c.get('user') as { sub?: string } | undefined; - if (!user?.sub) throw Errors.Unauthorized('Missing user identity'); - const body = c.req.valid('json'); - - const { token, url, expiresAt } = await c.var.services.guestInvite.mint(tenantId, { - role: body.role, - durationSeconds: body.durationSeconds, - createdBy: user.sub, - }); - - const baseUrl = getBaseUrl(c); - return c.json({ - success: true as const, - data: { - token, - url: url.startsWith('/') ? `${baseUrl}${url}` : `${baseUrl}/guest-join?token=${token}`, - expiresAt, - }, - }, 201); - }) - /** GET /api/team/defaults — read the three team-page toggles. */ + /** GET /api/team/defaults — read the team-page toggles. */ .openapi(withMcpMetadata({ method: 'get', path: '/defaults', operationId: 'getTeamDefaults', tags: ['team'], summary: "Get tenant team-page default toggles", - description: "Returns the three boolean toggles that govern the team page: teamModeDefault, apprenticeReviewRequired, guestInvitesEnabled. Used to drive UI state.", - middleware: [requireRole(['owner', 'admin', 'inspector', 'lead'])] as const, + description: "Returns the boolean toggles that govern the team page: teamModeDefault. Used to drive UI state.", + middleware: [requireRole('owner', 'manager', 'inspector')] as const, responses: { 200: { description: 'ok' } }, }, { scopes: ['read'], tier: 'extended' }), async (c) => { const tenantId = c.get('tenantId'); const db = drizzle(c.env.DB); const row = await db.select({ teamModeDefault: tenantConfigs.teamModeDefault, - apprenticeReviewRequired: tenantConfigs.apprenticeReviewRequired, - guestInvitesEnabled: tenantConfigs.guestInvitesEnabled, }).from(tenantConfigs).where(eq(tenantConfigs.tenantId, tenantId)).get(); return c.json({ success: true as const, data: row ?? { teamModeDefault: false, - apprenticeReviewRequired: false, - guestInvitesEnabled: true, }, }, 200); }) - /** PUT /api/team/defaults — patch any subset of the three toggles. */ + /** PUT /api/team/defaults — patch any subset of the toggles. */ .openapi(withMcpMetadata({ method: 'put', path: '/defaults', operationId: 'updateTeamDefaults', tags: ['team'], summary: "Update tenant team-page default toggles", - description: "Patches any subset of the three team-page toggles (teamModeDefault, apprenticeReviewRequired, guestInvitesEnabled). Missing keys leave existing values unchanged.", - middleware: [requireRole(['owner', 'admin'])] as const, + description: "Patches any subset of the team-page toggles (teamModeDefault). Missing keys leave existing values unchanged.", + middleware: [requireRole('owner', 'manager')] as const, request: { body: { content: { 'application/json': { schema: DefaultsSchema.describe('TODO describe schema field for the OpenInspection MCP integration') } } } }, responses: { 200: { description: 'ok' } }, }, { scopes: ['admin'], tier: 'extended' }), async (c) => { @@ -364,125 +198,11 @@ export const teamRoutes = createApiRouter() const body = c.req.valid('json'); const update: Partial = {}; if (body.teamModeDefault !== undefined) update.teamModeDefault = body.teamModeDefault; - if (body.apprenticeReviewRequired !== undefined) update.apprenticeReviewRequired = body.apprenticeReviewRequired; - if (body.guestInvitesEnabled !== undefined) update.guestInvitesEnabled = body.guestInvitesEnabled; if (Object.keys(update).length > 0) { await c.var.services.branding.updateBranding(tenantId, update); } return c.json({ success: true as const, data: { ok: true as const } }, 200); - }) - /** - * GET /api/team/apprentices — list every apprentice in the tenant - * with their mentor's name + a pending-review count. Drives the - * Apprentices section on /team. - */ - .openapi(withMcpMetadata({ - method: 'get', path: '/apprentices', - operationId: 'listTeamApprentices', - tags: ['team'], - summary: 'List apprentices with mentor and review counts', - description: 'Returns every apprentice in the tenant along with their mentor name and pending-review count. Drives the Apprentices section of the team page.', - middleware: [requireRole(['owner', 'admin', 'inspector', 'lead'])] as const, - responses: { 200: { description: 'ok' } }, - }, { scopes: ['read'], tier: 'extended' }), async (c) => { - const tenantId = c.get('tenantId'); - const db = drizzle(c.env.DB); - - const apprentices = await db.select({ - id: users.id, - name: users.name, - email: users.email, - mentorId: users.mentorId, - }).from(users) - .where(and(eq(users.tenantId, tenantId), eq(users.role, 'apprentice'))) - .all(); - - // Hydrate mentor names + pending counts. N+1 is acceptable here — - // tenants typically have a handful of apprentices, not hundreds. - const items = await Promise.all(apprentices.map(async a => { - const mentor = a.mentorId - ? await db.select({ name: users.name, email: users.email }) - .from(users).where(eq(users.id, a.mentorId)).get() - : null; - const pending = await db.select({ value: count() }) - .from(apprenticeReviews) - .where(and( - eq(apprenticeReviews.tenantId, tenantId), - eq(apprenticeReviews.apprenticeId, a.id), - eq(apprenticeReviews.status, 'pending'), - )).get(); - return { - id: a.id, - name: a.name ?? a.email, - mentorName: mentor?.name ?? mentor?.email ?? null, - pendingCount: pending?.value ?? 0, - }; - })); - - return c.json({ success: true as const, data: items }, 200); - }) - /** GET /api/team/guests — list active (non-expired) guest users. */ - .openapi(withMcpMetadata({ - method: 'get', path: '/guests', - operationId: 'listTeamGuests', - tags: ['team', 'guest'], - summary: 'List active guest accounts in tenant', - description: 'Returns all active (non-expired) guest user accounts in the tenant: filter is `expires_at IS NOT NULL AND > now`. Used by the team-page guest panel.', - middleware: [requireRole(['owner', 'admin'])] as const, - responses: { 200: { description: 'ok' } }, - }, { scopes: ['read'], tier: 'extended' }), async (c) => { - const tenantId = c.get('tenantId'); - const db = drizzle(c.env.DB); - const now = Math.floor(Date.now() / 1000); - - const rows = await db.select({ - id: users.id, - name: users.name, - email: users.email, - role: users.role, - expiresAt: users.expiresAt, - }).from(users).where(eq(users.tenantId, tenantId)).all(); - - const guests = rows - .filter(u => u.expiresAt != null && u.expiresAt > now) - .map(u => ({ - id: u.id, - name: u.name ?? u.email, - email: u.email, - role: u.role, - expiresAt: u.expiresAt, - })); - - return c.json({ success: true as const, data: guests }, 200); - }) - /** - * POST /api/team/guests/:id/revoke — set expires_at = now for a guest. - * Idempotent: revoking an already-expired guest is a no-op success. - */ - .openapi(withMcpMetadata({ - method: 'post', path: '/guests/{id}/revoke', - operationId: 'revokeTeamGuest', - tags: ['team', 'guest'], - summary: 'Revoke guest access immediately', - description: 'Marks the specified guest account as expired (sets expires_at = now). Idempotent — revoking an already-expired guest returns 200 success.', - middleware: [requireRole(['owner', 'admin'])] as const, - request: { params: z.object({ id: z.string().min(1).describe('TODO describe id field for the OpenInspection MCP integration') }).describe('TODO describe params field for the OpenInspection MCP integration') }, - responses: { 200: { description: 'ok' }, 404: { description: 'not found' } }, - }, { scopes: ['admin'], tier: 'extended' }), async (c) => { - const { id } = c.req.valid('param'); - const tenantId = c.get('tenantId'); - const db = drizzle(c.env.DB); - - const existing = await db.select({ id: users.id, expiresAt: users.expiresAt }) - .from(users).where(and(eq(users.id, id), eq(users.tenantId, tenantId))).get(); - if (!existing) throw Errors.NotFound('Guest not found'); - if (existing.expiresAt == null) throw Errors.BadRequest('User is not a guest (no expires_at)'); - - const now = Math.floor(Date.now() / 1000); - await db.update(users).set({ expiresAt: now }) - .where(and(eq(users.id, id), eq(users.tenantId, tenantId))); - return c.json({ success: true as const, data: { revokedAt: now } }, 200); }); export type TeamApi = typeof teamRoutes; diff --git a/server/api/template-migrations.ts b/server/api/template-migrations.ts index e04f08db6..6cddcba9a 100644 --- a/server/api/template-migrations.ts +++ b/server/api/template-migrations.ts @@ -24,7 +24,7 @@ const migrateRoute = createRoute(withMcpMetadata({ tags: ["templates"], summary: 'Migrate inspections from old template to new template', description: "Auto-generated placeholder for createTemplateMigrationMigrateTo (POST /{oldId}/migrate-to/{newId}, templates domain). TODO: replace with a real description sourced from the handler.", - middleware: [requireRole(['owner', 'admin'])] as const, + middleware: [requireRole('owner', 'manager')] as const, request: { params: MigrationParamsSchema.describe('TODO describe params field for the OpenInspection MCP integration'), body: { diff --git a/server/api/users.ts b/server/api/users.ts index f5dfd4b82..6a6dcc899 100644 --- a/server/api/users.ts +++ b/server/api/users.ts @@ -51,7 +51,7 @@ const saveSignatureRoute = createRoute(withMcpMetadata({ method: 'post', path: '/me/signature', tags: ['profile'], summary: 'Save the authenticated user\'s default signature image', - middleware: [requireRole(['owner', 'admin', 'inspector', 'lead'])], + middleware: [requireRole('owner', 'manager', 'inspector')], request: { body: { content: { 'application/json': { schema: UserDefaultSignatureSchema } } }, }, diff --git a/server/features/seat-quota/usage.ts b/server/features/seat-quota/usage.ts index 863055d86..8d8d80cfa 100644 --- a/server/features/seat-quota/usage.ts +++ b/server/features/seat-quota/usage.ts @@ -54,15 +54,13 @@ export async function getSeatUsage( const rawMax = tenantRow[0]?.maxUsers; const max: number | null = rawMax == null || rawMax <= 0 ? null : rawMax; - // Subsystem C P5 — expired guests must NOT count against the cap. - // Fetch the (id, expiresAt) projection and defer to the shared - // pure helper so guest-claim, settings-billing, and the invite - // middleware all agree on what "used" means. + // Every member counts as one seat. Defer to the shared pure helper so + // settings-billing and the invite middleware all agree on "used". const rows = await drizzleDb - .select({ id: users.id, expiresAt: users.expiresAt }) + .select({ id: users.id }) .from(users) .where(eq(users.tenantId, tenantId)); - const used = computeSeatsUsed(rows, Math.floor(Date.now() / 1000)); + const used = computeSeatsUsed(rows); const remaining = max === null ? Number.POSITIVE_INFINITY : Math.max(0, max - used); return { used, max, remaining }; diff --git a/server/index.ts b/server/index.ts index 51e77f36f..ca2957b5e 100644 --- a/server/index.ts +++ b/server/index.ts @@ -38,7 +38,6 @@ import coreAuthRoutes from './api/auth'; import identityRoutes from './api/identity'; import integrationsApiRoutes from './api/integrations'; import analyticsRoutes from './api/analytics'; -import guestRoutes from './api/guest'; import billingRoutes from './api/billing'; import usageRoutes from './api/usage'; import { registerPortalIntegration } from './portal/integration.module'; @@ -252,13 +251,13 @@ export const jwtAuthMiddleware: MiddlewareHandler = async (c, next) path === '/api/concierge/book-info' || path === '/api/concierge/book' || path === '/api/concierge/confirm-info'; - const isPublic = path.startsWith('/api/public/') || path.startsWith('/api/integration/') || path.startsWith('/api/admin/connect') || path.startsWith('/api/admin/silo') || path.startsWith('/api/ics/') || path.startsWith('/api/messages/public/') || path.startsWith('/api/guest/') || path === '/book' || path.startsWith('/book/') || path.startsWith('/inspector/') || path.startsWith('/embed/') || path.startsWith('/photos/') || path === '/' || path === '/status' || path.startsWith('/static/') || path.startsWith('/report/') || path.startsWith('/report-view/') || path.startsWith('/r/') || path.startsWith('/agreements/sign/') || path.startsWith('/checkout/') || path.startsWith('/sign/') || path.startsWith('/messages/') || path.startsWith('/m2m/') || path.startsWith('/verify/') || path.startsWith('/.well-known/') || STATIC_ASSET_EXT.test(path) || path === '/api/integrations/qbo/webhook' || path === '/api/integrations/stripe/webhook' || path.startsWith('/api/integrations/stripe/webhook/'); + const isPublic = path.startsWith('/api/public/') || path.startsWith('/api/integration/') || path.startsWith('/api/admin/connect') || path.startsWith('/api/admin/silo') || path.startsWith('/api/ics/') || path.startsWith('/api/messages/public/') || path === '/book' || path.startsWith('/book/') || path.startsWith('/inspector/') || path.startsWith('/embed/') || path.startsWith('/photos/') || path === '/' || path === '/status' || path.startsWith('/static/') || path.startsWith('/report/') || path.startsWith('/report-view/') || path.startsWith('/r/') || path.startsWith('/agreements/sign/') || path.startsWith('/checkout/') || path.startsWith('/sign/') || path.startsWith('/messages/') || path.startsWith('/m2m/') || path.startsWith('/verify/') || path.startsWith('/.well-known/') || STATIC_ASSET_EXT.test(path) || path === '/api/integrations/qbo/webhook' || path === '/api/integrations/stripe/webhook' || path.startsWith('/api/integrations/stripe/webhook/'); // Design System 0520 subsystem D P5 — observer surfaces are gated by // the dedicated observer-cookie middleware, not JWT. const isObserverPublic = path.startsWith('/observe/') || path === OBSERVER_EXPIRED_PATH; - if (isAuthPublic || isPublic || isAgentPublic || isConciergePublic || isObserverPublic || path === '/setup' || path === '/login' || path === '/join' || path === '/guest-join' || path.startsWith('/agreements/sign/')) return next(); + if (isAuthPublic || isPublic || isAgentPublic || isConciergePublic || isObserverPublic || path === '/setup' || path === '/login' || path === '/join' || path.startsWith('/agreements/sign/')) return next(); // First-time setup is gated solely by the SETUP_CODE secret, validated in // POST /api/auth/setup. No KV bootstrap code is generated here. @@ -427,8 +426,6 @@ const routes = app // Mount auth routes at canonical API path AND at root so that /setup, /login (POST), /join (POST) work without redirects .route('/api/auth', coreAuthRoutes) .route('/', coreAuthRoutes) - // Design System 0520 subsystem C — guest + billing. - .route('/api/guest', guestRoutes) .route('/api/billing', billingRoutes) .route('/api/usage', usageRoutes) // Design System 0520 subsystem E — identity / integrations / analytics. diff --git a/server/lib/auth/capabilities.ts b/server/lib/auth/capabilities.ts new file mode 100644 index 000000000..eb13e1150 --- /dev/null +++ b/server/lib/auth/capabilities.ts @@ -0,0 +1,58 @@ +import type { Role } from './roles'; + +export const TOGGLEABLE = ['publish', 'scheduleOthers', 'financial', 'manageContacts'] as const; +export type Capability = typeof TOGGLEABLE[number]; +export type CapabilitySet = Record; +export type PermissionOverrides = Partial; + +const ROLE_DEFAULTS: Record = { + owner: { publish: true, scheduleOthers: true, financial: true, manageContacts: true }, + manager: { publish: true, scheduleOthers: true, financial: true, manageContacts: true }, + inspector: { publish: true, scheduleOthers: false, financial: false, manageContacts: false }, + agent: { publish: false, scheduleOthers: false, financial: false, manageContacts: false }, +}; +/** owner is never reducible by overrides; agent is never elevated by them. */ +const FIXED: Partial>> = { + owner: { publish: true, scheduleOthers: true, financial: true, manageContacts: true }, + agent: { publish: false, scheduleOthers: false, financial: false, manageContacts: false }, +}; + +export function getCapabilities(role: Role, overrides: PermissionOverrides | null): CapabilitySet { + const base = { ...ROLE_DEFAULTS[role] }; + if (overrides) for (const cap of TOGGLEABLE) { + if (typeof overrides[cap] === 'boolean') base[cap] = overrides[cap] as boolean; + } + const pinned = FIXED[role]; + if (pinned) for (const cap of TOGGLEABLE) { + if (typeof pinned[cap] === 'boolean') base[cap] = pinned[cap] as boolean; + } + return base; +} + +export function parseOverrides(json: string | null | undefined): PermissionOverrides | null { + if (!json) return null; + try { + return whitelistOverrides(JSON.parse(json) as Record); + } catch { return null; } +} + +/** + * Coerce an unknown column value into PermissionOverrides. The + * `permission_overrides` column is drizzle `{ mode: 'json' }`, so a select may + * hand back an already-parsed object (json mode) OR a raw string (some drivers + * / test fixtures). Strings route through JSON.parse; objects are whitelisted + * directly. Either way only the four boolean capability keys survive — anything + * else (null, number, malformed JSON, extra keys) collapses to null. + */ +export function coerceOverrides(value: unknown): PermissionOverrides | null { + if (value == null) return null; + if (typeof value === 'string') return parseOverrides(value); + if (typeof value === 'object') return whitelistOverrides(value as Record); + return null; +} + +function whitelistOverrides(parsed: Record): PermissionOverrides | null { + const out: PermissionOverrides = {}; + for (const cap of TOGGLEABLE) if (typeof parsed[cap] === 'boolean') out[cap] = parsed[cap] as boolean; + return Object.keys(out).length ? out : null; +} diff --git a/server/lib/auth/roles.ts b/server/lib/auth/roles.ts new file mode 100644 index 000000000..a06c7c570 --- /dev/null +++ b/server/lib/auth/roles.ts @@ -0,0 +1,33 @@ +/** + * Single source of truth for the role taxonomy. Every consumer (Zod enums, + * drizzle column enums, requireRole, UI labels) MUST derive from ROLES rather + * than re-declaring string literals, so a role add/rename/remove is a one-line + * change with the compiler flagging every stale callsite. + */ +export const ROLES = ['owner', 'manager', 'inspector', 'agent'] as const; + +export type Role = typeof ROLES[number]; + +export const ROLE_LABELS: Record = { + owner: 'Owner', + manager: 'Manager', + inspector: 'Inspector', + agent: 'Agent', +}; + +/** + * Named role constants — prefer these over bare string literals in comparison + * and assignment sites (the no-restricted-syntax lint rule enforces this). + * Adding a new role requires updating ROLES above; this object is derived + * automatically so any typo here is a compile error. + */ +export const ROLE = { + OWNER: 'owner', + MANAGER: 'manager', + INSPECTOR: 'inspector', + AGENT: 'agent', +} as const satisfies Record; + +export function isRole(value: unknown): value is Role { + return typeof value === 'string' && (ROLES as readonly string[]).includes(value); +} diff --git a/server/lib/billing-summary.ts b/server/lib/billing-summary.ts index 5b4312496..ff742f8c5 100644 --- a/server/lib/billing-summary.ts +++ b/server/lib/billing-summary.ts @@ -1,10 +1,11 @@ /** - * Design System 0520 subsystem C P9 T9.1 — billing summary aggregator. + * Billing summary aggregator. * * Pure helper used by both `GET /api/billing/summary` (this repo) and - * the SettingsTeam page's "billing pointer" card (Phase 10). Splits the - * (permanent / guest) breakdown so the UI can show them side-by-side - * without an extra round trip. + * the SettingsTeam page's "billing pointer" card. Every member counts as + * one seat; the `permanent` / `guests` fields are retained for response + * shape stability (guests are always 0 since the guest subsystem was + * removed — `expires_at` is DEAD). */ import { computeSeatsUsed, type SeatUser } from './middleware/seat-guard'; @@ -27,19 +28,14 @@ const DEFAULT_MAX_USERS = 1; export function summariseSeats( users: SeatUser[], tenant: TenantBillingFields, - nowSeconds: number, ): BillingSummary { - const seatsUsed = computeSeatsUsed(users, nowSeconds); - const guests = users.filter(u => - u.expiresAt != null && u.expiresAt > nowSeconds, - ).length; - const permanent = seatsUsed - guests; + const seatsUsed = computeSeatsUsed(users); return { tier: tenant.tier ?? DEFAULT_TIER, maxUsers: tenant.maxUsers ?? DEFAULT_MAX_USERS, seatsUsed, - permanent, - guests, + permanent: seatsUsed, + guests: 0, }; } diff --git a/server/lib/db/schema/apprentice.ts b/server/lib/db/schema/apprentice.ts deleted file mode 100644 index f3a23d11d..000000000 --- a/server/lib/db/schema/apprentice.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Design System 0520 subsystem C phase 1 — ApprenticeReview queue. - * - * One row per item-field write submitted by an apprentice; mentor - * approves / rejects / edits each row before the value lands in - * inspection_results.data. - * - * status: - * - 'pending' — apprentice submitted, awaiting mentor - * - 'approved' — mentor accepted the apprentice's value - * - 'rejected' — mentor discarded; no write to inspection_results - * - 'edited' — mentor modified before applying; decision_value carries - * the final stored value - */ -import { sqliteTable, text, integer, index } from 'drizzle-orm/sqlite-core'; -import { sql } from 'drizzle-orm'; - -export const apprenticeReviews = sqliteTable('apprentice_reviews', { - id: text('id').primaryKey(), - tenantId: text('tenant_id').notNull(), - apprenticeId: text('apprentice_id').notNull(), - mentorId: text('mentor_id').notNull(), - inspectionId: text('inspection_id').notNull(), - itemId: text('item_id').notNull(), - field: text('field').notNull(), - proposedValue: text('proposed_value'), - status: text('status', { enum: ['pending', 'approved', 'rejected', 'edited'] }).notNull().default('pending'), - decisionValue: text('decision_value'), - decisionAt: integer('decision_at'), - submittedAt: integer('submitted_at').notNull(), - createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), -}, (t) => [ - index('apprentice_reviews_mentor_status_idx').on(t.tenantId, t.mentorId, t.status), - index('apprentice_reviews_inspection_item_idx').on(t.inspectionId, t.itemId), - index('apprentice_reviews_apprentice_idx').on(t.apprenticeId, t.status), -]); diff --git a/server/lib/db/schema/guest-invites.ts b/server/lib/db/schema/guest-invites.ts deleted file mode 100644 index 50f08bdf8..000000000 --- a/server/lib/db/schema/guest-invites.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Design System 0520 subsystem C phase 1 — GuestInvites. - * - * One row per shareable invite link the admin mints. Guests sign up - * through /api/guest/claim using the token; their resulting `users` row - * carries the matching role + an `expires_at` so the daily cron can - * auto-revoke when the duration ends. - * - * Per the simplified seat-quota model (spec amendment): guests count - * against tenants.max_users on claim — no separate per-guest billing. - */ -import { sqliteTable, text, integer, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; -import { sql } from 'drizzle-orm'; - -export const guestInvites = sqliteTable('guest_invites', { - id: text('id').primaryKey(), - tenantId: text('tenant_id').notNull(), - token: text('token').notNull().unique(), - role: text('role', { enum: ['lead', 'specialist', 'apprentice', 'office'] }).notNull(), - durationSeconds: integer('duration_seconds').notNull(), - expiresAt: integer('expires_at').notNull(), - claimedByUserId: text('claimed_by_user_id'), - claimedAt: integer('claimed_at'), - createdBy: text('created_by').notNull(), - createdAt: text('created_at').notNull().default(sql`(datetime('now'))`), - tokenHash: text('token_hash'), -}, (t) => [ - index('guest_invites_tenant_idx').on(t.tenantId), - uniqueIndex('idx_guest_invites_token_hash').on(t.tokenHash), -]); diff --git a/server/lib/db/schema/identity-links.ts b/server/lib/db/schema/identity-links.ts index 4a20deafb..9f16aa3d4 100644 --- a/server/lib/db/schema/identity-links.ts +++ b/server/lib/db/schema/identity-links.ts @@ -7,10 +7,10 @@ * JWT, then sets the canonical cookie so subsequent requests are * scoped to that workspace. * - * `linkedRole` mirrors the workspace role of the linked user (admin / - * inspector / lead / specialist / apprentice / office). `linkedDisplay- - * Name` snapshots the tenant + display name at link-time so the menu - * can render without a per-row join. + * `linkedRole` mirrors the workspace role of the linked user + * (owner / admin / inspector / agent). `linkedDisplayName` snapshots the + * tenant + display name at link-time so the menu can render without a + * per-row join. */ import { sqliteTable, text, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; diff --git a/server/lib/db/schema/index.ts b/server/lib/db/schema/index.ts index 0e5f9fdfe..ff30efe7c 100644 --- a/server/lib/db/schema/index.ts +++ b/server/lib/db/schema/index.ts @@ -45,9 +45,11 @@ export type { ReportPdf, NewReportPdf } from './report-pdf'; export { signingKeys, esignAuditLogs } from './esign'; export type { SigningKey, NewSigningKey, EsignAuditLog, NewEsignAuditLog } from './esign'; export { qboConnections, qboEntityMap, qboSyncErrors } from './qbo'; -// Design System 0520 subsystem C — apprentice review queue + guest invites -export { apprenticeReviews } from './apprentice'; -export { guestInvites } from './guest-invites'; +// Apprentice review-queue subsystem removed 2026-06-13. The physical +// `apprentice_reviews` table is orphaned (D1 cannot drop tables) but all +// schema + code is gone (apprentices became plain inspectors). +// Guest invite subsystem removed 2026-06-13. The physical `guest_invites` +// table is orphaned (D1 cannot drop tables) but all schema + code is gone. // Design System 0520 subsystem D — UnitTree hierarchy export { inspectionUnits } from './units'; // Design System 0520 subsystem D — ObserverLink (no-account read-only links) diff --git a/server/lib/db/schema/tenant.ts b/server/lib/db/schema/tenant.ts index df73cfacd..355d7e2e4 100644 --- a/server/lib/db/schema/tenant.ts +++ b/server/lib/db/schema/tenant.ts @@ -1,5 +1,6 @@ import { sqliteTable, text, integer, index, uniqueIndex, primaryKey } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; +import { ROLES } from '../../auth/roles'; export const tenants = sqliteTable('tenants', { id: text('id').primaryKey(), @@ -65,10 +66,10 @@ export const users = sqliteTable('users', { // check those before any reuse. Global AGENT slugs (tenant_id IS NULL, // role='agent') still use this column actively — do not repurpose. slug: text('slug'), - // DDL default 'admin' is FROZEN (D1 cannot alter column defaults without a + // DDL default is FROZEN (D1 cannot alter column defaults without a // table rebuild and users is FK-referenced). Every insert path MUST pass an // explicit role — audited 2026-06-05; enforced by review, not DDL. - role: text('role').notNull().default('admin'), + role: text('role', { enum: ROLES }).notNull().default('manager'), googleRefreshToken: text('google_refresh_token'), googleCalendarId: text('google_calendar_id'), onboardingState: text('onboarding_state', { mode: 'json' }).$type>(), @@ -91,30 +92,37 @@ export const users = sqliteTable('users', { // per worker isolate). Powers TeamStrip "last active Nm ago" pill and the // soft-presence fallback when WebSocket cannot connect. lastActiveAt: integer('last_active_at'), - // Design System 0520 subsystem C phase 1 — apprentice + specialist roles. - // mentorId = nullable FK → users.id; required for apprentices - // (apprentice writes route to mentor's review queue) - // assignedSectionIds = JSON array of section ids; non-empty restricts - // a specialist's edit scope. Empty = full access - // (lead / office) per canEdit() matrix. - // expiresAt = guest-invite expiry; non-null means the user - // was created via a guest token + auto-revokes - // past this epoch. + // Design System 0520 subsystem C phase 1 — role-extension columns. + // mentorId = DEAD (2026-06-13, apprentice subsystem removed). + // Formerly the apprentice's mentor FK → users.id — + // no reads/writes. + // assignedSectionIds = DEAD (2026-06-13). Formerly: JSON array of + // section ids restricting a specialist's edit + // scope. Specialist scoping deferred — no reads/writes. + // expiresAt = DEAD (2026-06-13, guest removal). Formerly the + // guest-invite expiry epoch — no reads/writes. + // DEAD (2026-06-13, apprentice subsystem removed) — no reads/writes mentorId: text('mentor_id'), + // DEAD (2026-06-13, guest removal / specialist deferred) — no reads/writes assignedSectionIds: text('assigned_section_ids').notNull().default('[]'), + // DEAD (2026-06-13, guest removal / specialist deferred) — no reads/writes expiresAt: integer('expires_at'), // Account soft-delete marker — set by POST /api/account/delete after // the user retypes their email to confirm. NULL = active. Kept rather // than hard-deleted so audit-linked rows remain referentially intact. deletedAt: integer('deleted_at', { mode: 'timestamp' }), // Legal-links feature — set when the account was created through a public - // form (agent signup / agent invite / guest join) while the operator had + // form (agent signup / agent invite) while the operator had // TERMS_URL/PRIVACY_URL configured. JSON: {at, ip, country, termsUrl, privacyUrl}. // Nullable: absent for accounts created before the feature or when the // operator runs without configured legal docs. termsAccepted: text('terms_accepted', { mode: 'json' }).$type<{ at: string; ip?: string; country?: string; termsUrl?: string; privacyUrl?: string; } | null>(), + // Role permission-template overrides (2026-06-13). Nullable JSON map of the + // four toggleable capabilities; absent/null = pure role template. + permissionOverrides: text('permission_overrides', { mode: 'json' }) + .$type(), }, (t) => [ index('idx_users_deleted_at').on(t.deletedAt), // DB-2: soft-deleted rows must not block re-inviting the same email. @@ -188,15 +196,21 @@ export const tenantInvites = sqliteTable('tenant_invites', { id: text('id').primaryKey(), tenantId: text('tenant_id').notNull().references(() => tenants.id), email: text('email').notNull(), - role: text('role').notNull().default('inspector'), + role: text('role', { enum: ROLES }).notNull().default('inspector'), // Schema Rules: state-machine column declares its enum (type-layer only). status: text('status', { enum: ['pending', 'accepted'] }).notNull().default('pending'), expiresAt: integer('expires_at', { mode: 'timestamp' }).notNull(), - // Design System 0520 subsystem C P5 — carry apprentice mentor + - // specialist section assignment from the InviteSeatModal into the - // eventual users row at accept time. NULL/empty for lead/office. + // Design System 0520 subsystem C P5 — carry role-extension fields from the + // InviteSeatModal into the eventual users row at accept time. + // DEAD (2026-06-13, apprentice subsystem removed) — written on invite but + // never replayed onto the users row; no behavior depends on it. mentorId: text('mentor_id'), assignedSectionIds: text('assigned_section_ids').notNull().default('[]'), + // Role permission-template overrides (2026-06-13). Mirrors + // users.permission_overrides — carries the inviter's chosen toggle diffs + // through accept onto the new users row. Null = pure role template. + permissionOverrides: text('permission_overrides', { mode: 'json' }) + .$type(), }, (t) => [ index('idx_invites_tenant').on(t.tenantId), // DB-9 — at most one OUTSTANDING invite per (tenant, email). Partial so an @@ -305,7 +319,9 @@ export const tenantConfigs = sqliteTable('tenant_configs', { enablePdfPipeline: integer('enable_pdf_pipeline', { mode: 'boolean' }).notNull().default(false), // Design System 0520 subsystem C P10 — /team Defaults section toggles. teamModeDefault: integer('team_mode_default', { mode: 'boolean' }).notNull().default(false), + // DEAD (2026-06-13, apprentice subsystem removed) — no reads/writes apprenticeReviewRequired: integer('apprentice_review_required', { mode: 'boolean' }).notNull().default(false), + // DEAD (2026-06-13, guest removal) — no reads/writes guestInvitesEnabled: integer('guest_invites_enabled', { mode: 'boolean' }).notNull().default(true), // Track H (IA-7 / P-6②) — which defect fields the publish gate REQUIRES. // Tenant default; per-inspection override on inspections.require_defect_ diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts index 69c1c7dbd..ed01c84ff 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -4,8 +4,6 @@ import { AdminService } from '../../services/admin.service'; import { UnitService } from '../../services/unit.service'; import { ObserverLinkService } from '../../services/observer-link.service'; import { ReportVersionService } from '../../services/report-version.service'; -import { ApprenticeService } from '../../services/apprentice.service'; -import { GuestInviteService } from '../../services/guest-invite.service'; import { AIService } from '../../services/ai.service'; import { AuthService } from '../../services/auth.service'; import { OutboxService } from '../../portal/outbox.service'; @@ -334,12 +332,6 @@ export async function diMiddleware(c: Context, next: Next) { case 'reportVersion': target.reportVersion = new ReportVersionService(c.env.DB, c.env.KEY_ENCRYPTION_SECRET || c.env.JWT_SECRET); break; - case 'apprentice': - target.apprentice = new ApprenticeService(c.env.DB); - break; - case 'guestInvite': - target.guestInvite = new GuestInviteService(c.env.DB); - break; case 'identity': target.identity = new IdentityService(c.env.DB); break; diff --git a/server/lib/middleware/rbac.ts b/server/lib/middleware/rbac.ts index 84ee5cc77..27d513706 100644 --- a/server/lib/middleware/rbac.ts +++ b/server/lib/middleware/rbac.ts @@ -1,47 +1,21 @@ import { Context, Next } from 'hono'; import { Errors } from '../errors'; +import type { Role } from '../auth/roles'; /** - * Design System 0520 subsystem C phase 1 task 1.3 — role-alias shim. - * - * Spec C introduces 4 explicit roles (lead / specialist / apprentice / - * office) alongside the legacy 'inspector' role. Per - * `feedback_pre_launch_no_compat`, no migration story is required — but - * the single 1-line alias `inspector → lead` is a low-cost ergonomic - * win that lets all existing inspector-* permission checks Just Work - * for the new 'lead' role without doubling up on `allowedRoles` arrays - * at every callsite. - * - * Future writers should call normaliseRole() when comparing role values - * against an allow-list. Callsites pre-dating this shim continue to - * work by virtue of the requireRole() middleware running normalisation - * before its includes() check. + * Enforce that the JWT-derived role is one of `roles`. Variadic + typed to + * `Role`, so removing a value from ROLES turns every stale callsite into a + * compile error (the rename is compiler-guided) and a typo'd role cannot + * compile. */ -export const ROLE_ALIASES: Record = { - 'inspector': 'lead', -}; - -export function normaliseRole(role: string): string { - return ROLE_ALIASES[role] ?? role; -} - -// Middleware to enforce specific roles based on the decoded JWT -export const requireRole = (allowedRoles: string[]) => { - // Expand the allow-list so callers can pass either 'inspector' or 'lead' - // and we accept both — bidirectional aliasing. - const expanded = new Set(allowedRoles); - for (const [from, to] of Object.entries(ROLE_ALIASES)) { - if (expanded.has(to)) expanded.add(from); - if (expanded.has(from)) expanded.add(to); +export const requireRole = (...roles: Role[]) => { + const allowed = new Set(roles); + return async (c: Context, next: Next) => { + const userRole = c.get('userRole'); + if (!userRole) throw Errors.Unauthorized('No role found in context'); + if (!allowed.has(userRole)) { + throw Errors.Forbidden(`Requires one of [${roles.join(', ')}]`); } - - return async (c: Context, next: Next) => { - const userRole = c.get('userRole'); // Populated by authMiddleware earlier - if (!userRole) throw Errors.Unauthorized('No role found in context'); - - if (!expanded.has(userRole) && !expanded.has(normaliseRole(userRole))) { - throw Errors.Forbidden(`Requires one of [${allowedRoles.join(', ')}]`); - } - return next(); - }; + return next(); + }; }; diff --git a/server/lib/middleware/require-capability.ts b/server/lib/middleware/require-capability.ts new file mode 100644 index 000000000..786ff67b7 --- /dev/null +++ b/server/lib/middleware/require-capability.ts @@ -0,0 +1,59 @@ +import { Context, Next } from 'hono'; +import { Errors } from '../errors'; +import { + getCapabilities, + coerceOverrides, + type Capability, + type PermissionOverrides, +} from '../auth/capabilities'; +import { isRole } from '../auth/roles'; +import { users } from '../db/schema'; + +/** + * Resolve the acting user's permission_overrides FRESH from the tenant-scoped + * DB. Overrides can be changed by an admin without the affected user + * re-logging-in, and the JWT does NOT carry them — so reading the column on + * every gated request is the only correct source of truth. + * + * Returns null (pure role template) when there is no sdb, no user id, the row + * is missing, or the column is empty. owner/agent capabilities are pinned in + * getCapabilities(), so a missing/stale row still yields correct results for + * those roles regardless of what we return here. + */ +export type OverrideResolver = (c: Context) => Promise; + +const resolveOverridesFromDb: OverrideResolver = async (c) => { + const userId = c.get('user')?.sub; + const sdb = c.get('sdb'); + if (!userId || !sdb) return null; + // getById is tenant-scoped (users has a tenantId column) and fail-closed. + const row = await sdb.getById(users, userId); + // permission_overrides is drizzle { mode: 'json' } → may be an object, + // a string, or null. coerceOverrides handles all three and whitelists + // to the four boolean capability keys. + return coerceOverrides(row?.permissionOverrides ?? null); +}; + +/** + * Layer a capability check ON TOP of an existing requireRole() gate. owner/admin + * always pass (defaults grant all four capabilities); the inspector role is the + * only one a per-user override can restrict (publish:false) or elevate + * (financial/scheduleOthers/manageContacts:true). agent is pinned to all-false. + * + * `resolveOverrides` is injectable purely so unit tests can supply overrides + * deterministically without a real D1; production always uses the DB resolver. + */ +export const requireCapability = ( + cap: Capability, + resolveOverrides: OverrideResolver = resolveOverridesFromDb, +) => async (c: Context, next: Next) => { + const role = c.get('userRole'); + if (!isRole(role)) throw Errors.Unauthorized('No role found in context'); + + const overrides = await resolveOverrides(c); + + if (!getCapabilities(role, overrides)[cap]) { + throw Errors.Forbidden(`Requires the '${cap}' capability`); + } + return next(); +}; diff --git a/server/lib/middleware/seat-guard.ts b/server/lib/middleware/seat-guard.ts index 466aed76c..4044462f8 100644 --- a/server/lib/middleware/seat-guard.ts +++ b/server/lib/middleware/seat-guard.ts @@ -1,33 +1,21 @@ /** - * Design System 0520 subsystem C phase 5 — unified seat-quota helpers. + * Unified seat-quota helpers. * - * Per the simplified seat model: permanent members + active guests both - * count against tenants.max_users. Guests are users with `expires_at` - * set; when the daily cron sweeps expired rows their seats free up - * automatically. No per-role billing. - * - * These helpers are pure so they can be unit-tested without a DB. The - * route-mounted middleware lives in `server/features/seat-quota/middleware` - * — it composes `getSeatUsage` (which now defers to `computeSeatsUsed`) - * with the profile gate. GuestInviteService.claim also uses these - * helpers directly to check quota before creating the new user row. + * Every member counts as one seat against `tenants.max_users`. These pure + * helpers can be unit-tested without a DB; the route-mounted middleware in + * `server/features/seat-quota/middleware` composes `getSeatUsage` (which + * defers to `computeSeatsUsed`) with the profile gate. */ export interface SeatUser { id: string; - expiresAt?: number | null; } /** - * Count seats actively held by `users` at the given timestamp. - * - * - Permanent members (`expires_at == null`) always count. - * - Guests count only while `expires_at > now`. The boundary is strict: - * an `expires_at` exactly equal to `now` is treated as expired so the - * cron's idempotent sweep does not race with claim checks. + * Count the seats held by `users`. Every member counts once. */ -export function computeSeatsUsed(users: SeatUser[], now: number): number { - return users.filter(u => u.expiresAt == null || u.expiresAt > now).length; +export function computeSeatsUsed(users: SeatUser[]): number { + return users.length; } /** diff --git a/server/lib/preflight.ts b/server/lib/preflight.ts index f90f6b8b9..28237368d 100644 --- a/server/lib/preflight.ts +++ b/server/lib/preflight.ts @@ -2,19 +2,14 @@ * Design System 0520 subsystem E P1.2 — Publish pre-flight aggregator. * * Pure helper. The service wrapper (inspection.service.ts.compute- - * Preflight) loads `inspections` + `inspection_results.data` + an - * apprentice pending count, then delegates here. + * Preflight) loads `inspections` + `inspection_results.data`, then + * delegates here. * - * Five gates: + * Gates: * • allRated — every item in results.data has rating OR value - * • apprenticeReviewed — no pending apprentice_reviews for this inspection * • propertyFactsComplete — all 5 required keys present in property_facts * • coverPhotoSet — inspections.cover_photo_id is non-null * • agreementSigned — inspections.agreement_signed_at is non-null - * - * Pass `pendingApprenticeCount: undefined` when the apprentice_reviews - * table does not exist (subsystem C absent) — the gate gracefully - * no-ops to "reviewed". */ export const REQUIRED_FACT_KEYS = [ @@ -40,8 +35,6 @@ export interface PreflightItem { export interface PreflightResult { allRated: boolean; unratedCount: number; - apprenticeReviewed: boolean; - apprenticePending: number; propertyFactsComplete: boolean; missingFacts: string[]; coverPhotoSet: boolean; @@ -53,7 +46,6 @@ export interface PreflightResult { export function computePreflightFromData( inspection: PreflightInspectionInput, items: Record, - pendingApprenticeCount: number | undefined, ): PreflightResult { const entries = Object.values(items); const unratedCount = entries.filter(i => i.rating == null && i.value == null).length; @@ -65,9 +57,6 @@ export function computePreflightFromData( return v == null || v === ''; }); - // Subsystem C dependency — when the count is undefined the - // apprentice_reviews table is presumed absent and the gate passes. - const pending = pendingApprenticeCount ?? 0; const FIELD_RE = /\[[A-Z_]+\]/g; let openFieldCount = 0; for (const item of entries) { @@ -80,8 +69,6 @@ export function computePreflightFromData( return { allRated, unratedCount, - apprenticeReviewed: pending === 0, - apprenticePending: pending, propertyFactsComplete: missingFacts.length === 0, missingFacts, coverPhotoSet: inspection.coverPhotoId != null, diff --git a/server/lib/random-token.ts b/server/lib/random-token.ts index 2857f4298..0782940db 100644 --- a/server/lib/random-token.ts +++ b/server/lib/random-token.ts @@ -1,8 +1,8 @@ /** * Shared url-safe random token generator. * - * Used by GuestInviteService + ObserverLinkService to mint opaque - * capability tokens stored in the DB. 32 bytes of crypto-random + * Used by ObserverLinkService to mint opaque capability tokens + * stored in the DB. 32 bytes of crypto-random * entropy → base64url (~43 chars, no padding) so the value is safe * to embed in URLs and cookies without further escaping. */ diff --git a/server/lib/rbac/can-edit.ts b/server/lib/rbac/can-edit.ts index 935c3311e..9283c7208 100644 --- a/server/lib/rbac/can-edit.ts +++ b/server/lib/rbac/can-edit.ts @@ -1,3 +1,5 @@ +import { ROLE } from '../auth/roles'; + /** * Design System 0520 subsystem C phase 4 — canEdit permission matrix. * @@ -7,22 +9,17 @@ * * Role outcomes: * - owner / admin → always true - * - office → always false (read-only seat by spec) - * - lead → true when caller is on the inspection + * - inspector → true when caller is on the inspection * (inspectorId / leadInspectorId / helperInspectorIds) - * - apprentice → same as lead at the canEdit boundary; the - * apprentice-write-to-queue routing happens in - * InspectionService.patchItem (subsystem C P2) - * - specialist → same as lead AND sectionId in user.assignedSectionIds - * - agent (legacy) → false (subsystem A buyer-agent view is read-only) - * - * Legacy 'inspector' role is aliased to 'lead' via normaliseRole. + * - agent → false (buyer-agent view is read-only) */ -import { normaliseRole } from '../middleware/rbac'; export interface CanEditUser { id: string; role: string; + // Legacy field kept for back-compat with existing callers. Section-scope + // edit restrictions were removed when the specialist role was collapsed + // into a plain inspector (2026-06-13) — this is no longer consulted. assignedSectionIds: string; // JSON-encoded string array } @@ -46,13 +43,14 @@ function safeJsonArray(raw: string): string[] { export function canEdit( user: CanEditUser, inspection: CanEditInspection, - sectionId?: string, + // Section-scope edit restrictions were removed with the specialist role + // (2026-06-13). The param is retained for call-site stability but unused. + _sectionId?: string, ): boolean { - const role = normaliseRole(user.role); + const role = user.role; - if (role === 'owner' || role === 'admin') return true; - if (role === 'office') return false; - if (role === 'agent') return false; + if (role === ROLE.OWNER || role === ROLE.MANAGER) return true; + if (role === ROLE.AGENT) return false; const helpers = safeJsonArray(inspection.helperInspectorIds); const onInspection = @@ -61,13 +59,7 @@ export function canEdit( helpers.includes(user.id); if (!onInspection) return false; - if (role === 'lead' || role === 'apprentice') return true; - - if (role === 'specialist') { - if (!sectionId) return false; - const sections = safeJsonArray(user.assignedSectionIds); - return sections.includes(sectionId); - } + if (role === ROLE.INSPECTOR) return true; // Unknown / new roles default to deny — safer than fail-open. return false; diff --git a/server/lib/report-section-numbering.ts b/server/lib/report-section-numbering.ts index 7f2b5136f..0af1a1d0c 100644 --- a/server/lib/report-section-numbering.ts +++ b/server/lib/report-section-numbering.ts @@ -13,8 +13,10 @@ * during render. */ +import { ROLE } from './auth/roles'; + /** Roles that may hop back into the editor from the published viewer. */ -const EDIT_ROLES = new Set(['owner', 'admin', 'inspector']); +const EDIT_ROLES = new Set([ROLE.OWNER, ROLE.MANAGER, ROLE.INSPECTOR]); /** Decide whether the current viewer should see the EDIT SECTION button. * Accepts `null` / `undefined` (anonymous public viewer) and unknown diff --git a/server/lib/route-metadata-standards.ts b/server/lib/route-metadata-standards.ts index f720354b8..cc82731f1 100644 --- a/server/lib/route-metadata-standards.ts +++ b/server/lib/route-metadata-standards.ts @@ -14,7 +14,7 @@ export const VALID_TAGS = [ 'agents', 'ai', 'invoices', 'services', 'messages', 'notifications', 'contacts', 'metrics', 'admin', 'sysadmin', 'audit', 'marketplace', 'recommendations', 'contractor-types', 'agreements', 'webhooks', - 'public', 'calendar', 'tags', 'ratings', 'guest', + 'public', 'calendar', 'tags', 'ratings', 'profile', 'identity', 'automations', 'integrations', 'qbo', 'sms', ] as const; diff --git a/server/lib/validations/admin.schema.ts b/server/lib/validations/admin.schema.ts index 4a758a76c..826211622 100644 --- a/server/lib/validations/admin.schema.ts +++ b/server/lib/validations/admin.schema.ts @@ -1,5 +1,6 @@ import { z } from '@hono/zod-openapi'; import { createApiResponseSchema } from './shared.schema'; +import { ROLES } from '../auth/roles'; /** * Validation schema for the branding configuration update. @@ -46,14 +47,18 @@ export const UpdateBrandingSchema = z.object({ */ export const InviteMemberSchema = z.object({ email: z.string().email('Invalid email address').openapi({ example: 'new-user@example.com' }).describe('TODO describe email field for the OpenInspection MCP integration'), - role: z.enum(['admin', 'inspector', 'agent', 'owner', 'lead', 'specialist', 'apprentice', 'office']) - .default('inspector').openapi({ example: 'lead' }).describe('TODO describe role field for the OpenInspection MCP integration'), - /** Required when role === 'apprentice'. Must be a user id from the - * inviting tenant. Carried through to users.mentor_id at accept. */ - mentorId: z.string().uuid().optional().openapi({ example: '6e9b6b1c-4a3f-4ae3-9c10-1f1c3f4d5e6a' }).describe('TODO describe mentorId field for the OpenInspection MCP integration'), - /** Used when role === 'specialist'. Section ids from the active - * template. Carried through to users.assigned_section_ids JSON. */ - assignedSectionIds: z.array(z.string().min(1)).optional().openapi({ example: ['s-roof', 's-elec'] }).describe('TODO describe assignedSectionIds field for the OpenInspection MCP integration'), + role: z.enum(ROLES) + .default('inspector').openapi({ example: 'inspector' }).describe('TODO describe role field for the OpenInspection MCP integration'), + // Role permission-template overrides (2026-06-13). Optional sparse map of the + // four toggleable capabilities. Only differing-from-template keys are sent; + // TeamService stores the diff (or null when nothing differs) and it is + // replayed onto the new users row at accept time. + permissionOverrides: z.object({ + publish: z.boolean().optional(), + scheduleOthers: z.boolean().optional(), + financial: z.boolean().optional(), + manageContacts: z.boolean().optional(), + }).partial().optional().openapi({ example: { publish: false } }).describe('Sparse capability override map for the invited member'), }).openapi('InviteMember'); /** diff --git a/server/services/admin.service.ts b/server/services/admin.service.ts index 60cfeddc3..e22f8ac2e 100644 --- a/server/services/admin.service.ts +++ b/server/services/admin.service.ts @@ -16,6 +16,7 @@ import { } from '../lib/db/schema'; import { Errors } from '../lib/errors'; import { runErasure } from '../lib/compliance/erasure-orchestrator'; +import type { Role } from '../lib/auth/roles'; import { IntegrationProvider, TenantUpdateParams } from '../lib/integration'; import { safeTimestamp } from '../lib/date'; @@ -60,7 +61,7 @@ export class AdminService { id: inviteId, tenantId, email, - role: role as 'owner' | 'admin' | 'inspector' | 'agent' | 'viewer', + role: role as Role, status: 'pending', expiresAt, }); diff --git a/server/services/apprentice.service.ts b/server/services/apprentice.service.ts deleted file mode 100644 index e6474f2ff..000000000 --- a/server/services/apprentice.service.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Design System 0520 subsystem C phase 2 — ApprenticeService. - * - * Apprentice writes (rating / notes / value) route into this queue - * instead of inspection_results.data directly. The mentor reviews + - * approves / rejects / edits each entry from /apprentice-review - * (Phase 3 page); on approve / edit the value lands in the canonical - * inspection state via InspectionService.patchItem(force: true). - * - * Tenant isolation via explicit tenantId on every public method. - */ -import { drizzle } from 'drizzle-orm/d1'; -import { and, eq } from 'drizzle-orm'; -import { apprenticeReviews, users } from '../lib/db/schema'; - -export type ApprenticeField = 'rating' | 'notes' | 'value'; -export type ApprenticeStatus = 'pending' | 'approved' | 'rejected' | 'edited'; - -export interface QueuedResult { - kind: 'queued'; - reviewId: string; -} - -export type DecideResult = - | { kind: 'ok' } - | { kind: 'not_found' }; - -export class ApprenticeService { - constructor(private db: D1Database) {} - - private getDrizzle() { - return drizzle(this.db); - } - - async submitForReview( - tenantId: string, - apprenticeId: string, - inspectionId: string, - itemId: string, - field: ApprenticeField, - value: unknown, - ): Promise { - const db = this.getDrizzle(); - - // Resolve mentor from the apprentice's user row. Apprentices without - // a mentor cannot submit — the route surface translates this into a - // 400 "Apprentice has no mentor" instead of writing a junk row. - const apprentice = await db.select().from(users) - .where(and(eq(users.id, apprenticeId), eq(users.tenantId, tenantId))) - .get(); - if (!apprentice?.mentorId) { - throw new Error('Apprentice has no mentor assigned'); - } - - const id = crypto.randomUUID(); - await db.insert(apprenticeReviews).values({ - id, - tenantId, - apprenticeId, - mentorId: apprentice.mentorId, - inspectionId, - itemId, - field, - proposedValue: JSON.stringify(value), - status: 'pending', - submittedAt: Math.floor(Date.now() / 1000), - createdAt: new Date().toISOString(), - }); - - return { kind: 'queued', reviewId: id }; - } - - async getById(tenantId: string, reviewId: string) { - const db = this.getDrizzle(); - return await db.select().from(apprenticeReviews) - .where(and(eq(apprenticeReviews.id, reviewId), eq(apprenticeReviews.tenantId, tenantId))) - .get() ?? null; - } - - async listPendingForMentor(tenantId: string, mentorId: string) { - const db = this.getDrizzle(); - return await db.select().from(apprenticeReviews) - .where(and( - eq(apprenticeReviews.tenantId, tenantId), - eq(apprenticeReviews.mentorId, mentorId), - eq(apprenticeReviews.status, 'pending'), - )) - .all(); - } - - async decide( - tenantId: string, - reviewId: string, - action: Exclude, - decisionValue?: unknown, - ): Promise { - const db = this.getDrizzle(); - const scope = and(eq(apprenticeReviews.id, reviewId), eq(apprenticeReviews.tenantId, tenantId)); - - const row = await db.select().from(apprenticeReviews).where(scope).get(); - if (!row) return { kind: 'not_found' }; - - await db.update(apprenticeReviews).set({ - status: action, - decisionAt: Math.floor(Date.now() / 1000), - decisionValue: action === 'edited' && decisionValue !== undefined - ? JSON.stringify(decisionValue) - : null, - }).where(scope); - return { kind: 'ok' }; - } -} diff --git a/server/services/auth.service.ts b/server/services/auth.service.ts index 1d66e40bc..4f28ff40e 100644 --- a/server/services/auth.service.ts +++ b/server/services/auth.service.ts @@ -134,6 +134,9 @@ export class AuthService { email: invite.email, passwordHash, role: invite.role, + // Carry the inviter's chosen permission-template overrides onto the + // new member row (null when the invite used the pure role template). + permissionOverrides: invite.permissionOverrides ?? null, ...(trimmedName ? { name: trimmedName } : {}), createdAt: new Date(), }); diff --git a/server/services/dashboard-prefs.service.ts b/server/services/dashboard-prefs.service.ts index 9272f1d09..6c30b33a7 100644 --- a/server/services/dashboard-prefs.service.ts +++ b/server/services/dashboard-prefs.service.ts @@ -50,7 +50,7 @@ export class DashboardPrefsService { /** * Writes the tenant default. The caller (an OpenAPI route guarded by - * `requireRole(['owner','admin'])`) is responsible for permission checks. + * `requireRole('owner','manager')`) is responsible for permission checks. * Performs an upsert against `tenant_configs`. */ async setColumnPrefs(tenantId: string, columns: string[]): Promise { diff --git a/server/services/guest-invite.service.ts b/server/services/guest-invite.service.ts deleted file mode 100644 index 673696f68..000000000 --- a/server/services/guest-invite.service.ts +++ /dev/null @@ -1,232 +0,0 @@ -/** - * Design System 0520 subsystem C phase 6 — GuestInviteService. - * - * mint(tenantId, …) → { token, url, expiresAt } - * claim(token, identity, ctx) → ok | expired | claimed | not_found | - * over_quota | invalid - * - * Per the simplified seat-quota model: guests count against the same - * tenants.max_users cap as permanent members — no separate per-guest - * billing. The service rejects with `over_quota` when the tenant is at - * cap so the route can surface 402 + upgradeUrl. - * - * `maxUsers` is passed in via the ClaimContext rather than read from - * a portal column — core's tenants table doesn't carry it; the value - * arrives via the existing portal → core M2M sync (subsystem C P8). - */ -import { drizzle } from 'drizzle-orm/d1'; -import { eq } from 'drizzle-orm'; -import { guestInvites, users, tenants } from '../lib/db/schema'; -import { hashPassword } from '../lib/password'; -import { mintToken, hashToken, deadTokenSentinel, resolveTokenRow } from '../lib/token-hash'; -import { computeSeatsUsed } from '../lib/middleware/seat-guard'; - -const DEFAULT_DURATION_SECONDS = 86_400; -const MIN_PASSWORD_LENGTH = 8; - -export interface MintInput { - role: 'lead' | 'specialist' | 'apprentice' | 'office'; - durationSeconds?: number; - createdBy: string; -} - -export interface ClaimIdentity { - name: string; - email: string; - password: string; -} - -export interface ClaimContext { - /** Tenant's seat quota — passed in by the route via portal M2M sync. */ - maxUsers: number; - /** - * Whether to enforce the seat cap. Set to `profile.hasSeatQuota` (true in - * SaaS, false in standalone). When false the quota check is skipped - * entirely so self-hosted deployments are genuinely unlimited. - */ - enforceSeatQuota: boolean; - /** Optional terms-acceptance blob (env-gated: set only when TERMS_URL/PRIVACY_URL configured). */ - termsAccepted?: { at: string; ip?: string; country?: string; termsUrl?: string; privacyUrl?: string }; -} - -export type ClaimResult = - | { kind: 'ok'; userId: string } - | { kind: 'expired' } - | { kind: 'claimed' } - | { kind: 'not_found' } - | { kind: 'over_quota'; upgradeUrl?: string } - | { kind: 'invalid'; reason: string }; - -export class GuestInviteService { - constructor(private db: D1Database) {} - - private getDrizzle() { - return drizzle(this.db); - } - - /** - * C-10 ③-B — preview metadata for the /guest-join accept page. guest_invites - * carries no email/inspection, so the page shows the WORKSPACE + ROLE the - * invite grants. Returns null for unknown / expired / already-claimed tokens - * so the page renders its "link unavailable" state. - */ - /** - * Track I-a — resolve a presented invite token to its row. Hash-first - * (token_hash), with a permanent legacy plaintext fallback that lazily - * upgrades the row in place (writes token_hash, clears the plaintext to a - * per-row sentinel). Tier-1: hash only — guest invites are short-lived and - * single-use, so no token_enc reconstruction is needed. - */ - private async resolveInvite(token: string): Promise { - const db = this.getDrizzle(); - return resolveTokenRow({ - presented: token, - byHash: async (hash) => - (await db.select().from(guestInvites).where(eq(guestInvites.tokenHash, hash)).get()) ?? null, - byPlaintext: async (t) => - (await db.select().from(guestInvites).where(eq(guestInvites.token, t)).get()) ?? null, - upgrade: async (legacy, hash) => { - await db.update(guestInvites) - .set({ tokenHash: hash, token: deadTokenSentinel(legacy.id) }) - .where(eq(guestInvites.id, legacy.id)); - }, - }); - } - - /** - * Public, no-JWT pre-lookup for the /guest/claim route: resolve a presented - * token to its tenant + seat cap WITHOUT the expired/claimed gating that - * getInviteInfo applies (the route needs the tenant even for an expired - * invite so claim() can return the precise error kind). Hash-first with the - * same permanent legacy plaintext fallback + lazy upgrade. Returns null only - * when the token matches no row. - */ - async resolveTenantForToken(token: string): Promise<{ tenantId: string; maxUsers: number } | null> { - const invite = await this.resolveInvite(token); - if (!invite) return null; - const db = this.getDrizzle(); - const tenant = await db.select({ maxUsers: tenants.maxUsers }).from(tenants) - .where(eq(tenants.id, invite.tenantId)).get(); - if (!tenant) return null; - return { tenantId: invite.tenantId, maxUsers: tenant.maxUsers }; - } - - async getInviteInfo(token: string): Promise<{ workspaceName: string; role: string; expiresAt: number } | null> { - const db = this.getDrizzle(); - const invite = await this.resolveInvite(token); - if (!invite) return null; - if (invite.claimedByUserId) return null; - if (invite.expiresAt < Math.floor(Date.now() / 1000)) return null; - const tenant = await db.select({ name: tenants.name }).from(tenants).where(eq(tenants.id, invite.tenantId)).get(); - return { workspaceName: tenant?.name ?? '', role: invite.role, expiresAt: invite.expiresAt }; - } - - async mint(tenantId: string, input: MintInput): Promise<{ - id: string; - token: string; - url: string; - expiresAt: number; - }> { - const db = this.getDrizzle(); - const id = crypto.randomUUID(); - const token = mintToken(); - const duration = input.durationSeconds ?? DEFAULT_DURATION_SECONDS; - const expiresAt = Math.floor(Date.now() / 1000) + duration; - - await db.insert(guestInvites).values({ - id, - tenantId, - // Never distributed — satisfies NOT NULL + UNIQUE on the legacy column. - token: deadTokenSentinel(id), - tokenHash: await hashToken(token), - role: input.role, - durationSeconds: duration, - expiresAt, - createdBy: input.createdBy, - createdAt: new Date().toISOString(), - }); - - // Workers runtime has no `location` global; this is a defensive - // origin lookup carried over from a browser-context predecessor. - // Use a typed cast rather than declare a global so we don't pollute - // shared types just for one optional read. - const loc = (globalThis as { location?: { origin?: string } }).location; - const url = `${loc?.origin ?? ''}/guest-join?token=${token}`; - return { id, token, url, expiresAt }; - } - - /** - * Anonymous token claim. On success creates a `users` row carrying - * role + expires_at + the claim-time tenantId. Idempotent: a second - * claim of the same token returns `claimed`. - */ - async claim(token: string, identity: ClaimIdentity, ctx: ClaimContext): Promise { - if (!identity.password || identity.password.length < MIN_PASSWORD_LENGTH) { - return { kind: 'invalid', reason: `password must be at least ${MIN_PASSWORD_LENGTH} chars` }; - } - - const db = this.getDrizzle(); - const invite = await this.resolveInvite(token); - if (!invite) return { kind: 'not_found' }; - if (invite.claimedByUserId) return { kind: 'claimed' }; - if (invite.expiresAt < Math.floor(Date.now() / 1000)) return { kind: 'expired' }; - - // Quota check — only enforced when ctx.enforceSeatQuota is true (SaaS). - // Standalone deployments set enforceSeatQuota=false so self-hosted users - // are genuinely unlimited and never silently capped at max_users. - if (ctx.enforceSeatQuota) { - // Defer to the shared computeSeatsUsed helper so permanent members - // + active guests are counted the same way here, in the seat-guard - // middleware, and on the billing summary. - const tenantUsers = await db.select({ id: users.id, expiresAt: users.expiresAt }) - .from(users) - .where(eq(users.tenantId, invite.tenantId)) - .all(); - const used = computeSeatsUsed(tenantUsers, Math.floor(Date.now() / 1000)); - if (used >= ctx.maxUsers) { - return { kind: 'over_quota' }; - } - } - - // Create user. - const userId = crypto.randomUUID(); - const passwordHash = await hashPassword(identity.password); - await db.insert(users).values({ - id: userId, - tenantId: invite.tenantId, - email: identity.email, - passwordHash, - name: identity.name, - role: invite.role, - expiresAt: invite.expiresAt, - createdAt: new Date(), - termsAccepted: ctx.termsAccepted ?? null, - }); - - // Mark invite as claimed. - await db.update(guestInvites).set({ - claimedByUserId: userId, - claimedAt: Math.floor(Date.now() / 1000), - }).where(eq(guestInvites.id, invite.id)); - - return { kind: 'ok', userId }; - } - - async list(tenantId: string) { - const db = this.getDrizzle(); - // Token material is projected OUT (post hash-at-rest sweep the - // plaintext column holds a dead sentinel and tokenHash must never - // reach a caller that might route it to a client). - return await db.select({ - id: guestInvites.id, - tenantId: guestInvites.tenantId, - role: guestInvites.role, - durationSeconds: guestInvites.durationSeconds, - expiresAt: guestInvites.expiresAt, - claimedByUserId: guestInvites.claimedByUserId, - claimedAt: guestInvites.claimedAt, - createdBy: guestInvites.createdBy, - createdAt: guestInvites.createdAt, - }).from(guestInvites).where(eq(guestInvites.tenantId, tenantId)).all(); - } -} diff --git a/server/services/inspection-results.service.ts b/server/services/inspection-results.service.ts index 4115a9bd4..3391d5e3d 100644 --- a/server/services/inspection-results.service.ts +++ b/server/services/inspection-results.service.ts @@ -14,8 +14,8 @@ import { findingKey, DEFAULT_UNIT } from '../lib/finding-key'; * path mutates, sharing the composite findingKey + version-bump semantics so * mixing single + batch writes is safe. * - * Conflict adjudication, apprentice queueing and compound `defectFields` / - * `itemAttribute` shape-folding live in InspectionService.patchItem — the + * Conflict adjudication and compound `defectFields` / `itemAttribute` + * shape-folding live in InspectionService.patchItem — the * batch service is intentionally simpler: forced last-writer-wins on each * scalar field. The form-renderer is the only caller and it serialises saves * locally; if we ever want batch + conflict the call site should funnel diff --git a/server/services/inspection.service.ts b/server/services/inspection.service.ts index 2591ae856..352a2489a 100644 --- a/server/services/inspection.service.ts +++ b/server/services/inspection.service.ts @@ -16,7 +16,6 @@ import { logger } from '../lib/logger'; import { RECOMMENDATION_CATEGORIES, RECOMMENDATION_CATEGORY_IDS } from '../lib/recommendation-categories'; import { computePreflightFromData } from '../lib/preflight'; import { decideFieldWrite, applyFieldWrite } from '../lib/field-version'; -import { ApprenticeService } from './apprentice.service'; import { syncInspectionAssignments } from '../lib/db/assignment-links'; import type { AgreementService } from './agreement.service'; import { findingKey, parseFindingKey, DEFAULT_UNIT } from '../lib/finding-key'; @@ -388,10 +387,7 @@ export class InspectionService { * Design System 0520 subsystem E P1.2 — Publish pre-flight gates. * * Loads the inspection + parsed inspection_results.data and - * delegates to the pure aggregator in server/lib/preflight.ts. The - * apprentice pending count is read from apprentice_reviews; if - * that table is missing (subsystem C not yet merged) we pass - * `undefined` so the gate gracefully no-ops to "reviewed". + * delegates to the pure aggregator in server/lib/preflight.ts. */ async computePreflight(inspectionId: string, tenantId: string) { if (!this.sdb) throw new Error('ScopedDB session missing'); @@ -411,18 +407,6 @@ export class InspectionService { } catch { return {}; } })(); - // Apprentice pending — subsystem C dependency. Wrap the query - // so a missing-table error degrades to undefined (gate passes). - let pendingApprenticeCount: number | undefined; - try { - const rows = await this.db.prepare( - 'SELECT COUNT(*) AS cnt FROM apprentice_reviews WHERE inspection_id = ?1 AND tenant_id = ?2 AND status = "pending"' - ).bind(inspectionId, tenantId).first<{ cnt: number }>(); - pendingApprenticeCount = rows?.cnt ?? 0; - } catch { - pendingApprenticeCount = undefined; - } - return computePreflightFromData( { coverPhotoId: (ins.coverPhotoId as string | null) ?? null, @@ -430,7 +414,6 @@ export class InspectionService { agreementSignedAt: (ins.agreementSignedAt as number | null) ?? null, }, items, - pendingApprenticeCount, ); } @@ -1638,7 +1621,6 @@ export class InspectionService { | { kind: 'ok'; newVersion: number; by: string; at: number } | { kind: 'conflict'; current: { value: unknown; by?: string; at?: number; v: number }; yours: { value: unknown; expectedVersion: number } } | { kind: 'not_found' } - | { kind: 'queued'; reviewId: string } > { // Verify ownership — throws if foreign tenant. try { @@ -1647,41 +1629,6 @@ export class InspectionService { return { kind: 'not_found' }; } - // Design System 0520 subsystem C phase 2 — apprentice write-gating. - // If the caller is an apprentice AND we're NOT in force mode (mentor - // approval re-applies values with force: true), route the write into - // the apprentice_reviews queue instead of mutating inspection_results - // directly. Mentor decides → ApprenticeService.decide → this method - // again with { force: true } to land the value. - // - // Soft-detect role from the users row. apprentice_reviews table may - // not exist on standalone profiles that opted out of subsystem C — - // graceful no-op: any error here falls through to the legacy write - // path, never blocking a regular inspector save. - if (!opts?.force) { - try { - const u = await this.getDrizzle().select().from(users) - .where(and(eq(users.id, userId), eq(users.tenantId, tenantId))) - .get(); - if (u?.role === 'apprentice') { - const apprenticeSvc = new ApprenticeService(this.db); - const queued = await apprenticeSvc.submitForReview( - tenantId, userId, inspectionId, itemId, field as 'rating' | 'notes' | 'value', value, - ); - return queued; - } - } catch { - // Table missing, schema mismatch, or apprentice without mentor - // — fall through to legacy write path. The ApprenticeService - // itself throws explicitly when a mentor is missing; in that - // edge case the route surface should surface 400 rather than - // silently writing as inspector, so re-throw if it's that - // specific message. - // (Pragmatic MVP: any error → legacy path. Mentor-missing UX - // lives at the route layer via a separate guard.) - } - } - const db = this.getDrizzle(); const existing = await db.select().from(inspectionResults) diff --git a/server/services/notification.service.ts b/server/services/notification.service.ts index 02fa84fa8..4d3abfc1c 100644 --- a/server/services/notification.service.ts +++ b/server/services/notification.service.ts @@ -1,6 +1,7 @@ import { drizzle } from 'drizzle-orm/d1'; import { eq, and, desc, isNull, inArray, lt, sql } from 'drizzle-orm'; import { notifications, users } from '../lib/db/schema'; +import { ROLE } from '../lib/auth/roles'; import { nanoid } from 'nanoid'; export type NotificationType = @@ -79,7 +80,7 @@ export class NotificationService { .from(users) .where(and( eq(users.tenantId, tenantId), - inArray(users.role, ['owner', 'admin']), + inArray(users.role, [ROLE.OWNER, ROLE.MANAGER]), )); if (admins.length === 0) return 0; const now = new Date(); diff --git a/server/services/team.service.ts b/server/services/team.service.ts index ca138b7d2..4db596e43 100644 --- a/server/services/team.service.ts +++ b/server/services/team.service.ts @@ -4,6 +4,15 @@ import { eq, and } from 'drizzle-orm'; import { UserRole } from '../types/auth'; import { Errors } from '../lib/errors'; import type { UserSyncOutbox } from '../lib/integration/user-sync'; +import { getCapabilities, TOGGLEABLE, type Capability, type PermissionOverrides } from '../lib/auth/capabilities'; + +/** + * Loose shape accepted from the validated invite body. Zod under + * `exactOptionalPropertyTypes` infers `boolean | undefined` per key, which is + * not assignable to the strict `PermissionOverrides`; `diffOverrides` reads each + * value through a `typeof === 'boolean'` guard so the looseness is safe. + */ +type RequestedOverrides = Partial>; export class TeamService { /** @@ -42,27 +51,10 @@ export class TeamService { tenantId: string; email: string; role: UserRole; - /** Required when `role === 'apprentice'`. Must be a user in the - * same tenant. Replayed onto users.mentor_id at accept time. */ - mentorId?: string; - /** Used when `role === 'specialist'`. Stored as JSON; replayed - * onto users.assigned_section_ids at accept time. Defaults to - * an empty array for non-specialist roles. */ - assignedSectionIds?: string[]; + permissionOverrides?: RequestedOverrides | null; }) { const db = this.getDB(); - // Subsystem C P5 — apprentice MUST have a mentor in the same - // tenant; the queue routing in InspectionService.patchItem - // refuses to enqueue without it. - if (params.role === 'apprentice') { - if (!params.mentorId) throw Errors.BadRequest('Mentor required for apprentice invites'); - const mentor = await db.select({ id: users.id }).from(users) - .where(and(eq(users.id, params.mentorId), eq(users.tenantId, params.tenantId))) - .limit(1); - if (mentor.length === 0) throw Errors.BadRequest('Mentor must be a member of the same team'); - } - // Seat-quota enforcement now lives in features/seat-quota/middleware // (mounted on POST /api/team/invite). The service only needs to // verify the invitee is not already a workspace member. @@ -71,6 +63,10 @@ export class TeamService { if (existing.length > 0) throw Errors.Conflict('User is already a member'); + // Only persist toggles that DIFFER from the role template, so an + // all-default invite stores null (single source of truth = the role). + const permissionOverrides = TeamService.diffOverrides(params.role, params.permissionOverrides); + // Create Invite (7-day expiry) const inviteToken = crypto.randomUUID(); const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); @@ -82,13 +78,30 @@ export class TeamService { role: params.role, status: 'pending', expiresAt, - ...(params.mentorId ? { mentorId: params.mentorId } : {}), - assignedSectionIds: JSON.stringify(params.assignedSectionIds ?? []), + permissionOverrides, }); return { token: inviteToken, expiresAt }; } + /** + * Reduce a requested override map to only the capabilities whose value + * differs from the role's template default. Returns null when nothing + * differs (the role template already covers the request) so the stored + * column stays null. owner/agent capabilities are pinned by getCapabilities, + * so a diff against the effective template never persists a moot toggle. + */ + static diffOverrides(role: UserRole, requested?: RequestedOverrides | null): PermissionOverrides | null { + if (!requested) return null; + const template = getCapabilities(role, null); + const diff: PermissionOverrides = {}; + for (const cap of TOGGLEABLE) { + const value = requested[cap]; + if (typeof value === 'boolean' && value !== template[cap]) diff[cap] = value; + } + return Object.keys(diff).length ? diff : null; + } + async removeMember(tenantId: string, userId: string, requesterId: string) { if (userId === requesterId) { throw Errors.BadRequest('Cannot remove yourself'); diff --git a/server/types/auth.ts b/server/types/auth.ts index e1d51953f..bc93c6b52 100644 --- a/server/types/auth.ts +++ b/server/types/auth.ts @@ -1,21 +1,15 @@ +import type { Role } from '../lib/auth/roles'; + export interface User { sub: string; - /** - * Subsystem C P5 extended the role surface from the legacy 3-role - * model (owner/admin/inspector) to a 4-role inspector hierarchy - * (lead/specialist/apprentice/office). `inspector` is retained as a - * legacy alias for `lead` and is normalised by ROLE_ALIASES in - * server/lib/middleware/rbac.ts so existing tokens keep verifying. - */ - role: 'owner' | 'admin' | 'inspector' | 'agent' - | 'lead' | 'specialist' | 'apprentice' | 'office'; + role: Role; // Agent Accounts A1 — tenantId is undefined for global agent accounts // (role='agent'). Each agent route resolves the active tenant per-request // via `resolveAgentTenant()`. tenantId?: string; } -export type UserRole = User['role']; +export type UserRole = Role; export interface BrandingConfig { siteName: string; diff --git a/server/types/hono.ts b/server/types/hono.ts index ff722e2f2..a94fa5bea 100644 --- a/server/types/hono.ts +++ b/server/types/hono.ts @@ -243,8 +243,6 @@ export interface AppServices { unit: import('../services/unit.service').UnitService; observerLink: import('../services/observer-link.service').ObserverLinkService; reportVersion: import('../services/report-version.service').ReportVersionService; - apprentice: import('../services/apprentice.service').ApprenticeService; - guestInvite: import('../services/guest-invite.service').GuestInviteService; identity: import('../services/identity.service').IdentityService; integrations: import('../services/integrations.service').IntegrationsService; analytics: import('../services/analytics.service').AnalyticsService; diff --git a/tests/e2e/subsystem-c-apprentice-flow.spec.ts b/tests/e2e/subsystem-c-apprentice-flow.spec.ts deleted file mode 100644 index a61b2ed58..000000000 --- a/tests/e2e/subsystem-c-apprentice-flow.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * Design System 0520 subsystem C P11 T11.1 — apprentice happy-path E2E. - * - * Currently skipped: requires a seeded multi-user tenant (admin + - * lead/mentor + apprentice + a team-mode inspection) which the global - * setup does not yet provision. The supporting service + UI pieces are - * exercised by: - * - * tests/unit/apprentice-service.spec.ts (7 tests, GREEN) - * tests/unit/can-edit.spec.ts (11 tests, GREEN) - * tests/unit/role-alias.spec.ts (GREEN) - * - * Unskip after adding the seed harness in tests/global-setup.ts. - */ -import { test, expect } from '@playwright/test'; - -test.skip('apprentice rates → mentor approves → value lands in inspection', async ({ browser }) => { - const apprenticeCtx = await browser.newContext(); - const mentorCtx = await browser.newContext(); - const appPage = await apprenticeCtx.newPage(); - const menPage = await mentorCtx.newPage(); - - // Apprentice login + rate item - await appPage.goto('/login'); - await appPage.fill('input[name=email]', 'apprentice-1@seed.test'); - await appPage.fill('input[name=password]', 'seedpassword'); - await appPage.click('button[type=submit]'); - await appPage.goto('/inspections/seed-team-inspection/edit'); - await appPage.click('[data-item-id=item-1] [data-rating=defect]'); - - // Mentor sees the badge → click → review page → approve - await menPage.goto('/login'); - await menPage.fill('input[name=email]', 'mentor-1@seed.test'); - await menPage.fill('input[name=password]', 'seedpassword'); - await menPage.click('button[type=submit]'); - await menPage.goto('/dashboard'); - await expect(menPage.locator('text=apprentice review(s) awaiting')).toBeVisible(); - await menPage.click('text=apprentice review(s) awaiting'); - await menPage.click('text=Approve'); - - // Apprentice's rating now landed on the canonical inspection state - await menPage.goto('/inspections/seed-team-inspection/edit'); - await expect(menPage.locator('[data-item-id=item-1] [data-rating-current=defect]')).toBeVisible(); -}); diff --git a/tests/e2e/subsystem-c-guest-claim.spec.ts b/tests/e2e/subsystem-c-guest-claim.spec.ts deleted file mode 100644 index 811db366f..000000000 --- a/tests/e2e/subsystem-c-guest-claim.spec.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Design System 0520 subsystem C P11 T11.2 — guest claim happy path. - * - * Skipped pending the multi-user seed harness. Covered indirectly by: - * - * tests/unit/guest-invite-service.spec.ts (7 tests, GREEN) - * tests/unit/seat-guard.spec.ts (6 tests, GREEN) - * - * The Alpine factories in public/js/invite-seat-modal.js + guest-join.js - * are static-source-only smoke-tested at lint time. - */ -import { test, expect } from '@playwright/test'; - -test.skip('admin mints guest invite → anonymous claim succeeds', async ({ browser }) => { - const adminCtx = await browser.newContext(); - const guestCtx = await browser.newContext(); - const adminP = await adminCtx.newPage(); - const guestP = await guestCtx.newPage(); - - await adminP.goto('/login'); - await adminP.fill('input[name=email]', 'admin@seed.test'); - await adminP.fill('input[name=password]', 'seedpassword'); - await adminP.click('button[type=submit]'); - await adminP.goto('/team'); - - await adminP.click('text=Invite'); - await adminP.click('text=Guest'); - await adminP.click('text=24h'); - await adminP.click('text=Generate link'); - const url = await adminP.locator('input[readonly]').inputValue(); - expect(url).toMatch(/\/guest-join\?token=/); - - await guestP.goto(url); - await guestP.fill('input[name=name]', 'Test Guest'); - await guestP.fill('input[name=email]', 'guest-e2e@test'); - await guestP.fill('input[name=password]', 'guestpass1234'); - await guestP.click('button[type=submit]'); - await expect(guestP).toHaveURL(/\/login/); -}); - -test.skip('admin tries to invite when tenant at quota → 402 surfaced', async ({ page }) => { - await page.goto('/login'); - await page.fill('input[name=email]', 'admin-full@seed.test'); - await page.fill('input[name=password]', 'seedpassword'); - await page.click('button[type=submit]'); - - page.on('dialog', d => d.dismiss()); - - await page.goto('/team'); - await page.click('text=Invite'); - await page.fill('input[type=email]', 'newbie@test'); - await page.click('text=Send invite'); - const r = await page.waitForResponse(r => r.url().includes('/api/team/invite')); - expect(r.status()).toBe(402); -}); diff --git a/tests/seed-fixtures.ts b/tests/seed-fixtures.ts index 851194653..36e613032 100644 --- a/tests/seed-fixtures.ts +++ b/tests/seed-fixtures.ts @@ -1,7 +1,7 @@ /** * Design System 0520 P10 — E2E seed fixtures. * - * Spawns a fresh standalone workspace + admin + lead + apprentice + + * Spawns a fresh standalone workspace + admin + a couple of inspectors + * a few inspections so the test.skip E2E specs across C/D/E can be * unskipped and run against `npm run dev`. * @@ -12,8 +12,7 @@ import { execSync } from 'child_process'; const ADMIN_EMAIL = 'admin-seed@seed.test'; const LEAD_EMAIL = 'inspector-a@seed.test'; -const APPRENTICE_EMAIL = 'apprentice-1@seed.test'; -const MENTOR_EMAIL = 'mentor-1@seed.test'; +const INSPECTOR_B_EMAIL = 'inspector-b@seed.test'; const ADMIN_FULL_EMAIL = 'admin-full@seed.test'; const MULTI_EMAIL = 'multi-tenant-user@seed.test'; const BRANCH_B_EMAIL = 'branch-b@seed.test'; @@ -60,13 +59,9 @@ export function seedFixtures(appDir: string): void { d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at) VALUES ('22222222-2222-2222-2222-222222222aa1', '${TENANT_A_ID}', '${LEAD_EMAIL}', '${SEED_PASSWORD_HASH}', 'Lead Inspector', 'inspector', '${now}')`, cwd); - d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, mentor_id, created_at) - VALUES ('33333333-3333-3333-3333-333333333aa1', '${TENANT_A_ID}', - '${APPRENTICE_EMAIL}', '${SEED_PASSWORD_HASH}', 'Seed Apprentice', 'apprentice', - '22222222-2222-2222-2222-222222222aa1', '${now}')`, cwd); d1(`INSERT OR REPLACE INTO users (id, tenant_id, email, password_hash, name, role, created_at) - VALUES ('44444444-4444-4444-4444-444444444aa1', '${TENANT_A_ID}', - '${MENTOR_EMAIL}', '${SEED_PASSWORD_HASH}', 'Seed Mentor', 'inspector', '${now}')`, cwd); + VALUES ('33333333-3333-3333-3333-333333333aa1', '${TENANT_A_ID}', + '${INSPECTOR_B_EMAIL}', '${SEED_PASSWORD_HASH}', 'Seed Inspector B', 'inspector', '${now}')`, cwd); // Seat-quota / at-cap admin for the over-quota E2E. d1(`INSERT OR REPLACE INTO tenants (id, name, slug, status, deployment_mode, tier, max_users, created_at) @@ -109,15 +104,14 @@ export function seedFixtures(appDir: string): void { d1(inspectionRow('seed-delivered-inspection', '5 Delivered Ln', 'delivered'), cwd); d1(inspectionRow('seed-republished-inspection', '6 Republished Ct', 'delivered'), cwd); - console.info('[seed-fixtures] Seeded tenants + 8 users + 6 inspections + 1 identity link.'); + console.info('[seed-fixtures] Seeded tenants + 7 users + 6 inspections + 1 identity link.'); } export const SEED_PASSWORD = 'seedpassword'; export const SEED_EMAILS = { admin: ADMIN_EMAIL, lead: LEAD_EMAIL, - apprentice: APPRENTICE_EMAIL, - mentor: MENTOR_EMAIL, + inspectorB: INSPECTOR_B_EMAIL, adminAtCap: ADMIN_FULL_EMAIL, multiTenant: MULTI_EMAIL, branchB: BRANCH_B_EMAIL, diff --git a/tests/unit/apprentice-service.spec.ts b/tests/unit/apprentice-service.spec.ts deleted file mode 100644 index 67a08b26f..000000000 --- a/tests/unit/apprentice-service.spec.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ApprenticeService } from '../../server/services/apprentice.service'; -import { createTestDb, setupSchema } from './db'; -import * as schema from '../../server/lib/db/schema'; -import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; - -vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); -import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; - -const TENANT = '00000000-0000-0000-0000-000000000099'; -const INSPECTION = '11111111-1111-1111-1111-111111111111'; -const APPRENTICE = '22222222-2222-2222-2222-222222222222'; -const MENTOR = '33333333-3333-3333-3333-333333333333'; -const ORPHAN = '44444444-4444-4444-4444-444444444444'; - -async function seed(testDb: BetterSQLite3Database) { - await testDb.insert(schema.tenants).values({ - id: TENANT, name: 'Acme', slug: 'acme', status: 'active', - deploymentMode: 'shared', tier: 'free', createdAt: new Date(), - }); - // Mentor first (apprentice references mentor_id). - await testDb.insert(schema.users).values({ - id: MENTOR, tenantId: TENANT, email: 'mentor@acme.test', - passwordHash: 'x', role: 'lead', createdAt: new Date(), - }); - await testDb.insert(schema.users).values({ - id: APPRENTICE, tenantId: TENANT, email: 'app@acme.test', - passwordHash: 'x', role: 'apprentice', mentorId: MENTOR, createdAt: new Date(), - }); - await testDb.insert(schema.users).values({ - id: ORPHAN, tenantId: TENANT, email: 'orphan@acme.test', - passwordHash: 'x', role: 'apprentice', createdAt: new Date(), - }); - await testDb.insert(schema.inspections).values({ - id: INSPECTION, tenantId: TENANT, inspectorId: MENTOR, - propertyAddress: '1 Main St', date: '2026-06-01', - status: 'draft', paymentStatus: 'unpaid', price: 0, - paymentRequired: false, agreementRequired: false, createdAt: new Date(), - }); -} - -describe('ApprenticeService (subsystem C P2)', () => { - let testDb: BetterSQLite3Database; - let svc: ApprenticeService; - - beforeEach(async () => { - const fix = createTestDb(); - testDb = fix.db; - await setupSchema(fix.sqlite); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockDrizzle as any).mockReturnValue(testDb); - await seed(testDb); - svc = new ApprenticeService({} as D1Database); - }); - - it('submitForReview inserts pending row with mentor + json-encoded value', async () => { - const out = await svc.submitForReview(TENANT, APPRENTICE, INSPECTION, 'item-1', 'rating', 'defect'); - expect(out.kind).toBe('queued'); - const row = await svc.getById(TENANT, out.reviewId); - expect(row).toMatchObject({ - apprenticeId: APPRENTICE, - mentorId: MENTOR, - inspectionId: INSPECTION, - itemId: 'item-1', - field: 'rating', - proposedValue:'"defect"', - status: 'pending', - }); - }); - - it('rejects apprentice without a mentor', async () => { - await expect(svc.submitForReview(TENANT, ORPHAN, INSPECTION, 'item-1', 'rating', 'sat')) - .rejects.toThrow(/no mentor/i); - }); - - it('listPendingForMentor returns only this mentor\'s pending rows', async () => { - await svc.submitForReview(TENANT, APPRENTICE, INSPECTION, 'item-1', 'rating', 'sat'); - await svc.submitForReview(TENANT, APPRENTICE, INSPECTION, 'item-2', 'rating', 'monitor'); - const out = await svc.listPendingForMentor(TENANT, MENTOR); - expect(out).toHaveLength(2); - }); - - it('decide approved updates status + decision_at', async () => { - const sub = await svc.submitForReview(TENANT, APPRENTICE, INSPECTION, 'item-1', 'rating', 'defect'); - const out = await svc.decide(TENANT, sub.reviewId, 'approved'); - expect(out.kind).toBe('ok'); - const row = await svc.getById(TENANT, sub.reviewId); - expect(row?.status).toBe('approved'); - expect(row?.decisionAt).toBeGreaterThan(0); - }); - - it('decide edited stores decision_value as JSON', async () => { - const sub = await svc.submitForReview(TENANT, APPRENTICE, INSPECTION, 'item-1', 'rating', 'defect'); - const out = await svc.decide(TENANT, sub.reviewId, 'edited', 'monitor'); - expect(out.kind).toBe('ok'); - const row = await svc.getById(TENANT, sub.reviewId); - expect(row?.status).toBe('edited'); - expect(row?.decisionValue).toBe('"monitor"'); - }); - - it('decide on missing reviewId returns not_found', async () => { - const out = await svc.decide(TENANT, 'no-such-review', 'approved'); - expect(out.kind).toBe('not_found'); - }); - - it('listPendingForMentor is tenant-scoped (foreign tenant returns empty)', async () => { - await svc.submitForReview(TENANT, APPRENTICE, INSPECTION, 'item-1', 'rating', 'sat'); - const out = await svc.listPendingForMentor('other-tenant', MENTOR); - expect(out).toEqual([]); - }); -}); diff --git a/tests/unit/auth.service.spec.ts b/tests/unit/auth.service.spec.ts index 98386965b..52ae99663 100644 --- a/tests/unit/auth.service.spec.ts +++ b/tests/unit/auth.service.spec.ts @@ -138,6 +138,35 @@ describe('AuthService', () => { expect(dbUser?.name).toBe('Jamie Rivera'); }); + it('joinTeam carries the invite permission overrides onto the new member row', async () => { + const token = 'invite-overrides'; + await testDb.insert(tenantInvites).values({ + id: token, tenantId: 't1', email: 'override@example.com', role: 'inspector', + status: 'pending', expiresAt: new Date(Date.now() + 1000000), invitedBy: 'u1', + // Inspector template has publish:true — this invite grants the extra + // scheduleOthers capability and revokes publish. + permissionOverrides: { publish: false, scheduleOthers: true }, + } as any); + + await authService.joinTeam(token, 'password'); + + const dbUser = await testDb.select().from(users).where(eq(users.email as any, 'override@example.com')).get(); + expect(dbUser?.permissionOverrides).toEqual({ publish: false, scheduleOthers: true }); + }); + + it('joinTeam leaves permission overrides null when the invite used the pure role template', async () => { + const token = 'invite-no-overrides'; + await testDb.insert(tenantInvites).values({ + id: token, tenantId: 't1', email: 'plain@example.com', role: 'inspector', + status: 'pending', expiresAt: new Date(Date.now() + 1000000), invitedBy: 'u1', + } as any); + + await authService.joinTeam(token, 'password'); + + const dbUser = await testDb.select().from(users).where(eq(users.email as any, 'plain@example.com')).get(); + expect(dbUser?.permissionOverrides ?? null).toBeNull(); + }); + it('getInviteInfo returns email + workspace name for a live invite, null otherwise (C-10 ③-B)', async () => { await testDb.insert(tenantInvites).values({ id: 'inv-live', tenantId: 't1', email: 'peek@example.com', role: 'inspector', diff --git a/tests/unit/availability-role-guard.spec.ts b/tests/unit/availability-role-guard.spec.ts index 2579dda73..fa8f98611 100644 --- a/tests/unit/availability-role-guard.spec.ts +++ b/tests/unit/availability-role-guard.spec.ts @@ -108,7 +108,7 @@ describe('GET /availability — on-behalf-of read role guard (IA-26)', () => { }); it('admin querying a different inspectorId gets 200', async () => { - const app = buildApp(db, { sub: CALLER_ID, role: 'admin' }); + const app = buildApp(db, { sub: CALLER_ID, role: 'manager' }); const res = await app.request(`/?inspectorId=${OTHER_ID}`, {}, FAKE_ENV); expect(res.status).toBe(200); }); @@ -161,7 +161,7 @@ describe('GET /overrides — on-behalf-of read role guard (IA-26)', () => { }); it('admin querying a different inspectorId gets 200', async () => { - const app = buildApp(db, { sub: CALLER_ID, role: 'admin' }); + const app = buildApp(db, { sub: CALLER_ID, role: 'manager' }); const res = await app.request(`/overrides?inspectorId=${OTHER_ID}`, {}, FAKE_ENV); expect(res.status).toBe(200); }); diff --git a/tests/unit/billing-summary.spec.ts b/tests/unit/billing-summary.spec.ts index 2186c6652..ae7c93d77 100644 --- a/tests/unit/billing-summary.spec.ts +++ b/tests/unit/billing-summary.spec.ts @@ -1,54 +1,37 @@ /** - * Design System 0520 subsystem C P9 T9.1 — billing summary pure helper. + * Billing summary pure helper. * * The route handler in server/api/billing.ts is a thin wrapper around two * drizzle queries + this pure aggregator. Splitting `summariseSeats` * out makes the seat-breakdown logic unit-testable without spinning a - * full Hono context. + * full Hono context. Every member counts as one seat; `guests` is always + * 0 since the guest subsystem was removed. */ import { describe, it, expect } from 'vitest'; import { summariseSeats } from '../../server/lib/billing-summary'; -describe('summariseSeats (subsystem C P9.1)', () => { - const NOW = 1_700_000_000; - - it('counts permanent + active guests, ignores expired', () => { - const users = [ - { id: 'u1', expiresAt: null }, // permanent - { id: 'u2', expiresAt: null }, // permanent - { id: 'g1', expiresAt: NOW + 100 }, // active guest - { id: 'g2', expiresAt: NOW - 100 }, // expired guest, excluded - ]; - const out = summariseSeats(users, { maxUsers: 5, tier: 'free' }, NOW); +describe('summariseSeats', () => { + it('counts every member once; guests always 0', () => { + const users = [{ id: 'u1' }, { id: 'u2' }, { id: 'u3' }]; + const out = summariseSeats(users, { maxUsers: 5, tier: 'free' }); expect(out).toEqual({ tier: 'free', maxUsers: 5, seatsUsed: 3, - permanent: 2, - guests: 1, + permanent: 3, + guests: 0, }); }); it('defaults missing tier to free and missing maxUsers to 1', () => { - const out = summariseSeats([], {}, NOW); + const out = summariseSeats([], {}); expect(out.tier).toBe('free'); expect(out.maxUsers).toBe(1); expect(out.seatsUsed).toBe(0); }); - it('treats expiresAt exactly equal to now as expired (boundary)', () => { - const users = [ - { id: 'g1', expiresAt: NOW }, // boundary → expired - { id: 'g2', expiresAt: NOW + 1 }, // active - ]; - const out = summariseSeats(users, { maxUsers: 3, tier: 'pro' }, NOW); - expect(out.guests).toBe(1); - expect(out.seatsUsed).toBe(1); - }); - - it('handles undefined expiresAt as permanent', () => { - const users = [{ id: 'u1' }]; - const out = summariseSeats(users, { maxUsers: 1, tier: 'free' }, NOW); + it('reports permanent equal to seatsUsed', () => { + const out = summariseSeats([{ id: 'u1' }], { maxUsers: 1, tier: 'free' }); expect(out.permanent).toBe(1); expect(out.guests).toBe(0); }); diff --git a/tests/unit/can-edit.spec.ts b/tests/unit/can-edit.spec.ts index 386640249..2102acf36 100644 --- a/tests/unit/can-edit.spec.ts +++ b/tests/unit/can-edit.spec.ts @@ -1,12 +1,13 @@ /** - * Design System 0520 subsystem C phase 4 — canEdit permission matrix. + * canEdit permission matrix (roles collapsed to owner/admin/inspector/agent + * — 2026-06-13). * * Owners + admins → always. - * Lead / apprentice → must be on the inspection (inspectorId / - * leadInspectorId / helperInspectorIds). - * Specialist → on-inspection AND sectionId in user.assigned_section_ids. - * Office → never (read-only seat). - * Legacy 'inspector' aliased to 'lead' via the role-alias shim. + * Inspector → must be on the inspection (inspectorId / + * leadInspectorId / helperInspectorIds). Section-scope restrictions + * (formerly the specialist role) were removed; an on-inspection + * inspector now has full edit access. + * Agent → never (buyer-agent surface, read-only). */ import { describe, it, expect } from 'vitest'; import { canEdit } from '../../server/lib/rbac/can-edit'; @@ -20,50 +21,33 @@ const baseInspection = { }; describe('canEdit (subsystem C P4)', () => { - it('owner / admin can edit anything', () => { + it('owner / manager can edit anything', () => { expect(canEdit({ id: 'u', role: 'owner', assignedSectionIds: '[]' }, baseInspection)).toBe(true); - expect(canEdit({ id: 'u', role: 'admin', assignedSectionIds: '[]' }, baseInspection)).toBe(true); + expect(canEdit({ id: 'u', role: 'manager', assignedSectionIds: '[]' }, baseInspection)).toBe(true); }); - it('lead can edit own inspections', () => { - expect(canEdit({ id: 'u-lead', role: 'lead', assignedSectionIds: '[]' }, baseInspection)).toBe(true); + it('inspector can edit own inspections', () => { + expect(canEdit({ id: 'u-lead', role: 'inspector', assignedSectionIds: '[]' }, baseInspection)).toBe(true); }); - it('lead cannot edit foreign inspection', () => { - expect(canEdit({ id: 'u-other', role: 'lead', assignedSectionIds: '[]' }, baseInspection)).toBe(false); + it('inspector cannot edit foreign inspection', () => { + expect(canEdit({ id: 'u-other', role: 'inspector', assignedSectionIds: '[]' }, baseInspection)).toBe(false); }); it('helper listed in helperInspectorIds can edit', () => { - expect(canEdit({ id: 'u-helper-1', role: 'lead', assignedSectionIds: '[]' }, baseInspection)).toBe(true); + expect(canEdit({ id: 'u-helper-1', role: 'inspector', assignedSectionIds: '[]' }, baseInspection)).toBe(true); }); - it('specialist needs sectionId AND that section in assigned list', () => { - const u = { id: 'u-helper-1', role: 'specialist', assignedSectionIds: '["s-roof"]' }; + it('on-inspection inspector has full access regardless of sectionId (specialist scoping removed)', () => { + const u = { id: 'u-helper-1', role: 'inspector', assignedSectionIds: '["s-roof"]' }; expect(canEdit(u, baseInspection, 's-roof')).toBe(true); - expect(canEdit(u, baseInspection, 's-elec')).toBe(false); - }); - - it('specialist without sectionId arg denies', () => { - const u = { id: 'u-helper-1', role: 'specialist', assignedSectionIds: '["s-roof"]' }; - expect(canEdit(u, baseInspection)).toBe(false); - }); - - it('apprentice gets lead-like access (queue routing happens in patchItem)', () => { - expect(canEdit({ id: 'u-helper-1', role: 'apprentice', assignedSectionIds: '[]' }, baseInspection)).toBe(true); - }); - - it('office can never edit', () => { - expect(canEdit({ id: 'u-lead', role: 'office', assignedSectionIds: '[]' }, baseInspection)).toBe(false); - expect(canEdit({ id: 'u-helper-1', role: 'office', assignedSectionIds: '[]' }, baseInspection)).toBe(false); - }); - - it('legacy inspector role aliased to lead', () => { - expect(canEdit({ id: 'u-lead', role: 'inspector', assignedSectionIds: '[]' }, baseInspection)).toBe(true); + expect(canEdit(u, baseInspection, 's-elec')).toBe(true); + expect(canEdit(u, baseInspection)).toBe(true); }); it('malformed helperInspectorIds JSON treated as empty', () => { const broken = { ...baseInspection, helperInspectorIds: 'not-json' }; - expect(canEdit({ id: 'u-helper-1', role: 'lead', assignedSectionIds: '[]' }, broken)).toBe(false); + expect(canEdit({ id: 'u-helper-1', role: 'inspector', assignedSectionIds: '[]' }, broken)).toBe(false); }); it('agent role denied (subsystem A buyer-agent surface, read-only)', () => { diff --git a/tests/unit/capabilities.spec.ts b/tests/unit/capabilities.spec.ts new file mode 100644 index 000000000..b580b01ed --- /dev/null +++ b/tests/unit/capabilities.spec.ts @@ -0,0 +1,21 @@ +import { describe, it, expect } from 'vitest'; +import { getCapabilities } from '../../server/lib/auth/capabilities'; + +describe('getCapabilities', () => { + it('inspector defaults: publish on, schedule self, no financial, no contacts', () => { + expect(getCapabilities('inspector', null)).toMatchObject({ publish: true, scheduleOthers: false, financial: false, manageContacts: false }); + }); + it('manager defaults: all four on', () => { + expect(getCapabilities('manager', null)).toMatchObject({ publish: true, scheduleOthers: true, financial: true, manageContacts: true }); + }); + it('overrides win over role defaults', () => { + const c = getCapabilities('inspector', { financial: true, publish: false }); + expect(c.financial).toBe(true); expect(c.publish).toBe(false); + }); + it('owner is always fully capable, ignoring reducing overrides', () => { + expect(getCapabilities('owner', { financial: false }).financial).toBe(true); + }); + it('agent has none of the staff capabilities', () => { + expect(getCapabilities('agent', null)).toMatchObject({ publish: false, scheduleOthers: false, financial: false, manageContacts: false }); + }); +}); diff --git a/tests/unit/guest-invite-info.spec.ts b/tests/unit/guest-invite-info.spec.ts deleted file mode 100644 index 437b7e1fc..000000000 --- a/tests/unit/guest-invite-info.spec.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { GuestInviteService } from '../../server/services/guest-invite.service'; -import { createTestDb, setupSchema } from './db'; -import { guestInvites, tenants } from '../../server/lib/db/schema'; - -// In-memory SQLite for the drizzle d1 adapter (same pattern as auth.service.spec). -vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); -import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; - -/** - * C-10 ③-B — GuestInviteService.getInviteInfo backs the /guest-join preview - * (workspace name + role + expiry). guest_invites carries no email/inspection, - * so the page shows the workspace + role the invite grants, not inspection info. - */ -describe('GuestInviteService.getInviteInfo — ③-B', () => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - let svc: GuestInviteService; let testDb: any; let sqlite: any; - const future = Math.floor(Date.now() / 1000) + 100_000; - const past = Math.floor(Date.now() / 1000) - 100; - - beforeEach(async () => { - const setup = createTestDb(); - testDb = setup.db; sqlite = setup.sqlite; - await setupSchema(sqlite); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockDrizzle as any).mockReturnValue(testDb); - await testDb.insert(tenants).values({ id: 't1', name: 'Acme Inspections', slug: 'acme', createdAt: new Date() }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - svc = new GuestInviteService({} as any); - }); - afterEach(() => { sqlite.close(); vi.clearAllMocks(); }); - - it('returns {workspaceName, role, expiresAt} for a live invite', async () => { - await testDb.insert(guestInvites).values({ id: 'g1', tenantId: 't1', token: 'tok', role: 'specialist', durationSeconds: 86400, expiresAt: future, createdBy: 'u1' }); - expect(await svc.getInviteInfo('tok')).toEqual({ workspaceName: 'Acme Inspections', role: 'specialist', expiresAt: future }); - }); - - it('returns null for an unknown token', async () => { - expect(await svc.getInviteInfo('nope')).toBeNull(); - }); - - it('returns null for an expired invite', async () => { - await testDb.insert(guestInvites).values({ id: 'g2', tenantId: 't1', token: 'exp', role: 'lead', durationSeconds: 1, expiresAt: past, createdBy: 'u1' }); - expect(await svc.getInviteInfo('exp')).toBeNull(); - }); - - it('returns null for an already-claimed invite', async () => { - await testDb.insert(guestInvites).values({ id: 'g3', tenantId: 't1', token: 'clm', role: 'lead', durationSeconds: 86400, expiresAt: future, createdBy: 'u1', claimedByUserId: 'u9', claimedAt: past }); - expect(await svc.getInviteInfo('clm')).toBeNull(); - }); -}); diff --git a/tests/unit/guest-invite-seat-gate.spec.ts b/tests/unit/guest-invite-seat-gate.spec.ts deleted file mode 100644 index 0943ee157..000000000 --- a/tests/unit/guest-invite-seat-gate.spec.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Task 7 — TDD: enforceSeatQuota flag on GuestInviteService.claim(). - * - * Verifies that the seat-quota check is only applied when - * ctx.enforceSeatQuota === true (SaaS), and skipped entirely when it - * is false (standalone / self-hosted). - */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { GuestInviteService } from '../../server/services/guest-invite.service'; -import { createTestDb, setupSchema } from './db'; -import * as schema from '../../server/lib/db/schema'; -import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; - -vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); -import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; - -const TENANT = '00000000-0000-0000-0000-000000000088'; - -async function seedTenant(testDb: BetterSQLite3Database) { - await testDb.insert(schema.tenants).values({ - id: TENANT, name: 'SeatGateCo', slug: 'seat-gate-co', status: 'active', - deploymentMode: 'shared', tier: 'free', createdAt: new Date(), - }); -} - -async function seedPermanentUser(testDb: BetterSQLite3Database, id: string, email: string) { - await testDb.insert(schema.users).values({ - id, - tenantId: TENANT, - email, - passwordHash: 'hash', - name: 'Existing User', - role: 'lead', - // expiresAt null => permanent member, always counts against seat quota - createdAt: new Date(), - }); -} - -describe('GuestInviteService — enforceSeatQuota gate (Task 7)', () => { - let testDb: BetterSQLite3Database; - let svc: GuestInviteService; - - beforeEach(async () => { - const fix = createTestDb(); - testDb = fix.db; - await setupSchema(fix.sqlite); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockDrizzle as any).mockReturnValue(testDb); - await seedTenant(testDb); - svc = new GuestInviteService({} as D1Database); - }); - - it('rejects with over_quota when enforceSeatQuota=true and at cap', async () => { - // Seed one permanent user — tenant is already at maxUsers=1. - await seedPermanentUser(testDb, 'u-existing-1', 'existing1@test.com'); - - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - const result = await svc.claim( - minted.token, - { name: 'Guest A', email: 'guesta@test.com', password: 'pw01234567' }, - { maxUsers: 1, enforceSeatQuota: true }, - ); - expect(result.kind).toBe('over_quota'); - }); - - it('allows the claim when enforceSeatQuota=false even at cap (standalone)', async () => { - // Same setup: one permanent user, tenant at maxUsers=1. - await seedPermanentUser(testDb, 'u-existing-2', 'existing2@test.com'); - - // Distinct unclaimed token and distinct email to avoid uniqueness collisions. - const minted2 = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - const result = await svc.claim( - minted2.token, - { name: 'Guest B', email: 'guestb@test.com', password: 'pw01234567' }, - { maxUsers: 1, enforceSeatQuota: false }, - ); - expect(result.kind).not.toBe('over_quota'); - // Should succeed (standalone is genuinely unlimited). - expect(result.kind).toBe('ok'); - }); -}); diff --git a/tests/unit/guest-invite-service.spec.ts b/tests/unit/guest-invite-service.spec.ts deleted file mode 100644 index 56d7428c3..000000000 --- a/tests/unit/guest-invite-service.spec.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Design System 0520 subsystem C phase 6 — GuestInviteService. - * - * mint + claim + over-quota enforcement. Per spec amendment (no - * per-guest billing), guests count against tenants.max_users on - * successful claim; the service rejects with `over_quota` when the - * tenant is already at quota. - */ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { eq } from 'drizzle-orm'; -import { GuestInviteService } from '../../server/services/guest-invite.service'; -import { createTestDb, setupSchema } from './db'; -import * as schema from '../../server/lib/db/schema'; -import { hashToken, deadTokenSentinel } from '../../server/lib/token-hash'; -import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; - -vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); -import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; - -const TENANT = '00000000-0000-0000-0000-000000000099'; - -async function seedTenant(testDb: BetterSQLite3Database, maxUsers = 5) { - await testDb.insert(schema.tenants).values({ - id: TENANT, name: 'Acme', slug: 'acme', status: 'active', - deploymentMode: 'shared', tier: 'free', createdAt: new Date(), - }); - // Core tenants table doesn't carry max_users (that lives on the portal - // side — synced to core via M2M). For unit tests the service falls back - // to a configurable cap; we pass maxUsers as the second arg to claim() - // via the test fixture. - return { maxUsers }; -} - -describe('GuestInviteService (subsystem C P6)', () => { - let testDb: BetterSQLite3Database; - let svc: GuestInviteService; - - beforeEach(async () => { - const fix = createTestDb(); - testDb = fix.db; - await setupSchema(fix.sqlite); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (mockDrizzle as any).mockReturnValue(testDb); - await seedTenant(testDb); - svc = new GuestInviteService({} as D1Database); - }); - - it('mint returns token + future expiry', async () => { - const out = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - expect(out.token).toMatch(/^[A-Za-z0-9_-]{30,}$/); - expect(out.url).toContain(out.token); - expect(out.expiresAt).toBeGreaterThan(Math.floor(Date.now() / 1000)); - }); - - it('claim returns over_quota when tenant at cap', async () => { - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - const out = await svc.claim(minted.token, { name: 'G', email: 'g@x', password: 'pw01234567' }, { maxUsers: 0, enforceSeatQuota: true }); - expect(out.kind).toBe('over_quota'); - }); - - it('claim creates user with role + expires_at when under quota', async () => { - const minted = await svc.mint(TENANT, { role: 'specialist', durationSeconds: 3600, createdBy: 'u-admin' }); - const out = await svc.claim(minted.token, { name: 'G', email: 'g@x', password: 'pw01234567' }, { maxUsers: 10, enforceSeatQuota: true }); - expect(out.kind).toBe('ok'); - if (out.kind === 'ok') { - const user = await testDb.select().from(schema.users).where(eq(schema.users.id, out.userId)).get(); - expect(user?.role).toBe('specialist'); - expect(user?.expiresAt).toBe(minted.expiresAt); - } - }); - - it('claim returns not_found for unknown token', async () => { - const out = await svc.claim('not-a-token', { name: 'G', email: 'g@x', password: 'pw01234567' }, { maxUsers: 10, enforceSeatQuota: true }); - expect(out.kind).toBe('not_found'); - }); - - it('claim returns claimed when token already used', async () => { - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - await svc.claim(minted.token, { name: 'A', email: 'a@x', password: 'pw01234567' }, { maxUsers: 10, enforceSeatQuota: true }); - const out = await svc.claim(minted.token, { name: 'B', email: 'b@x', password: 'pw01234567' }, { maxUsers: 10, enforceSeatQuota: true }); - expect(out.kind).toBe('claimed'); - }); - - it('claim returns expired past expiry', async () => { - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: -1, createdBy: 'u-admin' }); - const out = await svc.claim(minted.token, { name: 'G', email: 'g@x', password: 'pw01234567' }, { maxUsers: 10, enforceSeatQuota: true }); - expect(out.kind).toBe('expired'); - }); - - it('claim rejects short passwords (min length enforced)', async () => { - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - const out = await svc.claim(minted.token, { name: 'G', email: 'g@x', password: 'short' }, { maxUsers: 10, enforceSeatQuota: true }); - expect(out.kind).toBe('invalid'); - }); - - // ─── Track I-a — hash-at-rest (tier-1) ─────────────────────────────────── - it('(a) mint stores hash, NOT plaintext (legacy column is a sentinel)', async () => { - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - const row = await testDb.select().from(schema.guestInvites).where(eq(schema.guestInvites.id, minted.id)).get(); - expect(row?.token).toBe(deadTokenSentinel(minted.id)); - expect(row?.token).not.toBe(minted.token); - expect(row?.tokenHash).toBe(await hashToken(minted.token)); - }); - - it('(b) presenting the plaintext resolves via the hash path (getInviteInfo + claim)', async () => { - const minted = await svc.mint(TENANT, { role: 'specialist', durationSeconds: 3600, createdBy: 'u-admin' }); - const info = await svc.getInviteInfo(minted.token); - expect(info?.role).toBe('specialist'); - const out = await svc.claim(minted.token, { name: 'G', email: 'g@x', password: 'pw01234567' }, { maxUsers: 10, enforceSeatQuota: true }); - expect(out.kind).toBe('ok'); - }); - - it('(c) legacy plaintext row resolves AND is upgraded in place', async () => { - const legacyToken = 'legacy-guest-plaintext-token-1234567890'; - const id = crypto.randomUUID(); - await testDb.insert(schema.guestInvites).values({ - id, tenantId: TENANT, token: legacyToken, role: 'office', - durationSeconds: 86_400, expiresAt: Math.floor(Date.now() / 1000) + 3600, - createdBy: 'u-admin', createdAt: new Date().toISOString(), - }); - const info = await svc.getInviteInfo(legacyToken); - expect(info?.role).toBe('office'); - const row = await testDb.select().from(schema.guestInvites).where(eq(schema.guestInvites.id, id)).get(); - expect(row?.tokenHash).toBe(await hashToken(legacyToken)); - expect(row?.token).toBe(deadTokenSentinel(id)); - }); - - it('resolveTenantForToken resolves a hashed token to tenant + cap', async () => { - const minted = await svc.mint(TENANT, { role: 'lead', durationSeconds: 86_400, createdBy: 'u-admin' }); - const resolved = await svc.resolveTenantForToken(minted.token); - expect(resolved?.tenantId).toBe(TENANT); - expect(typeof resolved?.maxUsers).toBe('number'); - }); -}); diff --git a/tests/unit/inspection-agreement-request.spec.ts b/tests/unit/inspection-agreement-request.spec.ts index 4bdc80db9..cc8e8e9c9 100644 --- a/tests/unit/inspection-agreement-request.spec.ts +++ b/tests/unit/inspection-agreement-request.spec.ts @@ -36,7 +36,7 @@ const SLUG = 'acme'; let db: BetterSQLite3Database; let sendAgreementRequest: ReturnType; -function buildApp(role = 'admin') { +function buildApp(role = 'manager') { const app = new OpenAPIHono(); sendAgreementRequest = vi.fn().mockResolvedValue(undefined); app.use('*', async (c, next) => { @@ -66,14 +66,14 @@ const ENV = { DB: {}, APP_BASE_URL: 'https://acme.example.com' } as never; // auditFromContext reads c.executionCtx.waitUntil — supply a stub. const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; -function send(path: string, body: string, role = 'admin') { +function send(path: string, body: string, role = 'manager') { const req = new Request(`https://acme.example.com${path}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body, }); return buildApp(role).fetch(req, ENV, CTX); } -async function post(body: unknown, role = 'admin') { +async function post(body: unknown, role = 'manager') { return send(`/api/inspections/${INSP_ID}/agreement-requests`, JSON.stringify(body), role); } diff --git a/tests/unit/inspection-patch-settings.spec.ts b/tests/unit/inspection-patch-settings.spec.ts index d9d130f7f..af725cdf5 100644 --- a/tests/unit/inspection-patch-settings.spec.ts +++ b/tests/unit/inspection-patch-settings.spec.ts @@ -90,7 +90,7 @@ describe('PATCH /api/inspections/:id — settings save (B-22 follow-up)', () => }); it('assigns the template — a templateId-only payload returns 200 and persists', async () => { - expect(await patch('admin', { templateId: TPL_ID })).toBe(200); + expect(await patch('manager', { templateId: TPL_ID })).toBe(200); const row = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get(); expect((row as { templateId?: string | null }).templateId).toBe(TPL_ID); }); @@ -98,11 +98,11 @@ describe('PATCH /api/inspections/:id — settings save (B-22 follow-up)', () => it('a payload that reduces to no recognised fields is a 200 no-op (not a 500)', async () => { // Mirrors the sanitizer dropping every empty-string "unchanged" field: // the validated body is `{}`, which must not crash `.set({})`. - expect(await patch('admin', { unknownField: 'x' })).toBe(200); + expect(await patch('manager', { unknownField: 'x' })).toBe(200); }); it('still updates a normal scalar field', async () => { - expect(await patch('admin', { clientName: 'Updated Name' })).toBe(200); + expect(await patch('manager', { clientName: 'Updated Name' })).toBe(200); const row = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get(); expect((row as { clientName?: string }).clientName).toBe('Updated Name'); }); @@ -137,30 +137,30 @@ describe('PATCH /api/inspections/:id — settings save (B-22 follow-up)', () => it('DB-16: sets coverPhotoId to a pool photo key of this inspection (200)', async () => { await seedPoolRow(); - expect(await patch('admin', { coverPhotoId: POOL_KEY })).toBe(200); + expect(await patch('manager', { coverPhotoId: POOL_KEY })).toBe(200); const row = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get(); expect((row as { coverPhotoId?: string | null }).coverPhotoId).toBe(POOL_KEY); }); it('DB-16: sets coverPhotoId to an attached item photo key (200)', async () => { await seedAttachedPhoto(); - expect(await patch('admin', { coverPhotoId: ATTACHED_KEY })).toBe(200); + expect(await patch('manager', { coverPhotoId: ATTACHED_KEY })).toBe(200); const row = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get(); expect((row as { coverPhotoId?: string | null }).coverPhotoId).toBe(ATTACHED_KEY); }); it('DB-16: rejects a photo key belonging to a DIFFERENT inspection (400)', async () => { await seedPoolRow({ inspectionId: '550e8400-e29b-41d4-a716-446655449999' }); - expect(await patch('admin', { coverPhotoId: POOL_KEY })).toBe(400); + expect(await patch('manager', { coverPhotoId: POOL_KEY })).toBe(400); const row = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get(); expect((row as { coverPhotoId?: string | null }).coverPhotoId).toBeNull(); }); it('DB-16: rejects a dangling key (400) and accepts null to clear', async () => { - expect(await patch('admin', { coverPhotoId: POOL_KEY })).toBe(400); // no such photo at all + expect(await patch('manager', { coverPhotoId: POOL_KEY })).toBe(400); // no such photo at all await seedPoolRow(); - expect(await patch('admin', { coverPhotoId: POOL_KEY })).toBe(200); - expect(await patch('admin', { coverPhotoId: null })).toBe(200); + expect(await patch('manager', { coverPhotoId: POOL_KEY })).toBe(200); + expect(await patch('manager', { coverPhotoId: null })).toBe(200); const row = await db.select().from(schema.inspections).where(eq(schema.inspections.id, INSP_ID)).get(); expect((row as { coverPhotoId?: string | null }).coverPhotoId).toBeNull(); }); diff --git a/tests/unit/invoice-request-payment.spec.ts b/tests/unit/invoice-request-payment.spec.ts index efedd9fc4..13409e097 100644 --- a/tests/unit/invoice-request-payment.spec.ts +++ b/tests/unit/invoice-request-payment.spec.ts @@ -36,7 +36,7 @@ const SLUG = 'acme'; let db: BetterSQLite3Database; let sendInvoiceRequest: ReturnType; -function buildApp(role = 'admin') { +function buildApp(role = 'manager') { const app = new OpenAPIHono(); sendInvoiceRequest = vi.fn().mockResolvedValue(undefined); app.use('*', async (c, next) => { @@ -65,7 +65,7 @@ function buildApp(role = 'admin') { const ENV = { DB: {}, APP_BASE_URL: 'https://acme.example.com' } as never; const CTX = { waitUntil: () => {}, passThroughOnException: () => {} } as never; -function post(body: unknown, role = 'admin') { +function post(body: unknown, role = 'manager') { const req = new Request('https://acme.example.com/api/invoices/request-payment', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); diff --git a/tests/unit/notification.service.spec.ts b/tests/unit/notification.service.spec.ts index 49b13ff5a..adb77164a 100644 --- a/tests/unit/notification.service.spec.ts +++ b/tests/unit/notification.service.spec.ts @@ -18,7 +18,7 @@ async function seedTenantAndUsers(testDb: BetterSQLite3Database) { id: TENANT_B, name: 'B', slug: 'b', status: 'active', deploymentMode: 'shared', tier: 'free', createdAt: new Date() }, ]); await testDb.insert(schema.users).values([ - { id: USER_1, tenantId: TENANT_A, email: 'u1@a.com', name: 'U1', passwordHash: 'x', role: 'admin', createdAt: new Date() }, + { id: USER_1, tenantId: TENANT_A, email: 'u1@a.com', name: 'U1', passwordHash: 'x', role: 'manager', createdAt: new Date() }, { id: USER_2, tenantId: TENANT_A, email: 'u2@a.com', name: 'U2', passwordHash: 'x', role: 'owner', createdAt: new Date() }, ]); } diff --git a/tests/unit/preflight.spec.ts b/tests/unit/preflight.spec.ts index 37ccefd8f..a67bff3cb 100644 --- a/tests/unit/preflight.spec.ts +++ b/tests/unit/preflight.spec.ts @@ -19,7 +19,6 @@ describe('computePreflightFromData (subsystem E P1.2)', () => { const out = computePreflightFromData( { ...baseInspection }, { 'i-1': { rating: 'sat' }, 'i-2': { rating: null, value: null } }, - 0, ); expect(out.allRated).toBe(false); expect(out.unratedCount).toBe(1); @@ -29,14 +28,13 @@ describe('computePreflightFromData (subsystem E P1.2)', () => { const out = computePreflightFromData( { ...baseInspection }, { 'i-1': { rating: 'sat' }, 'i-2': { value: true } }, - 0, ); expect(out.allRated).toBe(true); expect(out.unratedCount).toBe(0); }); it('allRated false when no items exist at all (empty inspection blocks publish)', () => { - const out = computePreflightFromData({ ...baseInspection }, {}, 0); + const out = computePreflightFromData({ ...baseInspection }, {}); expect(out.allRated).toBe(false); }); @@ -44,7 +42,6 @@ describe('computePreflightFromData (subsystem E P1.2)', () => { const out = computePreflightFromData( { ...baseInspection, propertyFacts: { year_built: 1973, sqft: 1840 } }, { 'i-1': { rating: 'sat' } }, - 0, ); expect(out.propertyFactsComplete).toBe(false); expect(out.missingFacts).toEqual(['foundation', 'bedrooms', 'bathrooms']); @@ -57,34 +54,21 @@ describe('computePreflightFromData (subsystem E P1.2)', () => { bedrooms: 3, bathrooms: 2, } }, { 'i-1': { rating: 'sat' } }, - 0, ); expect(out.propertyFactsComplete).toBe(true); expect(out.missingFacts).toEqual([]); }); it('coverPhotoSet reflects the column presence', () => { - const a = computePreflightFromData({ ...baseInspection, coverPhotoId: 'p-1' }, { 'i-1': { rating: 'sat' } }, 0); - const b = computePreflightFromData({ ...baseInspection, coverPhotoId: null }, { 'i-1': { rating: 'sat' } }, 0); + const a = computePreflightFromData({ ...baseInspection, coverPhotoId: 'p-1' }, { 'i-1': { rating: 'sat' } }); + const b = computePreflightFromData({ ...baseInspection, coverPhotoId: null }, { 'i-1': { rating: 'sat' } }); expect(a.coverPhotoSet).toBe(true); expect(b.coverPhotoSet).toBe(false); }); - it('apprenticeReviewed false when pendingCount > 0', () => { - const out = computePreflightFromData({ ...baseInspection }, { 'i-1': { rating: 'sat' } }, 2); - expect(out.apprenticeReviewed).toBe(false); - expect(out.apprenticePending).toBe(2); - }); - - it('apprenticeReviewed true when pendingCount is undefined (subsystem C absent — graceful no-op)', () => { - const out = computePreflightFromData({ ...baseInspection }, { 'i-1': { rating: 'sat' } }, undefined); - expect(out.apprenticeReviewed).toBe(true); - expect(out.apprenticePending).toBe(0); - }); - it('agreementSigned reflects the timestamp column', () => { - const a = computePreflightFromData({ ...baseInspection, agreementSignedAt: 1_700_000_000 }, { 'i-1': { rating: 'sat' } }, 0); - const b = computePreflightFromData({ ...baseInspection, agreementSignedAt: null }, { 'i-1': { rating: 'sat' } }, 0); + const a = computePreflightFromData({ ...baseInspection, agreementSignedAt: 1_700_000_000 }, { 'i-1': { rating: 'sat' } }); + const b = computePreflightFromData({ ...baseInspection, agreementSignedAt: null }, { 'i-1': { rating: 'sat' } }); expect(a.agreementSigned).toBe(true); expect(b.agreementSigned).toBe(false); }); diff --git a/tests/unit/rbac-require-role.spec.ts b/tests/unit/rbac-require-role.spec.ts new file mode 100644 index 000000000..0fa26df7f --- /dev/null +++ b/tests/unit/rbac-require-role.spec.ts @@ -0,0 +1,20 @@ +import { describe, it, expect } from 'vitest'; +import { requireRole } from '../../server/lib/middleware/rbac'; + +function ctx(role: string | undefined) { + return { get: (k: string) => (k === 'userRole' ? role : undefined) } as any; +} + +describe('requireRole', () => { + it('calls next when the user role is allowed', async () => { + let called = false; + await requireRole('owner', 'admin')(ctx('admin'), async () => { called = true; }); + expect(called).toBe(true); + }); + it('throws Forbidden when the role is not allowed', async () => { + await expect(requireRole('owner')(ctx('inspector'), async () => {})).rejects.toThrow(); + }); + it('throws Unauthorized when no role on context', async () => { + await expect(requireRole('owner')(ctx(undefined), async () => {})).rejects.toThrow(); + }); +}); diff --git a/tests/unit/report-section-numbering.spec.ts b/tests/unit/report-section-numbering.spec.ts index 1b62831a7..3c30c1be0 100644 --- a/tests/unit/report-section-numbering.spec.ts +++ b/tests/unit/report-section-numbering.spec.ts @@ -11,9 +11,9 @@ import { */ describe('report-section-numbering', () => { describe('canEditSection', () => { - it('grants edit to owner / admin / inspector', () => { + it('grants edit to owner / manager / inspector', () => { expect(canEditSection('owner')).toBe(true); - expect(canEditSection('admin')).toBe(true); + expect(canEditSection('manager')).toBe(true); expect(canEditSection('inspector')).toBe(true); }); it('denies edit to client / agent / unknown roles', () => { diff --git a/tests/unit/require-capability.spec.ts b/tests/unit/require-capability.spec.ts new file mode 100644 index 000000000..7ae9e2915 --- /dev/null +++ b/tests/unit/require-capability.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi } from 'vitest'; +import { Hono } from 'hono'; +import { requireCapability, type OverrideResolver } from '../../server/lib/middleware/require-capability'; +import { coerceOverrides, getCapabilities, type PermissionOverrides } from '../../server/lib/auth/capabilities'; +import { AppError } from '../../server/lib/errors'; +import type { Role } from '../../server/lib/auth/roles'; + +/** + * Task 10 — requireCapability middleware. + * + * The middleware reads permission_overrides FRESH from the DB on every request + * (overrides change without re-login; the JWT never carries them). We inject a + * deterministic OverrideResolver instead of hitting D1, then assert the + * getCapabilities() decision surfaces as allow (next runs) or 403. + */ + +function buildApp( + cap: Parameters[0], + { role, overrides }: { role: Role; overrides: PermissionOverrides | null }, +) { + const resolver: OverrideResolver = vi.fn(async () => overrides); + const app = new Hono(); + app.onError((err, c) => { + if (err instanceof AppError) return c.json({ error: err.message }, err.status); + return c.json({ error: String(err) }, 500); + }); + app.use('*', async (c, next) => { + c.set('userRole', role); + c.set('user', { sub: 'user-1', role, tenantId: 't1' }); + await next(); + }); + app.get('/gated', requireCapability(cap, resolver), (c) => c.json({ ok: true })); + return { app, resolver }; +} + +async function call(app: Hono) { + const res = await app.request('/gated'); + return { status: res.status, body: await res.json() as Record }; +} + +describe('requireCapability middleware', () => { + it('inspector + no overrides → publish allowed, financial denied', async () => { + const pub = await call(buildApp('publish', { role: 'inspector', overrides: null }).app); + expect(pub.status).toBe(200); + expect(pub.body).toEqual({ ok: true }); + + const fin = await call(buildApp('financial', { role: 'inspector', overrides: null }).app); + expect(fin.status).toBe(403); + expect(fin.body.error).toContain('financial'); + }); + + it('inspector + {financial:true} → financial allowed', async () => { + const fin = await call(buildApp('financial', { role: 'inspector', overrides: { financial: true } }).app); + expect(fin.status).toBe(200); + expect(fin.body).toEqual({ ok: true }); + }); + + it('inspector + {publish:false} → publish denied (requires review)', async () => { + const pub = await call(buildApp('publish', { role: 'inspector', overrides: { publish: false } }).app); + expect(pub.status).toBe(403); + expect(pub.body.error).toContain('publish'); + }); + + it('owner → financial allowed even with {financial:false} override (pinned)', async () => { + const fin = await call(buildApp('financial', { role: 'owner', overrides: { financial: false } }).app); + expect(fin.status).toBe(200); + expect(fin.body).toEqual({ ok: true }); + }); + + it('resolves overrides fresh on every request (resolver is invoked)', async () => { + const { app, resolver } = buildApp('manageContacts', { role: 'inspector', overrides: { manageContacts: true } }); + await call(app); + expect(resolver).toHaveBeenCalledTimes(1); + }); + + it('401 when no role is present in context', async () => { + const app = new Hono(); + app.onError((err, c) => + err instanceof AppError ? c.json({ error: err.message }, err.status) : c.json({ error: String(err) }, 500), + ); + app.get('/gated', requireCapability('publish', async () => null), (c) => c.json({ ok: true })); + const res = await app.request('/gated'); + expect(res.status).toBe(401); + }); +}); + +describe('inspector capability-resolution for role-widened endpoints', () => { + // These endpoints (invoices list → financial; contact create/update/delete → + // manageContacts) now admit 'inspector' through the requireRole gate so the + // capability becomes the EFFECTIVE gate. An inspector is still default-denied; + // only an explicit {financial:true}/{manageContacts:true} override lets them + // through. Owner/manager default true and are unaffected. + it('inspector financial: override true → allowed, null → denied', () => { + expect(getCapabilities('inspector', { financial: true }).financial).toBe(true); + expect(getCapabilities('inspector', null).financial).toBe(false); + }); + it('inspector manageContacts: override true → allowed, null → denied', () => { + expect(getCapabilities('inspector', { manageContacts: true }).manageContacts).toBe(true); + expect(getCapabilities('inspector', null).manageContacts).toBe(false); + }); + it('owner/manager financial + manageContacts default true (role gate widening is a no-op for them)', () => { + expect(getCapabilities('owner', null).financial).toBe(true); + expect(getCapabilities('owner', null).manageContacts).toBe(true); + expect(getCapabilities('manager', null).financial).toBe(true); + expect(getCapabilities('manager', null).manageContacts).toBe(true); + }); +}); + +describe('coerceOverrides helper', () => { + it('parses a JSON string column value', () => { + expect(coerceOverrides('{"financial":true}')).toEqual({ financial: true }); + }); + it('whitelists an already-parsed object (json-mode column)', () => { + expect(coerceOverrides({ financial: true, bogus: 1, publish: false })).toEqual({ financial: true, publish: false }); + }); + it('returns null for null / empty / non-object / malformed', () => { + expect(coerceOverrides(null)).toBeNull(); + expect(coerceOverrides(undefined)).toBeNull(); + expect(coerceOverrides('{}')).toBeNull(); + expect(coerceOverrides('not json')).toBeNull(); + expect(coerceOverrides(42)).toBeNull(); + expect(coerceOverrides({ onlyJunk: 'x' })).toBeNull(); + }); +}); diff --git a/tests/unit/role-alias.spec.ts b/tests/unit/role-alias.spec.ts deleted file mode 100644 index 6d64802f4..000000000 --- a/tests/unit/role-alias.spec.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Design System 0520 subsystem C phase 1 task 1.3 — role-alias shim. - * - * Verifies the bidirectional 'inspector' ↔ 'lead' alias so that - * subsystem-C-aware allow-lists ('lead') still accept legacy tokens - * carrying 'inspector', and pre-spec routes (allowedRoles: 'inspector') - * accept new tokens carrying 'lead'. - */ -import { describe, it, expect } from 'vitest'; -import { normaliseRole, ROLE_ALIASES, requireRole } from '../../server/lib/middleware/rbac'; - -describe('normaliseRole', () => { - it('aliases inspector → lead', () => { - expect(normaliseRole('inspector')).toBe('lead'); - }); - - it('passes through other roles unchanged', () => { - expect(normaliseRole('owner')).toBe('owner'); - expect(normaliseRole('admin')).toBe('admin'); - expect(normaliseRole('lead')).toBe('lead'); - expect(normaliseRole('specialist')).toBe('specialist'); - expect(normaliseRole('apprentice')).toBe('apprentice'); - expect(normaliseRole('office')).toBe('office'); - expect(normaliseRole('agent')).toBe('agent'); - }); - - it('passes through unknown values verbatim', () => { - expect(normaliseRole('bogus')).toBe('bogus'); - }); -}); - -describe('ROLE_ALIASES contract', () => { - it('only maps inspector → lead today (pre-launch single rename)', () => { - expect(ROLE_ALIASES).toEqual({ inspector: 'lead' }); - }); -}); - -// Build a minimal Hono-like context stub for the requireRole tests. -function makeCtx(role: string) { - const store: Record = { userRole: role }; - return { - get: (k: string) => store[k], - set: (k: string, v: unknown) => { store[k] = v; }, - }; -} - -describe('requireRole alias acceptance', () => { - it('allowedRoles=[lead] accepts inspector-carrying token', async () => { - const guard = requireRole(['lead']); - const ctx = makeCtx('inspector'); - let nextCalled = false; - await guard(ctx as never, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - }); - - it('allowedRoles=[inspector] accepts lead-carrying token', async () => { - const guard = requireRole(['inspector']); - const ctx = makeCtx('lead'); - let nextCalled = false; - await guard(ctx as never, async () => { nextCalled = true; }); - expect(nextCalled).toBe(true); - }); - - it('allowedRoles=[admin] rejects inspector', async () => { - const guard = requireRole(['admin']); - const ctx = makeCtx('inspector'); - await expect(guard(ctx as never, async () => {})).rejects.toThrow(/requires one of/i); - }); - - it('missing role 401s', async () => { - const guard = requireRole(['lead']); - const ctx = { get: () => undefined, set: () => {} }; - await expect(guard(ctx as never, async () => {})).rejects.toThrow(/no role/i); - }); -}); diff --git a/tests/unit/role-enum-drift.spec.ts b/tests/unit/role-enum-drift.spec.ts new file mode 100644 index 000000000..c2278b2f7 --- /dev/null +++ b/tests/unit/role-enum-drift.spec.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { getTableConfig } from 'drizzle-orm/sqlite-core'; +import { ROLES } from '../../server/lib/auth/roles'; +import { users, tenantInvites } from '../../server/lib/db/schema'; + +function roleEnum(table: any): readonly string[] { + const col = getTableConfig(table).columns.find((c: any) => c.name === 'role'); + return col?.enumValues ?? []; +} + +describe('role enum drift', () => { + it('users.role enum matches ROLES', () => { + expect([...roleEnum(users)].sort()).toEqual([...ROLES].sort()); + }); + it('tenant_invites.role enum matches ROLES', () => { + expect([...roleEnum(tenantInvites)].sort()).toEqual([...ROLES].sort()); + }); +}); diff --git a/tests/unit/roles.spec.ts b/tests/unit/roles.spec.ts new file mode 100644 index 000000000..c6adfbf9c --- /dev/null +++ b/tests/unit/roles.spec.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from 'vitest'; +import { ROLES, ROLE_LABELS, isRole } from '../../server/lib/auth/roles'; + +describe('roles source-of-truth', () => { + it('exposes exactly the four canonical roles', () => { + expect([...ROLES]).toEqual(['owner', 'manager', 'inspector', 'agent']); + }); + + it('has a label for every role', () => { + for (const r of ROLES) expect(ROLE_LABELS[r]).toBeTruthy(); + }); + + it('isRole narrows valid + rejects invalid values', () => { + expect(isRole('owner')).toBe(true); + expect(isRole('office_staff')).toBe(false); + expect(isRole('lead')).toBe(false); + }); +}); diff --git a/tests/unit/seat-guard.spec.ts b/tests/unit/seat-guard.spec.ts index ff6ebcf42..55a3bb7f9 100644 --- a/tests/unit/seat-guard.spec.ts +++ b/tests/unit/seat-guard.spec.ts @@ -1,46 +1,19 @@ /** - * Design System 0520 subsystem C phase 5 — seat-guard helpers. + * seat-guard helpers. * - * Unified seat quota: permanent members + active (non-expired) guests - * all count uniformly against tenants.max_users. There is no separate - * guest billing or role-based quota. + * Seat quota: every member counts once against tenants.max_users. + * There is no guest/expiry or role-based quota. */ import { describe, it, expect } from 'vitest'; import { computeSeatsUsed, isAtOrOverQuota } from '../../server/lib/middleware/seat-guard'; -describe('seat-guard pure helpers (subsystem C P5)', () => { - const NOW = 1_700_000_000; - - it('counts active permanent members (no expires_at)', () => { - const users = [ - { id: 'u1', expiresAt: null }, - { id: 'u2', expiresAt: null }, - ]; - expect(computeSeatsUsed(users, NOW)).toBe(2); - }); - - it('counts active guests but excludes expired ones', () => { - const users = [ - { id: 'u1', expiresAt: null }, - { id: 'g1', expiresAt: NOW + 100 }, // active - { id: 'g2', expiresAt: NOW - 100 }, // expired - ]; - expect(computeSeatsUsed(users, NOW)).toBe(2); - }); - - it('treats expires_at exactly equal to now as expired', () => { - const users = [ - { id: 'g1', expiresAt: NOW }, // expired (boundary) - { id: 'g2', expiresAt: NOW + 1 }, // active - ]; - expect(computeSeatsUsed(users, NOW)).toBe(1); +describe('seat-guard pure helpers', () => { + it('counts every member once (no guest expiry semantics)', () => { + expect(computeSeatsUsed([{ id: 'a' }, { id: 'b' }, { id: 'c' }])).toBe(3); }); - it('handles undefined expires_at like null', () => { - const users = [ - { id: 'u1' }, // no expiresAt field - ]; - expect(computeSeatsUsed(users, NOW)).toBe(1); + it('counts an empty member list as zero seats', () => { + expect(computeSeatsUsed([])).toBe(0); }); it('isAtOrOverQuota is uniform across roles', () => { diff --git a/tests/unit/sms-api.spec.ts b/tests/unit/sms-api.spec.ts index fe0a2353b..bdfbd88ae 100644 --- a/tests/unit/sms-api.spec.ts +++ b/tests/unit/sms-api.spec.ts @@ -49,7 +49,7 @@ function buildApp(db: BetterSQLite3Database) { } return c.json({ success: false, error: { code: 'internal_error', message: String(err) } }, 500); }); - // Inject an owner identity so requireRole(['owner','admin']) passes for admin routes. + // Inject an owner identity so requireRole('owner','admin') passes for admin routes. app.use('*', async (c, next) => { c.set('tenantId', TENANT); c.set('userRole', 'owner'); diff --git a/tests/unit/team-invite.service.spec.ts b/tests/unit/team-invite.service.spec.ts index 04a669a7f..b55f6e4c3 100644 --- a/tests/unit/team-invite.service.spec.ts +++ b/tests/unit/team-invite.service.spec.ts @@ -1,8 +1,8 @@ /** - * Design System 0520 subsystem C phase 5 task 5.1 — TeamService.createInvite - * carries the new role-extension fields (assigned sections for - * specialists, mentor id for apprentices) through onto the tenant_invites - * row so they can be replayed onto the users row at accept time. + * TeamService.createInvite — roles collapsed to owner/admin/inspector/agent + * (2026-06-13). The apprentice mentor-id and specialist assigned-section + * extension fields were removed; createInvite now only carries the canonical + * role onto the tenant_invites row. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { eq } from 'drizzle-orm'; @@ -15,7 +15,7 @@ vi.mock('drizzle-orm/d1', () => ({ drizzle: vi.fn() })); import { drizzle as mockDrizzle } from 'drizzle-orm/d1'; const TENANT = '00000000-0000-0000-0000-0000000000c1'; -const MENTOR = '11111111-1111-1111-1111-1111111111c1'; +const ADMIN = '11111111-1111-1111-1111-1111111111c1'; async function seedTenant(testDb: BetterSQLite3Database) { await testDb.insert(schema.tenants).values({ @@ -23,12 +23,12 @@ async function seedTenant(testDb: BetterSQLite3Database) { deploymentMode: 'shared', tier: 'free', createdAt: new Date(), }); await testDb.insert(schema.users).values({ - id: MENTOR, tenantId: TENANT, email: 'lead@acme.test', - passwordHash: 'x', role: 'lead', createdAt: new Date(), + id: ADMIN, tenantId: TENANT, email: 'admin@acme.test', + passwordHash: 'x', role: 'admin', createdAt: new Date(), }); } -describe('TeamService.createInvite — 4-role extensions (subsystem C P5.1)', () => { +describe('TeamService.createInvite — canonical roles', () => { let svc: TeamService; let testDb: BetterSQLite3Database; @@ -42,63 +42,69 @@ describe('TeamService.createInvite — 4-role extensions (subsystem C P5.1)', () svc = new TeamService({} as D1Database); }); - it('creates an invite for a specialist with assigned section ids', async () => { + it('creates an inspector invite', async () => { const out = await svc.createInvite({ tenantId: TENANT, - email: 'spec@acme.test', - role: 'specialist', - assignedSectionIds: ['s-roof', 's-elec'], + email: 'insp@acme.test', + role: 'inspector', }); const row = await testDb.select().from(schema.tenantInvites) .where(eq(schema.tenantInvites.id, out.token)).get(); - expect(row?.role).toBe('specialist'); - expect(JSON.parse(row?.assignedSectionIds ?? '[]')).toEqual(['s-roof', 's-elec']); + expect(row?.role).toBe('inspector'); + // Extension columns default to empty/null — no longer written. expect(row?.mentorId).toBeNull(); + expect(JSON.parse(row?.assignedSectionIds ?? '[]')).toEqual([]); }); - it('creates an apprentice invite with mentor_id', async () => { + it('creates an admin invite', async () => { const out = await svc.createInvite({ tenantId: TENANT, - email: 'app@acme.test', - role: 'apprentice', - mentorId: MENTOR, + email: 'office@acme.test', + role: 'admin', }); - const row = await testDb.select().from(schema.tenantInvites) .where(eq(schema.tenantInvites.id, out.token)).get(); - expect(row?.role).toBe('apprentice'); - expect(row?.mentorId).toBe(MENTOR); + expect(row?.role).toBe('admin'); + expect(row?.mentorId).toBeNull(); expect(JSON.parse(row?.assignedSectionIds ?? '[]')).toEqual([]); }); - it('rejects an apprentice invite without a mentor', async () => { - await expect(svc.createInvite({ + it('stores only the overrides that differ from the role template', async () => { + // Inspector template = publish:true, scheduleOthers:false, financial:false, + // manageContacts:false. publish:true matches the template (dropped); + // scheduleOthers:true differs (kept). + const out = await svc.createInvite({ tenantId: TENANT, - email: 'app@acme.test', - role: 'apprentice', - })).rejects.toThrow(/mentor.*required/i); + email: 'diff@acme.test', + role: 'inspector', + permissionOverrides: { publish: true, scheduleOthers: true }, + }); + const row = await testDb.select().from(schema.tenantInvites) + .where(eq(schema.tenantInvites.id, out.token)).get(); + expect(row?.permissionOverrides).toEqual({ scheduleOthers: true }); }); - it('rejects when the named mentor does not exist in the tenant', async () => { - await expect(svc.createInvite({ + it('stores null when every requested override equals the role template', async () => { + const out = await svc.createInvite({ tenantId: TENANT, - email: 'app@acme.test', - role: 'apprentice', - mentorId: 'no-such-user', - })).rejects.toThrow(/mentor/i); + email: 'samedefault@acme.test', + role: 'inspector', + permissionOverrides: { publish: true, financial: false }, + }); + const row = await testDb.select().from(schema.tenantInvites) + .where(eq(schema.tenantInvites.id, out.token)).get(); + expect(row?.permissionOverrides ?? null).toBeNull(); }); - it('legacy lead/office invites still work with no extra fields', async () => { + it('stores null when no overrides are supplied', async () => { const out = await svc.createInvite({ tenantId: TENANT, - email: 'office@acme.test', - role: 'office', + email: 'none@acme.test', + role: 'manager', }); const row = await testDb.select().from(schema.tenantInvites) .where(eq(schema.tenantInvites.id, out.token)).get(); - expect(row?.role).toBe('office'); - expect(row?.mentorId).toBeNull(); - expect(JSON.parse(row?.assignedSectionIds ?? '[]')).toEqual([]); + expect(row?.permissionOverrides ?? null).toBeNull(); }); }); diff --git a/tests/web/unit/invite-overrides.spec.ts b/tests/web/unit/invite-overrides.spec.ts new file mode 100644 index 000000000..5a373d548 --- /dev/null +++ b/tests/web/unit/invite-overrides.spec.ts @@ -0,0 +1,54 @@ +/** + * Advanced-permissions disclosure on the invite modal (2026-06-13). happy-dom + * has no render harness (see send-agreement-modal.spec header), so the toggle + * set + the override-diff submit logic are unit-tested directly; the rendered + * disclosure is Chrome-verified. + */ +import { describe, it, expect } from "vitest"; +import { computeOverrideDiff, CAP_LABELS } from "~/components/modals/InviteSeatModal"; +import { getCapabilities, TOGGLEABLE } from "../../../server/lib/auth/capabilities"; + +describe("CAP_LABELS — the four advanced-permission toggles", () => { + it("labels every toggleable capability and only those", () => { + expect(Object.keys(CAP_LABELS).sort()).toEqual([...TOGGLEABLE].sort()); + expect(CAP_LABELS.publish).toBe("Publish reports"); + expect(CAP_LABELS.scheduleOthers).toBe("Schedule for others"); + expect(CAP_LABELS.financial).toBe("Financial data"); + expect(CAP_LABELS.manageContacts).toBe("Manage contacts"); + }); +}); + +describe("the disclosure initial state reflects the role template", () => { + it("inspector defaults: publish on, the rest off", () => { + const caps = getCapabilities("inspector", null); + expect(caps).toEqual({ + publish: true, scheduleOthers: false, financial: false, manageContacts: false, + }); + }); + it("manager defaults: all four on", () => { + const caps = getCapabilities("manager", null); + expect(caps).toEqual({ + publish: true, scheduleOthers: true, financial: true, manageContacts: true, + }); + }); +}); + +describe("computeOverrideDiff — only differing toggles are sent", () => { + it("returns an empty diff when the edited caps equal the role template", () => { + const role = "inspector" as const; + const caps = getCapabilities(role, null); + expect(computeOverrideDiff(role, caps)).toEqual({}); + }); + + it("returns only the keys that differ from the template", () => { + const role = "inspector" as const; + const caps = { ...getCapabilities(role, null), scheduleOthers: true }; + expect(computeOverrideDiff(role, caps)).toEqual({ scheduleOthers: true }); + }); + + it("captures a revoked default (manager template manageContacts on -> off)", () => { + const role = "manager" as const; + const caps = { ...getCapabilities(role, null), manageContacts: false }; + expect(computeOverrideDiff(role, caps)).toEqual({ manageContacts: false }); + }); +}); diff --git a/tests/workers/cmd-consumer.spec.ts b/tests/workers/cmd-consumer.spec.ts index beda9ad15..aee54948e 100644 --- a/tests/workers/cmd-consumer.spec.ts +++ b/tests/workers/cmd-consumer.spec.ts @@ -47,7 +47,7 @@ async function seedSchema(): Promise { "CREATE TABLE IF NOT EXISTS sync_outbox (id TEXT PRIMARY KEY, event_type TEXT NOT NULL, payload TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'pending', attempts INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, last_tried_at INTEGER, last_error TEXT);", ); await b.DB.exec( - "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, license_number TEXT, photo_url TEXT, default_signature_base64 TEXT, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, notify_on_referral INTEGER NOT NULL DEFAULT true, notify_on_report INTEGER NOT NULL DEFAULT true, notify_on_paid INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT);", + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, license_number TEXT, photo_url TEXT, default_signature_base64 TEXT, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, notify_on_referral INTEGER NOT NULL DEFAULT true, notify_on_report INTEGER NOT NULL DEFAULT true, notify_on_paid INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT);", ); await b.DB.exec( 'CREATE TABLE IF NOT EXISTS processed_cmd_events (event_id TEXT PRIMARY KEY, cmd_type TEXT NOT NULL, processed_at INTEGER NOT NULL);', diff --git a/tests/workers/cmd-fixtures.spec.ts b/tests/workers/cmd-fixtures.spec.ts index 0ab184b4a..1b130bb76 100644 --- a/tests/workers/cmd-fixtures.spec.ts +++ b/tests/workers/cmd-fixtures.spec.ts @@ -35,7 +35,7 @@ describe('cmd golden fixtures — consumer can apply every fixture (A-21)', () = // Full users DDL (mirrors cmd-consumer.spec.ts) — the replyto fixture // carries credentials, and the drizzle insert binds every column. await b.DB.exec( - "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, license_number TEXT, photo_url TEXT, default_signature_base64 TEXT, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, notify_on_referral INTEGER NOT NULL DEFAULT true, notify_on_report INTEGER NOT NULL DEFAULT true, notify_on_paid INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT);", + "CREATE TABLE IF NOT EXISTS users (id TEXT PRIMARY KEY, tenant_id TEXT, email TEXT NOT NULL, password_hash TEXT NOT NULL, name TEXT, phone TEXT, license_number TEXT, photo_url TEXT, default_signature_base64 TEXT, bio TEXT, service_areas TEXT, slug TEXT, role TEXT NOT NULL DEFAULT 'admin', google_refresh_token TEXT, google_calendar_id TEXT, google_access_token TEXT, google_token_expiry INTEGER, locale TEXT, onboarding_state TEXT, created_at INTEGER NOT NULL, totp_secret TEXT, totp_enabled INTEGER NOT NULL DEFAULT false, totp_recovery_codes TEXT, totp_verified_at INTEGER, notify_on_referral INTEGER NOT NULL DEFAULT true, notify_on_report INTEGER NOT NULL DEFAULT true, notify_on_paid INTEGER NOT NULL DEFAULT false, last_active_at INTEGER, mentor_id TEXT, assigned_section_ids TEXT NOT NULL DEFAULT '[]', expires_at INTEGER, signup_role TEXT, deleted_at INTEGER, terms_accepted TEXT, permission_overrides TEXT);", ); await b.DB.exec('CREATE TABLE IF NOT EXISTS processed_cmd_events (event_id TEXT PRIMARY KEY, cmd_type TEXT NOT NULL, processed_at INTEGER NOT NULL);'); await b.DB.exec('CREATE TABLE IF NOT EXISTS parked_cmd_events (id TEXT PRIMARY KEY, envelope TEXT NOT NULL, reason TEXT NOT NULL, received_at INTEGER NOT NULL);');