Rebuild the shell, add Calendar and Learn, and govern reads
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>
This commit is contained in:
@@ -0,0 +1,974 @@
|
||||
/**
|
||||
* The quarterly calendar — a projection, not a table.
|
||||
*
|
||||
* Everything with a date on it already lives somewhere: contracts expire,
|
||||
* obligations fall due, commitments open and close, holds lapse, export
|
||||
* authorisations run out. This service reads those columns where they are and
|
||||
* emits one common shape. Nothing here is stored, and nothing here can drift
|
||||
* from the record it describes.
|
||||
*
|
||||
* Three things shape the implementation.
|
||||
*
|
||||
* **One query per source, each with its own date predicate and its own
|
||||
* limit.** The convention elsewhere in this API is a flat `.limit(300)`
|
||||
* ordered by `updated_at`, with the caller filtering by date in the browser —
|
||||
* which means the deals actually closing this quarter are not guaranteed to be
|
||||
* in the response at all. That is precisely the bug this endpoint exists to
|
||||
* fix, so every predicate is server-side and every source is bounded
|
||||
* independently rather than competing for one budget.
|
||||
*
|
||||
* **Totals are separate aggregate queries.** If the header counted the rows in
|
||||
* the list it would under-report the moment any source truncated, and a
|
||||
* quarterly figure that silently shrinks is worse than no figure. The counts
|
||||
* are exact even when the list is cut short.
|
||||
*
|
||||
* **Renewal comes from `renewalAlarm()`.** The rule — expiry minus notice
|
||||
* days, only when auto-renewal is on — is defined once, in the contracts
|
||||
* service. The SQL below narrows candidates with the same arithmetic so the
|
||||
* scan stays bounded, but every date and every state on an emitted event comes
|
||||
* from calling that function. If the rule changes, it changes there.
|
||||
*/
|
||||
import {
|
||||
and,
|
||||
asc,
|
||||
count,
|
||||
eq,
|
||||
gt,
|
||||
gte,
|
||||
isNotNull,
|
||||
isNull,
|
||||
lt,
|
||||
or,
|
||||
sql,
|
||||
} from 'drizzle-orm';
|
||||
import {
|
||||
calendarEventId,
|
||||
completableSpanState,
|
||||
eventState,
|
||||
quarterOf,
|
||||
spanState,
|
||||
type CalendarEvent,
|
||||
type CalendarEventKind,
|
||||
type Quarter,
|
||||
} from '@pig/core';
|
||||
import {
|
||||
accounts,
|
||||
allocations,
|
||||
calendarEntries,
|
||||
capacityCommitments,
|
||||
complianceArtifacts,
|
||||
contractObligations,
|
||||
contracts,
|
||||
demandDeals,
|
||||
exportAuthorizations,
|
||||
supplyDeals,
|
||||
users,
|
||||
type Database,
|
||||
} from '@pig/db';
|
||||
import { renewalAlarm } from './contracts';
|
||||
|
||||
/** Per-source ceiling. Generous enough that a real quarter never reaches it. */
|
||||
const DEFAULT_SOURCE_LIMIT = 500;
|
||||
|
||||
export interface CalendarQuery {
|
||||
from: Date;
|
||||
/** Exclusive. Quarters are half-open so consecutive ones do not double-count. */
|
||||
to: Date;
|
||||
kinds?: readonly CalendarEventKind[];
|
||||
accountId?: string;
|
||||
ownerUserId?: string;
|
||||
fiscalYearStartMonth?: number;
|
||||
timeZone?: string;
|
||||
sourceLimit?: number;
|
||||
}
|
||||
|
||||
export interface CalendarTotals {
|
||||
/**
|
||||
* Σ acv × probability for deals whose expected close date falls in range.
|
||||
* The number a GTM lead reads first, and nothing in PIG computed it before.
|
||||
*/
|
||||
weightedPipelineCents: number;
|
||||
closingCount: number;
|
||||
renewalCount: number;
|
||||
obligationCount: number;
|
||||
expiringAuthorizationCount: number;
|
||||
}
|
||||
|
||||
export interface CalendarProjection {
|
||||
from: string;
|
||||
to: string;
|
||||
quarter: Quarter;
|
||||
events: CalendarEvent[];
|
||||
/** True when any single source hit its limit; the totals are still exact. */
|
||||
truncated: boolean;
|
||||
totals: CalendarTotals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the front end should go when an event is clicked.
|
||||
*
|
||||
* There is no record-detail route convention in this app yet — every page is
|
||||
* flat — so the page is the load-bearing half and the query parameter is a
|
||||
* hint the detail sheet can honour once one exists.
|
||||
*/
|
||||
function href(page: string, param: string, id: string): string {
|
||||
return `/${page}?${param}=${id}`;
|
||||
}
|
||||
|
||||
/** Drizzle returns numeric columns as strings; `probability` is one of them. */
|
||||
function numeric(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
export class CalendarService {
|
||||
constructor(
|
||||
private readonly db: Database,
|
||||
private readonly clock: () => Date = () => new Date(),
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The reader's own quarter boundary.
|
||||
*
|
||||
* `users.timezone` is settable through PATCH /api/me/preferences and until
|
||||
* now was read by nothing at all. A quarter is a local-midnight question, so
|
||||
* this is the first place it genuinely matters — and UTC remains the honest
|
||||
* fallback for a user who has never set one.
|
||||
*/
|
||||
async timeZoneFor(userId: string): Promise<string> {
|
||||
const [row] = await this.db
|
||||
.select({ timezone: users.timezone })
|
||||
.from(users)
|
||||
.where(eq(users.id, userId))
|
||||
.limit(1);
|
||||
return row?.timezone ?? 'UTC';
|
||||
}
|
||||
|
||||
async project(query: CalendarQuery): Promise<CalendarProjection> {
|
||||
const now = this.clock();
|
||||
const timeZone = query.timeZone ?? 'UTC';
|
||||
const fiscalYearStartMonth = query.fiscalYearStartMonth ?? 0;
|
||||
const limit = query.sourceLimit ?? DEFAULT_SOURCE_LIMIT;
|
||||
const wanted = query.kinds?.length ? new Set(query.kinds) : null;
|
||||
const wants = (kind: CalendarEventKind): boolean => !wanted || wanted.has(kind);
|
||||
|
||||
const collected: { events: CalendarEvent[]; truncated: boolean }[] = await Promise.all([
|
||||
wants('expected_close') ? this.expectedClose(query, now, limit) : empty(),
|
||||
wants('contract_effective')
|
||||
? this.contractDate(query, now, limit, 'contract_effective')
|
||||
: empty(),
|
||||
wants('contract_expiry')
|
||||
? this.contractDate(query, now, limit, 'contract_expiry')
|
||||
: empty(),
|
||||
wants('contract_executed')
|
||||
? this.contractDate(query, now, limit, 'contract_executed')
|
||||
: empty(),
|
||||
wants('renewal_notice') ? this.renewalNotices(query, now, limit) : empty(),
|
||||
wants('obligation_due') ? this.obligations(query, now, limit) : empty(),
|
||||
wants('capacity_window') ? this.capacityWindows(query, now, limit) : empty(),
|
||||
wants('allocation_window') ? this.allocationWindows(query, now, limit) : empty(),
|
||||
wants('hold_expiry') ? this.holdExpiries(query, now, limit) : empty(),
|
||||
wants('supply_available_from') ? this.supplyAvailability(query, now, limit) : empty(),
|
||||
wants('authorization_expiry') ? this.authorizationExpiries(query, now, limit) : empty(),
|
||||
wants('artifact_expiry') ? this.artifactExpiries(query, now, limit) : empty(),
|
||||
wants('calendar_entry') ? this.entries(query, now, limit) : empty(),
|
||||
]);
|
||||
|
||||
const events = collected
|
||||
.flatMap((source) => source.events)
|
||||
.sort((a, b) => a.startsAt.localeCompare(b.startsAt) || a.id.localeCompare(b.id));
|
||||
|
||||
return {
|
||||
from: query.from.toISOString(),
|
||||
to: query.to.toISOString(),
|
||||
quarter: quarterOf(query.from, fiscalYearStartMonth, timeZone),
|
||||
events,
|
||||
truncated: collected.some((source) => source.truncated),
|
||||
totals: await this.totals(query),
|
||||
};
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ totals
|
||||
|
||||
/**
|
||||
* Counted in SQL rather than off the event list, so a truncated source
|
||||
* cannot quietly shrink a quarterly figure. The kind filter is deliberately
|
||||
* ignored here: narrowing the list to one kind should not blank the header
|
||||
* the reader is narrowing against.
|
||||
*/
|
||||
private async totals(query: CalendarQuery): Promise<CalendarTotals> {
|
||||
const { from, to, accountId, ownerUserId } = query;
|
||||
|
||||
const [pipeline, renewals, obligations, authorizations] = await Promise.all([
|
||||
this.db
|
||||
.select({
|
||||
/**
|
||||
* A closed-won deal forecasts at certainty and a closed-lost one at
|
||||
* nothing, whatever `probability` still says; an open deal with no
|
||||
* forecast contributes nothing rather than its full value, because
|
||||
* an unfilled field is not a prediction of 100%.
|
||||
*/
|
||||
weightedCents: sql<string>`coalesce(sum(round(${demandDeals.acvCents} * (case
|
||||
when ${demandDeals.stage} = 'closed_won' then 1
|
||||
when ${demandDeals.stage} = 'closed_lost' then 0
|
||||
else coalesce(${demandDeals.probability}, 0) end))), 0)`,
|
||||
closing: sql<number>`count(*) filter (where ${demandDeals.stage} <> 'closed_lost')::int`,
|
||||
})
|
||||
.from(demandDeals)
|
||||
.where(
|
||||
and(
|
||||
gte(demandDeals.expectedCloseDate, from),
|
||||
lt(demandDeals.expectedCloseDate, to),
|
||||
accountId ? eq(demandDeals.accountId, accountId) : undefined,
|
||||
ownerUserId ? eq(demandDeals.ownerUserId, ownerUserId) : undefined,
|
||||
),
|
||||
),
|
||||
this.db
|
||||
.select({ value: count() })
|
||||
.from(contracts)
|
||||
.where(this.renewalPredicate(query)),
|
||||
this.db
|
||||
.select({ value: count() })
|
||||
.from(contractObligations)
|
||||
.innerJoin(contracts, eq(contracts.id, contractObligations.contractId))
|
||||
.where(
|
||||
and(
|
||||
gte(contractObligations.dueAt, from),
|
||||
lt(contractObligations.dueAt, to),
|
||||
// Outstanding only. A count that includes work already done reads
|
||||
// as a backlog that is not there.
|
||||
isNull(contractObligations.completedAt),
|
||||
accountId ? eq(contracts.accountId, accountId) : undefined,
|
||||
ownerUserId ? eq(contractObligations.ownerUserId, ownerUserId) : undefined,
|
||||
),
|
||||
),
|
||||
// An expiring authorisation has no owner column, so an owner filter can
|
||||
// only ever exclude it — reporting zero rather than the whole book.
|
||||
ownerUserId
|
||||
? Promise.resolve([{ value: 0 }])
|
||||
: this.db
|
||||
.select({ value: count() })
|
||||
.from(exportAuthorizations)
|
||||
.where(
|
||||
and(
|
||||
gte(exportAuthorizations.expiresAt, from),
|
||||
lt(exportAuthorizations.expiresAt, to),
|
||||
accountId ? eq(exportAuthorizations.accountId, accountId) : undefined,
|
||||
),
|
||||
),
|
||||
]);
|
||||
|
||||
return {
|
||||
weightedPipelineCents: Math.round(Number(pipeline[0]?.weightedCents ?? 0)),
|
||||
closingCount: pipeline[0]?.closing ?? 0,
|
||||
renewalCount: renewals[0]?.value ?? 0,
|
||||
obligationCount: obligations[0]?.value ?? 0,
|
||||
expiringAuthorizationCount: authorizations[0]?.value ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- sources
|
||||
|
||||
private async expectedClose(query: CalendarQuery, now: Date, limit: number) {
|
||||
const rows = await this.db
|
||||
.select({ deal: demandDeals, accountName: accounts.name })
|
||||
.from(demandDeals)
|
||||
.leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(demandDeals.expectedCloseDate, query.from),
|
||||
lt(demandDeals.expectedCloseDate, query.to),
|
||||
query.accountId ? eq(demandDeals.accountId, query.accountId) : undefined,
|
||||
query.ownerUserId ? eq(demandDeals.ownerUserId, query.ownerUserId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(demandDeals.expectedCloseDate))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ deal, accountName }) => {
|
||||
const at = deal.expectedCloseDate!;
|
||||
const probability = numeric(deal.probability);
|
||||
return {
|
||||
id: calendarEventId('demand_deal', deal.id, 'expectedCloseDate'),
|
||||
kind: 'expected_close' as const,
|
||||
title: deal.name,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
state: eventState({ at, now, completedAt: deal.closedAt }),
|
||||
accountId: deal.accountId,
|
||||
accountName,
|
||||
ownerUserId: deal.ownerUserId,
|
||||
amountCents: deal.acvCents,
|
||||
currency: deal.currency,
|
||||
recordType: 'demand_deal',
|
||||
recordId: deal.id,
|
||||
href: href('demand', 'deal', deal.id),
|
||||
meta: {
|
||||
stage: deal.stage,
|
||||
probability,
|
||||
productLine: deal.productLine,
|
||||
weightedCents:
|
||||
deal.acvCents !== null && probability !== null
|
||||
? Math.round(deal.acvCents * probability)
|
||||
: null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async contractDate(
|
||||
query: CalendarQuery,
|
||||
now: Date,
|
||||
limit: number,
|
||||
kind: 'contract_effective' | 'contract_expiry' | 'contract_executed',
|
||||
) {
|
||||
const column =
|
||||
kind === 'contract_effective'
|
||||
? contracts.effectiveAt
|
||||
: kind === 'contract_expiry'
|
||||
? contracts.expiresAt
|
||||
: contracts.executedAt;
|
||||
const field =
|
||||
kind === 'contract_effective'
|
||||
? 'effectiveAt'
|
||||
: kind === 'contract_expiry'
|
||||
? 'expiresAt'
|
||||
: 'executedAt';
|
||||
const label =
|
||||
kind === 'contract_effective'
|
||||
? 'takes effect'
|
||||
: kind === 'contract_expiry'
|
||||
? 'expires'
|
||||
: 'executed';
|
||||
|
||||
const rows = await this.db
|
||||
.select({ contract: contracts, accountName: accounts.name })
|
||||
.from(contracts)
|
||||
.leftJoin(accounts, eq(accounts.id, contracts.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(column, query.from),
|
||||
lt(column, query.to),
|
||||
query.accountId ? eq(contracts.accountId, query.accountId) : undefined,
|
||||
query.ownerUserId ? eq(contracts.ownerUserId, query.ownerUserId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(column))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ contract, accountName }) => {
|
||||
const at = contract[field]!;
|
||||
return {
|
||||
id: calendarEventId('contract', contract.id, field),
|
||||
kind,
|
||||
title: `${contract.title} ${label}`,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
// An executed date is a fact about the past, not an errand: it is
|
||||
// recorded as done so it does not sit in the overdue list forever.
|
||||
state:
|
||||
kind === 'contract_executed'
|
||||
? ('done' as const)
|
||||
: eventState({ at, now, completedAt: contract.terminatedAt }),
|
||||
accountId: contract.accountId,
|
||||
accountName,
|
||||
ownerUserId: contract.ownerUserId,
|
||||
amountCents: contract.valueCents,
|
||||
currency: contract.currency,
|
||||
recordType: 'contract',
|
||||
recordId: contract.id,
|
||||
href: href('contracts', 'contract', contract.id),
|
||||
meta: {
|
||||
contractType: contract.type,
|
||||
status: contract.status,
|
||||
side: contract.side,
|
||||
terminatedAt: contract.terminatedAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The SQL narrows; `renewalAlarm()` decides.
|
||||
*
|
||||
* The predicate repeats the expiry-minus-notice arithmetic only to keep the
|
||||
* scan bounded — the alternative is loading every auto-renewing contract in
|
||||
* the book. Every date and state that reaches a caller comes from the shared
|
||||
* function, so there is still exactly one definition of the rule.
|
||||
*/
|
||||
private renewalPredicate(query: CalendarQuery) {
|
||||
return and(
|
||||
eq(contracts.isAutoRenew, true),
|
||||
isNotNull(contracts.noticeDays),
|
||||
isNotNull(contracts.expiresAt),
|
||||
// A terminated contract will not renew, so its notice date is not a
|
||||
// deadline anyone should be chased about.
|
||||
isNull(contracts.terminatedAt),
|
||||
// The bounds are bound as ISO text and cast, not as `Date`: drizzle types
|
||||
// parameters from the column in a comparison, and a raw template has no
|
||||
// column to learn from, so postgres-js receives a Date it cannot encode
|
||||
// and the whole request 500s. Found by calling the endpoint.
|
||||
sql`${contracts.expiresAt} - make_interval(days => ${contracts.noticeDays}) >= ${query.from.toISOString()}::timestamptz`,
|
||||
sql`${contracts.expiresAt} - make_interval(days => ${contracts.noticeDays}) < ${query.to.toISOString()}::timestamptz`,
|
||||
query.accountId ? eq(contracts.accountId, query.accountId) : undefined,
|
||||
query.ownerUserId ? eq(contracts.ownerUserId, query.ownerUserId) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
private async renewalNotices(query: CalendarQuery, now: Date, limit: number) {
|
||||
const rows = await this.db
|
||||
.select({ contract: contracts, accountName: accounts.name })
|
||||
.from(contracts)
|
||||
.leftJoin(accounts, eq(accounts.id, contracts.accountId))
|
||||
.where(this.renewalPredicate(query))
|
||||
.orderBy(asc(contracts.expiresAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ contract, accountName }) => {
|
||||
const alarm = renewalAlarm(contract, now);
|
||||
const at = alarm.renewalNoticeAt!;
|
||||
return {
|
||||
id: calendarEventId('contract', contract.id, 'renewalNoticeAt'),
|
||||
kind: 'renewal_notice' as const,
|
||||
title: `Renewal notice — ${contract.title}`,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
// 'expired' means the window to give notice has gone; the notice date
|
||||
// itself is simply late until then.
|
||||
state:
|
||||
alarm.renewalState === 'expired'
|
||||
? ('overdue' as const)
|
||||
: eventState({ at, now }),
|
||||
accountId: contract.accountId,
|
||||
accountName,
|
||||
ownerUserId: contract.ownerUserId,
|
||||
amountCents: contract.valueCents,
|
||||
currency: contract.currency,
|
||||
recordType: 'contract',
|
||||
recordId: contract.id,
|
||||
href: href('contracts', 'contract', contract.id),
|
||||
meta: {
|
||||
renewalState: alarm.renewalState,
|
||||
expiresAt: contract.expiresAt?.toISOString() ?? null,
|
||||
noticeDays: contract.noticeDays,
|
||||
side: contract.side,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every obligation on every contract, in one query.
|
||||
*
|
||||
* Obligations were reachable only inside GET /api/contracts/:id, so a
|
||||
* quarter of them meant one request per contract. They are the dated things
|
||||
* most likely to be missed, which makes that the wrong place for them to be.
|
||||
*/
|
||||
private async obligations(query: CalendarQuery, now: Date, limit: number) {
|
||||
const rows = await this.db
|
||||
.select({
|
||||
obligation: contractObligations,
|
||||
contract: contracts,
|
||||
accountName: accounts.name,
|
||||
})
|
||||
.from(contractObligations)
|
||||
.innerJoin(contracts, eq(contracts.id, contractObligations.contractId))
|
||||
.leftJoin(accounts, eq(accounts.id, contracts.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(contractObligations.dueAt, query.from),
|
||||
lt(contractObligations.dueAt, query.to),
|
||||
query.accountId ? eq(contracts.accountId, query.accountId) : undefined,
|
||||
query.ownerUserId
|
||||
? eq(contractObligations.ownerUserId, query.ownerUserId)
|
||||
: undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(contractObligations.dueAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ obligation, contract, accountName }) => ({
|
||||
id: calendarEventId('contract_obligation', obligation.id, 'dueAt'),
|
||||
kind: 'obligation_due' as const,
|
||||
title: obligation.title,
|
||||
startsAt: obligation.dueAt.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
state: eventState({
|
||||
at: obligation.dueAt,
|
||||
now,
|
||||
completedAt: obligation.completedAt,
|
||||
}),
|
||||
accountId: contract.accountId,
|
||||
accountName,
|
||||
ownerUserId: obligation.ownerUserId,
|
||||
amountCents: null,
|
||||
currency: null,
|
||||
recordType: 'contract_obligation',
|
||||
recordId: obligation.id,
|
||||
href: href('contracts', 'contract', contract.id),
|
||||
meta: {
|
||||
obligationKind: obligation.kind,
|
||||
contractId: contract.id,
|
||||
contractTitle: contract.title,
|
||||
completedAt: obligation.completedAt?.toISOString() ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Commitment windows, split on the capacity shape where one is present.
|
||||
*
|
||||
* A commitment ramps and steps — it is not a rectangle — and `shape` is
|
||||
* authoritative over `startsAt`/`endsAt` when set. Drawing one bar across
|
||||
* the whole term shows a seller capacity in a month it does not exist in,
|
||||
* which is exactly the mistake the shape column was added to prevent.
|
||||
*/
|
||||
private async capacityWindows(query: CalendarQuery, now: Date, limit: number) {
|
||||
// No owner column anywhere on the supply chain of custody, so an owner
|
||||
// filter cannot be satisfied and must exclude the source outright.
|
||||
if (query.ownerUserId) return { events: [], truncated: false };
|
||||
|
||||
const rows = await this.db
|
||||
.select({ commitment: capacityCommitments, accountName: accounts.name })
|
||||
.from(capacityCommitments)
|
||||
.leftJoin(accounts, eq(accounts.id, capacityCommitments.accountId))
|
||||
.where(
|
||||
and(
|
||||
lt(capacityCommitments.startsAt, query.to),
|
||||
gt(capacityCommitments.endsAt, query.from),
|
||||
query.accountId ? eq(capacityCommitments.accountId, query.accountId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(capacityCommitments.startsAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
const truncated = rows.length > limit;
|
||||
if (truncated) rows.length = limit;
|
||||
|
||||
const events: CalendarEvent[] = [];
|
||||
for (const { commitment, accountName } of rows) {
|
||||
const base = {
|
||||
kind: 'capacity_window' as const,
|
||||
isSpan: true,
|
||||
accountId: commitment.accountId,
|
||||
accountName,
|
||||
ownerUserId: null,
|
||||
amountCents: null,
|
||||
currency: commitment.currency,
|
||||
recordType: 'capacity_commitment',
|
||||
recordId: commitment.id,
|
||||
href: href('capacity', 'commitment', commitment.id),
|
||||
};
|
||||
|
||||
const shape = commitment.shape;
|
||||
const subSpans =
|
||||
shape && shape.intervals.length >= 2 && shape.quantities.length >= 1
|
||||
? shape.intervals.slice(0, -1).map((boundary, index) => ({
|
||||
index,
|
||||
startsAt: new Date(boundary),
|
||||
endsAt: new Date(shape.intervals[index + 1]!),
|
||||
gpuCount: shape.quantities[index] ?? commitment.gpuCount,
|
||||
}))
|
||||
: [
|
||||
{
|
||||
index: null,
|
||||
startsAt: commitment.startsAt,
|
||||
endsAt: commitment.endsAt,
|
||||
gpuCount: commitment.gpuCount,
|
||||
},
|
||||
];
|
||||
|
||||
for (const span of subSpans) {
|
||||
if (Number.isNaN(span.startsAt.getTime()) || Number.isNaN(span.endsAt.getTime())) {
|
||||
continue;
|
||||
}
|
||||
if (span.startsAt >= query.to || span.endsAt <= query.from) continue;
|
||||
events.push({
|
||||
...base,
|
||||
id: calendarEventId(
|
||||
'capacity_commitment',
|
||||
commitment.id,
|
||||
span.index === null ? 'window' : `shape.${span.index}`,
|
||||
),
|
||||
title:
|
||||
span.index === null
|
||||
? commitment.name
|
||||
: `${commitment.name} — ${span.gpuCount}× ${commitment.gpuType}`,
|
||||
startsAt: span.startsAt.toISOString(),
|
||||
endsAt: span.endsAt.toISOString(),
|
||||
state: commitment.terminatedAt
|
||||
? ('done' as const)
|
||||
: spanState({ startsAt: span.startsAt, endsAt: span.endsAt, now }),
|
||||
meta: {
|
||||
gpuType: commitment.gpuType,
|
||||
gpuCount: span.gpuCount,
|
||||
envelopeGpuCount: commitment.gpuCount,
|
||||
shaped: span.index !== null,
|
||||
costPerGpuHourCents: commitment.costPerGpuHourCents,
|
||||
terminatedAt: commitment.terminatedAt?.toISOString() ?? null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { events, truncated };
|
||||
}
|
||||
|
||||
private async allocationWindows(query: CalendarQuery, now: Date, limit: number) {
|
||||
if (query.ownerUserId) return { events: [], truncated: false };
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
allocation: allocations,
|
||||
commitmentName: capacityCommitments.name,
|
||||
dealName: demandDeals.name,
|
||||
accountId: demandDeals.accountId,
|
||||
accountName: accounts.name,
|
||||
})
|
||||
.from(allocations)
|
||||
.leftJoin(
|
||||
capacityCommitments,
|
||||
eq(capacityCommitments.id, allocations.capacityCommitmentId),
|
||||
)
|
||||
.leftJoin(demandDeals, eq(demandDeals.id, allocations.demandDealId))
|
||||
.leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
|
||||
.where(
|
||||
and(
|
||||
lt(allocations.startsAt, query.to),
|
||||
gt(allocations.endsAt, query.from),
|
||||
query.accountId ? eq(demandDeals.accountId, query.accountId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(allocations.startsAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, (row) => {
|
||||
const { allocation } = row;
|
||||
const gpuHours = numeric(allocation.gpuHours) ?? 0;
|
||||
return {
|
||||
id: calendarEventId('allocation', allocation.id, 'window'),
|
||||
kind: 'allocation_window' as const,
|
||||
title:
|
||||
row.dealName ??
|
||||
(allocation.internalTeam
|
||||
? `Internal — ${allocation.internalTeam}`
|
||||
: (row.commitmentName ?? 'Allocation')),
|
||||
startsAt: allocation.startsAt.toISOString(),
|
||||
endsAt: allocation.endsAt.toISOString(),
|
||||
isSpan: true,
|
||||
state:
|
||||
allocation.releasedAt !== null
|
||||
? ('done' as const)
|
||||
: spanState({
|
||||
startsAt: allocation.startsAt,
|
||||
endsAt: allocation.endsAt,
|
||||
now,
|
||||
}),
|
||||
accountId: row.accountId ?? null,
|
||||
accountName: row.accountName ?? null,
|
||||
ownerUserId: null,
|
||||
// Revenue over the window, in cents — hours are fractional, money is not.
|
||||
amountCents: Math.round(gpuHours * allocation.pricePerGpuHourCents),
|
||||
currency: allocation.currency,
|
||||
recordType: 'allocation',
|
||||
recordId: allocation.id,
|
||||
href: href('capacity', 'allocation', allocation.id),
|
||||
meta: {
|
||||
status: allocation.status,
|
||||
guaranteeType: allocation.guaranteeType,
|
||||
gpuHours,
|
||||
internalTeam: allocation.internalTeam,
|
||||
commitmentId: allocation.capacityCommitmentId,
|
||||
releasedAt: allocation.releasedAt?.toISOString() ?? null,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A hold expiring is the one date on this calendar that changes what can be
|
||||
* sold: the moment it passes, the capacity returns to everyone else's
|
||||
* availability. It has never been visible anywhere.
|
||||
*/
|
||||
private async holdExpiries(query: CalendarQuery, now: Date, limit: number) {
|
||||
if (query.ownerUserId) return { events: [], truncated: false };
|
||||
|
||||
const rows = await this.db
|
||||
.select({
|
||||
allocation: allocations,
|
||||
dealName: demandDeals.name,
|
||||
accountId: demandDeals.accountId,
|
||||
accountName: accounts.name,
|
||||
})
|
||||
.from(allocations)
|
||||
.leftJoin(demandDeals, eq(demandDeals.id, allocations.demandDealId))
|
||||
.leftJoin(accounts, eq(accounts.id, demandDeals.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(allocations.holdExpiresAt, query.from),
|
||||
lt(allocations.holdExpiresAt, query.to),
|
||||
query.accountId ? eq(demandDeals.accountId, query.accountId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(allocations.holdExpiresAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, (row) => {
|
||||
const at = row.allocation.holdExpiresAt!;
|
||||
return {
|
||||
id: calendarEventId('allocation', row.allocation.id, 'holdExpiresAt'),
|
||||
kind: 'hold_expiry' as const,
|
||||
title: `Hold expires — ${row.dealName ?? 'unassigned capacity'}`,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
state: eventState({ at, now, completedAt: row.allocation.releasedAt }),
|
||||
accountId: row.accountId ?? null,
|
||||
accountName: row.accountName ?? null,
|
||||
ownerUserId: null,
|
||||
// What was turned away to keep the hold. Makes the deadline honest.
|
||||
amountCents: row.allocation.holdOpportunityCostCents,
|
||||
currency: row.allocation.currency,
|
||||
recordType: 'allocation',
|
||||
recordId: row.allocation.id,
|
||||
href: href('capacity', 'allocation', row.allocation.id),
|
||||
meta: {
|
||||
status: row.allocation.status,
|
||||
gpuHours: numeric(row.allocation.gpuHours),
|
||||
commitmentId: row.allocation.capacityCommitmentId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async supplyAvailability(query: CalendarQuery, now: Date, limit: number) {
|
||||
const rows = await this.db
|
||||
.select({ deal: supplyDeals, accountName: accounts.name })
|
||||
.from(supplyDeals)
|
||||
.leftJoin(accounts, eq(accounts.id, supplyDeals.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(supplyDeals.availableFrom, query.from),
|
||||
lt(supplyDeals.availableFrom, query.to),
|
||||
query.accountId ? eq(supplyDeals.accountId, query.accountId) : undefined,
|
||||
query.ownerUserId ? eq(supplyDeals.ownerUserId, query.ownerUserId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(supplyDeals.availableFrom))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ deal, accountName }) => {
|
||||
const at = deal.availableFrom!;
|
||||
return {
|
||||
id: calendarEventId('supply_deal', deal.id, 'availableFrom'),
|
||||
kind: 'supply_available_from' as const,
|
||||
title: `Capacity available — ${deal.name}`,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
state: eventState({ at, now, completedAt: deal.closedAt }),
|
||||
accountId: deal.accountId,
|
||||
accountName,
|
||||
ownerUserId: deal.ownerUserId,
|
||||
amountCents: null,
|
||||
currency: null,
|
||||
recordType: 'supply_deal',
|
||||
recordId: deal.id,
|
||||
href: href('supply', 'deal', deal.id),
|
||||
meta: {
|
||||
stage: deal.stage,
|
||||
gpuType: deal.gpuType,
|
||||
gpuCount: deal.gpuCount,
|
||||
targetCostPerGpuHourCents: deal.targetCostPerGpuHourCents,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* An expired export authorisation silently converts lawful business into
|
||||
* unlawful business. The schema says so and indexes the column for it, and
|
||||
* until this endpoint nothing in PIG read it — no endpoint, no screen.
|
||||
*/
|
||||
private async authorizationExpiries(query: CalendarQuery, now: Date, limit: number) {
|
||||
if (query.ownerUserId) return { events: [], truncated: false };
|
||||
|
||||
const rows = await this.db
|
||||
.select({ authorization: exportAuthorizations, accountName: accounts.name })
|
||||
.from(exportAuthorizations)
|
||||
.leftJoin(accounts, eq(accounts.id, exportAuthorizations.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(exportAuthorizations.expiresAt, query.from),
|
||||
lt(exportAuthorizations.expiresAt, query.to),
|
||||
query.accountId ? eq(exportAuthorizations.accountId, query.accountId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(exportAuthorizations.expiresAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ authorization, accountName }) => {
|
||||
const at = authorization.expiresAt!;
|
||||
return {
|
||||
id: calendarEventId('export_authorization', authorization.id, 'expiresAt'),
|
||||
kind: 'authorization_expiry' as const,
|
||||
title: `Export authorisation expires — ${accountName ?? 'account'}`,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
// Never 'done': an authorisation is not something anyone completes,
|
||||
// and marking a lapsed one finished is the failure mode itself.
|
||||
state: eventState({ at, now }),
|
||||
accountId: authorization.accountId,
|
||||
accountName,
|
||||
ownerUserId: null,
|
||||
amountCents: null,
|
||||
currency: null,
|
||||
recordType: 'export_authorization',
|
||||
recordId: authorization.id,
|
||||
href: href('accounts', 'account', authorization.accountId),
|
||||
meta: {
|
||||
authorizationType: authorization.authorizationType,
|
||||
reference: authorization.reference,
|
||||
// Rules in flux for this counterparty: re-verify, do not trust the date.
|
||||
volatile: authorization.volatile,
|
||||
evidenceUrl: authorization.evidenceUrl,
|
||||
verifiedByUserId: authorization.verifiedByUserId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async artifactExpiries(query: CalendarQuery, now: Date, limit: number) {
|
||||
if (query.ownerUserId) return { events: [], truncated: false };
|
||||
|
||||
const rows = await this.db
|
||||
.select({ artifact: complianceArtifacts, accountName: accounts.name })
|
||||
.from(complianceArtifacts)
|
||||
.leftJoin(accounts, eq(accounts.id, complianceArtifacts.accountId))
|
||||
.where(
|
||||
and(
|
||||
gte(complianceArtifacts.expiresAt, query.from),
|
||||
lt(complianceArtifacts.expiresAt, query.to),
|
||||
query.accountId ? eq(complianceArtifacts.accountId, query.accountId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(complianceArtifacts.expiresAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ artifact, accountName }) => {
|
||||
const at = artifact.expiresAt!;
|
||||
return {
|
||||
id: calendarEventId('compliance_artifact', artifact.id, 'expiresAt'),
|
||||
kind: 'artifact_expiry' as const,
|
||||
title: `${artifact.claim} expires — ${accountName ?? 'account'}`,
|
||||
startsAt: at.toISOString(),
|
||||
endsAt: null,
|
||||
isSpan: false,
|
||||
state: eventState({ at, now }),
|
||||
accountId: artifact.accountId,
|
||||
accountName,
|
||||
ownerUserId: null,
|
||||
amountCents: null,
|
||||
currency: null,
|
||||
recordType: 'compliance_artifact',
|
||||
recordId: artifact.id,
|
||||
href: href('accounts', 'account', artifact.accountId),
|
||||
meta: {
|
||||
claim: artifact.claim,
|
||||
scope: artifact.scope,
|
||||
// Certification versus self-declared alignment decides procurement,
|
||||
// so it travels with the deadline rather than being looked up later.
|
||||
isCertified: artifact.isCertified,
|
||||
soc2Type: artifact.soc2Type,
|
||||
verifiedByUserId: artifact.verifiedByUserId,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async entries(query: CalendarQuery, now: Date, limit: number) {
|
||||
const rows = await this.db
|
||||
.select({ entry: calendarEntries, accountName: accounts.name })
|
||||
.from(calendarEntries)
|
||||
.leftJoin(accounts, eq(accounts.id, calendarEntries.accountId))
|
||||
.where(
|
||||
and(
|
||||
// A dated entry with no end is a point; one with an end is a span,
|
||||
// and a span overlaps the window whenever it has not already closed.
|
||||
lt(calendarEntries.startsAt, query.to),
|
||||
or(
|
||||
and(isNull(calendarEntries.endsAt), gte(calendarEntries.startsAt, query.from)),
|
||||
and(isNotNull(calendarEntries.endsAt), gt(calendarEntries.endsAt, query.from)),
|
||||
),
|
||||
query.accountId ? eq(calendarEntries.accountId, query.accountId) : undefined,
|
||||
query.ownerUserId ? eq(calendarEntries.ownerUserId, query.ownerUserId) : undefined,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(calendarEntries.startsAt))
|
||||
.limit(limit + 1);
|
||||
|
||||
return bounded(rows, limit, ({ entry, accountName }) => ({
|
||||
id: calendarEventId('calendar_entry', entry.id, 'startsAt'),
|
||||
kind: 'calendar_entry' as const,
|
||||
title: entry.title,
|
||||
startsAt: entry.startsAt.toISOString(),
|
||||
endsAt: entry.endsAt?.toISOString() ?? null,
|
||||
isSpan: entry.endsAt !== null,
|
||||
// Not `spanState`: this is the one projected row type with a completion
|
||||
// column, so a closed window is overdue until `completed_at` says
|
||||
// otherwise. Whether a missed QBR is flagged must not depend on whether
|
||||
// its author happened to type an end time.
|
||||
state: entry.endsAt
|
||||
? completableSpanState({
|
||||
startsAt: entry.startsAt,
|
||||
endsAt: entry.endsAt,
|
||||
now,
|
||||
completedAt: entry.completedAt,
|
||||
})
|
||||
: eventState({ at: entry.startsAt, now, completedAt: entry.completedAt }),
|
||||
accountId: entry.accountId,
|
||||
accountName,
|
||||
ownerUserId: entry.ownerUserId,
|
||||
amountCents: null,
|
||||
currency: null,
|
||||
recordType: 'calendar_entry',
|
||||
recordId: entry.id,
|
||||
href: href('calendar', 'entry', entry.id),
|
||||
meta: {
|
||||
entryKind: entry.kind,
|
||||
allDay: entry.allDay,
|
||||
description: entry.description,
|
||||
demandDealId: entry.demandDealId,
|
||||
supplyDealId: entry.supplyDealId,
|
||||
completedAt: entry.completedAt?.toISOString() ?? null,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------- helpers
|
||||
|
||||
async function empty(): Promise<{ events: CalendarEvent[]; truncated: boolean }> {
|
||||
return { events: [], truncated: false };
|
||||
}
|
||||
|
||||
/**
|
||||
* Each source asks for one row more than its budget. Detecting truncation any
|
||||
* other way means either a second count query per source or silently returning
|
||||
* a partial quarter as if it were whole.
|
||||
*/
|
||||
function bounded<Row>(
|
||||
rows: Row[],
|
||||
limit: number,
|
||||
toEvent: (row: Row) => CalendarEvent,
|
||||
): { events: CalendarEvent[]; truncated: boolean } {
|
||||
const truncated = rows.length > limit;
|
||||
if (truncated) rows.length = limit;
|
||||
return { events: rows.map(toEvent), truncated };
|
||||
}
|
||||
Reference in New Issue
Block a user