Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
import {
assertWorkspaceSlug,
isLocale,
planOf,
DEFAULT_LOCALE,
type Locale,
type Member,
type Role,
type SubscriptionStatus,
type Workspace,
type WorkspaceStatus,
} from '@wellformed/shared';
import { newId } from '../engine/ids.js';
import { toAccount } from './accounts.js';
import type { ControlDb } from './db.js';
/**
* A workspace as the server knows it, including where its data lives.
*
* `dbFile` is relative to the tenant directory for workspaces Wellformed created,
* and absolute for a database adopted from a single-tenant install. The
* registry is the only thing that resolves it to a path.
*/
export interface WorkspaceRecord {
id: string;
slug: string;
name: string;
dbFile: string;
status: WorkspaceStatus;
currency: string;
locale: Locale;
createdAt: string;
createdBy: string | null;
}
interface WorkspaceRow {
id: string;
slug: string;
name: string;
db_file: string;
status: string;
currency: string;
locale: string;
created_at: string;
created_by: string | null;
}
/** A stored locale this build no longer supports falls back rather than crashes. */
function toLocale(value: string): Locale {
return isLocale(value) ? value : DEFAULT_LOCALE;
}
function toRecord(row: WorkspaceRow): WorkspaceRecord {
return {
id: row.id,
slug: row.slug,
name: row.name,
dbFile: row.db_file,
status: row.status as WorkspaceStatus,
currency: row.currency,
locale: toLocale(row.locale),
createdAt: row.created_at,
createdBy: row.created_by,
};
}
const COLUMNS = 'id, slug, name, db_file, status, currency, locale, created_at, created_by';
export function getWorkspaceBySlug(control: ControlDb, slug: string): WorkspaceRecord | null {
const row = control.sqlite
.prepare<[string], WorkspaceRow>(`SELECT ${COLUMNS} FROM workspace WHERE slug = ?`)
.get(slug.trim().toLowerCase());
return row ? toRecord(row) : null;
}
export function getWorkspaceById(control: ControlDb, id: string): WorkspaceRecord | null {
const row = control.sqlite
.prepare<[string], WorkspaceRow>(`SELECT ${COLUMNS} FROM workspace WHERE id = ?`)
.get(id);
return row ? toRecord(row) : null;
}
export function listAllWorkspaces(control: ControlDb): WorkspaceRecord[] {
return control.sqlite
.prepare<[], WorkspaceRow>(`SELECT ${COLUMNS} FROM workspace ORDER BY created_at`)
.all()
.map(toRecord);
}
export function workspaceExistsForFile(control: ControlDb, dbFile: string): boolean {
const row = control.sqlite
.prepare<[string], { n: number }>('SELECT COUNT(*) AS n FROM workspace WHERE db_file = ?')
.get(dbFile);
return (row?.n ?? 0) > 0;
}
export interface NewWorkspace {
slug: string;
name: string;
createdBy?: string | null;
/** The language the workspace opens in. Defaults to English. */
locale?: Locale;
/** Only supplied when adopting an existing file; otherwise derived from the id. */
dbFile?: string;
id?: string;
}
export function createWorkspace(control: ControlDb, input: NewWorkspace): WorkspaceRecord {
// Validated here rather than at each caller, because a workspace whose slug
// cannot appear in a URL is a workspace nobody can open — and the callers
// include adoption and the CLI, not only the signup route.
assertWorkspaceSlug(input.slug.trim().toLowerCase());
const id = input.id ?? newId('ws');
// The file is named after the immutable id, never the slug, so renaming a
// workspace is a metadata change and never touches the filesystem.
const dbFile = input.dbFile ?? `${id}.db`;
control.sqlite
.prepare(
'INSERT INTO workspace (id, slug, name, db_file, locale, created_by) VALUES (?, ?, ?, ?, ?, ?)',
)
.run(
id,
input.slug.trim().toLowerCase(),
input.name.trim(),
dbFile,
input.locale ?? DEFAULT_LOCALE,
input.createdBy ?? null,
);
return getWorkspaceById(control, id)!;
}
export function renameWorkspace(control: ControlDb, id: string, name: string): void {
control.sqlite.prepare('UPDATE workspace SET name = ? WHERE id = ?').run(name.trim(), id);
}
export function setWorkspaceSlug(control: ControlDb, id: string, slug: string): void {
control.sqlite.prepare('UPDATE workspace SET slug = ? WHERE id = ?').run(slug.trim().toLowerCase(), id);
}
export function setWorkspaceCurrency(control: ControlDb, id: string, currency: string): void {
control.sqlite
.prepare('UPDATE workspace SET currency = ? WHERE id = ?')
.run(currency.trim().toUpperCase(), id);
}
export function setWorkspaceLocale(control: ControlDb, id: string, locale: Locale): void {
control.sqlite.prepare('UPDATE workspace SET locale = ? WHERE id = ?').run(locale, id);
}
export function setWorkspaceStatus(control: ControlDb, id: string, status: WorkspaceStatus): void {
control.sqlite.prepare('UPDATE workspace SET status = ? WHERE id = ?').run(status, id);
}
export function deleteWorkspace(control: ControlDb, id: string): void {
control.sqlite.prepare('DELETE FROM workspace WHERE id = ?').run(id);
}
// ---- memberships ----------------------------------------------------------
/**
* The role `accountId` holds in `workspaceId`, or null when they hold none.
*
* This single lookup is the whole authorisation boundary between tenants: no
* membership row, no workspace database handle, no data. Every workspace-scoped
* route reaches its data through it.
*/
export function getMembershipRole(
control: ControlDb,
workspaceId: string,
accountId: string,
): Role | null {
const row = control.sqlite
.prepare<[string, string], { role: string }>(
'SELECT role FROM membership WHERE workspace_id = ? AND account_id = ?',
)
.get(workspaceId, accountId);
return (row?.role as Role) ?? null;
}
export function setMembership(
control: ControlDb,
workspaceId: string,
accountId: string,
role: Role,
): void {
control.sqlite
.prepare(
`INSERT INTO membership (workspace_id, account_id, role) VALUES (?, ?, ?)
ON CONFLICT (workspace_id, account_id) DO UPDATE SET role = excluded.role`,
)
.run(workspaceId, accountId, role);
}
export function removeMembership(control: ControlDb, workspaceId: string, accountId: string): void {
control.sqlite
.prepare('DELETE FROM membership WHERE workspace_id = ? AND account_id = ?')
.run(workspaceId, accountId);
}
/** Every workspace `accountId` can reach, with the role they hold in each. */
export function listWorkspacesForAccount(control: ControlDb, accountId: string): Workspace[] {
return control.sqlite
.prepare<
[string],
WorkspaceRow & { role: string; plan: string; billing_status: string; trial_ends_at: string | null }
>(
`SELECT w.id, w.slug, w.name, w.db_file, w.status, w.currency, w.locale, w.created_at,
w.created_by, w.plan, w.billing_status, w.trial_ends_at, m.role
FROM membership m JOIN workspace w ON w.id = m.workspace_id
WHERE m.account_id = ?
ORDER BY w.name COLLATE NOCASE`,
)
.all(accountId)
.map(toWorkspace);
}
/**
* A workspace as one account sees it: the record, their role, and enough of
* the plan for the shell to warn about a trial running out without a second
* request on every page load.
*/
function toWorkspace(
row: WorkspaceRow & { role: string; plan: string; billing_status: string; trial_ends_at: string | null },
): Workspace {
return {
id: row.id,
slug: row.slug,
name: row.name,
role: row.role as Role,
status: row.status as WorkspaceStatus,
currency: row.currency,
locale: toLocale(row.locale),
createdAt: row.created_at,
plan: planOf(row.plan).id,
billingStatus: row.billing_status as SubscriptionStatus,
trialEndsAt: row.trial_ends_at,
};
}
/** One workspace, as `accountId` sees it. Null when they hold no membership. */
export function workspaceForAccount(
control: ControlDb,
workspaceId: string,
accountId: string,
): Workspace | null {
const row = control.sqlite
.prepare<
[string, string],
WorkspaceRow & { role: string; plan: string; billing_status: string; trial_ends_at: string | null }
>(
`SELECT w.id, w.slug, w.name, w.db_file, w.status, w.currency, w.locale, w.created_at,
w.created_by, w.plan, w.billing_status, w.trial_ends_at, m.role
FROM membership m JOIN workspace w ON w.id = m.workspace_id
WHERE w.id = ? AND m.account_id = ?`,
)
.get(workspaceId, accountId);
return row ? toWorkspace(row) : null;
}
export function listMembers(control: ControlDb, workspaceId: string): Member[] {
return control.sqlite
.prepare<
[string],
{
id: string;
email: string;
name: string;
disabled: number;
created_at: string;
last_seen_at: string | null;
verified_at: string | null;
role: string;
joined_at: string;
}
>(
`SELECT a.id, a.email, a.name, a.disabled, a.created_at, a.last_seen_at, a.verified_at,
m.role, m.created_at AS joined_at
FROM membership m JOIN account a ON a.id = m.account_id
WHERE m.workspace_id = ?
ORDER BY m.created_at`,
)
.all(workspaceId)
.map((row) => ({ ...toAccount(row), role: row.role as Role, joinedAt: row.joined_at }));
}
/** Guards the "do not lock every admin out of a workspace" rule. */
export function countActiveAdmins(control: ControlDb, workspaceId: string): number {
const row = control.sqlite
.prepare<[string], { n: number }>(
`SELECT COUNT(*) AS n FROM membership m JOIN account a ON a.id = m.account_id
WHERE m.workspace_id = ? AND m.role = 'admin' AND a.disabled = 0`,
)
.get(workspaceId);
return row?.n ?? 0;
}
/** How many workspaces this account is the last remaining admin of. */
export function countWorkspacesOwnedAlone(control: ControlDb, accountId: string): number {
const row = control.sqlite
.prepare<[string, string], { n: number }>(
`SELECT COUNT(*) AS n FROM membership mine
WHERE mine.account_id = ? AND mine.role = 'admin'
AND (SELECT COUNT(*) FROM membership other JOIN account a ON a.id = other.account_id
WHERE other.workspace_id = mine.workspace_id AND other.role = 'admin'
AND other.account_id <> ? AND a.disabled = 0) = 0`,
)
.get(accountId, accountId);
return row?.n ?? 0;
}