Spreadsheet-UX op een echte relationele engine

main

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

7.4 kB Ruw bestand
import { createReadStream } from 'node:fs';
import type { FastifyInstance } from 'fastify';
import { slugifyWorkspaceName, type Workspace } from '@wellformed/shared';
import { badRequest, conflict } from '../errors.js';
import { transactControl } from '../control/db.js';
import {
  countActiveAdmins,
  deleteWorkspace,
  getWorkspaceBySlug,
  listMembers,
  listWorkspacesForAccount,
  removeMembership,
  renameWorkspace,
  setWorkspaceCurrency,
  setWorkspaceLocale,
  setWorkspaceSlug,
  workspaceForAccount,
} from '../control/workspaces.js';
import { localeFromHeader } from '../locale.js';
import { startTrial } from '../control/billing.js';
import { availableSlug, provisionWorkspace } from '../tenancy/provision.js';
import { snapshotFilename, snapshotWorkspace } from '../tenancy/export.js';
import { recordAudit } from '../engine/audit.js';
import { createWorkspaceSchema, updateWorkspaceSchema } from './schemas.js';

/**
 * Questions about the account's workspaces rather than about their contents.
 *
 * Outside `/api/w/:slug` because both have to work for someone who has no
 * workspace yet, or is about to make their second.
 */
export async function workspaceRoutes(app: FastifyInstance): Promise<void> {
  app.get('/api/workspaces', async (request): Promise<{ workspaces: Workspace[] }> => {
    const account = request.requireAccount();
    return { workspaces: listWorkspacesForAccount(app.control, account.id) };
  });

  app.post('/api/workspaces', async (request, reply) => {
    const account = request.requireAccount();
    const body = createWorkspaceSchema.parse(request.body);

    if (body.slug && getWorkspaceBySlug(app.control, body.slug)) {
      throw conflict(`The workspace address "${body.slug}" is already taken.`);
    }
    const slug = body.slug ?? availableSlug(app.control, slugifyWorkspaceName(body.name));

    const created = provisionWorkspace(app.control, app.registry, {
      slug,
      name: body.name,
      ownerId: account.id,
      demo: body.demo,
      locale: body.locale ?? localeFromHeader(request.headers['accept-language']),
    });

    // A new workspace starts on a trial of the paid plan, so the first thing
    // someone does in it is never met by a limit.
    startTrial(app.control, created.id);

    reply.code(201);
    return { workspace: workspaceForAccount(app.control, created.id, account.id)! };
  });
}

/**
 * Acting on one workspace. Registered inside the tenant scope, so membership
 * has already been proved by the time any of these run.
 */
export async function workspaceAdminRoutes(app: FastifyInstance): Promise<void> {
  app.patch('/settings', async (request) => {
    const { workspace } = request.ws;
    const account = request.ws.require('admin');
    const body = updateWorkspaceSchema.parse(request.body);

    if (body.slug && body.slug !== workspace.slug) {
      if (getWorkspaceBySlug(app.control, body.slug)) {
        throw conflict(`The workspace address "${body.slug}" is already taken.`);
      }
      setWorkspaceSlug(app.control, workspace.id, body.slug);
    }
    if (body.name && body.name !== workspace.name) {
      renameWorkspace(app.control, workspace.id, body.name);
      recordAudit(request.ws.db, request.ws.actor(), {
        kind: 'schema',
        old: workspace.name,
        nw: `RENAME WORKSPACE → ${body.name}`,
      });
    }
    // Audited like a schema change, because that is what it is from the reader's
    // side: every amount in the workspace changes what it says it is worth.
    if (body.currency && body.currency !== workspace.currency) {
      setWorkspaceCurrency(app.control, workspace.id, body.currency);
      recordAudit(request.ws.db, request.ws.actor(), {
        kind: 'schema',
        old: workspace.currency,
        nw: `SET CURRENCY → ${body.currency}`,
      });
    }
    // Same reasoning: every date and number in the workspace reads differently
    // afterwards, for everyone.
    if (body.locale && body.locale !== workspace.locale) {
      setWorkspaceLocale(app.control, workspace.id, body.locale);
      recordAudit(request.ws.db, request.ws.actor(), {
        kind: 'schema',
        old: workspace.locale,
        nw: `SET LANGUAGE → ${body.locale}`,
      });
    }

    const updated = listWorkspacesForAccount(app.control, account.id).find(
      (w) => w.id === workspace.id,
    );
    return { workspace: updated };
  });

  /**
   * Hand the whole workspace back as one SQLite file.
   *
   * Database-per-tenant earns its keep here: export is a file copy, not a
   * serialiser that has to be kept in step with every column type Wellformed grows.
   */
  app.get('/export', async (request, reply) => {
    const { workspace, db } = request.ws;
    request.ws.require('admin');
    // The snapshot carries the roster, which is the one thing an agent key is
    // never given a way to read. It should not arrive by the back door.
    request.ws.denyAgent('export this workspace');

    const members = listMembers(app.control, workspace.id);
    const snapshot = snapshotWorkspace(db, members);

    reply
      .header('content-type', 'application/vnd.sqlite3')
      .header('content-disposition', `attachment; filename="${snapshotFilename(workspace.slug)}"`);

    const stream = createReadStream(snapshot.file);
    stream.once('close', snapshot.dispose);
    stream.once('error', snapshot.dispose);
    return reply.send(stream);
  });

  /**
   * Delete a workspace and its database.
   *
   * Guarded by echoing the slug back, because nothing here is recoverable and
   * the control sits among ones that are.
   */
  app.delete('/settings', async (request) => {
    const { workspace } = request.ws;
    const account = request.ws.require('admin');
    // Everything else an agent does lands in the trail and can be argued with
    // afterwards. This deletes the trail too.
    request.ws.denyAgent('delete this workspace');

    const confirmation = (request.query as { confirm?: string }).confirm;
    if (confirmation !== workspace.slug) {
      throw badRequest(`Confirm this by sending ?confirm=${workspace.slug}.`);
    }

    const otherAdmins = listMembers(app.control, workspace.id).filter(
      (m) => m.role === 'admin' && m.id !== account.id && !m.disabled,
    );
    if (otherAdmins.length) {
      throw conflict(
        `"${workspace.name}" has other admins (${otherAdmins.map((m) => m.email).join(', ')}). Removing them first makes this a decision someone took, not one they discovered.`,
      );
    }

    transactControl(app.control, () => deleteWorkspace(app.control, workspace.id));
    // Order matters: an orphaned file is recoverable, a membership pointing at
    // a database that is no longer there is not.
    app.registry.destroy(workspace);

    return { ok: true };
  });

  /** Leave a workspace someone else runs. The last admin cannot simply walk out. */
  app.post('/leave', async (request) => {
    const { workspace, role } = request.ws;
    const account = request.requireAccount();
    // Giving up the membership the key depends on is a person's decision.
    request.ws.denyAgent('leave this workspace');

    if (role === 'admin' && countActiveAdmins(app.control, workspace.id) <= 1) {
      throw conflict('You are the last admin here. Promote someone else, or delete the workspace.');
    }

    recordAudit(request.ws.db, request.ws.actor(), {
      kind: 'auth',
      nw: `${account.email} left the workspace`,
    });
    removeMembership(app.control, workspace.id, account.id);

    return { ok: true };
  });
}