13dec6b4b8
Seven parallel agents and an adversarial verification pass. The three things worth knowing before reading the diff: RBAC WAS ALREADY BUILT. docs/build-plan.md marks F2 and F3 outstanding and is stale — packages/core/src/permissions.ts and lib/mutation.ts shipped long ago. So this does not rebuild them; it closes the gaps an audit found. The big one is that reads were entirely ungoverned: every GET was "any authenticated member", so a junior demand rep and a research contractor could both pull per-block supplier cost and break-even prices from /api/capacity/margin, and every contract's negotiated terms. For a company whose margin is the business, that was the hole that mattered. Adds book:read / economics:read / team:read, a readGuard middleware, and a `viewer` role below member. THE BUTTON AND THE 403 DISAGREED — the exact thing F3 said must never happen. Contracts.tsx never called can() at all, so its save button was always enabled against a server requiring contract:sign; Capacity.tsx gated commitment creation on deal:write/demand while the server wanted commitment:write/supply. POST /api/activities was the one write bypassing executeMutation: no capability check, and any member could mutate accounts.lastActivityAt as a side effect. It is now a proper mutation() behind activity:write. The shell becomes three panes — a collapsible shadcn sidebar with an account switcher on the Piggy accent, a header with real search, and Piggy docked to the right, page-aware and persistent across navigation. The phone keeps its bottom tab bar, which is the thing this product already beat trycompai/crm on, and gains the sidebar as a sheet. Calendar is a projection over thirteen dated sources rather than a new table, because a table would duplicate dates that already live on contracts, deals and commitments and would drift — and one ledger answering the question is the whole argument. It surfaces export_authorizations and compliance_artifacts, which had indexed expires_at columns, schema comments saying they must be alerted on, and no read endpoint or UI anywhere. Learn carries two tracks. Concepts are members-only; the platform track can be opened with a share code by someone with no account. The code mints a scoped learn-only token and never a Principal — every route here resolves a principal and then checks capabilities, so a principal-minting code would be one missing check away from leaking the book. "Only platform-track rows may be code-visible" is a database CHECK constraint as well as a write-path rule, and a test asserts a valid learn token still gets 401 on /api/dashboard, /api/accounts and /api/contracts — the same invariant scripts/deploy.sh refuses to ship without. CD becomes tag-to-ship. CI publishes an image to the Gitea registry on a release-* tag and cloud-2 pulls it, so no credential on the shared runner can execute anything on production — by construction rather than by policy. Both halves of deploy.sh's original rule survive: nothing on the runner reaches the host, and a human still decides when it ships. deploy.sh gains a rollback and a public-origin check, and PIG_IMAGE now reaches compose through `sudo env`, without which sudo's env_reset silently resolved every release to pig:local. Tests 141 -> 261. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
409 lines
13 KiB
TypeScript
409 lines
13 KiB
TypeScript
/**
|
||
* Quarters, and the one shape everything dated turns into.
|
||
*
|
||
* PIG's argument is that one ledger answers the question, so the calendar is a
|
||
* PROJECTION over records that already carry dates — contracts, obligations,
|
||
* deals, commitments, allocations, compliance artefacts — not a second store
|
||
* that would immediately drift from them. The only rows that live in their own
|
||
* table are the ones with no other home: a meeting, a QBR, a reminder.
|
||
*
|
||
* Kept in @pig/core because the browser and the API must agree on the event
|
||
* shape and on where a quarter begins.
|
||
*
|
||
* Two decisions are load-bearing here and are pinned by tests.
|
||
*
|
||
* **A quarter is half-open, [from, to).** Every temporal column in PIG is
|
||
* `timestamp with time zone`; there is not a single `date` column. So a
|
||
* quarter is an interval of instants, and consecutive quarters must tile
|
||
* without overlapping. An inclusive upper bound puts a contract expiring at
|
||
* exactly midnight on 1 October into both Q3 and Q4, and a GTM lead adding up
|
||
* two quarters then counts it twice.
|
||
*
|
||
* **A fiscal year is named for the calendar year it ENDS in.** This is the
|
||
* dominant convention among the companies whose paper PIG holds — a fiscal
|
||
* year beginning April 2026 and ending March 2027 is FY2027. The alternative
|
||
* (naming for the starting year) is also in use, which is exactly why the
|
||
* choice is stated here once rather than assumed at each call site.
|
||
*/
|
||
|
||
export type QuarterNumber = 1 | 2 | 3 | 4;
|
||
|
||
/** A fiscal or calendar quarter label, e.g. `2026-Q3`. */
|
||
export type Quarter = `${number}-Q${QuarterNumber}`;
|
||
|
||
/** Half-open interval of instants: `from` is included, `to` is not. */
|
||
export interface QuarterBounds {
|
||
from: Date;
|
||
to: Date;
|
||
quarter: Quarter;
|
||
}
|
||
|
||
/**
|
||
* Kinds of dated thing the projection can emit.
|
||
*
|
||
* Every one of these is derived from a record that already carries the date,
|
||
* except `calendar_entry`, which is the only row type the calendar owns.
|
||
*/
|
||
export const CALENDAR_EVENT_KINDS = [
|
||
'expected_close',
|
||
'contract_effective',
|
||
'contract_expiry',
|
||
'contract_executed',
|
||
'renewal_notice',
|
||
'obligation_due',
|
||
'capacity_window',
|
||
'allocation_window',
|
||
'hold_expiry',
|
||
'supply_available_from',
|
||
'authorization_expiry',
|
||
'artifact_expiry',
|
||
'calendar_entry',
|
||
] as const;
|
||
export type CalendarEventKind = (typeof CALENDAR_EVENT_KINDS)[number];
|
||
|
||
export function isCalendarEventKind(value: string): value is CalendarEventKind {
|
||
return (CALENDAR_EVENT_KINDS as readonly string[]).includes(value);
|
||
}
|
||
|
||
/** The kinds a human-owned `calendar_entries` row may take. */
|
||
export const CALENDAR_ENTRY_KINDS = [
|
||
'meeting',
|
||
'qbr',
|
||
'reminder',
|
||
'campaign',
|
||
'internal',
|
||
] as const;
|
||
export type CalendarEntryKind = (typeof CALENDAR_ENTRY_KINDS)[number];
|
||
|
||
/**
|
||
* `done` is set by a completion column, never by the clock — an obligation
|
||
* whose due date has passed is `overdue`, not finished, and conflating the two
|
||
* is how a missed renewal notice disappears from a screen.
|
||
*/
|
||
export type CalendarEventState = 'upcoming' | 'due' | 'overdue' | 'done';
|
||
|
||
export interface CalendarEvent {
|
||
/**
|
||
* `${recordType}:${recordId}:${field}` — synthesised, never stored. A
|
||
* derived projection has no primary key of its own, and inventing one in a
|
||
* table would mean the same expiry date living in two places.
|
||
*/
|
||
id: string;
|
||
kind: CalendarEventKind;
|
||
title: string;
|
||
/**
|
||
* ISO-8601. Strings rather than `Date` because this interface crosses the
|
||
* wire: the browser receives JSON, and a shape that only typechecks before
|
||
* serialisation is a shape the front end cannot honestly claim to hold.
|
||
*/
|
||
startsAt: string;
|
||
/** Null for a point in time. */
|
||
endsAt: string | null;
|
||
isSpan: boolean;
|
||
state: CalendarEventState;
|
||
accountId: string | null;
|
||
accountName: string | null;
|
||
ownerUserId: string | null;
|
||
/** Integer cents, per the money rule. Null where the event has no value. */
|
||
amountCents: number | null;
|
||
currency: string | null;
|
||
/** The table the event was derived from, e.g. `contract`. */
|
||
recordType: string;
|
||
recordId: string;
|
||
href: string;
|
||
meta: Record<string, unknown>;
|
||
}
|
||
|
||
/** Stable, storage-free identity for a projected event. */
|
||
export function calendarEventId(
|
||
recordType: string,
|
||
recordId: string,
|
||
field: string,
|
||
): string {
|
||
return `${recordType}:${recordId}:${field}`;
|
||
}
|
||
|
||
/**
|
||
* How soon before its date an event counts as `due` rather than `upcoming`.
|
||
* Seven days is the shortest horizon in which a renewal notice or an export
|
||
* authorisation can still realistically be acted on.
|
||
*/
|
||
export const CALENDAR_DUE_HORIZON_DAYS = 7;
|
||
|
||
const DAY_MS = 86_400_000;
|
||
|
||
export function eventState(input: {
|
||
at: Date;
|
||
now: Date;
|
||
completedAt?: Date | null;
|
||
dueWithinDays?: number;
|
||
}): CalendarEventState {
|
||
if (input.completedAt) return 'done';
|
||
const at = input.at.getTime();
|
||
const now = input.now.getTime();
|
||
if (at < now) return 'overdue';
|
||
const horizon = (input.dueWithinDays ?? CALENDAR_DUE_HORIZON_DAYS) * DAY_MS;
|
||
return at - now <= horizon ? 'due' : 'upcoming';
|
||
}
|
||
|
||
/**
|
||
* A span's state reads from its END, because a window that has started is not
|
||
* late — it is running. Only a window that has closed is behind us.
|
||
*
|
||
* `done` is right here only because these spans have no completion column: a
|
||
* capacity or allocation window IS the fact, and it is over when the clock says
|
||
* so. A span nobody could fail to do cannot be overdue. For anything a person
|
||
* was supposed to do inside the window, use `completableSpanState`.
|
||
*/
|
||
export function spanState(input: {
|
||
startsAt: Date;
|
||
endsAt: Date;
|
||
now: Date;
|
||
}): CalendarEventState {
|
||
if (input.endsAt.getTime() < input.now.getTime()) return 'done';
|
||
if (input.startsAt.getTime() <= input.now.getTime()) return 'due';
|
||
return eventState({ at: input.startsAt, now: input.now });
|
||
}
|
||
|
||
/**
|
||
* The same reading of a span for a row that CAN record completion — today only
|
||
* `calendar_entries`, which carries `completed_at`.
|
||
*
|
||
* The end still decides running versus past, but a closed window with nothing
|
||
* in the completion column is `overdue`, not `done`. Reusing `spanState` here
|
||
* made a missed QBR read as finished purely because its author had typed an end
|
||
* time — the byte-identical entry without one read `overdue` — which is exactly
|
||
* the conflation the note above `CalendarEventState` forbids.
|
||
*/
|
||
export function completableSpanState(input: {
|
||
startsAt: Date;
|
||
endsAt: Date;
|
||
now: Date;
|
||
completedAt?: Date | null;
|
||
}): CalendarEventState {
|
||
if (input.completedAt) return 'done';
|
||
if (input.endsAt.getTime() < input.now.getTime()) return 'overdue';
|
||
if (input.startsAt.getTime() <= input.now.getTime()) return 'due';
|
||
return eventState({ at: input.startsAt, now: input.now });
|
||
}
|
||
|
||
// ---------------------------------------------------------------- time zones
|
||
|
||
interface ZonedParts {
|
||
year: number;
|
||
month: number;
|
||
day: number;
|
||
hour: number;
|
||
minute: number;
|
||
second: number;
|
||
}
|
||
|
||
/**
|
||
* Constructing an `Intl.DateTimeFormat` is expensive enough to be worth
|
||
* keeping, but the key is a caller-supplied string that reaches here from a
|
||
* query parameter, so the map is capped: an unbounded one lets a loop over
|
||
* distinct values grow a long-lived API process without limit. The IANA
|
||
* database has well under 500 zones, so a real deployment never evicts;
|
||
* insertion-order eviction only ever bites junk.
|
||
*/
|
||
const FORMATTER_CACHE_LIMIT = 512;
|
||
|
||
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
||
|
||
/** Whether the runtime's ICU data recognises `timeZone` as an IANA zone. */
|
||
export function isValidTimeZone(timeZone: string): boolean {
|
||
try {
|
||
new Intl.DateTimeFormat('en-US', { timeZone });
|
||
return true;
|
||
} catch {
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Falls back to UTC rather than throwing. `users.timezone` is free text set
|
||
* through a preferences endpoint that does not validate it, so a stale or
|
||
* mistyped zone must degrade to a defensible answer instead of 500ing the
|
||
* whole calendar. Request-supplied zones are rejected at the route boundary
|
||
* instead, where a 400 can still tell the caller what was wrong.
|
||
*/
|
||
function formatterFor(timeZone: string): Intl.DateTimeFormat {
|
||
const cached = formatterCache.get(timeZone);
|
||
if (cached) return cached;
|
||
let formatter: Intl.DateTimeFormat;
|
||
try {
|
||
formatter = new Intl.DateTimeFormat('en-US', {
|
||
timeZone,
|
||
hourCycle: 'h23',
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit',
|
||
second: '2-digit',
|
||
});
|
||
} catch {
|
||
formatter = formatterFor('UTC');
|
||
}
|
||
if (formatterCache.size >= FORMATTER_CACHE_LIMIT) {
|
||
const oldest = formatterCache.keys().next();
|
||
if (!oldest.done) formatterCache.delete(oldest.value);
|
||
}
|
||
formatterCache.set(timeZone, formatter);
|
||
return formatter;
|
||
}
|
||
|
||
function zonedParts(instant: Date, timeZone: string): ZonedParts {
|
||
const parts = formatterFor(timeZone).formatToParts(instant);
|
||
const read = (type: Intl.DateTimeFormatPartTypes): number =>
|
||
Number(parts.find((part) => part.type === type)?.value ?? '0');
|
||
return {
|
||
year: read('year'),
|
||
month: read('month'),
|
||
day: read('day'),
|
||
hour: read('hour'),
|
||
minute: read('minute'),
|
||
second: read('second'),
|
||
};
|
||
}
|
||
|
||
function offsetMsAt(instant: Date, timeZone: string): number {
|
||
const parts = zonedParts(instant, timeZone);
|
||
const asIfUtc = Date.UTC(
|
||
parts.year,
|
||
parts.month - 1,
|
||
parts.day,
|
||
parts.hour,
|
||
parts.minute,
|
||
parts.second,
|
||
);
|
||
return asIfUtc - instant.getTime();
|
||
}
|
||
|
||
/**
|
||
* The instant at which local midnight begins on a given day in a given zone.
|
||
*
|
||
* Iterated rather than solved because the offset depends on the instant we are
|
||
* trying to find. Two passes settle every real zone including the daylight
|
||
* transitions; the third is insurance and costs nothing.
|
||
*/
|
||
function startOfZonedDay(
|
||
year: number,
|
||
month: number,
|
||
day: number,
|
||
timeZone: string,
|
||
): Date {
|
||
const wallClock = Date.UTC(year, month - 1, day);
|
||
let instant = wallClock;
|
||
for (let pass = 0; pass < 3; pass += 1) {
|
||
instant = wallClock - offsetMsAt(new Date(instant), timeZone);
|
||
}
|
||
return new Date(instant);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ quarters
|
||
|
||
function assertFiscalStart(fiscalYearStartMonth: number): void {
|
||
if (
|
||
!Number.isInteger(fiscalYearStartMonth) ||
|
||
fiscalYearStartMonth < 0 ||
|
||
fiscalYearStartMonth > 11
|
||
) {
|
||
throw new RangeError(
|
||
`fiscalYearStartMonth must be an integer month index 0–11, got ${fiscalYearStartMonth}.`,
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Which quarter an instant falls in.
|
||
*
|
||
* `fiscalYearStartMonth` is a zero-based month index: 0 for calendar quarters
|
||
* (the default), 3 for an April start, 9 for an October start. `timeZone`
|
||
* decides the boundary, and it matters: 31 December 23:00 in New York is
|
||
* already Q1 in London.
|
||
*/
|
||
export function quarterOf(
|
||
date: Date,
|
||
fiscalYearStartMonth = 0,
|
||
timeZone = 'UTC',
|
||
): Quarter {
|
||
assertFiscalStart(fiscalYearStartMonth);
|
||
const local = zonedParts(date, timeZone);
|
||
const monthIndex = local.month - 1;
|
||
// Months elapsed since the fiscal year began, 0–11.
|
||
const sinceStart = (monthIndex - fiscalYearStartMonth + 12) % 12;
|
||
const quarter = (Math.floor(sinceStart / 3) + 1) as QuarterNumber;
|
||
// Named for the year the fiscal year ends in — see the note at the top.
|
||
const startedThisCalendarYear = monthIndex >= fiscalYearStartMonth;
|
||
const fiscalYear =
|
||
fiscalYearStartMonth === 0
|
||
? local.year
|
||
: startedThisCalendarYear
|
||
? local.year + 1
|
||
: local.year;
|
||
return `${fiscalYear}-Q${quarter}`;
|
||
}
|
||
|
||
/**
|
||
* The half-open bounds of a quarter, as instants.
|
||
*
|
||
* `year` is the fiscal year label, not the calendar year in which the quarter
|
||
* starts — those differ for every non-calendar fiscal offset, which is the
|
||
* mistake this signature exists to make hard to write.
|
||
*/
|
||
export function quarterBounds(
|
||
year: number,
|
||
quarter: QuarterNumber,
|
||
fiscalYearStartMonth = 0,
|
||
timeZone = 'UTC',
|
||
): QuarterBounds {
|
||
assertFiscalStart(fiscalYearStartMonth);
|
||
if (!Number.isInteger(quarter) || quarter < 1 || quarter > 4) {
|
||
throw new RangeError(`quarter must be 1–4, got ${quarter}.`);
|
||
}
|
||
|
||
// Inverse of the labelling rule in quarterOf: a non-calendar fiscal year
|
||
// labelled `year` began in the previous calendar year.
|
||
const fiscalStartCalendarYear = fiscalYearStartMonth === 0 ? year : year - 1;
|
||
const startMonthIndex = fiscalYearStartMonth + (quarter - 1) * 3;
|
||
|
||
const from = startOfZonedDay(
|
||
fiscalStartCalendarYear + Math.floor(startMonthIndex / 12),
|
||
(startMonthIndex % 12) + 1,
|
||
1,
|
||
timeZone,
|
||
);
|
||
const endMonthIndex = startMonthIndex + 3;
|
||
const to = startOfZonedDay(
|
||
fiscalStartCalendarYear + Math.floor(endMonthIndex / 12),
|
||
(endMonthIndex % 12) + 1,
|
||
1,
|
||
timeZone,
|
||
);
|
||
return { from, to, quarter: `${year}-Q${quarter}` };
|
||
}
|
||
|
||
/** Splits `2026-Q3` back into its parts. Null when the label is malformed. */
|
||
export function parseQuarter(
|
||
label: string,
|
||
): { year: number; quarter: QuarterNumber } | null {
|
||
const match = /^(\d{4})-Q([1-4])$/.exec(label);
|
||
if (!match) return null;
|
||
return {
|
||
year: Number(match[1]),
|
||
quarter: Number(match[2]) as QuarterNumber,
|
||
};
|
||
}
|
||
|
||
/** The bounds of the quarter containing `date`. */
|
||
export function quarterBoundsFor(
|
||
date: Date,
|
||
fiscalYearStartMonth = 0,
|
||
timeZone = 'UTC',
|
||
): QuarterBounds {
|
||
const parsed = parseQuarter(quarterOf(date, fiscalYearStartMonth, timeZone));
|
||
if (!parsed) throw new Error('quarterOf produced an unparseable label.');
|
||
return quarterBounds(parsed.year, parsed.quarter, fiscalYearStartMonth, timeZone);
|
||
}
|