Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

18.2 kB Ruw bestand
import type {
  AgentKey,
  AuditEntry,
  BillingSummary,
  CellValue,
  ColumnFormat,
  ColumnMeta,
  ColumnType,
  Filter,
  ImportColumnOverride,
  ImportGroupOverride,
  ImportPreview,
  ImportResult,
  InviteInfo,
  Locale,
  Member,
  MetaResponse,
  NormalizeMerge,
  NormalizePlan,
  PendingInvite,
  PlanId,
  PublicConfig,
  Role,
  Row,
  RowsResponse,
  Rule,
  SessionResponse,
  Sort,
  Summaries,
  SummaryResponse,
  TableMeta,
  Tightening,
  TightenPlan,
  TightenWalkPlan,
  ViewDef,
  VoiceAssignment,
  Workspace,
} from '@wellformed/shared';

export class ApiError extends Error {
  constructor(
    readonly status: number,
    readonly code: string,
    message: string,
    readonly details?: unknown,
  ) {
    super(message);
    this.name = 'ApiError';
  }

  get isAuth(): boolean {
    return this.status === 401;
  }
}

async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
  const method = (init.method ?? 'GET').toUpperCase();
  const headers = new Headers(init.headers);
  if (init.body !== undefined) headers.set('content-type', 'application/json');

  let response: Response;
  try {
    response = await fetch(path, {
      ...init,
      headers,
      // The session lives in an httpOnly cookie; it must ride along.
      credentials: 'same-origin',
    });
  } catch (cause) {
    throw new ApiError(0, 'network', 'Cannot reach the Wellformed server.', cause);
  }

  if (response.status === 204) return undefined as T;

  const text = await response.text();
  const body: unknown = text ? safeJson(text) : null;

  if (!response.ok) {
    const shaped = body as { error?: string; message?: string; details?: unknown } | null;
    throw new ApiError(
      response.status,
      shaped?.error ?? 'error',
      shaped?.message ?? `${method} ${path} failed with ${response.status}.`,
      shaped?.details,
    );
  }

  return body as T;
}

function safeJson(text: string): unknown {
  try {
    return JSON.parse(text);
  } catch {
    return { message: text };
  }
}

const json = (body: unknown): RequestInit => ({ body: JSON.stringify(body) });

/**
 * The workspace every data call is addressed to.
 *
 * One document shows one workspace — switching is a full navigation, see
 * `state/route.ts` — so this is a property of the page, not something worth
 * threading through every call site. `main.tsx` sets it once from the URL
 * before anything renders.
 */
let activeSlug: string | null = null;

export function setActiveWorkspace(slug: string | null): void {
  activeSlug = slug;
}

export function activeWorkspace(): string | null {
  return activeSlug;
}

/** Build a workspace-scoped API path. Throws rather than silently calling nowhere. */
function w(path: string): string {
  if (!activeSlug) {
    throw new ApiError(0, 'no_workspace', 'No workspace is open.');
  }
  return `/api/w/${encodeURIComponent(activeSlug)}${path}`;
}

export interface RowsQuery {
  table: string;
  viewId?: string | null;
  filters?: Filter[];
  /** `undefined` keeps the view's sort; `null` explicitly clears it. */
  sort?: Sort | null;
  /** `undefined` keeps the view's hidden columns; a list replaces them whole. */
  hidden?: string[];
  search?: string;
  limit?: number;
  offset?: number;
}

function rowsUrl(query: RowsQuery): string {
  const params = new URLSearchParams();
  if (query.viewId) params.set('view', query.viewId);
  if (query.filters?.length) params.set('filters', JSON.stringify(query.filters));
  if (query.sort !== undefined) params.set('sort', JSON.stringify(query.sort));
  if (query.hidden !== undefined) params.set('hidden', JSON.stringify(query.hidden));
  if (query.search) params.set('q', query.search);
  params.set('limit', String(query.limit ?? 200));
  params.set('offset', String(query.offset ?? 0));
  return w(`/tables/${encodeURIComponent(query.table)}/rows?${params}`);
}

/** The aggregate row over the same rows, so it takes the same scope minus the page. */
export interface SummaryQuery {
  table: string;
  viewId?: string | null;
  filters?: Filter[];
  /** The grid's hidden columns, so a search scopes to the same rows it shows. */
  hidden?: string[];
  search?: string;
  summaries: Summaries;
}

function summaryUrl(query: SummaryQuery): string {
  const params = new URLSearchParams();
  if (query.viewId) params.set('view', query.viewId);
  if (query.filters?.length) params.set('filters', JSON.stringify(query.filters));
  if (query.hidden !== undefined) params.set('hidden', JSON.stringify(query.hidden));
  if (query.search) params.set('q', query.search);
  params.set('summaries', JSON.stringify(query.summaries));
  return w(`/tables/${encodeURIComponent(query.table)}/summary?${params}`);
}

export interface WriteResult {
  row: Row;
  workflows: string[];
}

export interface DeleteResult {
  restore: Record<string, CellValue>;
  label: string;
}

/**
 * A signup makes an account and nothing else; the workspace comes after,
 * from the picker.
 */
export interface SignupInput {
  email: string;
  name: string;
  password: string;
  /** The landing page impression this signup came from, when there was one. */
  voice?: string;
}

/** What to build into a new workspace: nothing, the demo's tables, or the demo whole. */
export type DemoChoice = 'none' | 'structure' | 'full';

/**
 * What to read: pasted text, or an uploaded file's bytes as base64.
 *
 * Exactly one of the two, and the server refuses both together rather than
 * picking. `headerRow`, `groupRow`, `sheet` and `columns` are the guesses the
 * preview makes and the operator can overrule; sending none means "guess
 * again". `groupRow: null` turns the group fold off explicitly.
 */
export interface ImportRequest {
  csv?: string;
  file?: { name: string; data: string };
  headerRow?: number;
  groupRow?: number | null;
  /** Trailing totals rows to leave out; 0 keeps every row. */
  footerRows?: number;
  sheet?: string;
  columns?: ImportColumnOverride[];
  /** Corrections to the group headings; one renames a whole group's columns. */
  groups?: ImportGroupOverride[];
}

export const api = {
  health: () => request<{ ok: boolean }>('/api/health'),

  /** What this install offers, readable with no session at all. */
  publicConfig: () => request<PublicConfig>('/api/public/config'),

  /** Which lines of the landing page this visit gets. See `state/voice.ts`. */
  voice: () => request<VoiceAssignment>('/api/public/voice'),

  /** A person, not a crawler, looked at it. Fire and forget. */
  voiceSeen: (id: string) =>
    request<void>(`/api/public/voice/${encodeURIComponent(id)}/seen`, { method: 'POST' }),

  // ---- the account, installation-wide -------------------------------------

  me: () => request<SessionResponse>('/api/auth/me'),

  login: (email: string, password: string) =>
    request<SessionResponse>('/api/auth/login', { method: 'POST', ...json({ email, password }) }),

  signup: (input: SignupInput) =>
    request<SessionResponse>('/api/auth/signup', { method: 'POST', ...json(input) }),

  logout: () => request<{ ok: true }>('/api/auth/logout', { method: 'POST' }),

  renameAccount: (name: string) =>
    request<SessionResponse>('/api/auth/me', { method: 'PATCH', ...json({ name }) }),

  changePassword: (current: string, next: string) =>
    request<{ ok: true }>('/api/auth/password', { method: 'POST', ...json({ current, next }) }),

  // ---- locked out, and confirming the address -----------------------------

  forgotPassword: (email: string) =>
    request<{ ok: true; delivery: 'email' | 'none' }>('/api/auth/forgot', {
      method: 'POST',
      ...json({ email }),
    }),

  readReset: (token: string) =>
    request<{ email: string; name: string; expiresAt: string }>(
      `/api/auth/reset/${encodeURIComponent(token)}`,
    ),

  resetPassword: (token: string, password: string) =>
    request<SessionResponse>(`/api/auth/reset/${encodeURIComponent(token)}`, {
      method: 'POST',
      ...json({ password }),
    }),

  sendVerification: () =>
    request<{ ok: true; delivery: 'email' | 'none' | 'already' }>('/api/auth/verify', {
      method: 'POST',
    }),

  confirmEmail: (token: string) =>
    request<{ ok: true; email: string }>(`/api/auth/verify/${encodeURIComponent(token)}`, {
      method: 'POST',
    }),

  // ---- workspaces ---------------------------------------------------------

  workspaces: () => request<{ workspaces: Workspace[] }>('/api/workspaces'),

  createWorkspace: (input: { name: string; slug?: string; demo?: DemoChoice; locale?: Locale }) =>
    request<{ workspace: Workspace }>('/api/workspaces', { method: 'POST', ...json(input) }),

  updateWorkspace: (input: { name?: string; slug?: string; currency?: string; locale?: Locale }) =>
    request<{ workspace: Workspace }>(w('/settings'), { method: 'PATCH', ...json(input) }),

  deleteWorkspace: (slug: string) =>
    request<{ ok: true }>(w(`/settings?confirm=${encodeURIComponent(slug)}`), { method: 'DELETE' }),

  leaveWorkspace: () => request<{ ok: true }>(w('/leave'), { method: 'POST' }),

  exportPath: () => w('/export'),

  // ---- people in this workspace -------------------------------------------

  members: () => request<{ members: Member[] }>(w('/members')),

  updateMember: (id: string, input: { role: Role }) =>
    request<{ member: Member }>(w(`/members/${encodeURIComponent(id)}`), {
      method: 'PATCH',
      ...json(input),
    }),

  removeMember: (id: string) =>
    request<{ ok: true }>(w(`/members/${encodeURIComponent(id)}`), { method: 'DELETE' }),

  invites: () => request<{ invites: PendingInvite[] }>(w('/invites')),

  createInvite: (input: { email: string; role: Role }) =>
    request<{ invite: PendingInvite }>(w('/invites'), { method: 'POST', ...json(input) }),

  revokeInvite: (id: string) =>
    request<{ ok: true }>(w(`/invites/${encodeURIComponent(id)}`), { method: 'DELETE' }),

  readInvite: (token: string) =>
    request<{ invite: InviteInfo }>(`/api/invites/${encodeURIComponent(token)}`),

  acceptInvite: (token: string, input: { name?: string; password?: string } = {}) =>
    request<{ workspace: { slug: string; name: string; role: Role } }>(
      `/api/invites/${encodeURIComponent(token)}/accept`,
      { method: 'POST', ...json(input) },
    ),

  // ---- keys an AI assistant works through ---------------------------------

  agentKeys: () => request<{ keys: AgentKey[] }>(w('/keys')),

  /** The response carries `secret`, and it is the only time it ever will. */
  createAgentKey: (input: { label: string; ceiling: Role; expiresInDays?: number | null }) =>
    request<{ key: AgentKey }>(w('/keys'), { method: 'POST', ...json(input) }),

  revokeAgentKey: (id: string) =>
    request<{ ok: true }>(w(`/keys/${encodeURIComponent(id)}`), { method: 'DELETE' }),

  // ---- what this workspace pays for ---------------------------------------

  billing: () => request<BillingSummary>(w('/billing')),

  checkout: (plan: PlanId, billingEmail?: string) =>
    request<{ url: string }>(w('/billing/checkout'), {
      method: 'POST',
      ...json({ plan, billingEmail }),
    }),

  setBillingContact: (billingEmail: string) =>
    request<{ billingEmail: string | null }>(w('/billing/contact'), {
      method: 'PATCH',
      ...json({ billingEmail }),
    }),

  billingPortal: () => request<{ url: string }>(w('/billing/portal')),

  // ---- getting something into an empty workspace --------------------------

  previewImport: (input: ImportRequest & { table?: string }) =>
    request<ImportPreview>(w('/import/preview'), { method: 'POST', ...json(input) }),

  importSheet: (
    input: ImportRequest & {
      /** Append into this table, or omit to create one with these names. */
      table?: string;
      name?: string;
      label?: string;
    },
  ) => request<ImportResult>(w('/import'), { method: 'POST', ...json(input) }),

  // ---- inside the open workspace ------------------------------------------

  meta: () => request<MetaResponse>(w('/meta')),

  rows: (query: RowsQuery) => request<RowsResponse>(rowsUrl(query)),

  summary: (query: SummaryQuery) => request<SummaryResponse>(summaryUrl(query)),

  createRow: (table: string, values: Record<string, unknown>, id?: string) =>
    request<WriteResult>(w(`/tables/${encodeURIComponent(table)}/rows`), {
      method: 'POST',
      ...json(id ? { values, id } : { values }),
    }),

  updateRow: (table: string, id: string, values: Record<string, unknown>) =>
    request<WriteResult>(w(`/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`), {
      method: 'PATCH',
      ...json({ values }),
    }),

  deleteRow: (table: string, id: string) =>
    request<DeleteResult>(w(`/tables/${encodeURIComponent(table)}/rows/${encodeURIComponent(id)}`), {
      method: 'DELETE',
    }),

  /** Labels only — the server derives every identifier. */
  createTable: (input: { label: string; displayLabel?: string; group?: string }) =>
    request<{ table: TableMeta }>(w('/tables'), { method: 'POST', ...json(input) }),

  /** One flat order for all tables; the sidebar's groups are runs within it. */
  reorderTables: (order: string[]) =>
    request<{ ok: true }>(w('/tables/reorder'), { method: 'POST', ...json({ order }) }),

  /** The label, row-label column and sidebar group; the SQL name is not up for changing. */
  updateTable: (table: string, input: { label?: string; display?: string; group?: string | null }) =>
    request<{ table: TableMeta }>(w(`/tables/${encodeURIComponent(table)}`), {
      method: 'PATCH',
      ...json(input),
    }),

  dropTable: (table: string) =>
    request<{ ok: true }>(w(`/tables/${encodeURIComponent(table)}`), { method: 'DELETE' }),

  addColumn: (
    table: string,
    input: {
      /** What the column is called; the identifier is derived from it. */
      label: string;
      type: ColumnType;
      options?: string[];
      target?: string;
      expr?: string;
      format?: ColumnFormat | null;
      required?: boolean;
      unique?: boolean;
      sequence?: string | null;
    },
  ) =>
    request<{ column: ColumnMeta }>(w(`/tables/${encodeURIComponent(table)}/columns`), {
      method: 'POST',
      ...json(input),
    }),

  /**
   * What tightening a column would cost, before it costs it. Read-only, and
   * asked again on every keystroke that changes the proposal.
   */
  planTighten: (table: string, column: string, input: Tightening) =>
    request<{ plan: TightenPlan }>(
      w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/tighten/plan`),
      { method: 'POST', ...json(input) },
    ),

  tighten: (table: string, column: string, input: Tightening) =>
    request<{ column: ColumnMeta; plan: TightenPlan; cleared: number }>(
      w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/tighten`),
      { method: 'POST', ...json(input) },
    ),

  /**
   * The tighten walk: every stored column looked at once, each with what its
   * values say it really is. Read-only; the steps are applied through
   * `normalize` and `tighten`, one column at a time.
   */
  walkTighten: (table: string) =>
    request<{ plan: TightenWalkPlan }>(w(`/tables/${encodeURIComponent(table)}/tighten/walk`), {
      method: 'POST',
      ...json({}),
    }),

  /**
   * The whole table read for spellings that are the same answer written
   * differently. Read-only; the walk's merge step.
   */
  planNormalize: (table: string) =>
    request<{ plan: NormalizePlan }>(w(`/tables/${encodeURIComponent(table)}/normalize/plan`), {
      method: 'POST',
      ...json({}),
    }),

  normalize: (table: string, column: string, merges: NormalizeMerge[]) =>
    request<{ column: ColumnMeta; changed: number }>(
      w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}/normalize`),
      { method: 'POST', ...json({ merges }) },
    ),

  updateColumn: (
    table: string,
    column: string,
    input: {
      /** What the column is called from now on; the identifier stays. */
      label?: string;
      options?: string[];
      expr?: string;
      format?: ColumnFormat | null;
      required?: boolean;
      sequence?: string | null;
    },
  ) =>
    request<{ column: ColumnMeta }>(
      w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`),
      { method: 'PATCH', ...json(input) },
    ),

  dropColumn: (table: string, column: string) =>
    request<{ ok: true }>(
      w(`/tables/${encodeURIComponent(table)}/columns/${encodeURIComponent(column)}`),
      { method: 'DELETE' },
    ),

  reorderColumns: (table: string, order: string[]) =>
    request<{ table: unknown }>(w(`/tables/${encodeURIComponent(table)}/columns/reorder`), {
      method: 'POST',
      ...json({ order }),
    }),

  setDisplayColumn: (table: string, display: string) =>
    request<{ table: unknown }>(w(`/tables/${encodeURIComponent(table)}`), {
      method: 'PATCH',
      ...json({ display }),
    }),

  validateFormula: (table: string, expr: string) =>
    request<{ ok: boolean; error: string | null }>(w('/formula/validate'), {
      method: 'POST',
      ...json({ table, expr }),
    }),

  createView: (
    table: string,
    input: {
      name: string;
      filters: Filter[];
      sort: Sort | null;
      summaries?: Summaries;
      hidden?: string[];
    },
  ) =>
    request<{ view: ViewDef }>(w(`/tables/${encodeURIComponent(table)}/views`), {
      method: 'POST',
      ...json(input),
    }),

  updateView: (
    id: string,
    input: {
      name?: string;
      filters?: Filter[];
      sort?: Sort | null;
      summaries?: Summaries;
      hidden?: string[];
    },
  ) =>
    request<{ view: ViewDef }>(w(`/views/${encodeURIComponent(id)}`), {
      method: 'PATCH',
      ...json(input),
    }),

  deleteView: (id: string) =>
    request<{ ok: true }>(w(`/views/${encodeURIComponent(id)}`), { method: 'DELETE' }),

  setRuleEnabled: (id: string, enabled: boolean) =>
    request<{ rule: Rule }>(w(`/rules/${encodeURIComponent(id)}`), {
      method: 'PATCH',
      ...json({ enabled }),
    }),

  audit: (query: { table?: string; rowId?: string; before?: number; limit?: number }) => {
    const params = new URLSearchParams();
    if (query.table) params.set('table', query.table);
    if (query.rowId) params.set('rowId', query.rowId);
    if (query.before) params.set('before', String(query.before));
    params.set('limit', String(query.limit ?? 60));
    return request<{ entries: AuditEntry[]; hasMore: boolean }>(w(`/audit?${params}`));
  },
};

export type Api = typeof api;