screentinker/server/routes/workspaces.js

78 lines
3 KiB
JavaScript

const express = require('express');
const router = express.Router();
const { db } = require('../db/database');
const { canAdminWorkspace } = require('../lib/permissions');
// Workspace management routes. Operates on a target workspace specified by
// URL param, NOT the caller's currently active workspace - so this router
// does NOT use resolveTenancy. Permission is gated via canAdminWorkspace()
// which evaluates against the target workspace, not req.workspaceRole.
const NAME_MAX = 80;
const SLUG_MAX = 60;
const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
// Rename a workspace. MVP scope: name + slug only. Permission: platform_admin,
// org_owner/admin of the parent org, or workspace_admin of the target ws.
router.patch('/:id', (req, res) => {
const ws = db.prepare('SELECT * FROM workspaces WHERE id = ?').get(req.params.id);
if (!ws) return res.status(404).json({ error: 'Workspace not found' });
if (!canAdminWorkspace(db, req.user, ws)) {
return res.status(403).json({ error: 'Admin access required' });
}
// Stamp the target workspace_id so activityLogger captures the right
// tenant attribution. This route doesn't use resolveTenancy (operates on
// a URL-param target, not the caller's active workspace), so req.workspaceId
// would otherwise be undefined and the audit row would have NULL workspace.
req.workspaceId = ws.id;
const updates = [];
const values = [];
if (req.body.name !== undefined) {
const name = String(req.body.name).trim();
if (!name) return res.status(400).json({ error: 'Name cannot be empty' });
if (name.length > NAME_MAX) return res.status(400).json({ error: `Name must be ${NAME_MAX} characters or fewer` });
updates.push('name = ?');
values.push(name);
}
if (req.body.slug !== undefined) {
// Empty string -> NULL (workspace has no slug). Otherwise normalize +
// validate against the URL-safe segment pattern.
const raw = String(req.body.slug || '').trim().toLowerCase();
if (raw === '') {
updates.push('slug = NULL');
} else {
if (raw.length > SLUG_MAX) return res.status(400).json({ error: `Slug must be ${SLUG_MAX} characters or fewer` });
if (!SLUG_RE.test(raw)) {
return res.status(400).json({ error: 'Slug must be lowercase letters, digits, and hyphens (no leading/trailing/double hyphens)' });
}
updates.push('slug = ?');
values.push(raw);
}
}
if (updates.length === 0) {
return res.status(400).json({ error: 'No fields to update' });
}
updates.push("updated_at = strftime('%s','now')");
values.push(req.params.id);
try {
db.prepare(`UPDATE workspaces SET ${updates.join(', ')} WHERE id = ?`).run(...values);
} catch (e) {
if (e.code === 'SQLITE_CONSTRAINT_UNIQUE' || /UNIQUE/i.test(e.message)) {
return res.status(409).json({ error: 'Slug already used in this organization' });
}
throw e;
}
const updated = db.prepare('SELECT id, name, slug, organization_id FROM workspaces WHERE id = ?').get(req.params.id);
res.json(updated);
});
module.exports = router;