Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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 };
});
}