@beignet/cli 0.0.26 → 0.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +26 -0
- package/README.md +59 -15
- package/dist/check.d.ts +61 -0
- package/dist/check.d.ts.map +1 -0
- package/dist/check.js +164 -0
- package/dist/check.js.map +1 -0
- package/dist/choices.d.ts +8 -0
- package/dist/choices.d.ts.map +1 -1
- package/dist/choices.js +14 -0
- package/dist/choices.js.map +1 -1
- package/dist/db.d.ts +8 -0
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +4 -4
- package/dist/db.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +156 -21
- package/dist/index.js.map +1 -1
- package/dist/inspect.d.ts.map +1 -1
- package/dist/inspect.js +121 -3
- package/dist/inspect.js.map +1 -1
- package/dist/make/inbox.d.ts +63 -0
- package/dist/make/inbox.d.ts.map +1 -0
- package/dist/make/inbox.js +1747 -0
- package/dist/make/inbox.js.map +1 -0
- package/dist/make/payments.d.ts +61 -0
- package/dist/make/payments.d.ts.map +1 -0
- package/dist/make/payments.js +1900 -0
- package/dist/make/payments.js.map +1 -0
- package/dist/make/shared.d.ts +592 -0
- package/dist/make/shared.d.ts.map +1 -0
- package/dist/make/shared.js +2233 -0
- package/dist/make/shared.js.map +1 -0
- package/dist/make/tenancy.d.ts +101 -0
- package/dist/make/tenancy.d.ts.map +1 -0
- package/dist/make/tenancy.js +4409 -0
- package/dist/make/tenancy.js.map +1 -0
- package/dist/make.d.ts +8 -44
- package/dist/make.d.ts.map +1 -1
- package/dist/make.js +57 -3689
- package/dist/make.js.map +1 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +45 -4
- package/dist/mcp.js.map +1 -1
- package/dist/provider-add.d.ts +19 -0
- package/dist/provider-add.d.ts.map +1 -0
- package/dist/provider-add.js +691 -0
- package/dist/provider-add.js.map +1 -0
- package/dist/provider-audit.d.ts.map +1 -1
- package/dist/provider-audit.js +77 -1
- package/dist/provider-audit.js.map +1 -1
- package/dist/registry-edits.d.ts +9 -0
- package/dist/registry-edits.d.ts.map +1 -1
- package/dist/registry-edits.js +32 -0
- package/dist/registry-edits.js.map +1 -1
- package/dist/task.d.ts +2 -0
- package/dist/task.d.ts.map +1 -1
- package/dist/task.js +2 -0
- package/dist/task.js.map +1 -1
- package/dist/templates/agents.d.ts.map +1 -1
- package/dist/templates/agents.js +29 -11
- package/dist/templates/agents.js.map +1 -1
- package/dist/templates/base.d.ts.map +1 -1
- package/dist/templates/base.js +42 -12
- package/dist/templates/base.js.map +1 -1
- package/dist/templates/db/sqlite.d.ts.map +1 -1
- package/dist/templates/db/sqlite.js +7 -1
- package/dist/templates/db/sqlite.js.map +1 -1
- package/dist/templates/server.d.ts.map +1 -1
- package/dist/templates/server.js +44 -37
- package/dist/templates/server.js.map +1 -1
- package/dist/templates/shared.d.ts +5 -0
- package/dist/templates/shared.d.ts.map +1 -1
- package/dist/templates/shared.js +5 -0
- package/dist/templates/shared.js.map +1 -1
- package/package.json +2 -2
- package/skills/app-structure/SKILL.md +1 -1
- package/src/check.ts +246 -0
- package/src/choices.ts +28 -0
- package/src/db.ts +4 -4
- package/src/index.ts +232 -21
- package/src/inspect.ts +168 -2
- package/src/make/inbox.ts +2015 -0
- package/src/make/payments.ts +2182 -0
- package/src/make/shared.ts +3638 -0
- package/src/make/tenancy.ts +4809 -0
- package/src/make.ts +388 -5355
- package/src/mcp.ts +66 -3
- package/src/provider-add.ts +926 -0
- package/src/provider-audit.ts +95 -3
- package/src/registry-edits.ts +47 -0
- package/src/task.ts +8 -10
- package/src/templates/agents.ts +29 -11
- package/src/templates/base.ts +43 -12
- package/src/templates/db/sqlite.ts +7 -1
- package/src/templates/server.ts +44 -37
- package/src/templates/shared.ts +5 -0
- package/src/test-helpers/generated-app.ts +168 -0
|
@@ -0,0 +1,4409 @@
|
|
|
1
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { loadBeignetConfig, resolveConfig, } from "../config.js";
|
|
4
|
+
import { appendToArrayExpression, arrayInitializerInfo, } from "../registry-edits.js";
|
|
5
|
+
import { serverFiles } from "../templates/server.js";
|
|
6
|
+
import { addNamedImport, addNamedTypeImport, aliasModule, appendCreateGatePolicy, appendDeferredPortKey, appendGatePolicyMember, appendSharedErrorEntries, appendTupleTypeMember, assertStandardApp, createRepositoriesReturnBodySource, detectResourceDatabase, detectResourcePersistence, detectUiShell, drizzleDialects, drizzleRepositoriesPath, drizzleSchemaIndexPath, fileExists, hasTopLevelObjectProperty, infrastructureDir, insertCreateRepositoriesProperty, insertTypeProperty, missingCapabilityBuilderFiles, notificationPortWirings, planGeneratedFiles, readOptionalFile, relativeModule, resourceNames, resourceSharedErrorsPath, seedEntrypointPath, typeBodySource, typeHasTopLevelProperty, uniqueStrings, updateDatabaseProviderPick, updateDatabaseResetTables, updateDrizzleSchemaIndexExports, updateOpenApiRouteContracts, updatePackageJson, updatePackageScripts, updateServerRoutes, updateTransactionPortsPick, wirePortProviders, writePlannedGeneratedFile, } from "./shared.js";
|
|
7
|
+
const workspaceDeferredPortKeys = ["workspaces", "members", "invites"];
|
|
8
|
+
const workspacePortEntries = [
|
|
9
|
+
{ key: "workspaces", type: "WorkspaceRepository" },
|
|
10
|
+
{ key: "members", type: "WorkspaceMemberRepository" },
|
|
11
|
+
{ key: "invites", type: "WorkspaceInviteRepository" },
|
|
12
|
+
];
|
|
13
|
+
const workspaceRepositoryFactories = [
|
|
14
|
+
{ key: "workspaces", factory: "createDrizzleWorkspaceRepository" },
|
|
15
|
+
{ key: "members", factory: "createDrizzleWorkspaceMemberRepository" },
|
|
16
|
+
{ key: "invites", factory: "createDrizzleWorkspaceInviteRepository" },
|
|
17
|
+
];
|
|
18
|
+
const workspaceContractNames = [
|
|
19
|
+
"listMyWorkspaces",
|
|
20
|
+
"createWorkspace",
|
|
21
|
+
"getCurrentWorkspace",
|
|
22
|
+
"updateWorkspace",
|
|
23
|
+
"switchWorkspace",
|
|
24
|
+
"listMembers",
|
|
25
|
+
"updateMemberRole",
|
|
26
|
+
"removeMember",
|
|
27
|
+
"listInvites",
|
|
28
|
+
"createInvite",
|
|
29
|
+
"revokeInvite",
|
|
30
|
+
"acceptInvite",
|
|
31
|
+
];
|
|
32
|
+
export async function makeTenancy(options) {
|
|
33
|
+
const targetDir = path.resolve(options.cwd ?? process.cwd());
|
|
34
|
+
const config = options.config
|
|
35
|
+
? resolveConfig(options.config)
|
|
36
|
+
: await loadBeignetConfig(targetDir);
|
|
37
|
+
const force = Boolean(options.force);
|
|
38
|
+
const dryRun = Boolean(options.dryRun);
|
|
39
|
+
await assertStandardApp(targetDir, config);
|
|
40
|
+
const persistence = await detectResourcePersistence(targetDir, config);
|
|
41
|
+
if (persistence !== "drizzle") {
|
|
42
|
+
throw new Error("beignet make tenancy expects a Drizzle-backed Beignet app because workspace and membership state must be durable. Add a database provider first, then run make tenancy again.");
|
|
43
|
+
}
|
|
44
|
+
const sharedErrorsFile = resourceSharedErrorsPath(config);
|
|
45
|
+
if (!(await fileExists(path.join(targetDir, sharedErrorsFile)))) {
|
|
46
|
+
throw new Error(`beignet make tenancy expects the shared error catalog ${sharedErrorsFile}. Restore the generated shared errors file, then run make tenancy again.`);
|
|
47
|
+
}
|
|
48
|
+
const database = await detectResourceDatabase(targetDir, config);
|
|
49
|
+
const hasShell = await detectUiShell(targetDir, config);
|
|
50
|
+
const workspaceNames = resourceNames("workspaces");
|
|
51
|
+
const generatedFiles = [
|
|
52
|
+
...(await missingCapabilityBuilderFiles(targetDir, "notifications", config)),
|
|
53
|
+
...tenancyFiles(config, database),
|
|
54
|
+
...(hasShell ? tenancyUiFiles(config) : []),
|
|
55
|
+
];
|
|
56
|
+
// Plan every write before performing any: replaced starter files first
|
|
57
|
+
// (the D5 replace-or-abort mechanic), then generated files, then all
|
|
58
|
+
// string-anchor edits with dryRun: true. An anchor miss or divergent file
|
|
59
|
+
// aborts before the generator touches the app.
|
|
60
|
+
const replacementPlans = [
|
|
61
|
+
await planReplacedStarterFile(targetDir, { path: tenantHelperPath(config), content: tenancyTenantHelperFile() }, serverFiles.tenantHelper, { force, manual: tenantHelperManualInstructions(config) }),
|
|
62
|
+
await planReplacedStarterFile(targetDir, { path: serverContextPath(config), content: tenancyServerContextFile() }, serverFiles.serverContext, { force, manual: serverContextManualInstructions(config) }),
|
|
63
|
+
];
|
|
64
|
+
const plannedFiles = await planGeneratedFiles(targetDir, generatedFiles, {
|
|
65
|
+
force,
|
|
66
|
+
});
|
|
67
|
+
const plannedUpdates = await updateTenancyWiring(targetDir, workspaceNames, config, { dryRun: true, hasShell });
|
|
68
|
+
const plannedProviderUpdates = (await wirePortProviders(targetDir, config, notificationPortWirings("make tenancy"), { dryRun: true })).updatedFiles;
|
|
69
|
+
const plannedSeedChanges = await registerTenancySeeds(targetDir, config, {
|
|
70
|
+
dryRun: true,
|
|
71
|
+
});
|
|
72
|
+
const allPlannedFiles = [...replacementPlans, ...plannedFiles];
|
|
73
|
+
const createdFiles = allPlannedFiles
|
|
74
|
+
.filter((file) => file.result === "created")
|
|
75
|
+
.map((file) => file.path);
|
|
76
|
+
const updatedFiles = allPlannedFiles
|
|
77
|
+
.filter((file) => file.result === "updated")
|
|
78
|
+
.map((file) => file.path);
|
|
79
|
+
const skippedFiles = allPlannedFiles
|
|
80
|
+
.filter((file) => file.result === "skipped")
|
|
81
|
+
.map((file) => file.path);
|
|
82
|
+
if (dryRun) {
|
|
83
|
+
updatedFiles.push(...plannedUpdates, ...plannedProviderUpdates, ...plannedSeedChanges.updatedFiles);
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
for (const file of allPlannedFiles) {
|
|
87
|
+
await writePlannedGeneratedFile(targetDir, file);
|
|
88
|
+
}
|
|
89
|
+
updatedFiles.push(...(await updateTenancyWiring(targetDir, workspaceNames, config, {
|
|
90
|
+
dryRun: false,
|
|
91
|
+
hasShell,
|
|
92
|
+
})));
|
|
93
|
+
updatedFiles.push(...(await wirePortProviders(targetDir, config, notificationPortWirings("make tenancy"), { dryRun: false })).updatedFiles);
|
|
94
|
+
const seedChanges = await registerTenancySeeds(targetDir, config, {
|
|
95
|
+
dryRun: false,
|
|
96
|
+
});
|
|
97
|
+
updatedFiles.push(...seedChanges.updatedFiles);
|
|
98
|
+
}
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
name: "tenancy",
|
|
102
|
+
targetDir,
|
|
103
|
+
dryRun,
|
|
104
|
+
files: uniqueStrings([
|
|
105
|
+
...generatedFiles.map((file) => file.path),
|
|
106
|
+
tenantHelperPath(config),
|
|
107
|
+
serverContextPath(config),
|
|
108
|
+
]),
|
|
109
|
+
createdFiles: uniqueStrings(createdFiles),
|
|
110
|
+
updatedFiles: uniqueStrings(updatedFiles),
|
|
111
|
+
skippedFiles: uniqueStrings(skippedFiles),
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Post-generate guidance lines for `beignet make tenancy`. The CLI command
|
|
116
|
+
* wraps these in its standard next-steps formatting.
|
|
117
|
+
*/
|
|
118
|
+
export function makeTenancyNextStepLines() {
|
|
119
|
+
return [
|
|
120
|
+
"Run beignet db generate and beignet db migrate so the workspaces, workspace_members, and workspace_invites tables exist.",
|
|
121
|
+
"Requests resolve the active workspace from memberships and the beignet-workspace cookie; users without a workspace have no tenant, so point them at Settings > Workspace to create one.",
|
|
122
|
+
"Run the db:seed script for a demo workspace with a sign-in-able demo admin (demo-admin@example.com).",
|
|
123
|
+
"Invite emails send through the in-memory mailer in development; swap server/providers.ts to a real mail provider such as @beignet/provider-mail-resend for delivery.",
|
|
124
|
+
"Membership is resolved with one indexed query per authenticated request; add caching around resolveRequestWorkspace if request volume warrants it.",
|
|
125
|
+
"Run your app's test, lint, and typecheck commands, then beignet lint and beignet doctor.",
|
|
126
|
+
];
|
|
127
|
+
}
|
|
128
|
+
export function tenantHelperPath(config) {
|
|
129
|
+
return path.posix.join(path.posix.dirname(config.paths.useCaseBuilder), "tenant.ts");
|
|
130
|
+
}
|
|
131
|
+
export function serverContextPath(config) {
|
|
132
|
+
return path.posix.join(path.posix.dirname(config.paths.server), "context.ts");
|
|
133
|
+
}
|
|
134
|
+
function tenantHelperManualInstructions(config) {
|
|
135
|
+
return `${tenantHelperPath(config)} has been customized, so make tenancy cannot replace it safely. Re-run with --force to overwrite it, or update it manually: export WORKSPACE_COOKIE_NAME = "beignet-workspace", replace resolveRequestTenant with an async resolveRequestWorkspace({ auth, req, ports }) that loads ports.members.listByUser(auth.user.id), picks the membership matching the cookie (falling back to the first), and returns { tenant: createTenant(workspaceId), membership }, and keep resolveServiceWorkspace(tenantId) for service contexts.`;
|
|
136
|
+
}
|
|
137
|
+
function serverContextManualInstructions(config) {
|
|
138
|
+
return `${serverContextPath(config)} has been customized, so make tenancy cannot replace it safely. Re-run with --force to overwrite it, or update it manually: in the request context call const { tenant, membership } = await resolveRequestWorkspace({ auth, req, ports }) from ${tenantHelperPath(config)}, spread the tenant when present, and set membership on the returned context (and membership: null in the service context).`;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Replace-or-abort planning for starter-owned files (D5).
|
|
142
|
+
*
|
|
143
|
+
* The current content is byte-compared against the pristine starter template
|
|
144
|
+
* and against this generator's own output. A match with the generator output
|
|
145
|
+
* plans a skip (idempotent re-run); a match with the pristine starter (or a
|
|
146
|
+
* missing file) plans the replacement; anything else aborts with manual
|
|
147
|
+
* instructions before any file is written. `--force` overrides.
|
|
148
|
+
*/
|
|
149
|
+
export async function planReplacedStarterFile(targetDir, file, pristineContent, options) {
|
|
150
|
+
const existing = await readOptionalFile(path.join(targetDir, file.path));
|
|
151
|
+
if (existing === file.content) {
|
|
152
|
+
return { ...file, result: "skipped" };
|
|
153
|
+
}
|
|
154
|
+
if (existing === undefined) {
|
|
155
|
+
return { ...file, result: "created" };
|
|
156
|
+
}
|
|
157
|
+
if (existing === pristineContent || options.force) {
|
|
158
|
+
return { ...file, result: "updated" };
|
|
159
|
+
}
|
|
160
|
+
throw new Error(options.manual);
|
|
161
|
+
}
|
|
162
|
+
export async function updateTenancyWiring(targetDir, workspaceNames, config, options) {
|
|
163
|
+
const updated = new Set();
|
|
164
|
+
const editOptions = { dryRun: options.dryRun };
|
|
165
|
+
if (await updatePackageJson(targetDir, editOptions)) {
|
|
166
|
+
updated.add("package.json");
|
|
167
|
+
}
|
|
168
|
+
if (await updateAppContextMembership(targetDir, config, editOptions)) {
|
|
169
|
+
updated.add(config.paths.appContext);
|
|
170
|
+
}
|
|
171
|
+
if (await updateWorkspacePortsIndex(targetDir, config, editOptions)) {
|
|
172
|
+
updated.add(config.paths.ports);
|
|
173
|
+
}
|
|
174
|
+
if (await updateWorkspaceInfrastructurePorts(targetDir, config, editOptions)) {
|
|
175
|
+
updated.add(config.paths.infrastructurePorts);
|
|
176
|
+
}
|
|
177
|
+
if (await updateDrizzleSchemaIndexExports(targetDir, config, {
|
|
178
|
+
exports: ["workspaceInvites", "workspaceMembers", "workspaces"],
|
|
179
|
+
module: "./workspaces",
|
|
180
|
+
}, editOptions)) {
|
|
181
|
+
updated.add(drizzleSchemaIndexPath(config));
|
|
182
|
+
}
|
|
183
|
+
if (await updateWorkspaceDrizzleRepositories(targetDir, config, editOptions)) {
|
|
184
|
+
updated.add(drizzleRepositoriesPath(config));
|
|
185
|
+
}
|
|
186
|
+
if (await updateDatabaseProviderPick(targetDir, config, workspaceDeferredPortKeys, editOptions)) {
|
|
187
|
+
updated.add(path.join(infrastructureDir(config), "db/provider.ts"));
|
|
188
|
+
}
|
|
189
|
+
if (await updateTransactionPortsPick(targetDir, config, workspaceDeferredPortKeys, "make tenancy", editOptions)) {
|
|
190
|
+
updated.add(path.join(infrastructureDir(config), "db/transaction-ports.ts"));
|
|
191
|
+
}
|
|
192
|
+
if (await updateDatabaseResetTables(targetDir, config, ["workspace_invites", "workspace_members", "workspaces"], editOptions)) {
|
|
193
|
+
updated.add(path.join(infrastructureDir(config), "db/reset.ts"));
|
|
194
|
+
}
|
|
195
|
+
if (await updateWorkspaceSharedErrors(targetDir, config, editOptions)) {
|
|
196
|
+
updated.add(resourceSharedErrorsPath(config));
|
|
197
|
+
}
|
|
198
|
+
if (await updateOpenApiRouteContracts(targetDir, config, {
|
|
199
|
+
contractNames: workspaceContractNames,
|
|
200
|
+
contractsModule: aliasModule(path.join(config.paths.features, "workspaces/contracts.ts")),
|
|
201
|
+
}, editOptions)) {
|
|
202
|
+
updated.add(config.paths.openapiRoute);
|
|
203
|
+
}
|
|
204
|
+
const routesFile = await updateServerRoutes(targetDir, workspaceNames, config, editOptions);
|
|
205
|
+
if (routesFile)
|
|
206
|
+
updated.add(routesFile);
|
|
207
|
+
if (options.hasShell) {
|
|
208
|
+
if (await updateSettingsNavigation(targetDir, editOptions)) {
|
|
209
|
+
updated.add("components/settings-nav.tsx");
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return [...updated];
|
|
213
|
+
}
|
|
214
|
+
async function registerTenancySeeds(targetDir, config, options) {
|
|
215
|
+
const changes = { updatedFiles: [] };
|
|
216
|
+
if (await updatePackageScripts(targetDir, { "db:seed": `bun ${seedEntrypointPath(config)}` }, options)) {
|
|
217
|
+
changes.updatedFiles.push("package.json");
|
|
218
|
+
}
|
|
219
|
+
return changes;
|
|
220
|
+
}
|
|
221
|
+
export async function updateAppContextMembership(targetDir, config, options) {
|
|
222
|
+
const filePath = path.join(targetDir, config.paths.appContext);
|
|
223
|
+
const original = await readFile(filePath, "utf8");
|
|
224
|
+
let next = addNamedTypeImport(original, "WorkspaceMembershipContext", aliasModule(path.join(config.paths.features, "workspaces/policy.ts")));
|
|
225
|
+
if (!typeHasTopLevelProperty(next, "AppContext", "membership")) {
|
|
226
|
+
next = insertTypeProperty(next, "AppContext", "membership?: WorkspaceMembershipContext | null;", `Could not find AppContext in ${config.paths.appContext}. Add membership?: WorkspaceMembershipContext | null; manually, or restore the generated app context before running make tenancy.`);
|
|
227
|
+
}
|
|
228
|
+
if (next === original)
|
|
229
|
+
return false;
|
|
230
|
+
if (!options.dryRun)
|
|
231
|
+
await writeFile(filePath, next);
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
export async function updateWorkspacePortsIndex(targetDir, config, options) {
|
|
235
|
+
const filePath = path.join(targetDir, config.paths.ports);
|
|
236
|
+
const original = await readFile(filePath, "utf8");
|
|
237
|
+
let next = original;
|
|
238
|
+
const portsModule = relativeModule(config.paths.ports, path.join(config.paths.features, "workspaces/ports.ts"));
|
|
239
|
+
const policyModule = relativeModule(config.paths.ports, path.join(config.paths.features, "workspaces/policy.ts"));
|
|
240
|
+
for (const entry of workspacePortEntries) {
|
|
241
|
+
next = addNamedTypeImport(next, entry.type, portsModule);
|
|
242
|
+
}
|
|
243
|
+
next = addNamedTypeImport(next, "workspacePolicy", policyModule);
|
|
244
|
+
for (const entry of workspacePortEntries) {
|
|
245
|
+
const property = `${entry.key}: ${entry.type};`;
|
|
246
|
+
if (!typeHasTopLevelProperty(next, "AppPorts", entry.key)) {
|
|
247
|
+
next = insertTypeProperty(next, "AppPorts", property, `Could not find AppPorts in ${config.paths.ports}. Add ${property} manually, or restore the generated ports file before running make tenancy.`);
|
|
248
|
+
}
|
|
249
|
+
if (typeBodySource(next, "AppTransactionPorts") !== undefined &&
|
|
250
|
+
!typeHasTopLevelProperty(next, "AppTransactionPorts", entry.key)) {
|
|
251
|
+
next = insertTypeProperty(next, "AppTransactionPorts", property, `Could not find AppTransactionPorts in ${config.paths.ports}. Add ${property} manually, or restore the generated ports file before running make tenancy.`);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
next = appendTupleTypeMember(next, "AppGate", "typeof workspacePolicy");
|
|
255
|
+
next = appendGatePolicyMember(next, "typeof workspacePolicy");
|
|
256
|
+
if (next === original)
|
|
257
|
+
return false;
|
|
258
|
+
if (!options.dryRun)
|
|
259
|
+
await writeFile(filePath, next);
|
|
260
|
+
return true;
|
|
261
|
+
}
|
|
262
|
+
export async function updateWorkspaceInfrastructurePorts(targetDir, config, options) {
|
|
263
|
+
const filePath = path.join(targetDir, config.paths.infrastructurePorts);
|
|
264
|
+
const original = await readFile(filePath, "utf8");
|
|
265
|
+
let next = original;
|
|
266
|
+
for (const portKey of workspaceDeferredPortKeys) {
|
|
267
|
+
const appended = appendDeferredPortKey(next, portKey);
|
|
268
|
+
if (appended === next && !new RegExp(`["']${portKey}["']`).test(next)) {
|
|
269
|
+
throw new Error(`Could not find the deferred port list in ${config.paths.infrastructurePorts}. Add "${portKey}" manually, or restore the generated infrastructure ports file before running make tenancy.`);
|
|
270
|
+
}
|
|
271
|
+
next = appended;
|
|
272
|
+
}
|
|
273
|
+
const policyModule = relativeModule(config.paths.infrastructurePorts, path.join(config.paths.features, "workspaces/policy.ts"));
|
|
274
|
+
next = addNamedImport(next, "workspacePolicy", policyModule);
|
|
275
|
+
next = appendCreateGatePolicy(next, "workspacePolicy", `Could not find createGate({ policies: [...] }) in ${config.paths.infrastructurePorts}. Add workspacePolicy to the gate policies manually, or restore the generated infrastructure ports file before running make tenancy.`);
|
|
276
|
+
if (next === original)
|
|
277
|
+
return false;
|
|
278
|
+
if (!options.dryRun)
|
|
279
|
+
await writeFile(filePath, next);
|
|
280
|
+
return true;
|
|
281
|
+
}
|
|
282
|
+
export async function updateWorkspaceDrizzleRepositories(targetDir, config, options) {
|
|
283
|
+
const file = drizzleRepositoriesPath(config);
|
|
284
|
+
const filePath = path.join(targetDir, file);
|
|
285
|
+
if (!(await fileExists(filePath)))
|
|
286
|
+
return false;
|
|
287
|
+
const original = await readFile(filePath, "utf8");
|
|
288
|
+
let next = original;
|
|
289
|
+
const repositoryModule = relativeModule(file, path.join(infrastructureDir(config), "workspaces/drizzle-workspace-repository.ts"));
|
|
290
|
+
for (const entry of workspaceRepositoryFactories) {
|
|
291
|
+
next = addNamedImport(next, entry.factory, repositoryModule);
|
|
292
|
+
}
|
|
293
|
+
for (const entry of workspaceRepositoryFactories) {
|
|
294
|
+
const body = createRepositoriesReturnBodySource(next);
|
|
295
|
+
const property = `${entry.key}: ${entry.factory}(db),`;
|
|
296
|
+
if (body === undefined) {
|
|
297
|
+
throw new Error(`Could not find return { in createRepositories in ${file}. Add ${property} manually, or restore the generated repositories file before running make tenancy.`);
|
|
298
|
+
}
|
|
299
|
+
if (!hasTopLevelObjectProperty(body, entry.key)) {
|
|
300
|
+
next = insertCreateRepositoriesProperty(next, property, `Could not find return { in createRepositories in ${file}. Add ${property} manually, or restore the generated repositories file before running make tenancy.`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (next === original)
|
|
304
|
+
return false;
|
|
305
|
+
if (!options.dryRun)
|
|
306
|
+
await writeFile(filePath, next);
|
|
307
|
+
return true;
|
|
308
|
+
}
|
|
309
|
+
export async function updateWorkspaceSharedErrors(targetDir, config, options) {
|
|
310
|
+
return appendSharedErrorEntries(targetDir, config, [
|
|
311
|
+
{
|
|
312
|
+
name: "WorkspaceNotFound",
|
|
313
|
+
entry: `WorkspaceNotFound: {
|
|
314
|
+
\t\tcode: "WORKSPACE_NOT_FOUND",
|
|
315
|
+
\t\tstatus: 404,
|
|
316
|
+
\t\tmessage: "Workspace not found",
|
|
317
|
+
\t},`,
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: "WorkspaceSlugTaken",
|
|
321
|
+
entry: `WorkspaceSlugTaken: {
|
|
322
|
+
\t\tcode: "WORKSPACE_SLUG_TAKEN",
|
|
323
|
+
\t\tstatus: 409,
|
|
324
|
+
\t\tmessage: "A workspace with this slug already exists",
|
|
325
|
+
\t},`,
|
|
326
|
+
},
|
|
327
|
+
{
|
|
328
|
+
name: "WorkspaceRequired",
|
|
329
|
+
entry: `WorkspaceRequired: {
|
|
330
|
+
\t\tcode: "WORKSPACE_REQUIRED",
|
|
331
|
+
\t\tstatus: 400,
|
|
332
|
+
\t\tmessage: "An active workspace is required",
|
|
333
|
+
\t},`,
|
|
334
|
+
},
|
|
335
|
+
{
|
|
336
|
+
name: "NotAWorkspaceMember",
|
|
337
|
+
entry: `NotAWorkspaceMember: {
|
|
338
|
+
\t\tcode: "NOT_A_WORKSPACE_MEMBER",
|
|
339
|
+
\t\tstatus: 403,
|
|
340
|
+
\t\tmessage: "You are not a member of this workspace",
|
|
341
|
+
\t},`,
|
|
342
|
+
},
|
|
343
|
+
{
|
|
344
|
+
name: "LastAdminRemoval",
|
|
345
|
+
entry: `LastAdminRemoval: {
|
|
346
|
+
\t\tcode: "LAST_ADMIN_REMOVAL",
|
|
347
|
+
\t\tstatus: 409,
|
|
348
|
+
\t\tmessage: "A workspace must keep at least one admin",
|
|
349
|
+
\t},`,
|
|
350
|
+
},
|
|
351
|
+
{
|
|
352
|
+
name: "InviteNotFound",
|
|
353
|
+
entry: `InviteNotFound: {
|
|
354
|
+
\t\tcode: "INVITE_NOT_FOUND",
|
|
355
|
+
\t\tstatus: 404,
|
|
356
|
+
\t\tmessage: "Invite not found",
|
|
357
|
+
\t},`,
|
|
358
|
+
},
|
|
359
|
+
{
|
|
360
|
+
name: "InviteExpired",
|
|
361
|
+
entry: `InviteExpired: {
|
|
362
|
+
\t\tcode: "INVITE_EXPIRED",
|
|
363
|
+
\t\tstatus: 410,
|
|
364
|
+
\t\tmessage: "Invite has expired",
|
|
365
|
+
\t},`,
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
name: "InviteAlreadyAccepted",
|
|
369
|
+
entry: `InviteAlreadyAccepted: {
|
|
370
|
+
\t\tcode: "INVITE_ALREADY_ACCEPTED",
|
|
371
|
+
\t\tstatus: 409,
|
|
372
|
+
\t\tmessage: "Invite has already been accepted",
|
|
373
|
+
\t},`,
|
|
374
|
+
},
|
|
375
|
+
{
|
|
376
|
+
name: "IdempotencyConflict",
|
|
377
|
+
entry: `IdempotencyConflict: {
|
|
378
|
+
\t\tcode: "IDEMPOTENCY_CONFLICT",
|
|
379
|
+
\t\tstatus: 409,
|
|
380
|
+
\t\tmessage: "Idempotency key was already used with a different request",
|
|
381
|
+
\t},`,
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
name: "IdempotencyInProgress",
|
|
385
|
+
entry: `IdempotencyInProgress: {
|
|
386
|
+
\t\tcode: "IDEMPOTENCY_IN_PROGRESS",
|
|
387
|
+
\t\tstatus: 409,
|
|
388
|
+
\t\tmessage: "Idempotency key is already being processed",
|
|
389
|
+
\t},`,
|
|
390
|
+
},
|
|
391
|
+
], "make tenancy", options);
|
|
392
|
+
}
|
|
393
|
+
export async function updateSettingsNavigation(targetDir, options) {
|
|
394
|
+
const file = "components/settings-nav.tsx";
|
|
395
|
+
const filePath = path.join(targetDir, file);
|
|
396
|
+
const original = await readOptionalFile(filePath);
|
|
397
|
+
const manual = `Add { href: "/settings/workspace", label: "Workspace" } and { href: "/settings/members", label: "Members" } to the settingsLinks array in ${file} manually, or restore the generated settings navigation before running make tenancy.`;
|
|
398
|
+
if (original === undefined) {
|
|
399
|
+
throw new Error(`Could not find ${file}. ${manual}`);
|
|
400
|
+
}
|
|
401
|
+
const entries = [
|
|
402
|
+
{ href: "/settings/workspace", label: "Workspace" },
|
|
403
|
+
{ href: "/settings/members", label: "Members" },
|
|
404
|
+
].filter((entry) => !original.includes(`"${entry.href}"`));
|
|
405
|
+
if (entries.length === 0)
|
|
406
|
+
return false;
|
|
407
|
+
const links = arrayInitializerInfo(original, "settingsLinks");
|
|
408
|
+
if (!links) {
|
|
409
|
+
throw new Error(`Could not find the settingsLinks array in ${file}. ${manual}`);
|
|
410
|
+
}
|
|
411
|
+
const nextArray = appendToArrayExpression(links.text, entries.map((entry) => `{ href: "${entry.href}", label: "${entry.label}" }`));
|
|
412
|
+
const next = `${original.slice(0, links.start)}${nextArray}${original.slice(links.end)}`;
|
|
413
|
+
if (next === original)
|
|
414
|
+
return false;
|
|
415
|
+
if (!options.dryRun)
|
|
416
|
+
await writeFile(filePath, next);
|
|
417
|
+
return true;
|
|
418
|
+
}
|
|
419
|
+
export function tenancyFiles(config, database) {
|
|
420
|
+
const featureDir = (file) => path.join(config.paths.features, "workspaces", file);
|
|
421
|
+
return [
|
|
422
|
+
{ path: featureDir("schemas.ts"), content: tenancySchemasFile() },
|
|
423
|
+
{ path: featureDir("contracts.ts"), content: tenancyContractsFile() },
|
|
424
|
+
{ path: featureDir("policy.ts"), content: tenancyPolicyFile() },
|
|
425
|
+
{ path: featureDir("ports.ts"), content: tenancyPortsFile() },
|
|
426
|
+
{
|
|
427
|
+
path: featureDir("use-cases/current-workspace.ts"),
|
|
428
|
+
content: currentWorkspaceHelperFile(config),
|
|
429
|
+
},
|
|
430
|
+
{
|
|
431
|
+
path: featureDir("use-cases/create-workspace.ts"),
|
|
432
|
+
content: createWorkspaceUseCaseFile(),
|
|
433
|
+
},
|
|
434
|
+
{
|
|
435
|
+
path: featureDir("use-cases/get-current-workspace.ts"),
|
|
436
|
+
content: getCurrentWorkspaceUseCaseFile(),
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
path: featureDir("use-cases/list-my-workspaces.ts"),
|
|
440
|
+
content: listMyWorkspacesUseCaseFile(),
|
|
441
|
+
},
|
|
442
|
+
{
|
|
443
|
+
path: featureDir("use-cases/switch-workspace.ts"),
|
|
444
|
+
content: switchWorkspaceUseCaseFile(),
|
|
445
|
+
},
|
|
446
|
+
{
|
|
447
|
+
path: featureDir("use-cases/update-workspace.ts"),
|
|
448
|
+
content: updateWorkspaceUseCaseFile(),
|
|
449
|
+
},
|
|
450
|
+
{
|
|
451
|
+
path: featureDir("use-cases/list-members.ts"),
|
|
452
|
+
content: listMembersUseCaseFile(),
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
path: featureDir("use-cases/update-member-role.ts"),
|
|
456
|
+
content: updateMemberRoleUseCaseFile(),
|
|
457
|
+
},
|
|
458
|
+
{
|
|
459
|
+
path: featureDir("use-cases/remove-member.ts"),
|
|
460
|
+
content: removeMemberUseCaseFile(),
|
|
461
|
+
},
|
|
462
|
+
{
|
|
463
|
+
path: featureDir("use-cases/list-invites.ts"),
|
|
464
|
+
content: listInvitesUseCaseFile(),
|
|
465
|
+
},
|
|
466
|
+
{
|
|
467
|
+
path: featureDir("use-cases/create-invite.ts"),
|
|
468
|
+
content: createInviteUseCaseFile(),
|
|
469
|
+
},
|
|
470
|
+
{
|
|
471
|
+
path: featureDir("use-cases/revoke-invite.ts"),
|
|
472
|
+
content: revokeInviteUseCaseFile(),
|
|
473
|
+
},
|
|
474
|
+
{
|
|
475
|
+
path: featureDir("use-cases/accept-invite.ts"),
|
|
476
|
+
content: acceptInviteUseCaseFile(),
|
|
477
|
+
},
|
|
478
|
+
{
|
|
479
|
+
path: featureDir("use-cases/index.ts"),
|
|
480
|
+
content: tenancyUseCasesIndexFile(),
|
|
481
|
+
},
|
|
482
|
+
{
|
|
483
|
+
path: featureDir("notifications/index.ts"),
|
|
484
|
+
content: tenancyNotificationsFile(),
|
|
485
|
+
},
|
|
486
|
+
{ path: featureDir("routes.ts"), content: tenancyRoutesFile(config) },
|
|
487
|
+
{ path: featureDir("seeds/index.ts"), content: tenancySeedsFile(config) },
|
|
488
|
+
{
|
|
489
|
+
path: seedEntrypointPath(config),
|
|
490
|
+
content: tenancyServerSeedFile(config),
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
path: featureDir("tests/factories/context.ts"),
|
|
494
|
+
content: tenancyContextFactoryFile(config),
|
|
495
|
+
},
|
|
496
|
+
{
|
|
497
|
+
path: featureDir("tests/factories/workspace.ts"),
|
|
498
|
+
content: workspaceFactoryFile(config),
|
|
499
|
+
},
|
|
500
|
+
{
|
|
501
|
+
path: featureDir("tests/factories/workspace-member.ts"),
|
|
502
|
+
content: workspaceMemberFactoryFile(config),
|
|
503
|
+
},
|
|
504
|
+
{
|
|
505
|
+
path: featureDir("tests/factories/workspace-invite.ts"),
|
|
506
|
+
content: workspaceInviteFactoryFile(config),
|
|
507
|
+
},
|
|
508
|
+
{
|
|
509
|
+
path: featureDir("tests/membership.test.ts"),
|
|
510
|
+
content: membershipTestFile(config),
|
|
511
|
+
},
|
|
512
|
+
{
|
|
513
|
+
path: featureDir("tests/invites.test.ts"),
|
|
514
|
+
content: invitesTestFile(),
|
|
515
|
+
},
|
|
516
|
+
{
|
|
517
|
+
path: featureDir("tests/policy.test.ts"),
|
|
518
|
+
content: policyTestFile(),
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
path: path.join(infrastructureDir(config), "workspaces/drizzle-workspace-repository.ts"),
|
|
522
|
+
content: workspaceDrizzleRepositoryFile(config, database),
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
path: path.join(infrastructureDir(config), "db/schema/workspaces.ts"),
|
|
526
|
+
content: workspaceDrizzleSchemaFile(database),
|
|
527
|
+
},
|
|
528
|
+
];
|
|
529
|
+
}
|
|
530
|
+
export function tenancyUiFiles(config) {
|
|
531
|
+
const featureDir = (file) => path.join(config.paths.features, "workspaces", file);
|
|
532
|
+
return [
|
|
533
|
+
{
|
|
534
|
+
path: featureDir("client/queries.ts"),
|
|
535
|
+
content: workspaceClientQueriesFile(),
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
path: featureDir("components/workspace-switcher.tsx"),
|
|
539
|
+
content: workspaceSwitcherComponentFile(),
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
path: featureDir("components/workspace-settings-form.tsx"),
|
|
543
|
+
content: workspaceSettingsFormComponentFile(),
|
|
544
|
+
},
|
|
545
|
+
{
|
|
546
|
+
path: featureDir("components/invite-form.tsx"),
|
|
547
|
+
content: inviteFormComponentFile(),
|
|
548
|
+
},
|
|
549
|
+
{
|
|
550
|
+
path: featureDir("components/members-settings.tsx"),
|
|
551
|
+
content: membersSettingsComponentFile(),
|
|
552
|
+
},
|
|
553
|
+
{
|
|
554
|
+
path: featureDir("components/invite-accept.tsx"),
|
|
555
|
+
content: inviteAcceptComponentFile(),
|
|
556
|
+
},
|
|
557
|
+
{
|
|
558
|
+
path: featureDir("components/index.ts"),
|
|
559
|
+
content: workspaceComponentsIndexFile(),
|
|
560
|
+
},
|
|
561
|
+
{
|
|
562
|
+
path: "app/(app)/settings/workspace/page.tsx",
|
|
563
|
+
content: settingsWorkspacePageFile(),
|
|
564
|
+
},
|
|
565
|
+
{
|
|
566
|
+
path: "app/(app)/settings/members/page.tsx",
|
|
567
|
+
content: settingsMembersPageFile(),
|
|
568
|
+
},
|
|
569
|
+
{
|
|
570
|
+
path: "app/invites/[token]/page.tsx",
|
|
571
|
+
content: inviteTokenPageFile(),
|
|
572
|
+
},
|
|
573
|
+
];
|
|
574
|
+
}
|
|
575
|
+
export function tenancySchemasFile() {
|
|
576
|
+
return `import { z } from "zod";
|
|
577
|
+
|
|
578
|
+
export const workspaceRoleSchema = z.enum(["admin", "member"]);
|
|
579
|
+
export const inviteStatusSchema = z.enum([
|
|
580
|
+
\t"pending",
|
|
581
|
+
\t"accepted",
|
|
582
|
+
\t"revoked",
|
|
583
|
+
\t"expired",
|
|
584
|
+
]);
|
|
585
|
+
|
|
586
|
+
export const workspaceNameSchema = z.string().min(1).max(100);
|
|
587
|
+
export const workspaceSlugSchema = z
|
|
588
|
+
\t.string()
|
|
589
|
+
\t.min(1)
|
|
590
|
+
\t.max(60)
|
|
591
|
+
\t.regex(
|
|
592
|
+
\t\t/^[a-z0-9]+(?:-[a-z0-9]+)*$/,
|
|
593
|
+
\t\t"Slugs are lowercase letters, numbers, and dashes.",
|
|
594
|
+
\t);
|
|
595
|
+
|
|
596
|
+
export const workspaceSchema = z.object({
|
|
597
|
+
\tid: z.string().min(1),
|
|
598
|
+
\tname: workspaceNameSchema,
|
|
599
|
+
\tslug: workspaceSlugSchema,
|
|
600
|
+
\tcreatedAt: z.string(),
|
|
601
|
+
\tupdatedAt: z.string(),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
export const workspaceMemberSchema = z.object({
|
|
605
|
+
\tid: z.string().min(1),
|
|
606
|
+
\tworkspaceId: z.string().min(1),
|
|
607
|
+
\tuserId: z.string().min(1),
|
|
608
|
+
\trole: workspaceRoleSchema,
|
|
609
|
+
\tjoinedAt: z.string(),
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
export const workspaceMemberWithUserSchema = workspaceMemberSchema.extend({
|
|
613
|
+
\tuser: z.object({
|
|
614
|
+
\t\tid: z.string().min(1),
|
|
615
|
+
\t\tname: z.string(),
|
|
616
|
+
\t\temail: z.string().nullable(),
|
|
617
|
+
\t\timage: z.string().nullable(),
|
|
618
|
+
\t}),
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
export const workspaceInviteSchema = z.object({
|
|
622
|
+
\tid: z.string().min(1),
|
|
623
|
+
\tworkspaceId: z.string().min(1),
|
|
624
|
+
\temail: z.string().email(),
|
|
625
|
+
\trole: workspaceRoleSchema,
|
|
626
|
+
\ttoken: z.string().min(1),
|
|
627
|
+
\tinvitedByUserId: z.string().min(1),
|
|
628
|
+
\tstatus: inviteStatusSchema,
|
|
629
|
+
\texpiresAt: z.string(),
|
|
630
|
+
\tacceptedAt: z.string().nullable(),
|
|
631
|
+
\tcreatedAt: z.string(),
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
export const workspaceListItemSchema = workspaceSchema.extend({
|
|
635
|
+
\trole: workspaceRoleSchema,
|
|
636
|
+
});
|
|
637
|
+
|
|
638
|
+
export const listMyWorkspacesOutputSchema = z.object({
|
|
639
|
+
\titems: z.array(workspaceListItemSchema),
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
export const createWorkspaceInputSchema = z.object({
|
|
643
|
+
\tname: workspaceNameSchema,
|
|
644
|
+
\tslug: workspaceSlugSchema,
|
|
645
|
+
});
|
|
646
|
+
|
|
647
|
+
export const updateWorkspaceInputSchema = z
|
|
648
|
+
\t.object({
|
|
649
|
+
\t\tname: workspaceNameSchema.optional(),
|
|
650
|
+
\t\tslug: workspaceSlugSchema.optional(),
|
|
651
|
+
\t})
|
|
652
|
+
\t.refine((value) => value.name !== undefined || value.slug !== undefined, {
|
|
653
|
+
\t\tmessage: "Provide a name or slug to update.",
|
|
654
|
+
\t});
|
|
655
|
+
|
|
656
|
+
export const membershipContextSchema = z.object({
|
|
657
|
+
\tworkspaceId: z.string().min(1),
|
|
658
|
+
\trole: workspaceRoleSchema,
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
export const currentWorkspaceOutputSchema = z.object({
|
|
662
|
+
\tworkspace: workspaceSchema.nullable(),
|
|
663
|
+
\tmembership: membershipContextSchema.nullable(),
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
export const switchWorkspaceInputSchema = z.object({
|
|
667
|
+
\tworkspaceId: z.string().min(1),
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
export const switchWorkspaceOutputSchema = z.object({
|
|
671
|
+
\tworkspaceId: z.string().min(1),
|
|
672
|
+
});
|
|
673
|
+
|
|
674
|
+
export const listMembersOutputSchema = z.object({
|
|
675
|
+
\titems: z.array(workspaceMemberWithUserSchema),
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
export const memberUserIdParamsSchema = z.object({
|
|
679
|
+
\tuserId: z.string().min(1),
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
export const updateMemberRoleInputSchema = z.object({
|
|
683
|
+
\trole: workspaceRoleSchema,
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
export const listInvitesOutputSchema = z.object({
|
|
687
|
+
\titems: z.array(workspaceInviteSchema),
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
export const createInviteInputSchema = z.object({
|
|
691
|
+
\temail: z.string().email(),
|
|
692
|
+
\trole: workspaceRoleSchema.default("member"),
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
export const inviteIdParamsSchema = z.object({
|
|
696
|
+
\tinviteId: z.string().min(1),
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
export const inviteTokenParamsSchema = z.object({
|
|
700
|
+
\ttoken: z.string().min(1),
|
|
701
|
+
});
|
|
702
|
+
|
|
703
|
+
export const acceptInviteOutputSchema = z.object({
|
|
704
|
+
\tworkspace: workspaceSchema,
|
|
705
|
+
\tmembership: workspaceMemberSchema,
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
export type WorkspaceRole = z.infer<typeof workspaceRoleSchema>;
|
|
709
|
+
export type InviteStatus = z.infer<typeof inviteStatusSchema>;
|
|
710
|
+
export type Workspace = z.infer<typeof workspaceSchema>;
|
|
711
|
+
export type WorkspaceMember = z.infer<typeof workspaceMemberSchema>;
|
|
712
|
+
export type WorkspaceMemberWithUser = z.infer<
|
|
713
|
+
\ttypeof workspaceMemberWithUserSchema
|
|
714
|
+
>;
|
|
715
|
+
export type WorkspaceInvite = z.infer<typeof workspaceInviteSchema>;
|
|
716
|
+
export type WorkspaceListItem = z.infer<typeof workspaceListItemSchema>;
|
|
717
|
+
export type CreateWorkspaceInput = z.infer<typeof createWorkspaceInputSchema>;
|
|
718
|
+
export type UpdateWorkspaceInput = z.infer<typeof updateWorkspaceInputSchema>;
|
|
719
|
+
export type CurrentWorkspaceOutput = z.infer<
|
|
720
|
+
\ttypeof currentWorkspaceOutputSchema
|
|
721
|
+
>;
|
|
722
|
+
export type CreateInviteInput = z.infer<typeof createInviteInputSchema>;
|
|
723
|
+
`;
|
|
724
|
+
}
|
|
725
|
+
export function tenancyContractsFile() {
|
|
726
|
+
return `import { defineContractGroup } from "@beignet/core/contracts";
|
|
727
|
+
import { z } from "zod";
|
|
728
|
+
import { errors } from "@/features/shared/errors";
|
|
729
|
+
import {
|
|
730
|
+
\tacceptInviteOutputSchema,
|
|
731
|
+
\tcreateInviteInputSchema,
|
|
732
|
+
\tcreateWorkspaceInputSchema,
|
|
733
|
+
\tcurrentWorkspaceOutputSchema,
|
|
734
|
+
\tinviteIdParamsSchema,
|
|
735
|
+
\tinviteTokenParamsSchema,
|
|
736
|
+
\tlistInvitesOutputSchema,
|
|
737
|
+
\tlistMembersOutputSchema,
|
|
738
|
+
\tlistMyWorkspacesOutputSchema,
|
|
739
|
+
\tmemberUserIdParamsSchema,
|
|
740
|
+
\tswitchWorkspaceInputSchema,
|
|
741
|
+
\tswitchWorkspaceOutputSchema,
|
|
742
|
+
\tupdateMemberRoleInputSchema,
|
|
743
|
+
\tupdateWorkspaceInputSchema,
|
|
744
|
+
\tworkspaceInviteSchema,
|
|
745
|
+
\tworkspaceMemberSchema,
|
|
746
|
+
\tworkspaceSchema,
|
|
747
|
+
} from "@/features/workspaces/schemas";
|
|
748
|
+
|
|
749
|
+
const ErrorResponseSchema = z.object({
|
|
750
|
+
\tcode: z.string(),
|
|
751
|
+
\tmessage: z.string(),
|
|
752
|
+
\trequestId: z.string().optional(),
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
const workspaces = defineContractGroup()
|
|
756
|
+
\t.namespace("workspaces")
|
|
757
|
+
\t.prefix("/api/workspaces")
|
|
758
|
+
\t.errors({ Unauthorized: errors.Unauthorized })
|
|
759
|
+
\t.responses({
|
|
760
|
+
\t\t500: ErrorResponseSchema,
|
|
761
|
+
\t});
|
|
762
|
+
|
|
763
|
+
const idempotencyHeadersSchema = z.object({
|
|
764
|
+
\t"idempotency-key": z.string().min(1),
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
const idempotentWorkspaces = workspaces.headers(idempotencyHeadersSchema);
|
|
768
|
+
|
|
769
|
+
const idempotencyMeta = {
|
|
770
|
+
\trequired: true as const,
|
|
771
|
+
\theader: "idempotency-key" as const,
|
|
772
|
+
\tscope: "actor" as const,
|
|
773
|
+
\tttlSec: 86_400,
|
|
774
|
+
};
|
|
775
|
+
|
|
776
|
+
const idempotencyErrors = {
|
|
777
|
+
\tIdempotencyConflict: errors.IdempotencyConflict,
|
|
778
|
+
\tIdempotencyInProgress: errors.IdempotencyInProgress,
|
|
779
|
+
};
|
|
780
|
+
|
|
781
|
+
export const listMyWorkspaces = workspaces
|
|
782
|
+
\t.get("/")
|
|
783
|
+
\t.meta({ auth: "required" })
|
|
784
|
+
\t.responses({
|
|
785
|
+
\t\t200: listMyWorkspacesOutputSchema,
|
|
786
|
+
\t})
|
|
787
|
+
\t.openapi({ summary: "List my workspaces", tags: ["workspaces"] });
|
|
788
|
+
|
|
789
|
+
export const createWorkspace = idempotentWorkspaces
|
|
790
|
+
\t.post("/")
|
|
791
|
+
\t.body(createWorkspaceInputSchema)
|
|
792
|
+
\t.meta({ auth: "required", idempotency: idempotencyMeta })
|
|
793
|
+
\t.errors({
|
|
794
|
+
\t\tWorkspaceSlugTaken: errors.WorkspaceSlugTaken,
|
|
795
|
+
\t\t...idempotencyErrors,
|
|
796
|
+
\t})
|
|
797
|
+
\t.responses({
|
|
798
|
+
\t\t201: workspaceSchema,
|
|
799
|
+
\t})
|
|
800
|
+
\t.openapi({ summary: "Create a workspace", tags: ["workspaces"] });
|
|
801
|
+
|
|
802
|
+
export const getCurrentWorkspace = workspaces
|
|
803
|
+
\t.get("/current")
|
|
804
|
+
\t.meta({ auth: "required" })
|
|
805
|
+
\t.responses({
|
|
806
|
+
\t\t200: currentWorkspaceOutputSchema,
|
|
807
|
+
\t})
|
|
808
|
+
\t.openapi({ summary: "Get the active workspace", tags: ["workspaces"] });
|
|
809
|
+
|
|
810
|
+
export const updateWorkspace = workspaces
|
|
811
|
+
\t.put("/current")
|
|
812
|
+
\t.body(updateWorkspaceInputSchema)
|
|
813
|
+
\t.meta({ auth: "required" })
|
|
814
|
+
\t.errors({
|
|
815
|
+
\t\tForbidden: errors.Forbidden,
|
|
816
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
817
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
818
|
+
\t\tWorkspaceSlugTaken: errors.WorkspaceSlugTaken,
|
|
819
|
+
\t})
|
|
820
|
+
\t.responses({
|
|
821
|
+
\t\t200: workspaceSchema,
|
|
822
|
+
\t})
|
|
823
|
+
\t.openapi({ summary: "Update the active workspace", tags: ["workspaces"] });
|
|
824
|
+
|
|
825
|
+
export const switchWorkspace = workspaces
|
|
826
|
+
\t.post("/switch")
|
|
827
|
+
\t.body(switchWorkspaceInputSchema)
|
|
828
|
+
\t.meta({ auth: "required" })
|
|
829
|
+
\t.errors({
|
|
830
|
+
\t\tNotAWorkspaceMember: errors.NotAWorkspaceMember,
|
|
831
|
+
\t})
|
|
832
|
+
\t.responses({
|
|
833
|
+
\t\t200: switchWorkspaceOutputSchema,
|
|
834
|
+
\t})
|
|
835
|
+
\t.openapi({ summary: "Switch the active workspace", tags: ["workspaces"] });
|
|
836
|
+
|
|
837
|
+
export const listMembers = workspaces
|
|
838
|
+
\t.get("/current/members")
|
|
839
|
+
\t.meta({ auth: "required" })
|
|
840
|
+
\t.errors({
|
|
841
|
+
\t\tForbidden: errors.Forbidden,
|
|
842
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
843
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
844
|
+
\t})
|
|
845
|
+
\t.responses({
|
|
846
|
+
\t\t200: listMembersOutputSchema,
|
|
847
|
+
\t})
|
|
848
|
+
\t.openapi({ summary: "List workspace members", tags: ["workspaces"] });
|
|
849
|
+
|
|
850
|
+
export const updateMemberRole = workspaces
|
|
851
|
+
\t.put("/current/members/:userId")
|
|
852
|
+
\t.pathParams(memberUserIdParamsSchema)
|
|
853
|
+
\t.body(updateMemberRoleInputSchema)
|
|
854
|
+
\t.meta({ auth: "required" })
|
|
855
|
+
\t.errors({
|
|
856
|
+
\t\tForbidden: errors.Forbidden,
|
|
857
|
+
\t\tLastAdminRemoval: errors.LastAdminRemoval,
|
|
858
|
+
\t\tNotAWorkspaceMember: errors.NotAWorkspaceMember,
|
|
859
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
860
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
861
|
+
\t})
|
|
862
|
+
\t.responses({
|
|
863
|
+
\t\t200: workspaceMemberSchema,
|
|
864
|
+
\t})
|
|
865
|
+
\t.openapi({ summary: "Change a member's role", tags: ["workspaces"] });
|
|
866
|
+
|
|
867
|
+
export const removeMember = workspaces
|
|
868
|
+
\t.delete("/current/members/:userId")
|
|
869
|
+
\t.pathParams(memberUserIdParamsSchema)
|
|
870
|
+
\t.meta({ auth: "required" })
|
|
871
|
+
\t.errors({
|
|
872
|
+
\t\tForbidden: errors.Forbidden,
|
|
873
|
+
\t\tLastAdminRemoval: errors.LastAdminRemoval,
|
|
874
|
+
\t\tNotAWorkspaceMember: errors.NotAWorkspaceMember,
|
|
875
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
876
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
877
|
+
\t})
|
|
878
|
+
\t.responses({
|
|
879
|
+
\t\t204: null,
|
|
880
|
+
\t})
|
|
881
|
+
\t.openapi({ summary: "Remove a workspace member", tags: ["workspaces"] });
|
|
882
|
+
|
|
883
|
+
export const listInvites = workspaces
|
|
884
|
+
\t.get("/current/invites")
|
|
885
|
+
\t.meta({ auth: "required" })
|
|
886
|
+
\t.errors({
|
|
887
|
+
\t\tForbidden: errors.Forbidden,
|
|
888
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
889
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
890
|
+
\t})
|
|
891
|
+
\t.responses({
|
|
892
|
+
\t\t200: listInvitesOutputSchema,
|
|
893
|
+
\t})
|
|
894
|
+
\t.openapi({ summary: "List workspace invites", tags: ["workspaces"] });
|
|
895
|
+
|
|
896
|
+
export const createInvite = idempotentWorkspaces
|
|
897
|
+
\t.post("/current/invites")
|
|
898
|
+
\t.body(createInviteInputSchema)
|
|
899
|
+
\t.meta({ auth: "required", idempotency: idempotencyMeta })
|
|
900
|
+
\t.errors({
|
|
901
|
+
\t\tForbidden: errors.Forbidden,
|
|
902
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
903
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
904
|
+
\t\t...idempotencyErrors,
|
|
905
|
+
\t})
|
|
906
|
+
\t.responses({
|
|
907
|
+
\t\t201: workspaceInviteSchema,
|
|
908
|
+
\t})
|
|
909
|
+
\t.openapi({ summary: "Invite someone to the workspace", tags: ["workspaces"] });
|
|
910
|
+
|
|
911
|
+
export const revokeInvite = workspaces
|
|
912
|
+
\t.delete("/current/invites/:inviteId")
|
|
913
|
+
\t.pathParams(inviteIdParamsSchema)
|
|
914
|
+
\t.meta({ auth: "required" })
|
|
915
|
+
\t.errors({
|
|
916
|
+
\t\tForbidden: errors.Forbidden,
|
|
917
|
+
\t\tInviteAlreadyAccepted: errors.InviteAlreadyAccepted,
|
|
918
|
+
\t\tInviteNotFound: errors.InviteNotFound,
|
|
919
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
920
|
+
\t\tWorkspaceRequired: errors.WorkspaceRequired,
|
|
921
|
+
\t})
|
|
922
|
+
\t.responses({
|
|
923
|
+
\t\t200: workspaceInviteSchema,
|
|
924
|
+
\t})
|
|
925
|
+
\t.openapi({ summary: "Revoke a workspace invite", tags: ["workspaces"] });
|
|
926
|
+
|
|
927
|
+
export const acceptInvite = workspaces
|
|
928
|
+
\t.post("/invites/:token/accept")
|
|
929
|
+
\t.pathParams(inviteTokenParamsSchema)
|
|
930
|
+
\t.meta({ auth: "required" })
|
|
931
|
+
\t.errors({
|
|
932
|
+
\t\tInviteAlreadyAccepted: errors.InviteAlreadyAccepted,
|
|
933
|
+
\t\tInviteExpired: errors.InviteExpired,
|
|
934
|
+
\t\tInviteNotFound: errors.InviteNotFound,
|
|
935
|
+
\t\tWorkspaceNotFound: errors.WorkspaceNotFound,
|
|
936
|
+
\t})
|
|
937
|
+
\t.responses({
|
|
938
|
+
\t\t200: acceptInviteOutputSchema,
|
|
939
|
+
\t})
|
|
940
|
+
\t.openapi({ summary: "Accept a workspace invite", tags: ["workspaces"] });
|
|
941
|
+
`;
|
|
942
|
+
}
|
|
943
|
+
export function tenancyPolicyFile() {
|
|
944
|
+
return `import {
|
|
945
|
+
\ttype ActivityActor,
|
|
946
|
+
\ttype ActivityTenant,
|
|
947
|
+
\tdefinePolicy,
|
|
948
|
+
\tdeny,
|
|
949
|
+
} from "@beignet/core/ports";
|
|
950
|
+
import type { Workspace, WorkspaceRole } from "@/features/workspaces/schemas";
|
|
951
|
+
|
|
952
|
+
/**
|
|
953
|
+
* Active workspace membership carried on the request context.
|
|
954
|
+
*/
|
|
955
|
+
export type WorkspaceMembershipContext = {
|
|
956
|
+
\tworkspaceId: string;
|
|
957
|
+
\trole: WorkspaceRole;
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
/**
|
|
961
|
+
* Authorization context for workspace policies. \`tenant.id\` is the active
|
|
962
|
+
* workspace id; \`membership\` is the actor's membership in that workspace, or
|
|
963
|
+
* null when the actor has none.
|
|
964
|
+
*/
|
|
965
|
+
export type AuthorizationContext = {
|
|
966
|
+
\tactor: ActivityActor;
|
|
967
|
+
\ttenant?: ActivityTenant;
|
|
968
|
+
\tmembership?: WorkspaceMembershipContext | null;
|
|
969
|
+
};
|
|
970
|
+
|
|
971
|
+
export function membershipFor(
|
|
972
|
+
\tctx: AuthorizationContext,
|
|
973
|
+
\tworkspaceId: string,
|
|
974
|
+
): WorkspaceMembershipContext | null {
|
|
975
|
+
\tif (!ctx.membership) return null;
|
|
976
|
+
\treturn ctx.membership.workspaceId === workspaceId ? ctx.membership : null;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
export function isWorkspaceMember(
|
|
980
|
+
\tctx: AuthorizationContext,
|
|
981
|
+
\tworkspaceId: string,
|
|
982
|
+
): boolean {
|
|
983
|
+
\treturn membershipFor(ctx, workspaceId) !== null;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
export function isWorkspaceAdmin(
|
|
987
|
+
\tctx: AuthorizationContext,
|
|
988
|
+
\tworkspaceId: string,
|
|
989
|
+
): boolean {
|
|
990
|
+
\treturn membershipFor(ctx, workspaceId)?.role === "admin";
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function denyNotMember(workspaceId: string) {
|
|
994
|
+
\treturn deny({
|
|
995
|
+
\t\treason: "You are not a member of this workspace.",
|
|
996
|
+
\t\tcode: "NOT_A_WORKSPACE_MEMBER",
|
|
997
|
+
\t\tdetails: { workspaceId },
|
|
998
|
+
\t});
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function denyNotAdmin(workspaceId: string) {
|
|
1002
|
+
\treturn deny({
|
|
1003
|
+
\t\treason: "Only workspace admins can do this.",
|
|
1004
|
+
\t\tcode: "WORKSPACE_ADMIN_REQUIRED",
|
|
1005
|
+
\t\tdetails: { workspaceId },
|
|
1006
|
+
\t});
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
export const workspacePolicy = definePolicy({
|
|
1010
|
+
\t"workspaces.update": (ctx: AuthorizationContext, workspace: Workspace) => {
|
|
1011
|
+
\t\tif (!isWorkspaceMember(ctx, workspace.id))
|
|
1012
|
+
\t\t\treturn denyNotMember(workspace.id);
|
|
1013
|
+
\t\treturn isWorkspaceAdmin(ctx, workspace.id) || denyNotAdmin(workspace.id);
|
|
1014
|
+
\t},
|
|
1015
|
+
\t"workspaces.members.view": (
|
|
1016
|
+
\t\tctx: AuthorizationContext,
|
|
1017
|
+
\t\tworkspace: Workspace,
|
|
1018
|
+
\t) => {
|
|
1019
|
+
\t\treturn isWorkspaceMember(ctx, workspace.id) || denyNotMember(workspace.id);
|
|
1020
|
+
\t},
|
|
1021
|
+
\t"workspaces.members.manage": (
|
|
1022
|
+
\t\tctx: AuthorizationContext,
|
|
1023
|
+
\t\tworkspace: Workspace,
|
|
1024
|
+
\t) => {
|
|
1025
|
+
\t\tif (!isWorkspaceMember(ctx, workspace.id))
|
|
1026
|
+
\t\t\treturn denyNotMember(workspace.id);
|
|
1027
|
+
\t\treturn isWorkspaceAdmin(ctx, workspace.id) || denyNotAdmin(workspace.id);
|
|
1028
|
+
\t},
|
|
1029
|
+
});
|
|
1030
|
+
`;
|
|
1031
|
+
}
|
|
1032
|
+
export function tenancyPortsFile() {
|
|
1033
|
+
return `import type {
|
|
1034
|
+
\tInviteStatus,
|
|
1035
|
+
\tWorkspace,
|
|
1036
|
+
\tWorkspaceInvite,
|
|
1037
|
+
\tWorkspaceMember,
|
|
1038
|
+
\tWorkspaceMemberWithUser,
|
|
1039
|
+
\tWorkspaceRole,
|
|
1040
|
+
} from "@/features/workspaces/schemas";
|
|
1041
|
+
|
|
1042
|
+
export type {
|
|
1043
|
+
\tInviteStatus,
|
|
1044
|
+
\tWorkspace,
|
|
1045
|
+
\tWorkspaceInvite,
|
|
1046
|
+
\tWorkspaceMember,
|
|
1047
|
+
\tWorkspaceMemberWithUser,
|
|
1048
|
+
\tWorkspaceRole,
|
|
1049
|
+
};
|
|
1050
|
+
|
|
1051
|
+
export type WorkspaceMembershipLookup = {
|
|
1052
|
+
\tworkspaceId: string;
|
|
1053
|
+
\tuserId: string;
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
export interface WorkspaceRepository {
|
|
1057
|
+
\tfindById(id: string): Promise<Workspace | null>;
|
|
1058
|
+
\tfindBySlug(slug: string): Promise<Workspace | null>;
|
|
1059
|
+
\tfindManyByIds(ids: string[]): Promise<Workspace[]>;
|
|
1060
|
+
\tcreate(data: { name: string; slug: string }): Promise<Workspace>;
|
|
1061
|
+
\tupdate(
|
|
1062
|
+
\t\tid: string,
|
|
1063
|
+
\t\tdata: { name?: string; slug?: string },
|
|
1064
|
+
\t): Promise<Workspace | null>;
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
export interface WorkspaceMemberRepository {
|
|
1068
|
+
\tfindMembership(
|
|
1069
|
+
\t\tlookup: WorkspaceMembershipLookup,
|
|
1070
|
+
\t): Promise<WorkspaceMember | null>;
|
|
1071
|
+
\tlistByUser(userId: string): Promise<WorkspaceMember[]>;
|
|
1072
|
+
\tlistByWorkspace(workspaceId: string): Promise<WorkspaceMemberWithUser[]>;
|
|
1073
|
+
\tcreate(data: {
|
|
1074
|
+
\t\tworkspaceId: string;
|
|
1075
|
+
\t\tuserId: string;
|
|
1076
|
+
\t\trole: WorkspaceRole;
|
|
1077
|
+
\t}): Promise<WorkspaceMember>;
|
|
1078
|
+
\tupdateRole(
|
|
1079
|
+
\t\tlookup: WorkspaceMembershipLookup,
|
|
1080
|
+
\t\trole: WorkspaceRole,
|
|
1081
|
+
\t): Promise<WorkspaceMember | null>;
|
|
1082
|
+
\tremove(lookup: WorkspaceMembershipLookup): Promise<boolean>;
|
|
1083
|
+
\tcountAdmins(workspaceId: string): Promise<number>;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
export interface WorkspaceInviteRepository {
|
|
1087
|
+
\tfindByToken(token: string): Promise<WorkspaceInvite | null>;
|
|
1088
|
+
\tlistByWorkspace(
|
|
1089
|
+
\t\tworkspaceId: string,
|
|
1090
|
+
\t\tstatus?: InviteStatus,
|
|
1091
|
+
\t): Promise<WorkspaceInvite[]>;
|
|
1092
|
+
\tcreate(data: {
|
|
1093
|
+
\t\tworkspaceId: string;
|
|
1094
|
+
\t\temail: string;
|
|
1095
|
+
\t\trole: WorkspaceRole;
|
|
1096
|
+
\t\ttoken: string;
|
|
1097
|
+
\t\tinvitedByUserId: string;
|
|
1098
|
+
\t\texpiresAt: string;
|
|
1099
|
+
\t}): Promise<WorkspaceInvite>;
|
|
1100
|
+
\tupdateStatus(
|
|
1101
|
+
\t\tid: string,
|
|
1102
|
+
\t\tstatus: InviteStatus,
|
|
1103
|
+
\t\tacceptedAt?: string,
|
|
1104
|
+
\t): Promise<WorkspaceInvite | null>;
|
|
1105
|
+
}
|
|
1106
|
+
`;
|
|
1107
|
+
}
|
|
1108
|
+
export function currentWorkspaceHelperFile(config) {
|
|
1109
|
+
return `import { requireTenantId } from "@beignet/core/ports";
|
|
1110
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
1111
|
+
import { appError } from "@/features/shared/errors";
|
|
1112
|
+
import type { Workspace } from "@/features/workspaces/ports";
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* Return the active workspace id from the request tenant or throw the
|
|
1116
|
+
* app-owned \`WorkspaceRequired\` error.
|
|
1117
|
+
*/
|
|
1118
|
+
export function requireWorkspaceId(ctx: AppContext): string {
|
|
1119
|
+
\treturn requireTenantId(ctx, {
|
|
1120
|
+
\t\terror: () => appError("WorkspaceRequired"),
|
|
1121
|
+
\t});
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Load the active workspace or throw. Shared by every use case that operates
|
|
1126
|
+
* on "the current workspace" resolved from the request tenant.
|
|
1127
|
+
*/
|
|
1128
|
+
export async function requireCurrentWorkspace(
|
|
1129
|
+
\tctx: AppContext,
|
|
1130
|
+
): Promise<Workspace> {
|
|
1131
|
+
\tconst workspaceId = requireWorkspaceId(ctx);
|
|
1132
|
+
\tconst workspace = await ctx.ports.workspaces.findById(workspaceId);
|
|
1133
|
+
|
|
1134
|
+
\tif (!workspace) {
|
|
1135
|
+
\t\tthrow appError("WorkspaceNotFound", { details: { workspaceId } });
|
|
1136
|
+
\t}
|
|
1137
|
+
|
|
1138
|
+
\treturn workspace;
|
|
1139
|
+
}
|
|
1140
|
+
`;
|
|
1141
|
+
}
|
|
1142
|
+
export function createWorkspaceUseCaseFile() {
|
|
1143
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1144
|
+
import { appError } from "@/features/shared/errors";
|
|
1145
|
+
import {
|
|
1146
|
+
\tcreateWorkspaceInputSchema,
|
|
1147
|
+
\tworkspaceSchema,
|
|
1148
|
+
} from "@/features/workspaces/schemas";
|
|
1149
|
+
import { useCase } from "@/lib/use-case";
|
|
1150
|
+
|
|
1151
|
+
export const createWorkspaceUseCase = useCase
|
|
1152
|
+
\t.command("workspaces.create")
|
|
1153
|
+
\t.input(createWorkspaceInputSchema)
|
|
1154
|
+
\t.output(workspaceSchema)
|
|
1155
|
+
\t.run(async ({ ctx, input }) => {
|
|
1156
|
+
\t\tconst userId = requireUserId(ctx);
|
|
1157
|
+
|
|
1158
|
+
\t\tconst existing = await ctx.ports.workspaces.findBySlug(input.slug);
|
|
1159
|
+
\t\tif (existing) {
|
|
1160
|
+
\t\t\tthrow appError("WorkspaceSlugTaken", { details: { slug: input.slug } });
|
|
1161
|
+
\t\t}
|
|
1162
|
+
|
|
1163
|
+
\t\tconst workspace = await ctx.ports.uow.transaction(async (tx) => {
|
|
1164
|
+
\t\t\tconst created = await tx.workspaces.create({
|
|
1165
|
+
\t\t\t\tname: input.name,
|
|
1166
|
+
\t\t\t\tslug: input.slug,
|
|
1167
|
+
\t\t\t});
|
|
1168
|
+
|
|
1169
|
+
\t\t\tawait tx.members.create({
|
|
1170
|
+
\t\t\t\tworkspaceId: created.id,
|
|
1171
|
+
\t\t\t\tuserId,
|
|
1172
|
+
\t\t\t\trole: "admin",
|
|
1173
|
+
\t\t\t});
|
|
1174
|
+
|
|
1175
|
+
\t\t\treturn created;
|
|
1176
|
+
\t\t});
|
|
1177
|
+
|
|
1178
|
+
\t\tctx.ports.logger.info("Workspace created", {
|
|
1179
|
+
\t\t\tworkspaceId: workspace.id,
|
|
1180
|
+
\t\t\tslug: workspace.slug,
|
|
1181
|
+
\t\t});
|
|
1182
|
+
|
|
1183
|
+
\t\treturn workspace;
|
|
1184
|
+
\t});
|
|
1185
|
+
`;
|
|
1186
|
+
}
|
|
1187
|
+
export function getCurrentWorkspaceUseCaseFile() {
|
|
1188
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1189
|
+
import { z } from "zod";
|
|
1190
|
+
import { currentWorkspaceOutputSchema } from "@/features/workspaces/schemas";
|
|
1191
|
+
import { useCase } from "@/lib/use-case";
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* Active workspace and membership for the current request. A user with zero
|
|
1195
|
+
* memberships gets a null workspace so the UI can route to workspace
|
|
1196
|
+
* creation.
|
|
1197
|
+
*/
|
|
1198
|
+
export const getCurrentWorkspaceUseCase = useCase
|
|
1199
|
+
\t.query("workspaces.getCurrent")
|
|
1200
|
+
\t.input(z.object({}))
|
|
1201
|
+
\t.output(currentWorkspaceOutputSchema)
|
|
1202
|
+
\t.run(async ({ ctx }) => {
|
|
1203
|
+
\t\trequireUserId(ctx);
|
|
1204
|
+
|
|
1205
|
+
\t\tconst workspaceId = ctx.tenant?.id;
|
|
1206
|
+
\t\tif (!workspaceId) {
|
|
1207
|
+
\t\t\treturn { workspace: null, membership: null };
|
|
1208
|
+
\t\t}
|
|
1209
|
+
|
|
1210
|
+
\t\tconst workspace = await ctx.ports.workspaces.findById(workspaceId);
|
|
1211
|
+
\t\tif (!workspace) {
|
|
1212
|
+
\t\t\treturn { workspace: null, membership: null };
|
|
1213
|
+
\t\t}
|
|
1214
|
+
|
|
1215
|
+
\t\treturn { workspace, membership: ctx.membership ?? null };
|
|
1216
|
+
\t});
|
|
1217
|
+
`;
|
|
1218
|
+
}
|
|
1219
|
+
export function listMyWorkspacesUseCaseFile() {
|
|
1220
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1221
|
+
import { z } from "zod";
|
|
1222
|
+
import { listMyWorkspacesOutputSchema } from "@/features/workspaces/schemas";
|
|
1223
|
+
import { useCase } from "@/lib/use-case";
|
|
1224
|
+
|
|
1225
|
+
export const listMyWorkspacesUseCase = useCase
|
|
1226
|
+
\t.query("workspaces.listMine")
|
|
1227
|
+
\t.input(z.object({}))
|
|
1228
|
+
\t.output(listMyWorkspacesOutputSchema)
|
|
1229
|
+
\t.run(async ({ ctx }) => {
|
|
1230
|
+
\t\tconst userId = requireUserId(ctx);
|
|
1231
|
+
\t\tconst memberships = await ctx.ports.members.listByUser(userId);
|
|
1232
|
+
|
|
1233
|
+
\t\tif (memberships.length === 0) {
|
|
1234
|
+
\t\t\treturn { items: [] };
|
|
1235
|
+
\t\t}
|
|
1236
|
+
|
|
1237
|
+
\t\tconst workspaces = await ctx.ports.workspaces.findManyByIds(
|
|
1238
|
+
\t\t\tmemberships.map((membership) => membership.workspaceId),
|
|
1239
|
+
\t\t);
|
|
1240
|
+
\t\tconst workspacesById = new Map(
|
|
1241
|
+
\t\t\tworkspaces.map((workspace) => [workspace.id, workspace]),
|
|
1242
|
+
\t\t);
|
|
1243
|
+
|
|
1244
|
+
\t\treturn {
|
|
1245
|
+
\t\t\titems: memberships.flatMap((membership) => {
|
|
1246
|
+
\t\t\t\tconst workspace = workspacesById.get(membership.workspaceId);
|
|
1247
|
+
\t\t\t\treturn workspace ? [{ ...workspace, role: membership.role }] : [];
|
|
1248
|
+
\t\t\t}),
|
|
1249
|
+
\t\t};
|
|
1250
|
+
\t});
|
|
1251
|
+
`;
|
|
1252
|
+
}
|
|
1253
|
+
export function switchWorkspaceUseCaseFile() {
|
|
1254
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1255
|
+
import { appError } from "@/features/shared/errors";
|
|
1256
|
+
import {
|
|
1257
|
+
\tswitchWorkspaceInputSchema,
|
|
1258
|
+
\tswitchWorkspaceOutputSchema,
|
|
1259
|
+
} from "@/features/workspaces/schemas";
|
|
1260
|
+
import { useCase } from "@/lib/use-case";
|
|
1261
|
+
|
|
1262
|
+
/**
|
|
1263
|
+
* Validates that the actor may switch to the requested workspace. The route
|
|
1264
|
+
* owns the response and sets the \`beignet-workspace\` cookie from this result.
|
|
1265
|
+
*/
|
|
1266
|
+
export const switchWorkspaceUseCase = useCase
|
|
1267
|
+
\t.command("workspaces.switch")
|
|
1268
|
+
\t.input(switchWorkspaceInputSchema)
|
|
1269
|
+
\t.output(switchWorkspaceOutputSchema)
|
|
1270
|
+
\t.run(async ({ ctx, input }) => {
|
|
1271
|
+
\t\tconst userId = requireUserId(ctx);
|
|
1272
|
+
\t\tconst membership = await ctx.ports.members.findMembership({
|
|
1273
|
+
\t\t\tworkspaceId: input.workspaceId,
|
|
1274
|
+
\t\t\tuserId,
|
|
1275
|
+
\t\t});
|
|
1276
|
+
|
|
1277
|
+
\t\tif (!membership) {
|
|
1278
|
+
\t\t\tthrow appError("NotAWorkspaceMember", {
|
|
1279
|
+
\t\t\t\tdetails: { workspaceId: input.workspaceId },
|
|
1280
|
+
\t\t\t});
|
|
1281
|
+
\t\t}
|
|
1282
|
+
|
|
1283
|
+
\t\treturn { workspaceId: input.workspaceId };
|
|
1284
|
+
\t});
|
|
1285
|
+
`;
|
|
1286
|
+
}
|
|
1287
|
+
export function updateWorkspaceUseCaseFile() {
|
|
1288
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1289
|
+
import { appError } from "@/features/shared/errors";
|
|
1290
|
+
import {
|
|
1291
|
+
\tupdateWorkspaceInputSchema,
|
|
1292
|
+
\tworkspaceSchema,
|
|
1293
|
+
} from "@/features/workspaces/schemas";
|
|
1294
|
+
import { useCase } from "@/lib/use-case";
|
|
1295
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1296
|
+
|
|
1297
|
+
export const updateWorkspaceUseCase = useCase
|
|
1298
|
+
\t.command("workspaces.update")
|
|
1299
|
+
\t.input(updateWorkspaceInputSchema)
|
|
1300
|
+
\t.output(workspaceSchema)
|
|
1301
|
+
\t.run(async ({ ctx, input }) => {
|
|
1302
|
+
\t\trequireUserId(ctx);
|
|
1303
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1304
|
+
\t\tawait ctx.gate.authorize("workspaces.update", workspace);
|
|
1305
|
+
|
|
1306
|
+
\t\tif (input.slug && input.slug !== workspace.slug) {
|
|
1307
|
+
\t\t\tconst taken = await ctx.ports.workspaces.findBySlug(input.slug);
|
|
1308
|
+
\t\t\tif (taken) {
|
|
1309
|
+
\t\t\t\tthrow appError("WorkspaceSlugTaken", { details: { slug: input.slug } });
|
|
1310
|
+
\t\t\t}
|
|
1311
|
+
\t\t}
|
|
1312
|
+
|
|
1313
|
+
\t\tconst updated = await ctx.ports.workspaces.update(workspace.id, {
|
|
1314
|
+
\t\t\t...(input.name !== undefined ? { name: input.name } : {}),
|
|
1315
|
+
\t\t\t...(input.slug !== undefined ? { slug: input.slug } : {}),
|
|
1316
|
+
\t\t});
|
|
1317
|
+
|
|
1318
|
+
\t\tif (!updated) {
|
|
1319
|
+
\t\t\tthrow appError("WorkspaceNotFound", {
|
|
1320
|
+
\t\t\t\tdetails: { workspaceId: workspace.id },
|
|
1321
|
+
\t\t\t});
|
|
1322
|
+
\t\t}
|
|
1323
|
+
|
|
1324
|
+
\t\treturn updated;
|
|
1325
|
+
\t});
|
|
1326
|
+
`;
|
|
1327
|
+
}
|
|
1328
|
+
export function listMembersUseCaseFile() {
|
|
1329
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1330
|
+
import { z } from "zod";
|
|
1331
|
+
import { listMembersOutputSchema } from "@/features/workspaces/schemas";
|
|
1332
|
+
import { useCase } from "@/lib/use-case";
|
|
1333
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1334
|
+
|
|
1335
|
+
export const listMembersUseCase = useCase
|
|
1336
|
+
\t.query("workspaces.members.list")
|
|
1337
|
+
\t.input(z.object({}))
|
|
1338
|
+
\t.output(listMembersOutputSchema)
|
|
1339
|
+
\t.run(async ({ ctx }) => {
|
|
1340
|
+
\t\trequireUserId(ctx);
|
|
1341
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1342
|
+
\t\tawait ctx.gate.authorize("workspaces.members.view", workspace);
|
|
1343
|
+
|
|
1344
|
+
\t\treturn {
|
|
1345
|
+
\t\t\titems: await ctx.ports.members.listByWorkspace(workspace.id),
|
|
1346
|
+
\t\t};
|
|
1347
|
+
\t});
|
|
1348
|
+
`;
|
|
1349
|
+
}
|
|
1350
|
+
export function updateMemberRoleUseCaseFile() {
|
|
1351
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1352
|
+
import { z } from "zod";
|
|
1353
|
+
import { appError } from "@/features/shared/errors";
|
|
1354
|
+
import {
|
|
1355
|
+
\tupdateMemberRoleInputSchema,
|
|
1356
|
+
\tworkspaceMemberSchema,
|
|
1357
|
+
} from "@/features/workspaces/schemas";
|
|
1358
|
+
import { useCase } from "@/lib/use-case";
|
|
1359
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1360
|
+
|
|
1361
|
+
const inputSchema = updateMemberRoleInputSchema.extend({
|
|
1362
|
+
\tuserId: z.string().min(1),
|
|
1363
|
+
});
|
|
1364
|
+
|
|
1365
|
+
export const updateMemberRoleUseCase = useCase
|
|
1366
|
+
\t.command("workspaces.members.updateRole")
|
|
1367
|
+
\t.input(inputSchema)
|
|
1368
|
+
\t.output(workspaceMemberSchema)
|
|
1369
|
+
\t.run(async ({ ctx, input }) => {
|
|
1370
|
+
\t\trequireUserId(ctx);
|
|
1371
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1372
|
+
\t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
|
|
1373
|
+
|
|
1374
|
+
\t\tconst lookup = { workspaceId: workspace.id, userId: input.userId };
|
|
1375
|
+
\t\tconst target = await ctx.ports.members.findMembership(lookup);
|
|
1376
|
+
\t\tif (!target) {
|
|
1377
|
+
\t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
|
|
1378
|
+
\t\t}
|
|
1379
|
+
|
|
1380
|
+
\t\tif (target.role === "admin" && input.role !== "admin") {
|
|
1381
|
+
\t\t\tconst adminCount = await ctx.ports.members.countAdmins(workspace.id);
|
|
1382
|
+
\t\t\tif (adminCount <= 1) {
|
|
1383
|
+
\t\t\t\tthrow appError("LastAdminRemoval", { details: lookup });
|
|
1384
|
+
\t\t\t}
|
|
1385
|
+
\t\t}
|
|
1386
|
+
|
|
1387
|
+
\t\tconst updated = await ctx.ports.members.updateRole(lookup, input.role);
|
|
1388
|
+
\t\tif (!updated) {
|
|
1389
|
+
\t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
|
|
1390
|
+
\t\t}
|
|
1391
|
+
|
|
1392
|
+
\t\treturn updated;
|
|
1393
|
+
\t});
|
|
1394
|
+
`;
|
|
1395
|
+
}
|
|
1396
|
+
export function removeMemberUseCaseFile() {
|
|
1397
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1398
|
+
import { z } from "zod";
|
|
1399
|
+
import { appError } from "@/features/shared/errors";
|
|
1400
|
+
import { memberUserIdParamsSchema } from "@/features/workspaces/schemas";
|
|
1401
|
+
import { useCase } from "@/lib/use-case";
|
|
1402
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1403
|
+
|
|
1404
|
+
export const removeMemberUseCase = useCase
|
|
1405
|
+
\t.command("workspaces.members.remove")
|
|
1406
|
+
\t.input(memberUserIdParamsSchema)
|
|
1407
|
+
\t.output(z.void())
|
|
1408
|
+
\t.run(async ({ ctx, input }) => {
|
|
1409
|
+
\t\trequireUserId(ctx);
|
|
1410
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1411
|
+
\t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
|
|
1412
|
+
|
|
1413
|
+
\t\tconst lookup = { workspaceId: workspace.id, userId: input.userId };
|
|
1414
|
+
\t\tconst target = await ctx.ports.members.findMembership(lookup);
|
|
1415
|
+
\t\tif (!target) {
|
|
1416
|
+
\t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
|
|
1417
|
+
\t\t}
|
|
1418
|
+
|
|
1419
|
+
\t\tif (target.role === "admin") {
|
|
1420
|
+
\t\t\tconst adminCount = await ctx.ports.members.countAdmins(workspace.id);
|
|
1421
|
+
\t\t\tif (adminCount <= 1) {
|
|
1422
|
+
\t\t\t\tthrow appError("LastAdminRemoval", { details: lookup });
|
|
1423
|
+
\t\t\t}
|
|
1424
|
+
\t\t}
|
|
1425
|
+
|
|
1426
|
+
\t\tawait ctx.ports.uow.transaction(async (tx) => {
|
|
1427
|
+
\t\t\tconst removed = await tx.members.remove(lookup);
|
|
1428
|
+
\t\t\tif (!removed) {
|
|
1429
|
+
\t\t\t\tthrow appError("NotAWorkspaceMember", { details: lookup });
|
|
1430
|
+
\t\t\t}
|
|
1431
|
+
\t\t});
|
|
1432
|
+
\t});
|
|
1433
|
+
`;
|
|
1434
|
+
}
|
|
1435
|
+
export function listInvitesUseCaseFile() {
|
|
1436
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1437
|
+
import { z } from "zod";
|
|
1438
|
+
import { listInvitesOutputSchema } from "@/features/workspaces/schemas";
|
|
1439
|
+
import { useCase } from "@/lib/use-case";
|
|
1440
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1441
|
+
|
|
1442
|
+
export const listInvitesUseCase = useCase
|
|
1443
|
+
\t.query("workspaces.invites.list")
|
|
1444
|
+
\t.input(z.object({}))
|
|
1445
|
+
\t.output(listInvitesOutputSchema)
|
|
1446
|
+
\t.run(async ({ ctx }) => {
|
|
1447
|
+
\t\trequireUserId(ctx);
|
|
1448
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1449
|
+
\t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
|
|
1450
|
+
|
|
1451
|
+
\t\treturn {
|
|
1452
|
+
\t\t\titems: await ctx.ports.invites.listByWorkspace(workspace.id),
|
|
1453
|
+
\t\t};
|
|
1454
|
+
\t});
|
|
1455
|
+
`;
|
|
1456
|
+
}
|
|
1457
|
+
export function createInviteUseCaseFile() {
|
|
1458
|
+
return `import { requireUser } from "@beignet/core/ports";
|
|
1459
|
+
import { WorkspaceInviteNotification } from "@/features/workspaces/notifications";
|
|
1460
|
+
import {
|
|
1461
|
+
\tcreateInviteInputSchema,
|
|
1462
|
+
\tworkspaceInviteSchema,
|
|
1463
|
+
} from "@/features/workspaces/schemas";
|
|
1464
|
+
import { useCase } from "@/lib/use-case";
|
|
1465
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1466
|
+
|
|
1467
|
+
const INVITE_TTL_MS = 14 * 24 * 60 * 60 * 1000;
|
|
1468
|
+
|
|
1469
|
+
export const createInviteUseCase = useCase
|
|
1470
|
+
\t.command("workspaces.invites.create")
|
|
1471
|
+
\t.input(createInviteInputSchema)
|
|
1472
|
+
\t.output(workspaceInviteSchema)
|
|
1473
|
+
\t.run(async ({ ctx, input }) => {
|
|
1474
|
+
\t\tconst user = requireUser(ctx);
|
|
1475
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1476
|
+
\t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
|
|
1477
|
+
|
|
1478
|
+
\t\tconst token = crypto.randomUUID();
|
|
1479
|
+
\t\tconst expiresAt = new Date(Date.now() + INVITE_TTL_MS).toISOString();
|
|
1480
|
+
|
|
1481
|
+
\t\tconst invite = await ctx.ports.invites.create({
|
|
1482
|
+
\t\t\tworkspaceId: workspace.id,
|
|
1483
|
+
\t\t\temail: input.email,
|
|
1484
|
+
\t\t\trole: input.role,
|
|
1485
|
+
\t\t\ttoken,
|
|
1486
|
+
\t\t\tinvitedByUserId: user.id,
|
|
1487
|
+
\t\t\texpiresAt,
|
|
1488
|
+
\t\t});
|
|
1489
|
+
|
|
1490
|
+
\t\tawait ctx.ports.notifications.send(
|
|
1491
|
+
\t\t\tWorkspaceInviteNotification,
|
|
1492
|
+
\t\t\t{
|
|
1493
|
+
\t\t\t\tinviteId: invite.id,
|
|
1494
|
+
\t\t\t\tworkspaceId: workspace.id,
|
|
1495
|
+
\t\t\t\tworkspaceName: workspace.name,
|
|
1496
|
+
\t\t\t\temail: invite.email,
|
|
1497
|
+
\t\t\t\trole: invite.role,
|
|
1498
|
+
\t\t\t\ttoken: invite.token,
|
|
1499
|
+
\t\t\t\tinvitedByName: user.name ?? undefined,
|
|
1500
|
+
\t\t\t},
|
|
1501
|
+
\t\t\t{
|
|
1502
|
+
\t\t\t\trequestId: ctx.requestId,
|
|
1503
|
+
\t\t\t\ttraceId: ctx.traceId,
|
|
1504
|
+
\t\t\t},
|
|
1505
|
+
\t\t);
|
|
1506
|
+
|
|
1507
|
+
\t\treturn invite;
|
|
1508
|
+
\t});
|
|
1509
|
+
`;
|
|
1510
|
+
}
|
|
1511
|
+
export function revokeInviteUseCaseFile() {
|
|
1512
|
+
return `import { requireUserId } from "@beignet/core/ports";
|
|
1513
|
+
import { appError } from "@/features/shared/errors";
|
|
1514
|
+
import {
|
|
1515
|
+
\tinviteIdParamsSchema,
|
|
1516
|
+
\tworkspaceInviteSchema,
|
|
1517
|
+
} from "@/features/workspaces/schemas";
|
|
1518
|
+
import { useCase } from "@/lib/use-case";
|
|
1519
|
+
import { requireCurrentWorkspace } from "./current-workspace";
|
|
1520
|
+
|
|
1521
|
+
export const revokeInviteUseCase = useCase
|
|
1522
|
+
\t.command("workspaces.invites.revoke")
|
|
1523
|
+
\t.input(inviteIdParamsSchema)
|
|
1524
|
+
\t.output(workspaceInviteSchema)
|
|
1525
|
+
\t.run(async ({ ctx, input }) => {
|
|
1526
|
+
\t\trequireUserId(ctx);
|
|
1527
|
+
\t\tconst workspace = await requireCurrentWorkspace(ctx);
|
|
1528
|
+
\t\tawait ctx.gate.authorize("workspaces.members.manage", workspace);
|
|
1529
|
+
|
|
1530
|
+
\t\tconst invites = await ctx.ports.invites.listByWorkspace(workspace.id);
|
|
1531
|
+
\t\tconst invite = invites.find((candidate) => candidate.id === input.inviteId);
|
|
1532
|
+
\t\tif (!invite) {
|
|
1533
|
+
\t\t\tthrow appError("InviteNotFound", {
|
|
1534
|
+
\t\t\t\tdetails: { inviteId: input.inviteId },
|
|
1535
|
+
\t\t\t});
|
|
1536
|
+
\t\t}
|
|
1537
|
+
\t\tif (invite.status === "accepted") {
|
|
1538
|
+
\t\t\tthrow appError("InviteAlreadyAccepted", {
|
|
1539
|
+
\t\t\t\tdetails: { inviteId: invite.id },
|
|
1540
|
+
\t\t\t});
|
|
1541
|
+
\t\t}
|
|
1542
|
+
|
|
1543
|
+
\t\tconst revoked = await ctx.ports.invites.updateStatus(invite.id, "revoked");
|
|
1544
|
+
\t\tif (!revoked) {
|
|
1545
|
+
\t\t\tthrow appError("InviteNotFound", { details: { inviteId: invite.id } });
|
|
1546
|
+
\t\t}
|
|
1547
|
+
|
|
1548
|
+
\t\treturn revoked;
|
|
1549
|
+
\t});
|
|
1550
|
+
`;
|
|
1551
|
+
}
|
|
1552
|
+
export function acceptInviteUseCaseFile() {
|
|
1553
|
+
return `import { requireUser } from "@beignet/core/ports";
|
|
1554
|
+
import { appError } from "@/features/shared/errors";
|
|
1555
|
+
import {
|
|
1556
|
+
\tacceptInviteOutputSchema,
|
|
1557
|
+
\tinviteTokenParamsSchema,
|
|
1558
|
+
} from "@/features/workspaces/schemas";
|
|
1559
|
+
import { useCase } from "@/lib/use-case";
|
|
1560
|
+
|
|
1561
|
+
/**
|
|
1562
|
+
* Accept a workspace invite by token. The token is the capability: any
|
|
1563
|
+
* authenticated user holding a pending, unexpired token may join.
|
|
1564
|
+
*/
|
|
1565
|
+
export const acceptInviteUseCase = useCase
|
|
1566
|
+
\t.command("workspaces.invites.accept")
|
|
1567
|
+
\t.input(inviteTokenParamsSchema)
|
|
1568
|
+
\t.output(acceptInviteOutputSchema)
|
|
1569
|
+
\t.run(async ({ ctx, input }) => {
|
|
1570
|
+
\t\tconst user = requireUser(ctx);
|
|
1571
|
+
|
|
1572
|
+
\t\tconst invite = await ctx.ports.invites.findByToken(input.token);
|
|
1573
|
+
\t\tif (!invite) {
|
|
1574
|
+
\t\t\tthrow appError("InviteNotFound");
|
|
1575
|
+
\t\t}
|
|
1576
|
+
\t\tif (invite.status === "accepted") {
|
|
1577
|
+
\t\t\tthrow appError("InviteAlreadyAccepted", {
|
|
1578
|
+
\t\t\t\tdetails: { inviteId: invite.id },
|
|
1579
|
+
\t\t\t});
|
|
1580
|
+
\t\t}
|
|
1581
|
+
\t\tif (invite.status !== "pending") {
|
|
1582
|
+
\t\t\tthrow appError("InviteNotFound", { details: { inviteId: invite.id } });
|
|
1583
|
+
\t\t}
|
|
1584
|
+
|
|
1585
|
+
\t\tconst now = new Date();
|
|
1586
|
+
\t\tif (Date.parse(invite.expiresAt) <= now.getTime()) {
|
|
1587
|
+
\t\t\tawait ctx.ports.invites.updateStatus(invite.id, "expired");
|
|
1588
|
+
\t\t\tthrow appError("InviteExpired", { details: { inviteId: invite.id } });
|
|
1589
|
+
\t\t}
|
|
1590
|
+
|
|
1591
|
+
\t\tconst workspace = await ctx.ports.workspaces.findById(invite.workspaceId);
|
|
1592
|
+
\t\tif (!workspace) {
|
|
1593
|
+
\t\t\tthrow appError("WorkspaceNotFound", {
|
|
1594
|
+
\t\t\t\tdetails: { workspaceId: invite.workspaceId },
|
|
1595
|
+
\t\t\t});
|
|
1596
|
+
\t\t}
|
|
1597
|
+
|
|
1598
|
+
\t\tconst existingMembership = await ctx.ports.members.findMembership({
|
|
1599
|
+
\t\t\tworkspaceId: workspace.id,
|
|
1600
|
+
\t\t\tuserId: user.id,
|
|
1601
|
+
\t\t});
|
|
1602
|
+
|
|
1603
|
+
\t\tconst membership = await ctx.ports.uow.transaction(async (tx) => {
|
|
1604
|
+
\t\t\tconst member =
|
|
1605
|
+
\t\t\t\texistingMembership ??
|
|
1606
|
+
\t\t\t\t(await tx.members.create({
|
|
1607
|
+
\t\t\t\t\tworkspaceId: workspace.id,
|
|
1608
|
+
\t\t\t\t\tuserId: user.id,
|
|
1609
|
+
\t\t\t\t\trole: invite.role,
|
|
1610
|
+
\t\t\t\t}));
|
|
1611
|
+
|
|
1612
|
+
\t\t\tconst accepted = await tx.invites.updateStatus(
|
|
1613
|
+
\t\t\t\tinvite.id,
|
|
1614
|
+
\t\t\t\t"accepted",
|
|
1615
|
+
\t\t\t\tnow.toISOString(),
|
|
1616
|
+
\t\t\t);
|
|
1617
|
+
\t\t\tif (!accepted) {
|
|
1618
|
+
\t\t\t\tthrow appError("InviteNotFound", { details: { inviteId: invite.id } });
|
|
1619
|
+
\t\t\t}
|
|
1620
|
+
|
|
1621
|
+
\t\t\treturn member;
|
|
1622
|
+
\t\t});
|
|
1623
|
+
|
|
1624
|
+
\t\treturn { workspace, membership };
|
|
1625
|
+
\t});
|
|
1626
|
+
`;
|
|
1627
|
+
}
|
|
1628
|
+
export function tenancyUseCasesIndexFile() {
|
|
1629
|
+
return `export { acceptInviteUseCase } from "./accept-invite";
|
|
1630
|
+
export { createInviteUseCase } from "./create-invite";
|
|
1631
|
+
export { createWorkspaceUseCase } from "./create-workspace";
|
|
1632
|
+
export {
|
|
1633
|
+
\trequireCurrentWorkspace,
|
|
1634
|
+
\trequireWorkspaceId,
|
|
1635
|
+
} from "./current-workspace";
|
|
1636
|
+
export { getCurrentWorkspaceUseCase } from "./get-current-workspace";
|
|
1637
|
+
export { listInvitesUseCase } from "./list-invites";
|
|
1638
|
+
export { listMembersUseCase } from "./list-members";
|
|
1639
|
+
export { listMyWorkspacesUseCase } from "./list-my-workspaces";
|
|
1640
|
+
export { removeMemberUseCase } from "./remove-member";
|
|
1641
|
+
export { revokeInviteUseCase } from "./revoke-invite";
|
|
1642
|
+
export { switchWorkspaceUseCase } from "./switch-workspace";
|
|
1643
|
+
export { updateMemberRoleUseCase } from "./update-member-role";
|
|
1644
|
+
export { updateWorkspaceUseCase } from "./update-workspace";
|
|
1645
|
+
`;
|
|
1646
|
+
}
|
|
1647
|
+
export function tenancyNotificationsFile() {
|
|
1648
|
+
return `import { defineMailNotificationChannel } from "@beignet/core/notifications";
|
|
1649
|
+
import { z } from "zod";
|
|
1650
|
+
import { env } from "@/lib/env";
|
|
1651
|
+
import { defineNotification } from "@/lib/notifications";
|
|
1652
|
+
|
|
1653
|
+
const invitePayloadSchema = z.object({
|
|
1654
|
+
\tinviteId: z.string().min(1),
|
|
1655
|
+
\tworkspaceId: z.string().min(1),
|
|
1656
|
+
\tworkspaceName: z.string().min(1),
|
|
1657
|
+
\temail: z.string().email(),
|
|
1658
|
+
\trole: z.enum(["admin", "member"]),
|
|
1659
|
+
\ttoken: z.string().min(1),
|
|
1660
|
+
\tinvitedByName: z.string().optional(),
|
|
1661
|
+
});
|
|
1662
|
+
|
|
1663
|
+
/**
|
|
1664
|
+
* Invitation email sent to the invited address with the accept link.
|
|
1665
|
+
*/
|
|
1666
|
+
export const WorkspaceInviteNotification = defineNotification(
|
|
1667
|
+
\t"workspaces.invite",
|
|
1668
|
+
\t{
|
|
1669
|
+
\t\tpayload: invitePayloadSchema,
|
|
1670
|
+
\t\tdescription: "Workspace invitation email with the accept link.",
|
|
1671
|
+
\t\tchannels: {
|
|
1672
|
+
\t\t\temail: defineMailNotificationChannel(({ payload }) => {
|
|
1673
|
+
\t\t\t\tconst acceptUrl = \`\${env.APP_URL}/invites/\${encodeURIComponent(payload.token)}\`;
|
|
1674
|
+
\t\t\t\tconst inviter = payload.invitedByName ?? "A workspace admin";
|
|
1675
|
+
|
|
1676
|
+
\t\t\t\treturn {
|
|
1677
|
+
\t\t\t\t\tto: payload.email,
|
|
1678
|
+
\t\t\t\t\tsubject: \`You're invited to \${payload.workspaceName}\`,
|
|
1679
|
+
\t\t\t\t\ttext: [
|
|
1680
|
+
\t\t\t\t\t\t\`\${inviter} invited you to join \${payload.workspaceName} as a \${payload.role}.\`,
|
|
1681
|
+
\t\t\t\t\t\t"",
|
|
1682
|
+
\t\t\t\t\t\t\`Accept the invite: \${acceptUrl}\`,
|
|
1683
|
+
\t\t\t\t\t].join("\\n"),
|
|
1684
|
+
\t\t\t\t};
|
|
1685
|
+
\t\t\t}),
|
|
1686
|
+
\t\t},
|
|
1687
|
+
\t},
|
|
1688
|
+
);
|
|
1689
|
+
`;
|
|
1690
|
+
}
|
|
1691
|
+
export function tenancyRoutesFile(config) {
|
|
1692
|
+
const routeFilePath = path.join(config.paths.features, "workspaces/routes.ts");
|
|
1693
|
+
return `import { defineRouteGroup } from "@beignet/next";
|
|
1694
|
+
import type { AppContext } from "${relativeModule(routeFilePath, config.paths.appContext)}";
|
|
1695
|
+
import * as workspaceContracts from "@/features/workspaces/contracts";
|
|
1696
|
+
import * as workspaceUseCases from "@/features/workspaces/use-cases";
|
|
1697
|
+
import { WORKSPACE_COOKIE_NAME } from "@/lib/tenant";
|
|
1698
|
+
|
|
1699
|
+
const WORKSPACE_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
1700
|
+
|
|
1701
|
+
function workspaceCookie(workspaceId: string): string {
|
|
1702
|
+
\treturn [
|
|
1703
|
+
\t\t\`\${WORKSPACE_COOKIE_NAME}=\${encodeURIComponent(workspaceId)}\`,
|
|
1704
|
+
\t\t"Path=/",
|
|
1705
|
+
\t\t"SameSite=Lax",
|
|
1706
|
+
\t\t"HttpOnly",
|
|
1707
|
+
\t\t\`Max-Age=\${WORKSPACE_COOKIE_MAX_AGE_SECONDS}\`,
|
|
1708
|
+
\t].join("; ");
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
export const workspaceRoutes = defineRouteGroup<AppContext>()({
|
|
1712
|
+
\tname: "workspaces",
|
|
1713
|
+
\troutes: [
|
|
1714
|
+
\t\t{
|
|
1715
|
+
\t\t\tcontract: workspaceContracts.listMyWorkspaces,
|
|
1716
|
+
\t\t\tuseCase: workspaceUseCases.listMyWorkspacesUseCase,
|
|
1717
|
+
\t\t},
|
|
1718
|
+
\t\t{
|
|
1719
|
+
\t\t\tcontract: workspaceContracts.createWorkspace,
|
|
1720
|
+
\t\t\tuseCase: workspaceUseCases.createWorkspaceUseCase,
|
|
1721
|
+
\t\t},
|
|
1722
|
+
\t\t{
|
|
1723
|
+
\t\t\tcontract: workspaceContracts.getCurrentWorkspace,
|
|
1724
|
+
\t\t\tuseCase: workspaceUseCases.getCurrentWorkspaceUseCase,
|
|
1725
|
+
\t\t},
|
|
1726
|
+
\t\t{
|
|
1727
|
+
\t\t\tcontract: workspaceContracts.updateWorkspace,
|
|
1728
|
+
\t\t\tuseCase: workspaceUseCases.updateWorkspaceUseCase,
|
|
1729
|
+
\t\t},
|
|
1730
|
+
\t\t// Full \`handle\` escape hatch: the route owns the response so it can set
|
|
1731
|
+
\t\t// the active-workspace cookie that \`resolveRequestWorkspace\` reads.
|
|
1732
|
+
\t\t{
|
|
1733
|
+
\t\t\tcontract: workspaceContracts.switchWorkspace,
|
|
1734
|
+
\t\t\thandle: async ({ ctx, body }) => {
|
|
1735
|
+
\t\t\t\tconst result = await workspaceUseCases.switchWorkspaceUseCase.run({
|
|
1736
|
+
\t\t\t\t\tctx,
|
|
1737
|
+
\t\t\t\t\tinput: body,
|
|
1738
|
+
\t\t\t\t});
|
|
1739
|
+
|
|
1740
|
+
\t\t\t\treturn {
|
|
1741
|
+
\t\t\t\t\tstatus: 200,
|
|
1742
|
+
\t\t\t\t\theaders: {
|
|
1743
|
+
\t\t\t\t\t\t"set-cookie": workspaceCookie(result.workspaceId),
|
|
1744
|
+
\t\t\t\t\t},
|
|
1745
|
+
\t\t\t\t\tbody: result,
|
|
1746
|
+
\t\t\t\t};
|
|
1747
|
+
\t\t\t},
|
|
1748
|
+
\t\t},
|
|
1749
|
+
\t\t{
|
|
1750
|
+
\t\t\tcontract: workspaceContracts.listMembers,
|
|
1751
|
+
\t\t\tuseCase: workspaceUseCases.listMembersUseCase,
|
|
1752
|
+
\t\t},
|
|
1753
|
+
\t\t{
|
|
1754
|
+
\t\t\tcontract: workspaceContracts.updateMemberRole,
|
|
1755
|
+
\t\t\tuseCase: workspaceUseCases.updateMemberRoleUseCase,
|
|
1756
|
+
\t\t},
|
|
1757
|
+
\t\t{
|
|
1758
|
+
\t\t\tcontract: workspaceContracts.removeMember,
|
|
1759
|
+
\t\t\tuseCase: workspaceUseCases.removeMemberUseCase,
|
|
1760
|
+
\t\t},
|
|
1761
|
+
\t\t{
|
|
1762
|
+
\t\t\tcontract: workspaceContracts.listInvites,
|
|
1763
|
+
\t\t\tuseCase: workspaceUseCases.listInvitesUseCase,
|
|
1764
|
+
\t\t},
|
|
1765
|
+
\t\t{
|
|
1766
|
+
\t\t\tcontract: workspaceContracts.createInvite,
|
|
1767
|
+
\t\t\tuseCase: workspaceUseCases.createInviteUseCase,
|
|
1768
|
+
\t\t},
|
|
1769
|
+
\t\t{
|
|
1770
|
+
\t\t\tcontract: workspaceContracts.revokeInvite,
|
|
1771
|
+
\t\t\tuseCase: workspaceUseCases.revokeInviteUseCase,
|
|
1772
|
+
\t\t},
|
|
1773
|
+
\t\t{
|
|
1774
|
+
\t\t\tcontract: workspaceContracts.acceptInvite,
|
|
1775
|
+
\t\t\tuseCase: workspaceUseCases.acceptInviteUseCase,
|
|
1776
|
+
\t\t},
|
|
1777
|
+
\t],
|
|
1778
|
+
});
|
|
1779
|
+
`;
|
|
1780
|
+
}
|
|
1781
|
+
export function tenancySeedsFile(config) {
|
|
1782
|
+
return `import { defineSeed, type SeedDef } from "@beignet/core/testing";
|
|
1783
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
1784
|
+
|
|
1785
|
+
export const DEMO_WORKSPACE_SLUG = "demo";
|
|
1786
|
+
export const DEMO_ADMIN_EMAIL = "demo-admin@example.com";
|
|
1787
|
+
export const DEMO_ADMIN_PASSWORD = "demo-password-1234";
|
|
1788
|
+
|
|
1789
|
+
/**
|
|
1790
|
+
* Context the workspace seed runs with: the app service context plus the
|
|
1791
|
+
* Better Auth user id for the demo admin. The composing seed script
|
|
1792
|
+
* (server/seed.ts) creates the user through Better Auth and passes its id,
|
|
1793
|
+
* because user ids are not reachable through app ports.
|
|
1794
|
+
*/
|
|
1795
|
+
export type WorkspaceSeedContext = AppContext & {
|
|
1796
|
+
\tdemoAdminUserId: string;
|
|
1797
|
+
};
|
|
1798
|
+
|
|
1799
|
+
/**
|
|
1800
|
+
* Demo workspace with the demo admin as its only member. Idempotent: skips
|
|
1801
|
+
* when the demo workspace slug already exists.
|
|
1802
|
+
*/
|
|
1803
|
+
export const WorkspaceDemoSeed = defineSeed<
|
|
1804
|
+
\t"workspaces.demo",
|
|
1805
|
+
\tWorkspaceSeedContext
|
|
1806
|
+
>("workspaces.demo", {
|
|
1807
|
+
\tdescription: "Demo workspace with the demo admin as its only member.",
|
|
1808
|
+
\tasync run(ctx) {
|
|
1809
|
+
\t\tif (await ctx.ports.workspaces.findBySlug(DEMO_WORKSPACE_SLUG)) {
|
|
1810
|
+
\t\t\treturn;
|
|
1811
|
+
\t\t}
|
|
1812
|
+
|
|
1813
|
+
\t\tconst workspace = await ctx.ports.workspaces.create({
|
|
1814
|
+
\t\t\tname: "Demo Workspace",
|
|
1815
|
+
\t\t\tslug: DEMO_WORKSPACE_SLUG,
|
|
1816
|
+
\t\t});
|
|
1817
|
+
\t\tawait ctx.ports.members.create({
|
|
1818
|
+
\t\t\tworkspaceId: workspace.id,
|
|
1819
|
+
\t\t\tuserId: ctx.demoAdminUserId,
|
|
1820
|
+
\t\t\trole: "admin",
|
|
1821
|
+
\t\t});
|
|
1822
|
+
\t},
|
|
1823
|
+
});
|
|
1824
|
+
|
|
1825
|
+
export const workspaceSeeds = [
|
|
1826
|
+
\tWorkspaceDemoSeed,
|
|
1827
|
+
] as const satisfies readonly SeedDef<WorkspaceSeedContext>[];
|
|
1828
|
+
`;
|
|
1829
|
+
}
|
|
1830
|
+
export function tenancyServerSeedFile(config) {
|
|
1831
|
+
const seedFilePath = seedEntrypointPath(config);
|
|
1832
|
+
return `import { createServiceActor } from "@beignet/core/ports";
|
|
1833
|
+
import { runSeeds } from "@beignet/core/testing";
|
|
1834
|
+
import {
|
|
1835
|
+
\tDEMO_ADMIN_EMAIL,
|
|
1836
|
+
\tDEMO_ADMIN_PASSWORD,
|
|
1837
|
+
\ttype WorkspaceSeedContext,
|
|
1838
|
+
\tworkspaceSeeds,
|
|
1839
|
+
} from "@/features/workspaces/seeds";
|
|
1840
|
+
import { auth } from "@/lib/better-auth";
|
|
1841
|
+
import { getServer } from "${relativeModule(seedFilePath, config.paths.server)}";
|
|
1842
|
+
|
|
1843
|
+
/**
|
|
1844
|
+
* Demo dataset composition. The demo admin is created through Better Auth so
|
|
1845
|
+
* it owns password hashing and the account rows; the workspace seed receives
|
|
1846
|
+
* the resolved user id through its seed context. Idempotent: rerunning
|
|
1847
|
+
* against a seeded database changes nothing. Other generators and
|
|
1848
|
+
* \`beignet make seed\` append their feature seed registries to this list;
|
|
1849
|
+
* plain \`AppContext\` seeds run fine with the extended workspace context.
|
|
1850
|
+
*/
|
|
1851
|
+
const seeds = [...workspaceSeeds] as const;
|
|
1852
|
+
|
|
1853
|
+
async function ensureDemoAdminUserId(): Promise<string> {
|
|
1854
|
+
\ttry {
|
|
1855
|
+
\t\tconst signedUp = await auth.api.signUpEmail({
|
|
1856
|
+
\t\t\tbody: {
|
|
1857
|
+
\t\t\t\temail: DEMO_ADMIN_EMAIL,
|
|
1858
|
+
\t\t\t\tname: "Demo Admin",
|
|
1859
|
+
\t\t\t\tpassword: DEMO_ADMIN_PASSWORD,
|
|
1860
|
+
\t\t\t},
|
|
1861
|
+
\t\t});
|
|
1862
|
+
\t\treturn signedUp.user.id;
|
|
1863
|
+
\t} catch {
|
|
1864
|
+
\t\t// The demo admin already exists; sign in with the demo credentials to
|
|
1865
|
+
\t\t// resolve its id.
|
|
1866
|
+
\t\tconst signedIn = await auth.api.signInEmail({
|
|
1867
|
+
\t\t\tbody: {
|
|
1868
|
+
\t\t\t\temail: DEMO_ADMIN_EMAIL,
|
|
1869
|
+
\t\t\t\tpassword: DEMO_ADMIN_PASSWORD,
|
|
1870
|
+
\t\t\t},
|
|
1871
|
+
\t\t});
|
|
1872
|
+
\t\treturn signedIn.user.id;
|
|
1873
|
+
\t}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
const demoAdminUserId = await ensureDemoAdminUserId();
|
|
1877
|
+
const server = await getServer();
|
|
1878
|
+
|
|
1879
|
+
try {
|
|
1880
|
+
\tawait server.runServiceContext(
|
|
1881
|
+
\t\t{ actor: createServiceActor("beignet-seed") },
|
|
1882
|
+
\t\tasync (ctx) => {
|
|
1883
|
+
\t\t\tawait runSeeds<WorkspaceSeedContext>({
|
|
1884
|
+
\t\t\t\tseeds,
|
|
1885
|
+
\t\t\t\tctx: { ...ctx, demoAdminUserId },
|
|
1886
|
+
\t\t\t});
|
|
1887
|
+
\t\t},
|
|
1888
|
+
\t);
|
|
1889
|
+
|
|
1890
|
+
\tconsole.log("Database seeded.");
|
|
1891
|
+
\tconsole.log(
|
|
1892
|
+
\t\t\`Demo admin: \${DEMO_ADMIN_EMAIL} (password "\${DEMO_ADMIN_PASSWORD}")\`,
|
|
1893
|
+
\t);
|
|
1894
|
+
} finally {
|
|
1895
|
+
\tawait server.stop();
|
|
1896
|
+
}
|
|
1897
|
+
`;
|
|
1898
|
+
}
|
|
1899
|
+
export function tenancyContextFactoryFile(config) {
|
|
1900
|
+
return `import { createTenant } from "@beignet/core/ports";
|
|
1901
|
+
import {
|
|
1902
|
+
\tcreateTestAnonymousActor,
|
|
1903
|
+
\tcreateTestUserActor,
|
|
1904
|
+
} from "@beignet/core/ports/testing";
|
|
1905
|
+
import {
|
|
1906
|
+
\tcreateTestContextFactory,
|
|
1907
|
+
\tcreateTestPorts,
|
|
1908
|
+
\ttype TestPortsFixture,
|
|
1909
|
+
} from "@beignet/core/testing";
|
|
1910
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
1911
|
+
import type { WorkspaceMembershipContext } from "@/features/workspaces/policy";
|
|
1912
|
+
import type {
|
|
1913
|
+
\tWorkspace,
|
|
1914
|
+
\tWorkspaceInvite,
|
|
1915
|
+
\tWorkspaceInviteRepository,
|
|
1916
|
+
\tWorkspaceMember,
|
|
1917
|
+
\tWorkspaceMemberRepository,
|
|
1918
|
+
\tWorkspaceMemberWithUser,
|
|
1919
|
+
\tWorkspaceRepository,
|
|
1920
|
+
} from "@/features/workspaces/ports";
|
|
1921
|
+
import { appPorts } from "@/infra/app-ports";
|
|
1922
|
+
import type { AppTransactionPorts } from "@/ports";
|
|
1923
|
+
|
|
1924
|
+
export type MemoryWorkspacePorts = {
|
|
1925
|
+
\tworkspaces: WorkspaceRepository;
|
|
1926
|
+
\tmembers: WorkspaceMemberRepository;
|
|
1927
|
+
\tinvites: WorkspaceInviteRepository;
|
|
1928
|
+
};
|
|
1929
|
+
|
|
1930
|
+
/**
|
|
1931
|
+
* In-memory implementations of the three workspace repository ports, shared
|
|
1932
|
+
* by the workspace tests and usable from any test that needs tenancy state.
|
|
1933
|
+
*/
|
|
1934
|
+
export function createMemoryWorkspacePorts(): MemoryWorkspacePorts {
|
|
1935
|
+
\tconst workspaces = new Map<string, Workspace>();
|
|
1936
|
+
\tconst members = new Map<string, WorkspaceMember>();
|
|
1937
|
+
\tconst invites = new Map<string, WorkspaceInvite>();
|
|
1938
|
+
\tlet joinSequence = 0;
|
|
1939
|
+
|
|
1940
|
+
\tconst memberKey = (lookup: { workspaceId: string; userId: string }) =>
|
|
1941
|
+
\t\t\`\${lookup.workspaceId}:\${lookup.userId}\`;
|
|
1942
|
+
|
|
1943
|
+
\treturn {
|
|
1944
|
+
\t\tworkspaces: {
|
|
1945
|
+
\t\t\tasync findById(id) {
|
|
1946
|
+
\t\t\t\treturn workspaces.get(id) ?? null;
|
|
1947
|
+
\t\t\t},
|
|
1948
|
+
\t\t\tasync findBySlug(slug) {
|
|
1949
|
+
\t\t\t\treturn (
|
|
1950
|
+
\t\t\t\t\t[...workspaces.values()].find(
|
|
1951
|
+
\t\t\t\t\t\t(workspace) => workspace.slug === slug,
|
|
1952
|
+
\t\t\t\t\t) ?? null
|
|
1953
|
+
\t\t\t\t);
|
|
1954
|
+
\t\t\t},
|
|
1955
|
+
\t\t\tasync findManyByIds(ids) {
|
|
1956
|
+
\t\t\t\treturn ids.flatMap((id) => {
|
|
1957
|
+
\t\t\t\t\tconst workspace = workspaces.get(id);
|
|
1958
|
+
\t\t\t\t\treturn workspace ? [workspace] : [];
|
|
1959
|
+
\t\t\t\t});
|
|
1960
|
+
\t\t\t},
|
|
1961
|
+
\t\t\tasync create(data) {
|
|
1962
|
+
\t\t\t\tconst now = new Date().toISOString();
|
|
1963
|
+
\t\t\t\tconst workspace: Workspace = {
|
|
1964
|
+
\t\t\t\t\tid: crypto.randomUUID(),
|
|
1965
|
+
\t\t\t\t\tname: data.name,
|
|
1966
|
+
\t\t\t\t\tslug: data.slug,
|
|
1967
|
+
\t\t\t\t\tcreatedAt: now,
|
|
1968
|
+
\t\t\t\t\tupdatedAt: now,
|
|
1969
|
+
\t\t\t\t};
|
|
1970
|
+
\t\t\t\tworkspaces.set(workspace.id, workspace);
|
|
1971
|
+
\t\t\t\treturn workspace;
|
|
1972
|
+
\t\t\t},
|
|
1973
|
+
\t\t\tasync update(id, data) {
|
|
1974
|
+
\t\t\t\tconst existing = workspaces.get(id);
|
|
1975
|
+
\t\t\t\tif (!existing) return null;
|
|
1976
|
+
\t\t\t\tconst next: Workspace = {
|
|
1977
|
+
\t\t\t\t\t...existing,
|
|
1978
|
+
\t\t\t\t\t...(data.name !== undefined ? { name: data.name } : {}),
|
|
1979
|
+
\t\t\t\t\t...(data.slug !== undefined ? { slug: data.slug } : {}),
|
|
1980
|
+
\t\t\t\t\tupdatedAt: new Date().toISOString(),
|
|
1981
|
+
\t\t\t\t};
|
|
1982
|
+
\t\t\t\tworkspaces.set(id, next);
|
|
1983
|
+
\t\t\t\treturn next;
|
|
1984
|
+
\t\t\t},
|
|
1985
|
+
\t\t},
|
|
1986
|
+
\t\tmembers: {
|
|
1987
|
+
\t\t\tasync findMembership(lookup) {
|
|
1988
|
+
\t\t\t\treturn members.get(memberKey(lookup)) ?? null;
|
|
1989
|
+
\t\t\t},
|
|
1990
|
+
\t\t\tasync listByUser(userId) {
|
|
1991
|
+
\t\t\t\treturn [...members.values()]
|
|
1992
|
+
\t\t\t\t\t.filter((member) => member.userId === userId)
|
|
1993
|
+
\t\t\t\t\t.sort((left, right) => left.joinedAt.localeCompare(right.joinedAt));
|
|
1994
|
+
\t\t\t},
|
|
1995
|
+
\t\t\tasync listByWorkspace(workspaceId) {
|
|
1996
|
+
\t\t\t\treturn [...members.values()]
|
|
1997
|
+
\t\t\t\t\t.filter((member) => member.workspaceId === workspaceId)
|
|
1998
|
+
\t\t\t\t\t.sort((left, right) => left.joinedAt.localeCompare(right.joinedAt))
|
|
1999
|
+
\t\t\t\t\t.map(
|
|
2000
|
+
\t\t\t\t\t\t(member): WorkspaceMemberWithUser => ({
|
|
2001
|
+
\t\t\t\t\t\t\t...member,
|
|
2002
|
+
\t\t\t\t\t\t\tuser: {
|
|
2003
|
+
\t\t\t\t\t\t\t\tid: member.userId,
|
|
2004
|
+
\t\t\t\t\t\t\t\tname: member.userId,
|
|
2005
|
+
\t\t\t\t\t\t\t\temail: \`\${member.userId}@example.com\`,
|
|
2006
|
+
\t\t\t\t\t\t\t\timage: null,
|
|
2007
|
+
\t\t\t\t\t\t\t},
|
|
2008
|
+
\t\t\t\t\t\t}),
|
|
2009
|
+
\t\t\t\t\t);
|
|
2010
|
+
\t\t\t},
|
|
2011
|
+
\t\t\tasync create(data) {
|
|
2012
|
+
\t\t\t\tjoinSequence += 1;
|
|
2013
|
+
\t\t\t\tconst member: WorkspaceMember = {
|
|
2014
|
+
\t\t\t\t\tid: crypto.randomUUID(),
|
|
2015
|
+
\t\t\t\t\tworkspaceId: data.workspaceId,
|
|
2016
|
+
\t\t\t\t\tuserId: data.userId,
|
|
2017
|
+
\t\t\t\t\trole: data.role,
|
|
2018
|
+
\t\t\t\t\tjoinedAt: new Date(joinSequence).toISOString(),
|
|
2019
|
+
\t\t\t\t};
|
|
2020
|
+
\t\t\t\tmembers.set(memberKey(data), member);
|
|
2021
|
+
\t\t\t\treturn member;
|
|
2022
|
+
\t\t\t},
|
|
2023
|
+
\t\t\tasync updateRole(lookup, role) {
|
|
2024
|
+
\t\t\t\tconst existing = members.get(memberKey(lookup));
|
|
2025
|
+
\t\t\t\tif (!existing) return null;
|
|
2026
|
+
\t\t\t\tconst next = { ...existing, role };
|
|
2027
|
+
\t\t\t\tmembers.set(memberKey(lookup), next);
|
|
2028
|
+
\t\t\t\treturn next;
|
|
2029
|
+
\t\t\t},
|
|
2030
|
+
\t\t\tasync remove(lookup) {
|
|
2031
|
+
\t\t\t\treturn members.delete(memberKey(lookup));
|
|
2032
|
+
\t\t\t},
|
|
2033
|
+
\t\t\tasync countAdmins(workspaceId) {
|
|
2034
|
+
\t\t\t\treturn [...members.values()].filter(
|
|
2035
|
+
\t\t\t\t\t(member) =>
|
|
2036
|
+
\t\t\t\t\t\tmember.workspaceId === workspaceId && member.role === "admin",
|
|
2037
|
+
\t\t\t\t).length;
|
|
2038
|
+
\t\t\t},
|
|
2039
|
+
\t\t},
|
|
2040
|
+
\t\tinvites: {
|
|
2041
|
+
\t\t\tasync findByToken(token) {
|
|
2042
|
+
\t\t\t\treturn (
|
|
2043
|
+
\t\t\t\t\t[...invites.values()].find((invite) => invite.token === token) ??
|
|
2044
|
+
\t\t\t\t\tnull
|
|
2045
|
+
\t\t\t\t);
|
|
2046
|
+
\t\t\t},
|
|
2047
|
+
\t\t\tasync listByWorkspace(workspaceId, status) {
|
|
2048
|
+
\t\t\t\treturn [...invites.values()]
|
|
2049
|
+
\t\t\t\t\t.filter(
|
|
2050
|
+
\t\t\t\t\t\t(invite) =>
|
|
2051
|
+
\t\t\t\t\t\t\tinvite.workspaceId === workspaceId &&
|
|
2052
|
+
\t\t\t\t\t\t\t(!status || invite.status === status),
|
|
2053
|
+
\t\t\t\t\t)
|
|
2054
|
+
\t\t\t\t\t.sort((left, right) =>
|
|
2055
|
+
\t\t\t\t\t\tright.createdAt.localeCompare(left.createdAt),
|
|
2056
|
+
\t\t\t\t\t);
|
|
2057
|
+
\t\t\t},
|
|
2058
|
+
\t\t\tasync create(data) {
|
|
2059
|
+
\t\t\t\tconst invite: WorkspaceInvite = {
|
|
2060
|
+
\t\t\t\t\tid: crypto.randomUUID(),
|
|
2061
|
+
\t\t\t\t\tworkspaceId: data.workspaceId,
|
|
2062
|
+
\t\t\t\t\temail: data.email,
|
|
2063
|
+
\t\t\t\t\trole: data.role,
|
|
2064
|
+
\t\t\t\t\ttoken: data.token,
|
|
2065
|
+
\t\t\t\t\tinvitedByUserId: data.invitedByUserId,
|
|
2066
|
+
\t\t\t\t\tstatus: "pending",
|
|
2067
|
+
\t\t\t\t\texpiresAt: data.expiresAt,
|
|
2068
|
+
\t\t\t\t\tacceptedAt: null,
|
|
2069
|
+
\t\t\t\t\tcreatedAt: new Date().toISOString(),
|
|
2070
|
+
\t\t\t\t};
|
|
2071
|
+
\t\t\t\tinvites.set(invite.id, invite);
|
|
2072
|
+
\t\t\t\treturn invite;
|
|
2073
|
+
\t\t\t},
|
|
2074
|
+
\t\t\tasync updateStatus(id, status, acceptedAt) {
|
|
2075
|
+
\t\t\t\tconst existing = invites.get(id);
|
|
2076
|
+
\t\t\t\tif (!existing) return null;
|
|
2077
|
+
\t\t\t\tconst next: WorkspaceInvite = {
|
|
2078
|
+
\t\t\t\t\t...existing,
|
|
2079
|
+
\t\t\t\t\tstatus,
|
|
2080
|
+
\t\t\t\t\tacceptedAt: acceptedAt ?? null,
|
|
2081
|
+
\t\t\t\t};
|
|
2082
|
+
\t\t\t\tinvites.set(id, next);
|
|
2083
|
+
\t\t\t\treturn next;
|
|
2084
|
+
\t\t\t},
|
|
2085
|
+
\t\t},
|
|
2086
|
+
\t};
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
export type WorkspaceContextOptions = {
|
|
2090
|
+
\t/** Omit to create an anonymous context. */
|
|
2091
|
+
\tuserId?: string;
|
|
2092
|
+
\tuserName?: string;
|
|
2093
|
+
\tuserEmail?: string;
|
|
2094
|
+
\t/** Active workspace (ctx.tenant.id). */
|
|
2095
|
+
\tworkspaceId?: string;
|
|
2096
|
+
\t/** Membership in the active workspace; defaults to null. */
|
|
2097
|
+
\tmembership?: WorkspaceMembershipContext | null;
|
|
2098
|
+
};
|
|
2099
|
+
|
|
2100
|
+
export type WorkspaceTestFixture = TestPortsFixture<
|
|
2101
|
+
\tAppContext["ports"],
|
|
2102
|
+
\tAppTransactionPorts
|
|
2103
|
+
> & {
|
|
2104
|
+
\trepositories: MemoryWorkspacePorts;
|
|
2105
|
+
\tcreateContext: (options?: WorkspaceContextOptions) => AppContext;
|
|
2106
|
+
};
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* Use-case fixture: memory workspace repositories, the real app gate, and
|
|
2110
|
+
* memory ports for everything else.
|
|
2111
|
+
*/
|
|
2112
|
+
export function createWorkspaceFixture(): WorkspaceTestFixture {
|
|
2113
|
+
\tconst repositories = createMemoryWorkspacePorts();
|
|
2114
|
+
\tconst fixture = createTestPorts<AppContext["ports"], AppTransactionPorts>({
|
|
2115
|
+
\t\tbase: appPorts,
|
|
2116
|
+
\t\toverrides: {
|
|
2117
|
+
\t\t\tgate: appPorts.gate,
|
|
2118
|
+
\t\t\t...repositories,
|
|
2119
|
+
\t\t},
|
|
2120
|
+
\t});
|
|
2121
|
+
|
|
2122
|
+
\tconst createContext = (
|
|
2123
|
+
\t\toptions: WorkspaceContextOptions = {},
|
|
2124
|
+
\t): AppContext => {
|
|
2125
|
+
\t\tconst actor = options.userId
|
|
2126
|
+
\t\t\t? createTestUserActor(options.userId, {
|
|
2127
|
+
\t\t\t\t\tdisplayName: options.userName,
|
|
2128
|
+
\t\t\t\t})
|
|
2129
|
+
\t\t\t: createTestAnonymousActor();
|
|
2130
|
+
\t\tconst factory = createTestContextFactory<AppContext, AppContext["ports"]>({
|
|
2131
|
+
\t\t\tports: fixture.ports,
|
|
2132
|
+
\t\t\tactor,
|
|
2133
|
+
\t\t\ttenant: options.workspaceId ? createTenant(options.workspaceId) : null,
|
|
2134
|
+
\t\t\tauth: options.userId
|
|
2135
|
+
\t\t\t\t? {
|
|
2136
|
+
\t\t\t\t\t\tuser: {
|
|
2137
|
+
\t\t\t\t\t\t\tid: options.userId,
|
|
2138
|
+
\t\t\t\t\t\t\tname: options.userName ?? options.userId,
|
|
2139
|
+
\t\t\t\t\t\t\temail: options.userEmail ?? \`\${options.userId}@example.com\`,
|
|
2140
|
+
\t\t\t\t\t\t},
|
|
2141
|
+
\t\t\t\t\t\tsession: { id: \`session_\${options.userId}\` },
|
|
2142
|
+
\t\t\t\t\t}
|
|
2143
|
+
\t\t\t\t: null,
|
|
2144
|
+
\t\t\trequestId: "test-request",
|
|
2145
|
+
\t\t\ttraceId: "test-trace",
|
|
2146
|
+
\t\t\textra: { membership: options.membership ?? null },
|
|
2147
|
+
\t\t});
|
|
2148
|
+
|
|
2149
|
+
\t\treturn factory();
|
|
2150
|
+
\t};
|
|
2151
|
+
|
|
2152
|
+
\treturn {
|
|
2153
|
+
\t\t...fixture,
|
|
2154
|
+
\t\trepositories,
|
|
2155
|
+
\t\tcreateContext,
|
|
2156
|
+
\t};
|
|
2157
|
+
}
|
|
2158
|
+
`;
|
|
2159
|
+
}
|
|
2160
|
+
export function workspaceFactoryFile(config) {
|
|
2161
|
+
return `import { createFactory } from "@beignet/core/testing";
|
|
2162
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
2163
|
+
import type { Workspace } from "@/features/workspaces/schemas";
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Builds full workspace entities for policy tests and persists name + slug
|
|
2167
|
+
* through the workspace repository port.
|
|
2168
|
+
*/
|
|
2169
|
+
export const workspaceFactory = createFactory<
|
|
2170
|
+
\t"workspaces.workspace",
|
|
2171
|
+
\tWorkspace,
|
|
2172
|
+
\tAppContext,
|
|
2173
|
+
\tWorkspace
|
|
2174
|
+
>("workspaces.workspace", {
|
|
2175
|
+
\tdefaults: ({ sequence, clock }): Workspace => ({
|
|
2176
|
+
\t\tid: \`00000000-0000-4000-8000-\${String(sequence).padStart(12, "0")}\`,
|
|
2177
|
+
\t\tname: \`Workspace \${sequence}\`,
|
|
2178
|
+
\t\tslug: \`workspace-\${sequence}\`,
|
|
2179
|
+
\t\tcreatedAt: clock.now().toISOString(),
|
|
2180
|
+
\t\tupdatedAt: clock.now().toISOString(),
|
|
2181
|
+
\t}),
|
|
2182
|
+
\tpersist: (ctx, value) =>
|
|
2183
|
+
\t\tctx.ports.workspaces.create({ name: value.name, slug: value.slug }),
|
|
2184
|
+
});
|
|
2185
|
+
`;
|
|
2186
|
+
}
|
|
2187
|
+
export function workspaceMemberFactoryFile(config) {
|
|
2188
|
+
return `import { createFactory } from "@beignet/core/testing";
|
|
2189
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
2190
|
+
import type {
|
|
2191
|
+
\tWorkspaceMember,
|
|
2192
|
+
\tWorkspaceRole,
|
|
2193
|
+
} from "@/features/workspaces/schemas";
|
|
2194
|
+
|
|
2195
|
+
type WorkspaceMemberSeed = {
|
|
2196
|
+
\tworkspaceId: string;
|
|
2197
|
+
\tuserId: string;
|
|
2198
|
+
\trole: WorkspaceRole;
|
|
2199
|
+
};
|
|
2200
|
+
|
|
2201
|
+
export const workspaceMemberFactory = createFactory<
|
|
2202
|
+
\t"workspaces.member",
|
|
2203
|
+
\tWorkspaceMemberSeed,
|
|
2204
|
+
\tAppContext,
|
|
2205
|
+
\tWorkspaceMember
|
|
2206
|
+
>("workspaces.member", {
|
|
2207
|
+
\tdefaults: ({ sequence }): WorkspaceMemberSeed => ({
|
|
2208
|
+
\t\tworkspaceId: "workspace-1",
|
|
2209
|
+
\t\tuserId: \`user-\${sequence}\`,
|
|
2210
|
+
\t\trole: "member",
|
|
2211
|
+
\t}),
|
|
2212
|
+
\tpersist: (ctx, value) => ctx.ports.members.create(value),
|
|
2213
|
+
});
|
|
2214
|
+
`;
|
|
2215
|
+
}
|
|
2216
|
+
export function workspaceInviteFactoryFile(config) {
|
|
2217
|
+
return `import { createFactory } from "@beignet/core/testing";
|
|
2218
|
+
import type { AppContext } from "${aliasModule(config.paths.appContext)}";
|
|
2219
|
+
import type {
|
|
2220
|
+
\tWorkspaceInvite,
|
|
2221
|
+
\tWorkspaceRole,
|
|
2222
|
+
} from "@/features/workspaces/schemas";
|
|
2223
|
+
|
|
2224
|
+
type WorkspaceInviteSeed = {
|
|
2225
|
+
\tworkspaceId: string;
|
|
2226
|
+
\temail: string;
|
|
2227
|
+
\trole: WorkspaceRole;
|
|
2228
|
+
\ttoken: string;
|
|
2229
|
+
\tinvitedByUserId: string;
|
|
2230
|
+
\texpiresAt: string;
|
|
2231
|
+
};
|
|
2232
|
+
|
|
2233
|
+
export const workspaceInviteFactory = createFactory<
|
|
2234
|
+
\t"workspaces.invite",
|
|
2235
|
+
\tWorkspaceInviteSeed,
|
|
2236
|
+
\tAppContext,
|
|
2237
|
+
\tWorkspaceInvite
|
|
2238
|
+
>("workspaces.invite", {
|
|
2239
|
+
\tdefaults: ({ sequence, clock }): WorkspaceInviteSeed => ({
|
|
2240
|
+
\t\tworkspaceId: "workspace-1",
|
|
2241
|
+
\t\temail: \`invitee-\${sequence}@example.com\`,
|
|
2242
|
+
\t\trole: "member",
|
|
2243
|
+
\t\ttoken: \`invite-token-\${sequence}\`,
|
|
2244
|
+
\t\tinvitedByUserId: "user-1",
|
|
2245
|
+
\t\texpiresAt: new Date(
|
|
2246
|
+
\t\t\tclock.now().getTime() + 14 * 24 * 60 * 60 * 1000,
|
|
2247
|
+
\t\t).toISOString(),
|
|
2248
|
+
\t}),
|
|
2249
|
+
\tpersist: (ctx, value) => ctx.ports.invites.create(value),
|
|
2250
|
+
});
|
|
2251
|
+
`;
|
|
2252
|
+
}
|
|
2253
|
+
export function membershipTestFile(config) {
|
|
2254
|
+
const tenantModule = aliasModule(tenantHelperPath(config));
|
|
2255
|
+
return `import { describe, expect, test } from "bun:test";
|
|
2256
|
+
import type { AuthSession } from "@/ports/auth";
|
|
2257
|
+
import {
|
|
2258
|
+
\tcreateWorkspaceUseCase,
|
|
2259
|
+
\tlistMyWorkspacesUseCase,
|
|
2260
|
+
\tremoveMemberUseCase,
|
|
2261
|
+
\tswitchWorkspaceUseCase,
|
|
2262
|
+
\tupdateMemberRoleUseCase,
|
|
2263
|
+
} from "@/features/workspaces/use-cases";
|
|
2264
|
+
import { resolveRequestWorkspace, WORKSPACE_COOKIE_NAME } from "${tenantModule}";
|
|
2265
|
+
import { createWorkspaceFixture } from "./factories/context";
|
|
2266
|
+
import { workspaceFactory } from "./factories/workspace";
|
|
2267
|
+
import { workspaceMemberFactory } from "./factories/workspace-member";
|
|
2268
|
+
|
|
2269
|
+
function authFor(userId: string): AuthSession {
|
|
2270
|
+
\treturn {
|
|
2271
|
+
\t\tuser: { id: userId, name: userId, email: \`\${userId}@example.com\` },
|
|
2272
|
+
\t\tsession: { id: \`session_\${userId}\` },
|
|
2273
|
+
\t};
|
|
2274
|
+
}
|
|
2275
|
+
|
|
2276
|
+
describe("createWorkspaceUseCase", () => {
|
|
2277
|
+
\ttest("creates the workspace with the creator as admin", async () => {
|
|
2278
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2279
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
|
|
2280
|
+
|
|
2281
|
+
\t\tconst workspace = await createWorkspaceUseCase.run({
|
|
2282
|
+
\t\t\tctx,
|
|
2283
|
+
\t\t\tinput: { name: "Acme", slug: "acme" },
|
|
2284
|
+
\t\t});
|
|
2285
|
+
|
|
2286
|
+
\t\texpect(workspace).toMatchObject({ name: "Acme", slug: "acme" });
|
|
2287
|
+
\t\tawait expect(
|
|
2288
|
+
\t\t\tfixture.repositories.members.findMembership({
|
|
2289
|
+
\t\t\t\tworkspaceId: workspace.id,
|
|
2290
|
+
\t\t\t\tuserId: "user-amelia",
|
|
2291
|
+
\t\t\t}),
|
|
2292
|
+
\t\t).resolves.toMatchObject({ role: "admin" });
|
|
2293
|
+
\t});
|
|
2294
|
+
|
|
2295
|
+
\ttest("rejects duplicate slugs", async () => {
|
|
2296
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2297
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
|
|
2298
|
+
\t\tawait createWorkspaceUseCase.run({
|
|
2299
|
+
\t\t\tctx,
|
|
2300
|
+
\t\t\tinput: { name: "Acme", slug: "acme" },
|
|
2301
|
+
\t\t});
|
|
2302
|
+
|
|
2303
|
+
\t\tawait expect(
|
|
2304
|
+
\t\t\tcreateWorkspaceUseCase.run({
|
|
2305
|
+
\t\t\t\tctx,
|
|
2306
|
+
\t\t\t\tinput: { name: "Acme Again", slug: "acme" },
|
|
2307
|
+
\t\t\t}),
|
|
2308
|
+
\t\t).rejects.toMatchObject({ code: "WORKSPACE_SLUG_TAKEN", status: 409 });
|
|
2309
|
+
\t});
|
|
2310
|
+
|
|
2311
|
+
\ttest("requires an authenticated user", async () => {
|
|
2312
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2313
|
+
\t\tconst ctx = fixture.createContext();
|
|
2314
|
+
|
|
2315
|
+
\t\tawait expect(
|
|
2316
|
+
\t\t\tcreateWorkspaceUseCase.run({
|
|
2317
|
+
\t\t\t\tctx,
|
|
2318
|
+
\t\t\t\tinput: { name: "Acme", slug: "acme" },
|
|
2319
|
+
\t\t\t}),
|
|
2320
|
+
\t\t).rejects.toMatchObject({ code: "UNAUTHORIZED" });
|
|
2321
|
+
\t});
|
|
2322
|
+
});
|
|
2323
|
+
|
|
2324
|
+
describe("listMyWorkspacesUseCase", () => {
|
|
2325
|
+
\ttest("lists the actor's workspaces with membership roles", async () => {
|
|
2326
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2327
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
|
|
2328
|
+
\t\tconst first = await workspaceFactory.create(ctx);
|
|
2329
|
+
\t\tconst second = await workspaceFactory.create(ctx);
|
|
2330
|
+
\t\tawait workspaceMemberFactory.create(ctx, {
|
|
2331
|
+
\t\t\tworkspaceId: first.id,
|
|
2332
|
+
\t\t\tuserId: "user-amelia",
|
|
2333
|
+
\t\t\trole: "admin",
|
|
2334
|
+
\t\t});
|
|
2335
|
+
\t\tawait workspaceMemberFactory.create(ctx, {
|
|
2336
|
+
\t\t\tworkspaceId: second.id,
|
|
2337
|
+
\t\t\tuserId: "user-amelia",
|
|
2338
|
+
\t\t\trole: "member",
|
|
2339
|
+
\t\t});
|
|
2340
|
+
\t\tawait workspaceMemberFactory.create(ctx, {
|
|
2341
|
+
\t\t\tworkspaceId: second.id,
|
|
2342
|
+
\t\t\tuserId: "user-other",
|
|
2343
|
+
\t\t\trole: "admin",
|
|
2344
|
+
\t\t});
|
|
2345
|
+
|
|
2346
|
+
\t\tconst listed = await listMyWorkspacesUseCase.run({ ctx, input: {} });
|
|
2347
|
+
|
|
2348
|
+
\t\texpect(listed.items).toHaveLength(2);
|
|
2349
|
+
\t\texpect(listed.items).toEqual(
|
|
2350
|
+
\t\t\texpect.arrayContaining([
|
|
2351
|
+
\t\t\t\texpect.objectContaining({ id: first.id, role: "admin" }),
|
|
2352
|
+
\t\t\t\texpect.objectContaining({ id: second.id, role: "member" }),
|
|
2353
|
+
\t\t\t]),
|
|
2354
|
+
\t\t);
|
|
2355
|
+
\t});
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
describe("switchWorkspaceUseCase", () => {
|
|
2359
|
+
\ttest("allows members and rejects non-members", async () => {
|
|
2360
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2361
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
|
|
2362
|
+
\t\tconst workspace = await workspaceFactory.create(ctx);
|
|
2363
|
+
\t\tawait workspaceMemberFactory.create(ctx, {
|
|
2364
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2365
|
+
\t\t\tuserId: "user-amelia",
|
|
2366
|
+
\t\t\trole: "member",
|
|
2367
|
+
\t\t});
|
|
2368
|
+
|
|
2369
|
+
\t\tawait expect(
|
|
2370
|
+
\t\t\tswitchWorkspaceUseCase.run({
|
|
2371
|
+
\t\t\t\tctx,
|
|
2372
|
+
\t\t\t\tinput: { workspaceId: workspace.id },
|
|
2373
|
+
\t\t\t}),
|
|
2374
|
+
\t\t).resolves.toEqual({ workspaceId: workspace.id });
|
|
2375
|
+
|
|
2376
|
+
\t\tconst intruderCtx = fixture.createContext({ userId: "user-intruder" });
|
|
2377
|
+
\t\tawait expect(
|
|
2378
|
+
\t\t\tswitchWorkspaceUseCase.run({
|
|
2379
|
+
\t\t\t\tctx: intruderCtx,
|
|
2380
|
+
\t\t\t\tinput: { workspaceId: workspace.id },
|
|
2381
|
+
\t\t\t}),
|
|
2382
|
+
\t\t).rejects.toMatchObject({ code: "NOT_A_WORKSPACE_MEMBER", status: 403 });
|
|
2383
|
+
\t});
|
|
2384
|
+
});
|
|
2385
|
+
|
|
2386
|
+
describe("resolveRequestWorkspace", () => {
|
|
2387
|
+
\ttest("resolves the active workspace from memberships and the cookie", async () => {
|
|
2388
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2389
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-amelia" });
|
|
2390
|
+
\t\tconst first = await workspaceFactory.create(ctx);
|
|
2391
|
+
\t\tconst second = await workspaceFactory.create(ctx);
|
|
2392
|
+
\t\tawait workspaceMemberFactory.create(ctx, {
|
|
2393
|
+
\t\t\tworkspaceId: first.id,
|
|
2394
|
+
\t\t\tuserId: "user-amelia",
|
|
2395
|
+
\t\t\trole: "admin",
|
|
2396
|
+
\t\t});
|
|
2397
|
+
\t\tawait workspaceMemberFactory.create(ctx, {
|
|
2398
|
+
\t\t\tworkspaceId: second.id,
|
|
2399
|
+
\t\t\tuserId: "user-amelia",
|
|
2400
|
+
\t\t\trole: "member",
|
|
2401
|
+
\t\t});
|
|
2402
|
+
|
|
2403
|
+
\t\tconst withoutCookie = await resolveRequestWorkspace({
|
|
2404
|
+
\t\t\tauth: authFor("user-amelia"),
|
|
2405
|
+
\t\t\treq: { headers: new Headers() },
|
|
2406
|
+
\t\t\tports: fixture.ports,
|
|
2407
|
+
\t\t});
|
|
2408
|
+
\t\texpect(withoutCookie.tenant?.id).toBe(first.id);
|
|
2409
|
+
\t\texpect(withoutCookie.membership).toMatchObject({
|
|
2410
|
+
\t\t\tworkspaceId: first.id,
|
|
2411
|
+
\t\t\trole: "admin",
|
|
2412
|
+
\t\t});
|
|
2413
|
+
|
|
2414
|
+
\t\tconst withCookie = await resolveRequestWorkspace({
|
|
2415
|
+
\t\t\tauth: authFor("user-amelia"),
|
|
2416
|
+
\t\t\treq: {
|
|
2417
|
+
\t\t\t\theaders: new Headers({
|
|
2418
|
+
\t\t\t\t\tcookie: \`\${WORKSPACE_COOKIE_NAME}=\${encodeURIComponent(second.id)}\`,
|
|
2419
|
+
\t\t\t\t}),
|
|
2420
|
+
\t\t\t},
|
|
2421
|
+
\t\t\tports: fixture.ports,
|
|
2422
|
+
\t\t});
|
|
2423
|
+
\t\texpect(withCookie.tenant?.id).toBe(second.id);
|
|
2424
|
+
\t\texpect(withCookie.membership).toMatchObject({
|
|
2425
|
+
\t\t\tworkspaceId: second.id,
|
|
2426
|
+
\t\t\trole: "member",
|
|
2427
|
+
\t\t});
|
|
2428
|
+
\t});
|
|
2429
|
+
|
|
2430
|
+
\ttest("resolves no workspace for anonymous or membership-less users", async () => {
|
|
2431
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2432
|
+
|
|
2433
|
+
\t\tawait expect(
|
|
2434
|
+
\t\t\tresolveRequestWorkspace({
|
|
2435
|
+
\t\t\t\tauth: null,
|
|
2436
|
+
\t\t\t\treq: { headers: new Headers() },
|
|
2437
|
+
\t\t\t\tports: fixture.ports,
|
|
2438
|
+
\t\t\t}),
|
|
2439
|
+
\t\t).resolves.toEqual({ tenant: undefined, membership: null });
|
|
2440
|
+
|
|
2441
|
+
\t\tawait expect(
|
|
2442
|
+
\t\t\tresolveRequestWorkspace({
|
|
2443
|
+
\t\t\t\tauth: authFor("user-lonely"),
|
|
2444
|
+
\t\t\t\treq: { headers: new Headers() },
|
|
2445
|
+
\t\t\t\tports: fixture.ports,
|
|
2446
|
+
\t\t\t}),
|
|
2447
|
+
\t\t).resolves.toEqual({ tenant: undefined, membership: null });
|
|
2448
|
+
\t});
|
|
2449
|
+
});
|
|
2450
|
+
|
|
2451
|
+
describe("member management", () => {
|
|
2452
|
+
\tasync function seedWorkspace(fixture: ReturnType<typeof createWorkspaceFixture>) {
|
|
2453
|
+
\t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2454
|
+
\t\tconst workspace = await workspaceFactory.create(seedCtx);
|
|
2455
|
+
\t\tawait workspaceMemberFactory.create(seedCtx, {
|
|
2456
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2457
|
+
\t\t\tuserId: "user-admin",
|
|
2458
|
+
\t\t\trole: "admin",
|
|
2459
|
+
\t\t});
|
|
2460
|
+
\t\tawait workspaceMemberFactory.create(seedCtx, {
|
|
2461
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2462
|
+
\t\t\tuserId: "user-member",
|
|
2463
|
+
\t\t\trole: "member",
|
|
2464
|
+
\t\t});
|
|
2465
|
+
\t\treturn workspace;
|
|
2466
|
+
\t}
|
|
2467
|
+
|
|
2468
|
+
\ttest("admins can promote members", async () => {
|
|
2469
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2470
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2471
|
+
\t\tconst ctx = fixture.createContext({
|
|
2472
|
+
\t\t\tuserId: "user-admin",
|
|
2473
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2474
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
|
|
2475
|
+
\t\t});
|
|
2476
|
+
|
|
2477
|
+
\t\tconst updated = await updateMemberRoleUseCase.run({
|
|
2478
|
+
\t\t\tctx,
|
|
2479
|
+
\t\t\tinput: { userId: "user-member", role: "admin" },
|
|
2480
|
+
\t\t});
|
|
2481
|
+
|
|
2482
|
+
\t\texpect(updated.role).toBe("admin");
|
|
2483
|
+
\t\tawait expect(
|
|
2484
|
+
\t\t\tfixture.repositories.members.countAdmins(workspace.id),
|
|
2485
|
+
\t\t).resolves.toBe(2);
|
|
2486
|
+
\t});
|
|
2487
|
+
|
|
2488
|
+
\ttest("refuses to demote or remove the last admin", async () => {
|
|
2489
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2490
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2491
|
+
\t\tconst ctx = fixture.createContext({
|
|
2492
|
+
\t\t\tuserId: "user-admin",
|
|
2493
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2494
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
|
|
2495
|
+
\t\t});
|
|
2496
|
+
|
|
2497
|
+
\t\tawait expect(
|
|
2498
|
+
\t\t\tupdateMemberRoleUseCase.run({
|
|
2499
|
+
\t\t\t\tctx,
|
|
2500
|
+
\t\t\t\tinput: { userId: "user-admin", role: "member" },
|
|
2501
|
+
\t\t\t}),
|
|
2502
|
+
\t\t).rejects.toMatchObject({ code: "LAST_ADMIN_REMOVAL", status: 409 });
|
|
2503
|
+
\t\tawait expect(
|
|
2504
|
+
\t\t\tremoveMemberUseCase.run({ ctx, input: { userId: "user-admin" } }),
|
|
2505
|
+
\t\t).rejects.toMatchObject({ code: "LAST_ADMIN_REMOVAL", status: 409 });
|
|
2506
|
+
\t});
|
|
2507
|
+
|
|
2508
|
+
\ttest("members cannot manage roles or remove members", async () => {
|
|
2509
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2510
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2511
|
+
\t\tconst ctx = fixture.createContext({
|
|
2512
|
+
\t\t\tuserId: "user-member",
|
|
2513
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2514
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "member" },
|
|
2515
|
+
\t\t});
|
|
2516
|
+
|
|
2517
|
+
\t\tawait expect(
|
|
2518
|
+
\t\t\tupdateMemberRoleUseCase.run({
|
|
2519
|
+
\t\t\t\tctx,
|
|
2520
|
+
\t\t\t\tinput: { userId: "user-admin", role: "member" },
|
|
2521
|
+
\t\t\t}),
|
|
2522
|
+
\t\t).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
|
|
2523
|
+
\t\tawait expect(
|
|
2524
|
+
\t\t\tremoveMemberUseCase.run({ ctx, input: { userId: "user-admin" } }),
|
|
2525
|
+
\t\t).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
|
|
2526
|
+
\t});
|
|
2527
|
+
|
|
2528
|
+
\ttest("removes members and rejects unknown targets", async () => {
|
|
2529
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2530
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2531
|
+
\t\tconst ctx = fixture.createContext({
|
|
2532
|
+
\t\t\tuserId: "user-admin",
|
|
2533
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2534
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
|
|
2535
|
+
\t\t});
|
|
2536
|
+
|
|
2537
|
+
\t\tawait removeMemberUseCase.run({ ctx, input: { userId: "user-member" } });
|
|
2538
|
+
\t\tawait expect(
|
|
2539
|
+
\t\t\tfixture.repositories.members.findMembership({
|
|
2540
|
+
\t\t\t\tworkspaceId: workspace.id,
|
|
2541
|
+
\t\t\t\tuserId: "user-member",
|
|
2542
|
+
\t\t\t}),
|
|
2543
|
+
\t\t).resolves.toBeNull();
|
|
2544
|
+
|
|
2545
|
+
\t\tawait expect(
|
|
2546
|
+
\t\t\tremoveMemberUseCase.run({ ctx, input: { userId: "user-ghost" } }),
|
|
2547
|
+
\t\t).rejects.toMatchObject({ code: "NOT_A_WORKSPACE_MEMBER" });
|
|
2548
|
+
\t});
|
|
2549
|
+
});
|
|
2550
|
+
`;
|
|
2551
|
+
}
|
|
2552
|
+
export function invitesTestFile() {
|
|
2553
|
+
return `import { describe, expect, test } from "bun:test";
|
|
2554
|
+
import { createInvite } from "@/features/workspaces/contracts";
|
|
2555
|
+
import {
|
|
2556
|
+
\tacceptInviteUseCase,
|
|
2557
|
+
\tcreateInviteUseCase,
|
|
2558
|
+
\trevokeInviteUseCase,
|
|
2559
|
+
} from "@/features/workspaces/use-cases";
|
|
2560
|
+
import {
|
|
2561
|
+
\tcreateWorkspaceFixture,
|
|
2562
|
+
\ttype WorkspaceTestFixture,
|
|
2563
|
+
} from "./factories/context";
|
|
2564
|
+
import { workspaceFactory } from "./factories/workspace";
|
|
2565
|
+
import { workspaceInviteFactory } from "./factories/workspace-invite";
|
|
2566
|
+
import { workspaceMemberFactory } from "./factories/workspace-member";
|
|
2567
|
+
|
|
2568
|
+
async function seedWorkspace(fixture: WorkspaceTestFixture) {
|
|
2569
|
+
\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2570
|
+
\tconst workspace = await workspaceFactory.create(seedCtx, {
|
|
2571
|
+
\t\tname: "Acme",
|
|
2572
|
+
\t\tslug: "acme",
|
|
2573
|
+
\t});
|
|
2574
|
+
\tawait workspaceMemberFactory.create(seedCtx, {
|
|
2575
|
+
\t\tworkspaceId: workspace.id,
|
|
2576
|
+
\t\tuserId: "user-admin",
|
|
2577
|
+
\t\trole: "admin",
|
|
2578
|
+
\t});
|
|
2579
|
+
\treturn workspace;
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
describe("createInviteUseCase", () => {
|
|
2583
|
+
\ttest("creates a pending invite and sends the invite email", async () => {
|
|
2584
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2585
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2586
|
+
\t\tconst ctx = fixture.createContext({
|
|
2587
|
+
\t\t\tuserId: "user-admin",
|
|
2588
|
+
\t\t\tuserName: "Amelia",
|
|
2589
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2590
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
|
|
2591
|
+
\t\t});
|
|
2592
|
+
|
|
2593
|
+
\t\tconst invite = await createInviteUseCase.run({
|
|
2594
|
+
\t\t\tctx,
|
|
2595
|
+
\t\t\tinput: { email: "taylor@example.com", role: "member" },
|
|
2596
|
+
\t\t});
|
|
2597
|
+
|
|
2598
|
+
\t\texpect(invite).toMatchObject({
|
|
2599
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2600
|
+
\t\t\temail: "taylor@example.com",
|
|
2601
|
+
\t\t\trole: "member",
|
|
2602
|
+
\t\t\tstatus: "pending",
|
|
2603
|
+
\t\t\tinvitedByUserId: "user-admin",
|
|
2604
|
+
\t\t});
|
|
2605
|
+
\t\texpect(invite.token.length).toBeGreaterThan(0);
|
|
2606
|
+
|
|
2607
|
+
\t\texpect(fixture.notifications.deliveries).toMatchObject([
|
|
2608
|
+
\t\t\t{
|
|
2609
|
+
\t\t\t\tnotificationName: "workspaces.invite",
|
|
2610
|
+
\t\t\t\tpayload: {
|
|
2611
|
+
\t\t\t\t\tinviteId: invite.id,
|
|
2612
|
+
\t\t\t\t\tworkspaceName: "Acme",
|
|
2613
|
+
\t\t\t\t\temail: "taylor@example.com",
|
|
2614
|
+
\t\t\t\t\ttoken: invite.token,
|
|
2615
|
+
\t\t\t\t},
|
|
2616
|
+
\t\t\t},
|
|
2617
|
+
\t\t]);
|
|
2618
|
+
\t});
|
|
2619
|
+
|
|
2620
|
+
\ttest("denies non-admin members", async () => {
|
|
2621
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2622
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2623
|
+
\t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2624
|
+
\t\tawait workspaceMemberFactory.create(seedCtx, {
|
|
2625
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2626
|
+
\t\t\tuserId: "user-member",
|
|
2627
|
+
\t\t\trole: "member",
|
|
2628
|
+
\t\t});
|
|
2629
|
+
\t\tconst ctx = fixture.createContext({
|
|
2630
|
+
\t\t\tuserId: "user-member",
|
|
2631
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2632
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "member" },
|
|
2633
|
+
\t\t});
|
|
2634
|
+
|
|
2635
|
+
\t\tawait expect(
|
|
2636
|
+
\t\t\tcreateInviteUseCase.run({
|
|
2637
|
+
\t\t\t\tctx,
|
|
2638
|
+
\t\t\t\tinput: { email: "x@example.com", role: "member" },
|
|
2639
|
+
\t\t\t}),
|
|
2640
|
+
\t\t).rejects.toMatchObject({ code: "FORBIDDEN", status: 403 });
|
|
2641
|
+
\t\texpect(fixture.notifications.deliveries).toHaveLength(0);
|
|
2642
|
+
\t});
|
|
2643
|
+
|
|
2644
|
+
\ttest("declares required idempotency on the contract", () => {
|
|
2645
|
+
\t\texpect(createInvite.metadata.idempotency).toMatchObject({
|
|
2646
|
+
\t\t\trequired: true,
|
|
2647
|
+
\t\t\tscope: "actor",
|
|
2648
|
+
\t\t});
|
|
2649
|
+
\t});
|
|
2650
|
+
});
|
|
2651
|
+
|
|
2652
|
+
describe("acceptInviteUseCase", () => {
|
|
2653
|
+
\ttest("creates the membership and marks the invite accepted", async () => {
|
|
2654
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2655
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2656
|
+
\t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2657
|
+
\t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
|
|
2658
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2659
|
+
\t\t\temail: "priya@example.com",
|
|
2660
|
+
\t\t\tinvitedByUserId: "user-admin",
|
|
2661
|
+
\t\t});
|
|
2662
|
+
|
|
2663
|
+
\t\tconst ctx = fixture.createContext({
|
|
2664
|
+
\t\t\tuserId: "user-priya",
|
|
2665
|
+
\t\t\tuserName: "Priya",
|
|
2666
|
+
\t\t});
|
|
2667
|
+
\t\tconst result = await acceptInviteUseCase.run({
|
|
2668
|
+
\t\t\tctx,
|
|
2669
|
+
\t\t\tinput: { token: invite.token },
|
|
2670
|
+
\t\t});
|
|
2671
|
+
|
|
2672
|
+
\t\texpect(result.workspace.id).toBe(workspace.id);
|
|
2673
|
+
\t\texpect(result.membership).toMatchObject({
|
|
2674
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2675
|
+
\t\t\tuserId: "user-priya",
|
|
2676
|
+
\t\t\trole: "member",
|
|
2677
|
+
\t\t});
|
|
2678
|
+
|
|
2679
|
+
\t\tconst reloaded = await fixture.repositories.invites.findByToken(
|
|
2680
|
+
\t\t\tinvite.token,
|
|
2681
|
+
\t\t);
|
|
2682
|
+
\t\texpect(reloaded?.status).toBe("accepted");
|
|
2683
|
+
\t\texpect(reloaded?.acceptedAt).not.toBeNull();
|
|
2684
|
+
\t});
|
|
2685
|
+
|
|
2686
|
+
\ttest("rejects an already accepted invite", async () => {
|
|
2687
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2688
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2689
|
+
\t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2690
|
+
\t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
|
|
2691
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2692
|
+
\t\t\tinvitedByUserId: "user-admin",
|
|
2693
|
+
\t\t});
|
|
2694
|
+
|
|
2695
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-priya" });
|
|
2696
|
+
\t\tawait acceptInviteUseCase.run({ ctx, input: { token: invite.token } });
|
|
2697
|
+
|
|
2698
|
+
\t\tawait expect(
|
|
2699
|
+
\t\t\tacceptInviteUseCase.run({ ctx, input: { token: invite.token } }),
|
|
2700
|
+
\t\t).rejects.toMatchObject({ code: "INVITE_ALREADY_ACCEPTED", status: 409 });
|
|
2701
|
+
\t});
|
|
2702
|
+
|
|
2703
|
+
\ttest("expires stale invites and rejects them", async () => {
|
|
2704
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2705
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2706
|
+
\t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2707
|
+
\t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
|
|
2708
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2709
|
+
\t\t\tinvitedByUserId: "user-admin",
|
|
2710
|
+
\t\t\texpiresAt: new Date(Date.now() - 1000).toISOString(),
|
|
2711
|
+
\t\t});
|
|
2712
|
+
|
|
2713
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-priya" });
|
|
2714
|
+
\t\tawait expect(
|
|
2715
|
+
\t\t\tacceptInviteUseCase.run({ ctx, input: { token: invite.token } }),
|
|
2716
|
+
\t\t).rejects.toMatchObject({ code: "INVITE_EXPIRED", status: 410 });
|
|
2717
|
+
|
|
2718
|
+
\t\tconst reloaded = await fixture.repositories.invites.findByToken(
|
|
2719
|
+
\t\t\tinvite.token,
|
|
2720
|
+
\t\t);
|
|
2721
|
+
\t\texpect(reloaded?.status).toBe("expired");
|
|
2722
|
+
\t});
|
|
2723
|
+
|
|
2724
|
+
\ttest("rejects unknown tokens", async () => {
|
|
2725
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2726
|
+
\t\tconst ctx = fixture.createContext({ userId: "user-priya" });
|
|
2727
|
+
|
|
2728
|
+
\t\tawait expect(
|
|
2729
|
+
\t\t\tacceptInviteUseCase.run({ ctx, input: { token: "nope" } }),
|
|
2730
|
+
\t\t).rejects.toMatchObject({ code: "INVITE_NOT_FOUND", status: 404 });
|
|
2731
|
+
\t});
|
|
2732
|
+
});
|
|
2733
|
+
|
|
2734
|
+
describe("revokeInviteUseCase", () => {
|
|
2735
|
+
\ttest("revokes pending invites and rejects unknown invites", async () => {
|
|
2736
|
+
\t\tconst fixture = createWorkspaceFixture();
|
|
2737
|
+
\t\tconst workspace = await seedWorkspace(fixture);
|
|
2738
|
+
\t\tconst seedCtx = fixture.createContext({ userId: "user-admin" });
|
|
2739
|
+
\t\tconst invite = await workspaceInviteFactory.create(seedCtx, {
|
|
2740
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2741
|
+
\t\t\tinvitedByUserId: "user-admin",
|
|
2742
|
+
\t\t});
|
|
2743
|
+
\t\tconst ctx = fixture.createContext({
|
|
2744
|
+
\t\t\tuserId: "user-admin",
|
|
2745
|
+
\t\t\tworkspaceId: workspace.id,
|
|
2746
|
+
\t\t\tmembership: { workspaceId: workspace.id, role: "admin" },
|
|
2747
|
+
\t\t});
|
|
2748
|
+
|
|
2749
|
+
\t\tconst revoked = await revokeInviteUseCase.run({
|
|
2750
|
+
\t\t\tctx,
|
|
2751
|
+
\t\t\tinput: { inviteId: invite.id },
|
|
2752
|
+
\t\t});
|
|
2753
|
+
\t\texpect(revoked.status).toBe("revoked");
|
|
2754
|
+
|
|
2755
|
+
\t\tawait expect(
|
|
2756
|
+
\t\t\trevokeInviteUseCase.run({ ctx, input: { inviteId: "missing" } }),
|
|
2757
|
+
\t\t).rejects.toMatchObject({ code: "INVITE_NOT_FOUND" });
|
|
2758
|
+
\t});
|
|
2759
|
+
});
|
|
2760
|
+
`;
|
|
2761
|
+
}
|
|
2762
|
+
export function policyTestFile() {
|
|
2763
|
+
return `import { describe, expect, it } from "bun:test";
|
|
2764
|
+
import { createPolicyTester } from "@beignet/core/ports/testing";
|
|
2765
|
+
import { workspacePolicy } from "@/features/workspaces/policy";
|
|
2766
|
+
import { workspaceFactory } from "./factories/workspace";
|
|
2767
|
+
|
|
2768
|
+
const abilities = [
|
|
2769
|
+
\t"workspaces.update",
|
|
2770
|
+
\t"workspaces.members.view",
|
|
2771
|
+
\t"workspaces.members.manage",
|
|
2772
|
+
] as const;
|
|
2773
|
+
|
|
2774
|
+
describe("workspacePolicy", () => {
|
|
2775
|
+
\tit("documents the admin/member/anonymous matrix per ability", async () => {
|
|
2776
|
+
\t\tconst tester = createPolicyTester({ policies: [workspacePolicy] });
|
|
2777
|
+
\t\tconst workspace = workspaceFactory.build({ id: "ws-1" });
|
|
2778
|
+
|
|
2779
|
+
\t\tconst adminCtx = {
|
|
2780
|
+
\t\t\tactor: { type: "user" as const, id: "user-admin" },
|
|
2781
|
+
\t\t\tmembership: { workspaceId: "ws-1", role: "admin" as const },
|
|
2782
|
+
\t\t};
|
|
2783
|
+
\t\tconst memberCtx = {
|
|
2784
|
+
\t\t\tactor: { type: "user" as const, id: "user-member" },
|
|
2785
|
+
\t\t\tmembership: { workspaceId: "ws-1", role: "member" as const },
|
|
2786
|
+
\t\t};
|
|
2787
|
+
\t\tconst otherWorkspaceCtx = {
|
|
2788
|
+
\t\t\tactor: { type: "user" as const, id: "user-other" },
|
|
2789
|
+
\t\t\tmembership: { workspaceId: "ws-2", role: "admin" as const },
|
|
2790
|
+
\t\t};
|
|
2791
|
+
\t\tconst anonymousCtx = {
|
|
2792
|
+
\t\t\tactor: { type: "anonymous" as const },
|
|
2793
|
+
\t\t\tmembership: null,
|
|
2794
|
+
\t\t};
|
|
2795
|
+
|
|
2796
|
+
\t\tawait expect(
|
|
2797
|
+
\t\t\ttester.assertMatrix([
|
|
2798
|
+
\t\t\t\t// Admin-only abilities.
|
|
2799
|
+
\t\t\t\t...(
|
|
2800
|
+
\t\t\t\t\t["workspaces.update", "workspaces.members.manage"] as const
|
|
2801
|
+
\t\t\t\t).flatMap((ability) => [
|
|
2802
|
+
\t\t\t\t\t{
|
|
2803
|
+
\t\t\t\t\t\tname: \`admins are allowed \${ability}\`,
|
|
2804
|
+
\t\t\t\t\t\tctx: adminCtx,
|
|
2805
|
+
\t\t\t\t\t\tability,
|
|
2806
|
+
\t\t\t\t\t\tsubject: workspace,
|
|
2807
|
+
\t\t\t\t\t\texpected: "allow" as const,
|
|
2808
|
+
\t\t\t\t\t},
|
|
2809
|
+
\t\t\t\t\t{
|
|
2810
|
+
\t\t\t\t\t\tname: \`members are denied \${ability}\`,
|
|
2811
|
+
\t\t\t\t\t\tctx: memberCtx,
|
|
2812
|
+
\t\t\t\t\t\tability,
|
|
2813
|
+
\t\t\t\t\t\tsubject: workspace,
|
|
2814
|
+
\t\t\t\t\t\texpected: "deny" as const,
|
|
2815
|
+
\t\t\t\t\t\tcode: "WORKSPACE_ADMIN_REQUIRED",
|
|
2816
|
+
\t\t\t\t\t},
|
|
2817
|
+
\t\t\t\t]),
|
|
2818
|
+
\t\t\t\t// Any member may view members.
|
|
2819
|
+
\t\t\t\t{
|
|
2820
|
+
\t\t\t\t\tname: "members can view members",
|
|
2821
|
+
\t\t\t\t\tctx: memberCtx,
|
|
2822
|
+
\t\t\t\t\tability: "workspaces.members.view",
|
|
2823
|
+
\t\t\t\t\tsubject: workspace,
|
|
2824
|
+
\t\t\t\t\texpected: "allow",
|
|
2825
|
+
\t\t\t\t},
|
|
2826
|
+
\t\t\t\t// Cross-workspace admins and anonymous visitors are non-members.
|
|
2827
|
+
\t\t\t\t...abilities.flatMap((ability) => [
|
|
2828
|
+
\t\t\t\t\t{
|
|
2829
|
+
\t\t\t\t\t\tname: \`another workspace's admin is denied \${ability}\`,
|
|
2830
|
+
\t\t\t\t\t\tctx: otherWorkspaceCtx,
|
|
2831
|
+
\t\t\t\t\t\tability,
|
|
2832
|
+
\t\t\t\t\t\tsubject: workspace,
|
|
2833
|
+
\t\t\t\t\t\texpected: "deny" as const,
|
|
2834
|
+
\t\t\t\t\t\tcode: "NOT_A_WORKSPACE_MEMBER",
|
|
2835
|
+
\t\t\t\t\t},
|
|
2836
|
+
\t\t\t\t\t{
|
|
2837
|
+
\t\t\t\t\t\tname: \`anonymous visitors are denied \${ability}\`,
|
|
2838
|
+
\t\t\t\t\t\tctx: anonymousCtx,
|
|
2839
|
+
\t\t\t\t\t\tability,
|
|
2840
|
+
\t\t\t\t\t\tsubject: workspace,
|
|
2841
|
+
\t\t\t\t\t\texpected: "deny" as const,
|
|
2842
|
+
\t\t\t\t\t\tcode: "NOT_A_WORKSPACE_MEMBER",
|
|
2843
|
+
\t\t\t\t\t},
|
|
2844
|
+
\t\t\t\t]),
|
|
2845
|
+
\t\t\t]),
|
|
2846
|
+
\t\t).resolves.toBeUndefined();
|
|
2847
|
+
\t});
|
|
2848
|
+
});
|
|
2849
|
+
`;
|
|
2850
|
+
}
|
|
2851
|
+
export function workspaceDrizzleSchemaFile(database) {
|
|
2852
|
+
const dialect = drizzleDialects[database];
|
|
2853
|
+
const column = database === "mysql"
|
|
2854
|
+
? (columnName) => `varchar("${columnName}", { length: 255 })`
|
|
2855
|
+
: (columnName) => `text("${columnName}")`;
|
|
2856
|
+
const imports = database === "mysql"
|
|
2857
|
+
? "index, mysqlTable, uniqueIndex, varchar"
|
|
2858
|
+
: `index, ${dialect.tableFunction}, text, uniqueIndex`;
|
|
2859
|
+
return `import { ${imports} } from "${dialect.columnModule}";
|
|
2860
|
+
import type {
|
|
2861
|
+
\tInviteStatus,
|
|
2862
|
+
\tWorkspaceRole,
|
|
2863
|
+
} from "@/features/workspaces/schemas";
|
|
2864
|
+
|
|
2865
|
+
export const workspaces = ${dialect.tableFunction}(
|
|
2866
|
+
\t"workspaces",
|
|
2867
|
+
\t{
|
|
2868
|
+
\t\tid: ${column("id")}.primaryKey(),
|
|
2869
|
+
\t\tname: ${column("name")}.notNull(),
|
|
2870
|
+
\t\tslug: ${column("slug")}.notNull(),
|
|
2871
|
+
\t\tcreatedAt: ${column("created_at")}.notNull(),
|
|
2872
|
+
\t\tupdatedAt: ${column("updated_at")}.notNull(),
|
|
2873
|
+
\t},
|
|
2874
|
+
\t(table) => ({
|
|
2875
|
+
\t\tslugUnique: uniqueIndex("workspaces_slug_unique").on(table.slug),
|
|
2876
|
+
\t}),
|
|
2877
|
+
);
|
|
2878
|
+
|
|
2879
|
+
export const workspaceMembers = ${dialect.tableFunction}(
|
|
2880
|
+
\t"workspace_members",
|
|
2881
|
+
\t{
|
|
2882
|
+
\t\tid: ${column("id")}.primaryKey(),
|
|
2883
|
+
\t\tworkspaceId: ${column("workspace_id")}.notNull(),
|
|
2884
|
+
\t\tuserId: ${column("user_id")}.notNull(),
|
|
2885
|
+
\t\trole: ${column("role")}.$type<WorkspaceRole>().notNull(),
|
|
2886
|
+
\t\tjoinedAt: ${column("joined_at")}.notNull(),
|
|
2887
|
+
\t},
|
|
2888
|
+
\t(table) => ({
|
|
2889
|
+
\t\tworkspaceUserUnique: uniqueIndex(
|
|
2890
|
+
\t\t\t"workspace_members_workspace_user_unique",
|
|
2891
|
+
\t\t).on(table.workspaceId, table.userId),
|
|
2892
|
+
\t\tuserIdx: index("workspace_members_user_idx").on(table.userId),
|
|
2893
|
+
\t}),
|
|
2894
|
+
);
|
|
2895
|
+
|
|
2896
|
+
export const workspaceInvites = ${dialect.tableFunction}(
|
|
2897
|
+
\t"workspace_invites",
|
|
2898
|
+
\t{
|
|
2899
|
+
\t\tid: ${column("id")}.primaryKey(),
|
|
2900
|
+
\t\tworkspaceId: ${column("workspace_id")}.notNull(),
|
|
2901
|
+
\t\temail: ${column("email")}.notNull(),
|
|
2902
|
+
\t\trole: ${column("role")}.$type<WorkspaceRole>().notNull(),
|
|
2903
|
+
\t\ttoken: ${column("token")}.notNull(),
|
|
2904
|
+
\t\tinvitedByUserId: ${column("invited_by_user_id")}.notNull(),
|
|
2905
|
+
\t\tstatus: ${column("status")}.$type<InviteStatus>().notNull(),
|
|
2906
|
+
\t\texpiresAt: ${column("expires_at")}.notNull(),
|
|
2907
|
+
\t\tacceptedAt: ${column("accepted_at")},
|
|
2908
|
+
\t\tcreatedAt: ${column("created_at")}.notNull(),
|
|
2909
|
+
\t},
|
|
2910
|
+
\t(table) => ({
|
|
2911
|
+
\t\ttokenUnique: uniqueIndex("workspace_invites_token_unique").on(
|
|
2912
|
+
\t\t\ttable.token,
|
|
2913
|
+
\t\t),
|
|
2914
|
+
\t\tworkspaceStatusIdx: index("workspace_invites_workspace_status_idx").on(
|
|
2915
|
+
\t\t\ttable.workspaceId,
|
|
2916
|
+
\t\t\ttable.status,
|
|
2917
|
+
\t\t),
|
|
2918
|
+
\t}),
|
|
2919
|
+
);
|
|
2920
|
+
`;
|
|
2921
|
+
}
|
|
2922
|
+
export function workspaceDrizzleRepositoryFile(config, database) {
|
|
2923
|
+
const dialect = drizzleDialects[database];
|
|
2924
|
+
const portsModule = aliasModule(path.join(config.paths.features, "workspaces/ports.ts"));
|
|
2925
|
+
const workspaceCreate = dialect.supportsReturning
|
|
2926
|
+
? `\t\t\tconst [row] = await db
|
|
2927
|
+
\t\t\t\t.insert(schema.workspaces)
|
|
2928
|
+
\t\t\t\t.values(values)
|
|
2929
|
+
\t\t\t\t.returning();
|
|
2930
|
+
|
|
2931
|
+
\t\t\tif (!row) {
|
|
2932
|
+
\t\t\t\tthrow new Error("Failed to create workspace.");
|
|
2933
|
+
\t\t\t}
|
|
2934
|
+
|
|
2935
|
+
\t\t\treturn toWorkspace(row);`
|
|
2936
|
+
: `\t\t\tawait db.insert(schema.workspaces).values(values);
|
|
2937
|
+
|
|
2938
|
+
\t\t\tconst [row] = await db
|
|
2939
|
+
\t\t\t\t.select()
|
|
2940
|
+
\t\t\t\t.from(schema.workspaces)
|
|
2941
|
+
\t\t\t\t.where(eq(schema.workspaces.id, values.id))
|
|
2942
|
+
\t\t\t\t.limit(1);
|
|
2943
|
+
|
|
2944
|
+
\t\t\tif (!row) {
|
|
2945
|
+
\t\t\t\tthrow new Error("Failed to create workspace.");
|
|
2946
|
+
\t\t\t}
|
|
2947
|
+
|
|
2948
|
+
\t\t\treturn toWorkspace(row);`;
|
|
2949
|
+
const workspaceUpdate = dialect.supportsReturning
|
|
2950
|
+
? `\t\t\tconst [row] = await db
|
|
2951
|
+
\t\t\t\t.update(schema.workspaces)
|
|
2952
|
+
\t\t\t\t.set(updates)
|
|
2953
|
+
\t\t\t\t.where(eq(schema.workspaces.id, id))
|
|
2954
|
+
\t\t\t\t.returning();
|
|
2955
|
+
|
|
2956
|
+
\t\t\treturn row ? toWorkspace(row) : null;`
|
|
2957
|
+
: `\t\t\tawait db
|
|
2958
|
+
\t\t\t\t.update(schema.workspaces)
|
|
2959
|
+
\t\t\t\t.set(updates)
|
|
2960
|
+
\t\t\t\t.where(eq(schema.workspaces.id, id));
|
|
2961
|
+
|
|
2962
|
+
\t\t\tconst [row] = await db
|
|
2963
|
+
\t\t\t\t.select()
|
|
2964
|
+
\t\t\t\t.from(schema.workspaces)
|
|
2965
|
+
\t\t\t\t.where(eq(schema.workspaces.id, id))
|
|
2966
|
+
\t\t\t\t.limit(1);
|
|
2967
|
+
|
|
2968
|
+
\t\t\treturn row ? toWorkspace(row) : null;`;
|
|
2969
|
+
const memberCreate = dialect.supportsReturning
|
|
2970
|
+
? `\t\t\tconst [row] = await db
|
|
2971
|
+
\t\t\t\t.insert(schema.workspaceMembers)
|
|
2972
|
+
\t\t\t\t.values(values)
|
|
2973
|
+
\t\t\t\t.returning();
|
|
2974
|
+
|
|
2975
|
+
\t\t\tif (!row) {
|
|
2976
|
+
\t\t\t\tthrow new Error("Failed to create workspace membership.");
|
|
2977
|
+
\t\t\t}
|
|
2978
|
+
|
|
2979
|
+
\t\t\treturn toMember(row);`
|
|
2980
|
+
: `\t\t\tawait db.insert(schema.workspaceMembers).values(values);
|
|
2981
|
+
|
|
2982
|
+
\t\t\tconst [row] = await db
|
|
2983
|
+
\t\t\t\t.select()
|
|
2984
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
2985
|
+
\t\t\t\t.where(eq(schema.workspaceMembers.id, values.id))
|
|
2986
|
+
\t\t\t\t.limit(1);
|
|
2987
|
+
|
|
2988
|
+
\t\t\tif (!row) {
|
|
2989
|
+
\t\t\t\tthrow new Error("Failed to create workspace membership.");
|
|
2990
|
+
\t\t\t}
|
|
2991
|
+
|
|
2992
|
+
\t\t\treturn toMember(row);`;
|
|
2993
|
+
const memberUpdateRole = dialect.supportsReturning
|
|
2994
|
+
? `\t\t\tconst [row] = await db
|
|
2995
|
+
\t\t\t\t.update(schema.workspaceMembers)
|
|
2996
|
+
\t\t\t\t.set({ role })
|
|
2997
|
+
\t\t\t\t.where(membershipWhere(lookup))
|
|
2998
|
+
\t\t\t\t.returning();
|
|
2999
|
+
|
|
3000
|
+
\t\t\treturn row ? toMember(row) : null;`
|
|
3001
|
+
: `\t\t\tawait db
|
|
3002
|
+
\t\t\t\t.update(schema.workspaceMembers)
|
|
3003
|
+
\t\t\t\t.set({ role })
|
|
3004
|
+
\t\t\t\t.where(membershipWhere(lookup));
|
|
3005
|
+
|
|
3006
|
+
\t\t\tconst [row] = await db
|
|
3007
|
+
\t\t\t\t.select()
|
|
3008
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
3009
|
+
\t\t\t\t.where(membershipWhere(lookup))
|
|
3010
|
+
\t\t\t\t.limit(1);
|
|
3011
|
+
|
|
3012
|
+
\t\t\treturn row ? toMember(row) : null;`;
|
|
3013
|
+
const memberRemove = dialect.supportsReturning
|
|
3014
|
+
? `\t\t\tconst rows = await db
|
|
3015
|
+
\t\t\t\t.delete(schema.workspaceMembers)
|
|
3016
|
+
\t\t\t\t.where(membershipWhere(lookup))
|
|
3017
|
+
\t\t\t\t.returning({ id: schema.workspaceMembers.id });
|
|
3018
|
+
|
|
3019
|
+
\t\t\treturn rows.length > 0;`
|
|
3020
|
+
: `\t\t\tconst [existing] = await db
|
|
3021
|
+
\t\t\t\t.select({ id: schema.workspaceMembers.id })
|
|
3022
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
3023
|
+
\t\t\t\t.where(membershipWhere(lookup))
|
|
3024
|
+
\t\t\t\t.limit(1);
|
|
3025
|
+
\t\t\tif (!existing) return false;
|
|
3026
|
+
|
|
3027
|
+
\t\t\tawait db
|
|
3028
|
+
\t\t\t\t.delete(schema.workspaceMembers)
|
|
3029
|
+
\t\t\t\t.where(membershipWhere(lookup));
|
|
3030
|
+
|
|
3031
|
+
\t\t\treturn true;`;
|
|
3032
|
+
const inviteCreate = dialect.supportsReturning
|
|
3033
|
+
? `\t\t\tconst [row] = await db
|
|
3034
|
+
\t\t\t\t.insert(schema.workspaceInvites)
|
|
3035
|
+
\t\t\t\t.values(values)
|
|
3036
|
+
\t\t\t\t.returning();
|
|
3037
|
+
|
|
3038
|
+
\t\t\tif (!row) {
|
|
3039
|
+
\t\t\t\tthrow new Error("Failed to create workspace invite.");
|
|
3040
|
+
\t\t\t}
|
|
3041
|
+
|
|
3042
|
+
\t\t\treturn toInvite(row);`
|
|
3043
|
+
: `\t\t\tawait db.insert(schema.workspaceInvites).values(values);
|
|
3044
|
+
|
|
3045
|
+
\t\t\tconst [row] = await db
|
|
3046
|
+
\t\t\t\t.select()
|
|
3047
|
+
\t\t\t\t.from(schema.workspaceInvites)
|
|
3048
|
+
\t\t\t\t.where(eq(schema.workspaceInvites.id, values.id))
|
|
3049
|
+
\t\t\t\t.limit(1);
|
|
3050
|
+
|
|
3051
|
+
\t\t\tif (!row) {
|
|
3052
|
+
\t\t\t\tthrow new Error("Failed to create workspace invite.");
|
|
3053
|
+
\t\t\t}
|
|
3054
|
+
|
|
3055
|
+
\t\t\treturn toInvite(row);`;
|
|
3056
|
+
const inviteUpdateStatus = dialect.supportsReturning
|
|
3057
|
+
? `\t\t\tconst [row] = await db
|
|
3058
|
+
\t\t\t\t.update(schema.workspaceInvites)
|
|
3059
|
+
\t\t\t\t.set({ status, acceptedAt: acceptedAt ?? null })
|
|
3060
|
+
\t\t\t\t.where(eq(schema.workspaceInvites.id, id))
|
|
3061
|
+
\t\t\t\t.returning();
|
|
3062
|
+
|
|
3063
|
+
\t\t\treturn row ? toInvite(row) : null;`
|
|
3064
|
+
: `\t\t\tawait db
|
|
3065
|
+
\t\t\t\t.update(schema.workspaceInvites)
|
|
3066
|
+
\t\t\t\t.set({ status, acceptedAt: acceptedAt ?? null })
|
|
3067
|
+
\t\t\t\t.where(eq(schema.workspaceInvites.id, id));
|
|
3068
|
+
|
|
3069
|
+
\t\t\tconst [row] = await db
|
|
3070
|
+
\t\t\t\t.select()
|
|
3071
|
+
\t\t\t\t.from(schema.workspaceInvites)
|
|
3072
|
+
\t\t\t\t.where(eq(schema.workspaceInvites.id, id))
|
|
3073
|
+
\t\t\t\t.limit(1);
|
|
3074
|
+
|
|
3075
|
+
\t\t\treturn row ? toInvite(row) : null;`;
|
|
3076
|
+
return `import type { ${dialect.dbTypeName} } from "@beignet/provider-db-drizzle/${dialect.subpath}";
|
|
3077
|
+
import { and, asc, count, desc, eq, inArray } from "drizzle-orm";
|
|
3078
|
+
import type {
|
|
3079
|
+
\tWorkspace,
|
|
3080
|
+
\tWorkspaceInvite,
|
|
3081
|
+
\tWorkspaceInviteRepository,
|
|
3082
|
+
\tWorkspaceMember,
|
|
3083
|
+
\tWorkspaceMemberRepository,
|
|
3084
|
+
\tWorkspaceMembershipLookup,
|
|
3085
|
+
\tWorkspaceMemberWithUser,
|
|
3086
|
+
\tWorkspaceRepository,
|
|
3087
|
+
} from "${portsModule}";
|
|
3088
|
+
import * as schema from "@/infra/db/schema";
|
|
3089
|
+
|
|
3090
|
+
type WorkspaceRow = typeof schema.workspaces.$inferSelect;
|
|
3091
|
+
type MemberRow = typeof schema.workspaceMembers.$inferSelect;
|
|
3092
|
+
type InviteRow = typeof schema.workspaceInvites.$inferSelect;
|
|
3093
|
+
|
|
3094
|
+
function toWorkspace(row: WorkspaceRow): Workspace {
|
|
3095
|
+
\treturn {
|
|
3096
|
+
\t\tid: row.id,
|
|
3097
|
+
\t\tname: row.name,
|
|
3098
|
+
\t\tslug: row.slug,
|
|
3099
|
+
\t\tcreatedAt: row.createdAt,
|
|
3100
|
+
\t\tupdatedAt: row.updatedAt,
|
|
3101
|
+
\t};
|
|
3102
|
+
}
|
|
3103
|
+
|
|
3104
|
+
function toMember(row: MemberRow): WorkspaceMember {
|
|
3105
|
+
\treturn {
|
|
3106
|
+
\t\tid: row.id,
|
|
3107
|
+
\t\tworkspaceId: row.workspaceId,
|
|
3108
|
+
\t\tuserId: row.userId,
|
|
3109
|
+
\t\trole: row.role,
|
|
3110
|
+
\t\tjoinedAt: row.joinedAt,
|
|
3111
|
+
\t};
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
function toInvite(row: InviteRow): WorkspaceInvite {
|
|
3115
|
+
\treturn {
|
|
3116
|
+
\t\tid: row.id,
|
|
3117
|
+
\t\tworkspaceId: row.workspaceId,
|
|
3118
|
+
\t\temail: row.email,
|
|
3119
|
+
\t\trole: row.role,
|
|
3120
|
+
\t\ttoken: row.token,
|
|
3121
|
+
\t\tinvitedByUserId: row.invitedByUserId,
|
|
3122
|
+
\t\tstatus: row.status,
|
|
3123
|
+
\t\texpiresAt: row.expiresAt,
|
|
3124
|
+
\t\tacceptedAt: row.acceptedAt,
|
|
3125
|
+
\t\tcreatedAt: row.createdAt,
|
|
3126
|
+
\t};
|
|
3127
|
+
}
|
|
3128
|
+
|
|
3129
|
+
function membershipWhere(lookup: WorkspaceMembershipLookup) {
|
|
3130
|
+
\treturn and(
|
|
3131
|
+
\t\teq(schema.workspaceMembers.workspaceId, lookup.workspaceId),
|
|
3132
|
+
\t\teq(schema.workspaceMembers.userId, lookup.userId),
|
|
3133
|
+
\t);
|
|
3134
|
+
}
|
|
3135
|
+
|
|
3136
|
+
export function createDrizzleWorkspaceRepository(
|
|
3137
|
+
\tdb: ${dialect.dbTypeName}<typeof schema>,
|
|
3138
|
+
): WorkspaceRepository {
|
|
3139
|
+
\treturn {
|
|
3140
|
+
\t\tasync findById(id) {
|
|
3141
|
+
\t\t\tconst [row] = await db
|
|
3142
|
+
\t\t\t\t.select()
|
|
3143
|
+
\t\t\t\t.from(schema.workspaces)
|
|
3144
|
+
\t\t\t\t.where(eq(schema.workspaces.id, id))
|
|
3145
|
+
\t\t\t\t.limit(1);
|
|
3146
|
+
|
|
3147
|
+
\t\t\treturn row ? toWorkspace(row) : null;
|
|
3148
|
+
\t\t},
|
|
3149
|
+
|
|
3150
|
+
\t\tasync findBySlug(slug) {
|
|
3151
|
+
\t\t\tconst [row] = await db
|
|
3152
|
+
\t\t\t\t.select()
|
|
3153
|
+
\t\t\t\t.from(schema.workspaces)
|
|
3154
|
+
\t\t\t\t.where(eq(schema.workspaces.slug, slug))
|
|
3155
|
+
\t\t\t\t.limit(1);
|
|
3156
|
+
|
|
3157
|
+
\t\t\treturn row ? toWorkspace(row) : null;
|
|
3158
|
+
\t\t},
|
|
3159
|
+
|
|
3160
|
+
\t\tasync findManyByIds(ids) {
|
|
3161
|
+
\t\t\tif (ids.length === 0) return [];
|
|
3162
|
+
|
|
3163
|
+
\t\t\tconst rows = await db
|
|
3164
|
+
\t\t\t\t.select()
|
|
3165
|
+
\t\t\t\t.from(schema.workspaces)
|
|
3166
|
+
\t\t\t\t.where(inArray(schema.workspaces.id, ids));
|
|
3167
|
+
|
|
3168
|
+
\t\t\treturn rows.map(toWorkspace);
|
|
3169
|
+
\t\t},
|
|
3170
|
+
|
|
3171
|
+
\t\tasync create(data) {
|
|
3172
|
+
\t\t\tconst now = new Date().toISOString();
|
|
3173
|
+
\t\t\tconst values = {
|
|
3174
|
+
\t\t\t\tid: crypto.randomUUID(),
|
|
3175
|
+
\t\t\t\tname: data.name,
|
|
3176
|
+
\t\t\t\tslug: data.slug,
|
|
3177
|
+
\t\t\t\tcreatedAt: now,
|
|
3178
|
+
\t\t\t\tupdatedAt: now,
|
|
3179
|
+
\t\t\t};
|
|
3180
|
+
|
|
3181
|
+
${workspaceCreate}
|
|
3182
|
+
\t\t},
|
|
3183
|
+
|
|
3184
|
+
\t\tasync update(id, data) {
|
|
3185
|
+
\t\t\tconst updates: Partial<typeof schema.workspaces.$inferInsert> = {
|
|
3186
|
+
\t\t\t\tupdatedAt: new Date().toISOString(),
|
|
3187
|
+
\t\t\t};
|
|
3188
|
+
\t\t\tif (data.name !== undefined) updates.name = data.name;
|
|
3189
|
+
\t\t\tif (data.slug !== undefined) updates.slug = data.slug;
|
|
3190
|
+
|
|
3191
|
+
${workspaceUpdate}
|
|
3192
|
+
\t\t},
|
|
3193
|
+
\t};
|
|
3194
|
+
}
|
|
3195
|
+
|
|
3196
|
+
export function createDrizzleWorkspaceMemberRepository(
|
|
3197
|
+
\tdb: ${dialect.dbTypeName}<typeof schema>,
|
|
3198
|
+
): WorkspaceMemberRepository {
|
|
3199
|
+
\treturn {
|
|
3200
|
+
\t\tasync findMembership(lookup) {
|
|
3201
|
+
\t\t\tconst [row] = await db
|
|
3202
|
+
\t\t\t\t.select()
|
|
3203
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
3204
|
+
\t\t\t\t.where(membershipWhere(lookup))
|
|
3205
|
+
\t\t\t\t.limit(1);
|
|
3206
|
+
|
|
3207
|
+
\t\t\treturn row ? toMember(row) : null;
|
|
3208
|
+
\t\t},
|
|
3209
|
+
|
|
3210
|
+
\t\tasync listByUser(userId) {
|
|
3211
|
+
\t\t\tconst rows = await db
|
|
3212
|
+
\t\t\t\t.select()
|
|
3213
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
3214
|
+
\t\t\t\t.where(eq(schema.workspaceMembers.userId, userId))
|
|
3215
|
+
\t\t\t\t.orderBy(asc(schema.workspaceMembers.joinedAt));
|
|
3216
|
+
|
|
3217
|
+
\t\t\treturn rows.map(toMember);
|
|
3218
|
+
\t\t},
|
|
3219
|
+
|
|
3220
|
+
\t\tasync listByWorkspace(workspaceId) {
|
|
3221
|
+
\t\t\tconst rows = await db
|
|
3222
|
+
\t\t\t\t.select({
|
|
3223
|
+
\t\t\t\t\tmember: schema.workspaceMembers,
|
|
3224
|
+
\t\t\t\t\tuser: {
|
|
3225
|
+
\t\t\t\t\t\tid: schema.user.id,
|
|
3226
|
+
\t\t\t\t\t\tname: schema.user.name,
|
|
3227
|
+
\t\t\t\t\t\temail: schema.user.email,
|
|
3228
|
+
\t\t\t\t\t\timage: schema.user.image,
|
|
3229
|
+
\t\t\t\t\t},
|
|
3230
|
+
\t\t\t\t})
|
|
3231
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
3232
|
+
\t\t\t\t.innerJoin(
|
|
3233
|
+
\t\t\t\t\tschema.user,
|
|
3234
|
+
\t\t\t\t\teq(schema.workspaceMembers.userId, schema.user.id),
|
|
3235
|
+
\t\t\t\t)
|
|
3236
|
+
\t\t\t\t.where(eq(schema.workspaceMembers.workspaceId, workspaceId))
|
|
3237
|
+
\t\t\t\t.orderBy(asc(schema.workspaceMembers.joinedAt));
|
|
3238
|
+
|
|
3239
|
+
\t\t\treturn rows.map(
|
|
3240
|
+
\t\t\t\t(row): WorkspaceMemberWithUser => ({
|
|
3241
|
+
\t\t\t\t\t...toMember(row.member),
|
|
3242
|
+
\t\t\t\t\tuser: {
|
|
3243
|
+
\t\t\t\t\t\tid: row.user.id,
|
|
3244
|
+
\t\t\t\t\t\tname: row.user.name,
|
|
3245
|
+
\t\t\t\t\t\temail: row.user.email,
|
|
3246
|
+
\t\t\t\t\t\timage: row.user.image,
|
|
3247
|
+
\t\t\t\t\t},
|
|
3248
|
+
\t\t\t\t}),
|
|
3249
|
+
\t\t\t);
|
|
3250
|
+
\t\t},
|
|
3251
|
+
|
|
3252
|
+
\t\tasync create(data) {
|
|
3253
|
+
\t\t\tconst values = {
|
|
3254
|
+
\t\t\t\tid: crypto.randomUUID(),
|
|
3255
|
+
\t\t\t\tworkspaceId: data.workspaceId,
|
|
3256
|
+
\t\t\t\tuserId: data.userId,
|
|
3257
|
+
\t\t\t\trole: data.role,
|
|
3258
|
+
\t\t\t\tjoinedAt: new Date().toISOString(),
|
|
3259
|
+
\t\t\t};
|
|
3260
|
+
|
|
3261
|
+
${memberCreate}
|
|
3262
|
+
\t\t},
|
|
3263
|
+
|
|
3264
|
+
\t\tasync updateRole(lookup, role) {
|
|
3265
|
+
${memberUpdateRole}
|
|
3266
|
+
\t\t},
|
|
3267
|
+
|
|
3268
|
+
\t\tasync remove(lookup) {
|
|
3269
|
+
${memberRemove}
|
|
3270
|
+
\t\t},
|
|
3271
|
+
|
|
3272
|
+
\t\tasync countAdmins(workspaceId) {
|
|
3273
|
+
\t\t\tconst [row] = await db
|
|
3274
|
+
\t\t\t\t.select({ total: count() })
|
|
3275
|
+
\t\t\t\t.from(schema.workspaceMembers)
|
|
3276
|
+
\t\t\t\t.where(
|
|
3277
|
+
\t\t\t\t\tand(
|
|
3278
|
+
\t\t\t\t\t\teq(schema.workspaceMembers.workspaceId, workspaceId),
|
|
3279
|
+
\t\t\t\t\t\teq(schema.workspaceMembers.role, "admin"),
|
|
3280
|
+
\t\t\t\t\t),
|
|
3281
|
+
\t\t\t\t);
|
|
3282
|
+
|
|
3283
|
+
\t\t\treturn row?.total ?? 0;
|
|
3284
|
+
\t\t},
|
|
3285
|
+
\t};
|
|
3286
|
+
}
|
|
3287
|
+
|
|
3288
|
+
export function createDrizzleWorkspaceInviteRepository(
|
|
3289
|
+
\tdb: ${dialect.dbTypeName}<typeof schema>,
|
|
3290
|
+
): WorkspaceInviteRepository {
|
|
3291
|
+
\treturn {
|
|
3292
|
+
\t\tasync findByToken(token) {
|
|
3293
|
+
\t\t\tconst [row] = await db
|
|
3294
|
+
\t\t\t\t.select()
|
|
3295
|
+
\t\t\t\t.from(schema.workspaceInvites)
|
|
3296
|
+
\t\t\t\t.where(eq(schema.workspaceInvites.token, token))
|
|
3297
|
+
\t\t\t\t.limit(1);
|
|
3298
|
+
|
|
3299
|
+
\t\t\treturn row ? toInvite(row) : null;
|
|
3300
|
+
\t\t},
|
|
3301
|
+
|
|
3302
|
+
\t\tasync listByWorkspace(workspaceId, status) {
|
|
3303
|
+
\t\t\tconst where = status
|
|
3304
|
+
\t\t\t\t? and(
|
|
3305
|
+
\t\t\t\t\t\teq(schema.workspaceInvites.workspaceId, workspaceId),
|
|
3306
|
+
\t\t\t\t\t\teq(schema.workspaceInvites.status, status),
|
|
3307
|
+
\t\t\t\t\t)
|
|
3308
|
+
\t\t\t\t: eq(schema.workspaceInvites.workspaceId, workspaceId);
|
|
3309
|
+
|
|
3310
|
+
\t\t\tconst rows = await db
|
|
3311
|
+
\t\t\t\t.select()
|
|
3312
|
+
\t\t\t\t.from(schema.workspaceInvites)
|
|
3313
|
+
\t\t\t\t.where(where)
|
|
3314
|
+
\t\t\t\t.orderBy(desc(schema.workspaceInvites.createdAt));
|
|
3315
|
+
|
|
3316
|
+
\t\t\treturn rows.map(toInvite);
|
|
3317
|
+
\t\t},
|
|
3318
|
+
|
|
3319
|
+
\t\tasync create(data) {
|
|
3320
|
+
\t\t\tconst values = {
|
|
3321
|
+
\t\t\t\tid: crypto.randomUUID(),
|
|
3322
|
+
\t\t\t\tworkspaceId: data.workspaceId,
|
|
3323
|
+
\t\t\t\temail: data.email,
|
|
3324
|
+
\t\t\t\trole: data.role,
|
|
3325
|
+
\t\t\t\ttoken: data.token,
|
|
3326
|
+
\t\t\t\tinvitedByUserId: data.invitedByUserId,
|
|
3327
|
+
\t\t\t\tstatus: "pending" as const,
|
|
3328
|
+
\t\t\t\texpiresAt: data.expiresAt,
|
|
3329
|
+
\t\t\t\tacceptedAt: null,
|
|
3330
|
+
\t\t\t\tcreatedAt: new Date().toISOString(),
|
|
3331
|
+
\t\t\t};
|
|
3332
|
+
|
|
3333
|
+
${inviteCreate}
|
|
3334
|
+
\t\t},
|
|
3335
|
+
|
|
3336
|
+
\t\tasync updateStatus(id, status, acceptedAt) {
|
|
3337
|
+
${inviteUpdateStatus}
|
|
3338
|
+
\t\t},
|
|
3339
|
+
\t};
|
|
3340
|
+
}
|
|
3341
|
+
`;
|
|
3342
|
+
}
|
|
3343
|
+
export function tenancyTenantHelperFile() {
|
|
3344
|
+
return `import {
|
|
3345
|
+
\ttype ActivityTenant,
|
|
3346
|
+
\ttype AuthRequestLike,
|
|
3347
|
+
\tcreateTenant,
|
|
3348
|
+
\tisUnboundPort,
|
|
3349
|
+
} from "@beignet/core/ports";
|
|
3350
|
+
import type { WorkspaceMembershipContext } from "@/features/workspaces/policy";
|
|
3351
|
+
import type { AppPorts } from "@/ports";
|
|
3352
|
+
import type { AuthSession } from "@/ports/auth";
|
|
3353
|
+
|
|
3354
|
+
/**
|
|
3355
|
+
* Cookie set by POST /api/workspaces/switch to pick the active workspace.
|
|
3356
|
+
*/
|
|
3357
|
+
export const WORKSPACE_COOKIE_NAME = "beignet-workspace";
|
|
3358
|
+
|
|
3359
|
+
export type WorkspaceResolution = {
|
|
3360
|
+
\ttenant?: ActivityTenant;
|
|
3361
|
+
\tmembership: WorkspaceMembershipContext | null;
|
|
3362
|
+
};
|
|
3363
|
+
|
|
3364
|
+
export type ResolveRequestWorkspaceInput = {
|
|
3365
|
+
\tauth: AuthSession | null;
|
|
3366
|
+
\treq: AuthRequestLike;
|
|
3367
|
+
\tports: Pick<AppPorts, "members">;
|
|
3368
|
+
};
|
|
3369
|
+
|
|
3370
|
+
/**
|
|
3371
|
+
* Resolve the active workspace and membership for a request.
|
|
3372
|
+
*
|
|
3373
|
+
* Authenticated users resolve through their memberships, with the
|
|
3374
|
+
* \`beignet-workspace\` cookie choosing among them and the first membership as
|
|
3375
|
+
* the fallback. Anonymous requests and users with zero memberships get no
|
|
3376
|
+
* tenant so the UI can route to workspace creation.
|
|
3377
|
+
*/
|
|
3378
|
+
export async function resolveRequestWorkspace({
|
|
3379
|
+
\tauth,
|
|
3380
|
+
\treq,
|
|
3381
|
+
\tports,
|
|
3382
|
+
}: ResolveRequestWorkspaceInput): Promise<WorkspaceResolution> {
|
|
3383
|
+
\tif (!auth) {
|
|
3384
|
+
\t\treturn { tenant: undefined, membership: null };
|
|
3385
|
+
\t}
|
|
3386
|
+
|
|
3387
|
+
\t// Focused test apps built on createTestPorts may leave the workspace ports
|
|
3388
|
+
\t// unbound; they resolve no workspace instead of failing on first use.
|
|
3389
|
+
\tif (isUnboundPort(ports.members)) {
|
|
3390
|
+
\t\treturn { tenant: undefined, membership: null };
|
|
3391
|
+
\t}
|
|
3392
|
+
|
|
3393
|
+
\tconst memberships = await ports.members.listByUser(auth.user.id);
|
|
3394
|
+
\tif (memberships.length === 0) {
|
|
3395
|
+
\t\treturn { tenant: undefined, membership: null };
|
|
3396
|
+
\t}
|
|
3397
|
+
|
|
3398
|
+
\tconst cookieWorkspaceId = readWorkspaceCookie(req);
|
|
3399
|
+
\tconst active =
|
|
3400
|
+
\t\tmemberships.find(
|
|
3401
|
+
\t\t\t(membership) => membership.workspaceId === cookieWorkspaceId,
|
|
3402
|
+
\t\t) ?? memberships[0];
|
|
3403
|
+
\tif (!active) {
|
|
3404
|
+
\t\treturn { tenant: undefined, membership: null };
|
|
3405
|
+
\t}
|
|
3406
|
+
|
|
3407
|
+
\treturn {
|
|
3408
|
+
\t\ttenant: createTenant(active.workspaceId),
|
|
3409
|
+
\t\tmembership: { workspaceId: active.workspaceId, role: active.role },
|
|
3410
|
+
\t};
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
/**
|
|
3414
|
+
* Resolve a workspace for service work (schedules, tasks, outbox drains).
|
|
3415
|
+
* Service work either targets an explicit workspace or iterates workspaces
|
|
3416
|
+
* itself.
|
|
3417
|
+
*/
|
|
3418
|
+
export function resolveServiceWorkspace(workspaceId: string | undefined): {
|
|
3419
|
+
\ttenant?: ActivityTenant;
|
|
3420
|
+
} {
|
|
3421
|
+
\tconst normalized = normalizeWorkspaceId(workspaceId);
|
|
3422
|
+
\treturn { tenant: normalized ? createTenant(normalized) : undefined };
|
|
3423
|
+
}
|
|
3424
|
+
|
|
3425
|
+
function readWorkspaceCookie(req: AuthRequestLike): string | undefined {
|
|
3426
|
+
\tconst header = req.headers.get("cookie");
|
|
3427
|
+
\tif (!header) return undefined;
|
|
3428
|
+
|
|
3429
|
+
\tfor (const part of header.split(";")) {
|
|
3430
|
+
\t\tconst separator = part.indexOf("=");
|
|
3431
|
+
\t\tif (separator === -1) continue;
|
|
3432
|
+
\t\tconst name = part.slice(0, separator).trim();
|
|
3433
|
+
\t\tif (name !== WORKSPACE_COOKIE_NAME) continue;
|
|
3434
|
+
\t\tconst value = part.slice(separator + 1).trim();
|
|
3435
|
+
\t\treturn normalizeWorkspaceId(decodeURIComponent(value));
|
|
3436
|
+
\t}
|
|
3437
|
+
|
|
3438
|
+
\treturn undefined;
|
|
3439
|
+
}
|
|
3440
|
+
|
|
3441
|
+
function normalizeWorkspaceId(value: unknown): string | undefined {
|
|
3442
|
+
\treturn typeof value === "string" && value.trim().length > 0
|
|
3443
|
+
\t\t? value.trim()
|
|
3444
|
+
\t\t: undefined;
|
|
3445
|
+
}
|
|
3446
|
+
`;
|
|
3447
|
+
}
|
|
3448
|
+
export function tenancyServerContextFile() {
|
|
3449
|
+
return `import "@beignet/core/server-only";
|
|
3450
|
+
import {
|
|
3451
|
+
\ttype ActivityActor,
|
|
3452
|
+
\tcreateAnonymousActor,
|
|
3453
|
+
\tcreateServiceActor,
|
|
3454
|
+
\tcreateUserActor,
|
|
3455
|
+
} from "@beignet/core/ports";
|
|
3456
|
+
import { defineServerContext } from "@beignet/core/server";
|
|
3457
|
+
import type { TraceContext } from "@beignet/core/tracing";
|
|
3458
|
+
import type { AppContext, AppRuntimePorts } from "@/app-context";
|
|
3459
|
+
import { resolveRequestWorkspace, resolveServiceWorkspace } from "@/lib/tenant";
|
|
3460
|
+
|
|
3461
|
+
export type AppServiceContextInput =
|
|
3462
|
+
\t| {
|
|
3463
|
+
\t\t\tactor?: ActivityActor;
|
|
3464
|
+
\t\t\ttenantId?: string;
|
|
3465
|
+
\t }
|
|
3466
|
+
\t| undefined;
|
|
3467
|
+
|
|
3468
|
+
/**
|
|
3469
|
+
* Context blueprint shared by the runtime server and route tests. The active
|
|
3470
|
+
* workspace becomes \`ctx.tenant\` and the actor's membership in it becomes
|
|
3471
|
+
* \`ctx.membership\`.
|
|
3472
|
+
*/
|
|
3473
|
+
export const appContext = defineServerContext<AppContext, AppRuntimePorts>()({
|
|
3474
|
+
\tgate: (ports) => ports.gate,
|
|
3475
|
+
\trequest: async ({ req, ports, requestId, trace }) => {
|
|
3476
|
+
\t\tconst auth = await ports.auth.getSession(req);
|
|
3477
|
+
\t\tconst { tenant, membership } = await resolveRequestWorkspace({
|
|
3478
|
+
\t\t\tauth,
|
|
3479
|
+
\t\t\treq,
|
|
3480
|
+
\t\t\tports,
|
|
3481
|
+
\t\t});
|
|
3482
|
+
|
|
3483
|
+
\t\treturn {
|
|
3484
|
+
\t\t\trequestId,
|
|
3485
|
+
\t\t\tactor: auth
|
|
3486
|
+
\t\t\t\t? createUserActor(auth.user.id, {
|
|
3487
|
+
\t\t\t\t\t\tdisplayName: auth.user.name,
|
|
3488
|
+
\t\t\t\t\t\tmetadata: membership
|
|
3489
|
+
\t\t\t\t\t\t\t? { membershipRole: membership.role }
|
|
3490
|
+
\t\t\t\t\t\t\t: undefined,
|
|
3491
|
+
\t\t\t\t\t})
|
|
3492
|
+
\t\t\t\t: createAnonymousActor(),
|
|
3493
|
+
\t\t\tauth,
|
|
3494
|
+
\t\t\t...trace,
|
|
3495
|
+
\t\t\tports,
|
|
3496
|
+
\t\t\t...(tenant ? { tenant } : {}),
|
|
3497
|
+
\t\t\tmembership,
|
|
3498
|
+
\t\t};
|
|
3499
|
+
\t},
|
|
3500
|
+
\tservice: ({
|
|
3501
|
+
\t\tports,
|
|
3502
|
+
\t\tinput,
|
|
3503
|
+
\t\trequestId,
|
|
3504
|
+
\t\ttrace,
|
|
3505
|
+
\t}: {
|
|
3506
|
+
\t\tports: AppRuntimePorts;
|
|
3507
|
+
\t\tinput: AppServiceContextInput;
|
|
3508
|
+
\t\trequestId: string;
|
|
3509
|
+
\t\ttrace: TraceContext;
|
|
3510
|
+
\t}) => {
|
|
3511
|
+
\t\tconst { tenant } = resolveServiceWorkspace(input?.tenantId);
|
|
3512
|
+
|
|
3513
|
+
\t\treturn {
|
|
3514
|
+
\t\t\trequestId,
|
|
3515
|
+
\t\t\tactor: input?.actor ?? createServiceActor("beignet-service"),
|
|
3516
|
+
\t\t\tauth: null,
|
|
3517
|
+
\t\t\t...trace,
|
|
3518
|
+
\t\t\tports,
|
|
3519
|
+
\t\t\t...(tenant ? { tenant } : {}),
|
|
3520
|
+
\t\t\tmembership: null,
|
|
3521
|
+
\t\t};
|
|
3522
|
+
\t},
|
|
3523
|
+
});
|
|
3524
|
+
`;
|
|
3525
|
+
}
|
|
3526
|
+
export function workspaceClientQueriesFile() {
|
|
3527
|
+
return `import type { QueryClient } from "@tanstack/react-query";
|
|
3528
|
+
import { rq } from "@/client";
|
|
3529
|
+
import {
|
|
3530
|
+
\tacceptInvite,
|
|
3531
|
+
\tcreateInvite,
|
|
3532
|
+
\tcreateWorkspace,
|
|
3533
|
+
\tgetCurrentWorkspace,
|
|
3534
|
+
\tlistInvites,
|
|
3535
|
+
\tlistMembers,
|
|
3536
|
+
\tlistMyWorkspaces,
|
|
3537
|
+
\tremoveMember,
|
|
3538
|
+
\trevokeInvite,
|
|
3539
|
+
\tswitchWorkspace,
|
|
3540
|
+
\tupdateMemberRole,
|
|
3541
|
+
\tupdateWorkspace,
|
|
3542
|
+
} from "@/features/workspaces/contracts";
|
|
3543
|
+
|
|
3544
|
+
export function currentWorkspaceQueryOptions() {
|
|
3545
|
+
\treturn rq(getCurrentWorkspace).queryOptions();
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
export function myWorkspacesQueryOptions() {
|
|
3549
|
+
\treturn rq(listMyWorkspaces).queryOptions();
|
|
3550
|
+
}
|
|
3551
|
+
|
|
3552
|
+
export function membersQueryOptions(options?: { enabled?: boolean }) {
|
|
3553
|
+
\treturn rq(listMembers).queryOptions(options);
|
|
3554
|
+
}
|
|
3555
|
+
|
|
3556
|
+
export function invitesQueryOptions(options?: { enabled?: boolean }) {
|
|
3557
|
+
\treturn rq(listInvites).queryOptions(options);
|
|
3558
|
+
}
|
|
3559
|
+
|
|
3560
|
+
export function createWorkspaceMutationOptions() {
|
|
3561
|
+
\treturn rq(createWorkspace).mutationOptions();
|
|
3562
|
+
}
|
|
3563
|
+
|
|
3564
|
+
export function updateWorkspaceMutationOptions() {
|
|
3565
|
+
\treturn rq(updateWorkspace).mutationOptions();
|
|
3566
|
+
}
|
|
3567
|
+
|
|
3568
|
+
export function switchWorkspaceMutationOptions() {
|
|
3569
|
+
\treturn rq(switchWorkspace).mutationOptions();
|
|
3570
|
+
}
|
|
3571
|
+
|
|
3572
|
+
export function updateMemberRoleMutationOptions() {
|
|
3573
|
+
\treturn rq(updateMemberRole).mutationOptions();
|
|
3574
|
+
}
|
|
3575
|
+
|
|
3576
|
+
export function removeMemberMutationOptions() {
|
|
3577
|
+
\treturn rq(removeMember).mutationOptions();
|
|
3578
|
+
}
|
|
3579
|
+
|
|
3580
|
+
export function createInviteMutationOptions() {
|
|
3581
|
+
\treturn rq(createInvite).mutationOptions();
|
|
3582
|
+
}
|
|
3583
|
+
|
|
3584
|
+
export function revokeInviteMutationOptions() {
|
|
3585
|
+
\treturn rq(revokeInvite).mutationOptions();
|
|
3586
|
+
}
|
|
3587
|
+
|
|
3588
|
+
export function acceptInviteMutationOptions() {
|
|
3589
|
+
\treturn rq(acceptInvite).mutationOptions();
|
|
3590
|
+
}
|
|
3591
|
+
|
|
3592
|
+
export function invalidateWorkspaces(queryClient: QueryClient) {
|
|
3593
|
+
\treturn Promise.all([
|
|
3594
|
+
\t\trq(getCurrentWorkspace).invalidate(queryClient),
|
|
3595
|
+
\t\trq(listMyWorkspaces).invalidate(queryClient),
|
|
3596
|
+
\t]);
|
|
3597
|
+
}
|
|
3598
|
+
|
|
3599
|
+
export function invalidateMembers(queryClient: QueryClient) {
|
|
3600
|
+
\treturn rq(listMembers).invalidate(queryClient);
|
|
3601
|
+
}
|
|
3602
|
+
|
|
3603
|
+
export function invalidateInvites(queryClient: QueryClient) {
|
|
3604
|
+
\treturn rq(listInvites).invalidate(queryClient);
|
|
3605
|
+
}
|
|
3606
|
+
`;
|
|
3607
|
+
}
|
|
3608
|
+
export function workspaceSwitcherComponentFile() {
|
|
3609
|
+
return `"use client";
|
|
3610
|
+
|
|
3611
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3612
|
+
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react";
|
|
3613
|
+
import { useRouter } from "next/navigation";
|
|
3614
|
+
import {
|
|
3615
|
+
\tDropdownMenu,
|
|
3616
|
+
\tDropdownMenuContent,
|
|
3617
|
+
\tDropdownMenuItem,
|
|
3618
|
+
\tDropdownMenuTrigger,
|
|
3619
|
+
} from "@/components/ui/dropdown-menu";
|
|
3620
|
+
import {
|
|
3621
|
+
\tcurrentWorkspaceQueryOptions,
|
|
3622
|
+
\tmyWorkspacesQueryOptions,
|
|
3623
|
+
\tswitchWorkspaceMutationOptions,
|
|
3624
|
+
} from "@/features/workspaces/client/queries";
|
|
3625
|
+
import { cn } from "@/lib/utils";
|
|
3626
|
+
|
|
3627
|
+
function WorkspaceMark({ name }: { name: string }) {
|
|
3628
|
+
\treturn (
|
|
3629
|
+
\t\t<span className="inline-flex size-6 shrink-0 items-center justify-center rounded bg-primary font-bold text-[11px] text-primary-foreground">
|
|
3630
|
+
\t\t\t{(name || "?").slice(0, 1).toUpperCase()}
|
|
3631
|
+
\t\t</span>
|
|
3632
|
+
\t);
|
|
3633
|
+
}
|
|
3634
|
+
|
|
3635
|
+
/**
|
|
3636
|
+
* Lists the actor's workspaces and switches the active one through
|
|
3637
|
+
* POST /api/workspaces/switch, which sets the \`beignet-workspace\` cookie.
|
|
3638
|
+
*/
|
|
3639
|
+
export function WorkspaceSwitcher() {
|
|
3640
|
+
\tconst router = useRouter();
|
|
3641
|
+
\tconst queryClient = useQueryClient();
|
|
3642
|
+
\tconst currentQuery = useQuery(currentWorkspaceQueryOptions());
|
|
3643
|
+
\tconst workspacesQuery = useQuery(myWorkspacesQueryOptions());
|
|
3644
|
+
|
|
3645
|
+
\tconst switchMutation = useMutation({
|
|
3646
|
+
\t\t...switchWorkspaceMutationOptions(),
|
|
3647
|
+
\t\tonSuccess: async () => {
|
|
3648
|
+
\t\t\tawait queryClient.invalidateQueries();
|
|
3649
|
+
\t\t\trouter.refresh();
|
|
3650
|
+
\t\t},
|
|
3651
|
+
\t});
|
|
3652
|
+
|
|
3653
|
+
\tconst current = currentQuery.data?.workspace ?? null;
|
|
3654
|
+
\tconst workspaces = workspacesQuery.data?.items ?? [];
|
|
3655
|
+
|
|
3656
|
+
\treturn (
|
|
3657
|
+
\t\t<DropdownMenu>
|
|
3658
|
+
\t\t\t<DropdownMenuTrigger
|
|
3659
|
+
\t\t\t\tclassName="flex h-9 w-full items-center gap-2 rounded-md border px-2 text-left text-sm font-medium hover:bg-muted"
|
|
3660
|
+
\t\t\t\taria-label="Switch workspace"
|
|
3661
|
+
\t\t\t\tdisabled={switchMutation.isPending}
|
|
3662
|
+
\t\t\t>
|
|
3663
|
+
\t\t\t\t<WorkspaceMark name={current?.name ?? "?"} />
|
|
3664
|
+
\t\t\t\t<span className="flex-1 truncate">
|
|
3665
|
+
\t\t\t\t\t{current?.name ?? "Select workspace"}
|
|
3666
|
+
\t\t\t\t</span>
|
|
3667
|
+
\t\t\t\t<ChevronsUpDownIcon className="size-3.5 text-muted-foreground" />
|
|
3668
|
+
\t\t\t</DropdownMenuTrigger>
|
|
3669
|
+
\t\t\t<DropdownMenuContent align="start" className="w-56">
|
|
3670
|
+
\t\t\t\t<p className="px-2 py-1.5 text-xs font-medium text-muted-foreground">
|
|
3671
|
+
\t\t\t\t\tWorkspaces
|
|
3672
|
+
\t\t\t\t</p>
|
|
3673
|
+
\t\t\t\t{workspaces.map((workspace) => (
|
|
3674
|
+
\t\t\t\t\t<DropdownMenuItem
|
|
3675
|
+
\t\t\t\t\t\tkey={workspace.id}
|
|
3676
|
+
\t\t\t\t\t\tonSelect={() => {
|
|
3677
|
+
\t\t\t\t\t\t\tif (workspace.id === current?.id) return;
|
|
3678
|
+
\t\t\t\t\t\t\tswitchMutation.mutate({
|
|
3679
|
+
\t\t\t\t\t\t\t\tbody: { workspaceId: workspace.id },
|
|
3680
|
+
\t\t\t\t\t\t\t});
|
|
3681
|
+
\t\t\t\t\t\t}}
|
|
3682
|
+
\t\t\t\t\t>
|
|
3683
|
+
\t\t\t\t\t\t<WorkspaceMark name={workspace.name} />
|
|
3684
|
+
\t\t\t\t\t\t<span className="flex-1 truncate">{workspace.name}</span>
|
|
3685
|
+
\t\t\t\t\t\t<CheckIcon
|
|
3686
|
+
\t\t\t\t\t\t\tclassName={cn(
|
|
3687
|
+
\t\t\t\t\t\t\t\t"size-4",
|
|
3688
|
+
\t\t\t\t\t\t\t\tworkspace.id === current?.id ? "opacity-100" : "opacity-0",
|
|
3689
|
+
\t\t\t\t\t\t\t)}
|
|
3690
|
+
\t\t\t\t\t\t/>
|
|
3691
|
+
\t\t\t\t\t</DropdownMenuItem>
|
|
3692
|
+
\t\t\t\t))}
|
|
3693
|
+
\t\t\t\t{workspaces.length === 0 ? (
|
|
3694
|
+
\t\t\t\t\t<DropdownMenuItem disabled>No workspaces yet</DropdownMenuItem>
|
|
3695
|
+
\t\t\t\t) : null}
|
|
3696
|
+
\t\t\t</DropdownMenuContent>
|
|
3697
|
+
\t\t</DropdownMenu>
|
|
3698
|
+
\t);
|
|
3699
|
+
}
|
|
3700
|
+
`;
|
|
3701
|
+
}
|
|
3702
|
+
export function workspaceSettingsFormComponentFile() {
|
|
3703
|
+
return `"use client";
|
|
3704
|
+
|
|
3705
|
+
import { rootFormError } from "@beignet/react-hook-form";
|
|
3706
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3707
|
+
import { useRouter } from "next/navigation";
|
|
3708
|
+
import { rhf } from "@/client/forms";
|
|
3709
|
+
import { Button } from "@/components/ui/button";
|
|
3710
|
+
import {
|
|
3711
|
+
\tCard,
|
|
3712
|
+
\tCardContent,
|
|
3713
|
+
\tCardDescription,
|
|
3714
|
+
\tCardHeader,
|
|
3715
|
+
\tCardTitle,
|
|
3716
|
+
} from "@/components/ui/card";
|
|
3717
|
+
import { Input } from "@/components/ui/input";
|
|
3718
|
+
import { Label } from "@/components/ui/label";
|
|
3719
|
+
import {
|
|
3720
|
+
\tcreateWorkspaceMutationOptions,
|
|
3721
|
+
\tcurrentWorkspaceQueryOptions,
|
|
3722
|
+
\tinvalidateWorkspaces,
|
|
3723
|
+
\tupdateWorkspaceMutationOptions,
|
|
3724
|
+
} from "@/features/workspaces/client/queries";
|
|
3725
|
+
import { createWorkspace, updateWorkspace } from "@/features/workspaces/contracts";
|
|
3726
|
+
|
|
3727
|
+
const createWorkspaceForm = rhf(createWorkspace);
|
|
3728
|
+
const updateWorkspaceForm = rhf(updateWorkspace);
|
|
3729
|
+
|
|
3730
|
+
function CreateWorkspaceCard() {
|
|
3731
|
+
\tconst router = useRouter();
|
|
3732
|
+
\tconst queryClient = useQueryClient();
|
|
3733
|
+
\tconst form = createWorkspaceForm.useForm({
|
|
3734
|
+
\t\tdefaultValues: { name: "", slug: "" },
|
|
3735
|
+
\t});
|
|
3736
|
+
|
|
3737
|
+
\tconst createMutation = useMutation({
|
|
3738
|
+
\t\t...createWorkspaceMutationOptions(),
|
|
3739
|
+
\t\tonSuccess: async () => {
|
|
3740
|
+
\t\t\tawait invalidateWorkspaces(queryClient);
|
|
3741
|
+
\t\t\trouter.refresh();
|
|
3742
|
+
\t\t},
|
|
3743
|
+
\t\tonError: (error) => {
|
|
3744
|
+
\t\t\tform.setError(
|
|
3745
|
+
\t\t\t\t"root",
|
|
3746
|
+
\t\t\t\trootFormError(error, "Could not create the workspace."),
|
|
3747
|
+
\t\t\t);
|
|
3748
|
+
\t\t},
|
|
3749
|
+
\t});
|
|
3750
|
+
|
|
3751
|
+
\tconst onSubmit = form.handleSubmit((body) => {
|
|
3752
|
+
\t\tform.clearErrors("root");
|
|
3753
|
+
\t\tcreateMutation.mutate({ idempotencyKey: crypto.randomUUID(), body });
|
|
3754
|
+
\t});
|
|
3755
|
+
|
|
3756
|
+
\treturn (
|
|
3757
|
+
\t\t<Card>
|
|
3758
|
+
\t\t\t<CardHeader>
|
|
3759
|
+
\t\t\t\t<CardTitle>Create a workspace</CardTitle>
|
|
3760
|
+
\t\t\t\t<CardDescription>
|
|
3761
|
+
\t\t\t\t\tYou have no active workspace yet. Create one to get started.
|
|
3762
|
+
\t\t\t\t</CardDescription>
|
|
3763
|
+
\t\t\t</CardHeader>
|
|
3764
|
+
\t\t\t<CardContent>
|
|
3765
|
+
\t\t\t\t<form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
|
|
3766
|
+
\t\t\t\t\t<div className="flex flex-col gap-2">
|
|
3767
|
+
\t\t\t\t\t\t<Label htmlFor="workspace-name">Name</Label>
|
|
3768
|
+
\t\t\t\t\t\t<Input
|
|
3769
|
+
\t\t\t\t\t\t\tid="workspace-name"
|
|
3770
|
+
\t\t\t\t\t\t\tplaceholder="Acme"
|
|
3771
|
+
\t\t\t\t\t\t\trequired
|
|
3772
|
+
\t\t\t\t\t\t\t{...form.register("name")}
|
|
3773
|
+
\t\t\t\t\t\t/>
|
|
3774
|
+
\t\t\t\t\t</div>
|
|
3775
|
+
\t\t\t\t\t<div className="flex flex-col gap-2">
|
|
3776
|
+
\t\t\t\t\t\t<Label htmlFor="workspace-slug">Slug</Label>
|
|
3777
|
+
\t\t\t\t\t\t<Input
|
|
3778
|
+
\t\t\t\t\t\t\tid="workspace-slug"
|
|
3779
|
+
\t\t\t\t\t\t\tplaceholder="acme"
|
|
3780
|
+
\t\t\t\t\t\t\trequired
|
|
3781
|
+
\t\t\t\t\t\t\t{...form.register("slug")}
|
|
3782
|
+
\t\t\t\t\t\t/>
|
|
3783
|
+
\t\t\t\t\t\t{form.formState.errors.slug ? (
|
|
3784
|
+
\t\t\t\t\t\t\t<p className="text-sm text-destructive">
|
|
3785
|
+
\t\t\t\t\t\t\t\t{form.formState.errors.slug.message}
|
|
3786
|
+
\t\t\t\t\t\t\t</p>
|
|
3787
|
+
\t\t\t\t\t\t) : null}
|
|
3788
|
+
\t\t\t\t\t</div>
|
|
3789
|
+
\t\t\t\t\t{form.formState.errors.root ? (
|
|
3790
|
+
\t\t\t\t\t\t<p className="text-sm text-destructive">
|
|
3791
|
+
\t\t\t\t\t\t\t{form.formState.errors.root.message}
|
|
3792
|
+
\t\t\t\t\t\t</p>
|
|
3793
|
+
\t\t\t\t\t) : null}
|
|
3794
|
+
\t\t\t\t\t<Button type="submit" disabled={createMutation.isPending}>
|
|
3795
|
+
\t\t\t\t\t\t{createMutation.isPending ? "Creating..." : "Create workspace"}
|
|
3796
|
+
\t\t\t\t\t</Button>
|
|
3797
|
+
\t\t\t\t</form>
|
|
3798
|
+
\t\t\t</CardContent>
|
|
3799
|
+
\t\t</Card>
|
|
3800
|
+
\t);
|
|
3801
|
+
}
|
|
3802
|
+
|
|
3803
|
+
export function WorkspaceSettingsForm() {
|
|
3804
|
+
\tconst router = useRouter();
|
|
3805
|
+
\tconst queryClient = useQueryClient();
|
|
3806
|
+
\tconst currentQuery = useQuery(currentWorkspaceQueryOptions());
|
|
3807
|
+
\tconst workspace = currentQuery.data?.workspace ?? null;
|
|
3808
|
+
\tconst isAdmin = currentQuery.data?.membership?.role === "admin";
|
|
3809
|
+
|
|
3810
|
+
\tconst form = updateWorkspaceForm.useForm({
|
|
3811
|
+
\t\tvalues: {
|
|
3812
|
+
\t\t\tname: workspace?.name ?? "",
|
|
3813
|
+
\t\t\tslug: workspace?.slug ?? "",
|
|
3814
|
+
\t\t},
|
|
3815
|
+
\t});
|
|
3816
|
+
|
|
3817
|
+
\tconst updateMutation = useMutation({
|
|
3818
|
+
\t\t...updateWorkspaceMutationOptions(),
|
|
3819
|
+
\t\tonSuccess: async () => {
|
|
3820
|
+
\t\t\tawait invalidateWorkspaces(queryClient);
|
|
3821
|
+
\t\t\trouter.refresh();
|
|
3822
|
+
\t\t},
|
|
3823
|
+
\t\tonError: (error) => {
|
|
3824
|
+
\t\t\tform.setError(
|
|
3825
|
+
\t\t\t\t"root",
|
|
3826
|
+
\t\t\t\trootFormError(error, "Could not update the workspace."),
|
|
3827
|
+
\t\t\t);
|
|
3828
|
+
\t\t},
|
|
3829
|
+
\t});
|
|
3830
|
+
|
|
3831
|
+
\tif (currentQuery.isPending) {
|
|
3832
|
+
\t\treturn <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
3833
|
+
\t}
|
|
3834
|
+
|
|
3835
|
+
\tif (!workspace) {
|
|
3836
|
+
\t\treturn <CreateWorkspaceCard />;
|
|
3837
|
+
\t}
|
|
3838
|
+
|
|
3839
|
+
\tif (!isAdmin) {
|
|
3840
|
+
\t\treturn (
|
|
3841
|
+
\t\t\t<Card>
|
|
3842
|
+
\t\t\t\t<CardHeader>
|
|
3843
|
+
\t\t\t\t\t<CardTitle>{workspace.name}</CardTitle>
|
|
3844
|
+
\t\t\t\t\t<CardDescription>
|
|
3845
|
+
\t\t\t\t\t\tOnly workspace admins can change workspace settings.
|
|
3846
|
+
\t\t\t\t\t</CardDescription>
|
|
3847
|
+
\t\t\t\t</CardHeader>
|
|
3848
|
+
\t\t\t</Card>
|
|
3849
|
+
\t\t);
|
|
3850
|
+
\t}
|
|
3851
|
+
|
|
3852
|
+
\tconst onSubmit = form.handleSubmit((body) => {
|
|
3853
|
+
\t\tform.clearErrors("root");
|
|
3854
|
+
\t\tupdateMutation.mutate({ body });
|
|
3855
|
+
\t});
|
|
3856
|
+
|
|
3857
|
+
\treturn (
|
|
3858
|
+
\t\t<Card>
|
|
3859
|
+
\t\t\t<CardHeader>
|
|
3860
|
+
\t\t\t\t<CardTitle>Workspace</CardTitle>
|
|
3861
|
+
\t\t\t\t<CardDescription>
|
|
3862
|
+
\t\t\t\t\tRename the workspace or change its slug.
|
|
3863
|
+
\t\t\t\t</CardDescription>
|
|
3864
|
+
\t\t\t</CardHeader>
|
|
3865
|
+
\t\t\t<CardContent>
|
|
3866
|
+
\t\t\t\t<form className="flex max-w-sm flex-col gap-4" onSubmit={onSubmit}>
|
|
3867
|
+
\t\t\t\t\t<div className="flex flex-col gap-2">
|
|
3868
|
+
\t\t\t\t\t\t<Label htmlFor="workspace-name">Name</Label>
|
|
3869
|
+
\t\t\t\t\t\t<Input id="workspace-name" {...form.register("name")} />
|
|
3870
|
+
\t\t\t\t\t\t{form.formState.errors.name ? (
|
|
3871
|
+
\t\t\t\t\t\t\t<p className="text-sm text-destructive">
|
|
3872
|
+
\t\t\t\t\t\t\t\t{form.formState.errors.name.message}
|
|
3873
|
+
\t\t\t\t\t\t\t</p>
|
|
3874
|
+
\t\t\t\t\t\t) : null}
|
|
3875
|
+
\t\t\t\t\t</div>
|
|
3876
|
+
\t\t\t\t\t<div className="flex flex-col gap-2">
|
|
3877
|
+
\t\t\t\t\t\t<Label htmlFor="workspace-slug">Slug</Label>
|
|
3878
|
+
\t\t\t\t\t\t<Input id="workspace-slug" {...form.register("slug")} />
|
|
3879
|
+
\t\t\t\t\t\t{form.formState.errors.slug ? (
|
|
3880
|
+
\t\t\t\t\t\t\t<p className="text-sm text-destructive">
|
|
3881
|
+
\t\t\t\t\t\t\t\t{form.formState.errors.slug.message}
|
|
3882
|
+
\t\t\t\t\t\t\t</p>
|
|
3883
|
+
\t\t\t\t\t\t) : null}
|
|
3884
|
+
\t\t\t\t\t</div>
|
|
3885
|
+
\t\t\t\t\t{form.formState.errors.root ? (
|
|
3886
|
+
\t\t\t\t\t\t<p className="text-sm text-destructive">
|
|
3887
|
+
\t\t\t\t\t\t\t{form.formState.errors.root.message}
|
|
3888
|
+
\t\t\t\t\t\t</p>
|
|
3889
|
+
\t\t\t\t\t) : null}
|
|
3890
|
+
\t\t\t\t\t<Button type="submit" disabled={updateMutation.isPending}>
|
|
3891
|
+
\t\t\t\t\t\t{updateMutation.isPending ? "Saving..." : "Save"}
|
|
3892
|
+
\t\t\t\t\t</Button>
|
|
3893
|
+
\t\t\t\t</form>
|
|
3894
|
+
\t\t\t</CardContent>
|
|
3895
|
+
\t\t</Card>
|
|
3896
|
+
\t);
|
|
3897
|
+
}
|
|
3898
|
+
`;
|
|
3899
|
+
}
|
|
3900
|
+
export function inviteFormComponentFile() {
|
|
3901
|
+
return `"use client";
|
|
3902
|
+
|
|
3903
|
+
import { rootFormError } from "@beignet/react-hook-form";
|
|
3904
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
3905
|
+
import { rhf } from "@/client/forms";
|
|
3906
|
+
import { Button } from "@/components/ui/button";
|
|
3907
|
+
import { Input } from "@/components/ui/input";
|
|
3908
|
+
import { Label } from "@/components/ui/label";
|
|
3909
|
+
import {
|
|
3910
|
+
\tcreateInviteMutationOptions,
|
|
3911
|
+
\tinvalidateInvites,
|
|
3912
|
+
} from "@/features/workspaces/client/queries";
|
|
3913
|
+
import { createInvite } from "@/features/workspaces/contracts";
|
|
3914
|
+
|
|
3915
|
+
const createInviteForm = rhf(createInvite);
|
|
3916
|
+
|
|
3917
|
+
const roleSelectClassName =
|
|
3918
|
+
\t"h-9 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs";
|
|
3919
|
+
|
|
3920
|
+
export function InviteForm() {
|
|
3921
|
+
\tconst queryClient = useQueryClient();
|
|
3922
|
+
\tconst form = createInviteForm.useForm({
|
|
3923
|
+
\t\tdefaultValues: { email: "", role: "member" },
|
|
3924
|
+
\t});
|
|
3925
|
+
|
|
3926
|
+
\tconst inviteMutation = useMutation({
|
|
3927
|
+
\t\t...createInviteMutationOptions(),
|
|
3928
|
+
\t\tonSuccess: async () => {
|
|
3929
|
+
\t\t\tform.reset({ email: "", role: "member" });
|
|
3930
|
+
\t\t\tawait invalidateInvites(queryClient);
|
|
3931
|
+
\t\t},
|
|
3932
|
+
\t\tonError: (error) => {
|
|
3933
|
+
\t\t\tform.setError("root", rootFormError(error, "Could not send the invite."));
|
|
3934
|
+
\t\t},
|
|
3935
|
+
\t});
|
|
3936
|
+
|
|
3937
|
+
\tconst onSubmit = form.handleSubmit((body) => {
|
|
3938
|
+
\t\tform.clearErrors("root");
|
|
3939
|
+
\t\tinviteMutation.mutate({ idempotencyKey: crypto.randomUUID(), body });
|
|
3940
|
+
\t});
|
|
3941
|
+
|
|
3942
|
+
\treturn (
|
|
3943
|
+
\t\t<form className="flex flex-col gap-2" onSubmit={onSubmit}>
|
|
3944
|
+
\t\t\t<div className="flex flex-wrap items-end gap-2">
|
|
3945
|
+
\t\t\t\t<div className="flex min-w-52 flex-1 flex-col gap-2">
|
|
3946
|
+
\t\t\t\t\t<Label htmlFor="invite-email">Invite by email</Label>
|
|
3947
|
+
\t\t\t\t\t<Input
|
|
3948
|
+
\t\t\t\t\t\tid="invite-email"
|
|
3949
|
+
\t\t\t\t\t\ttype="email"
|
|
3950
|
+
\t\t\t\t\t\tplaceholder="teammate@example.com"
|
|
3951
|
+
\t\t\t\t\t\trequired
|
|
3952
|
+
\t\t\t\t\t\t{...form.register("email")}
|
|
3953
|
+
\t\t\t\t\t/>
|
|
3954
|
+
\t\t\t\t</div>
|
|
3955
|
+
\t\t\t\t<select
|
|
3956
|
+
\t\t\t\t\tclassName={roleSelectClassName}
|
|
3957
|
+
\t\t\t\t\taria-label="Invite role"
|
|
3958
|
+
\t\t\t\t\t{...form.register("role")}
|
|
3959
|
+
\t\t\t\t>
|
|
3960
|
+
\t\t\t\t\t<option value="member">Member</option>
|
|
3961
|
+
\t\t\t\t\t<option value="admin">Admin</option>
|
|
3962
|
+
\t\t\t\t</select>
|
|
3963
|
+
\t\t\t\t<Button type="submit" disabled={inviteMutation.isPending}>
|
|
3964
|
+
\t\t\t\t\t{inviteMutation.isPending ? "Sending..." : "Send invite"}
|
|
3965
|
+
\t\t\t\t</Button>
|
|
3966
|
+
\t\t\t</div>
|
|
3967
|
+
\t\t\t{form.formState.errors.email ? (
|
|
3968
|
+
\t\t\t\t<p className="text-sm text-destructive">
|
|
3969
|
+
\t\t\t\t\t{form.formState.errors.email.message}
|
|
3970
|
+
\t\t\t\t</p>
|
|
3971
|
+
\t\t\t) : null}
|
|
3972
|
+
\t\t\t{form.formState.errors.root ? (
|
|
3973
|
+
\t\t\t\t<p className="text-sm text-destructive">
|
|
3974
|
+
\t\t\t\t\t{form.formState.errors.root.message}
|
|
3975
|
+
\t\t\t\t</p>
|
|
3976
|
+
\t\t\t) : null}
|
|
3977
|
+
\t\t</form>
|
|
3978
|
+
\t);
|
|
3979
|
+
}
|
|
3980
|
+
`;
|
|
3981
|
+
}
|
|
3982
|
+
export function membersSettingsComponentFile() {
|
|
3983
|
+
return `"use client";
|
|
3984
|
+
|
|
3985
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3986
|
+
import { useState } from "react";
|
|
3987
|
+
import { authClient } from "@/client/auth-client";
|
|
3988
|
+
import { Button } from "@/components/ui/button";
|
|
3989
|
+
import {
|
|
3990
|
+
\tCard,
|
|
3991
|
+
\tCardContent,
|
|
3992
|
+
\tCardHeader,
|
|
3993
|
+
\tCardTitle,
|
|
3994
|
+
} from "@/components/ui/card";
|
|
3995
|
+
import { Separator } from "@/components/ui/separator";
|
|
3996
|
+
import {
|
|
3997
|
+
\tcurrentWorkspaceQueryOptions,
|
|
3998
|
+
\tinvalidateInvites,
|
|
3999
|
+
\tinvalidateMembers,
|
|
4000
|
+
\tinvitesQueryOptions,
|
|
4001
|
+
\tmembersQueryOptions,
|
|
4002
|
+
\tremoveMemberMutationOptions,
|
|
4003
|
+
\trevokeInviteMutationOptions,
|
|
4004
|
+
\tupdateMemberRoleMutationOptions,
|
|
4005
|
+
} from "@/features/workspaces/client/queries";
|
|
4006
|
+
import { InviteForm } from "@/features/workspaces/components/invite-form";
|
|
4007
|
+
import type { WorkspaceMemberWithUser } from "@/features/workspaces/schemas";
|
|
4008
|
+
|
|
4009
|
+
const roleSelectClassName =
|
|
4010
|
+
\t"h-8 rounded-md border border-input bg-transparent px-2 text-sm shadow-xs";
|
|
4011
|
+
|
|
4012
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
4013
|
+
\tif (error && typeof error === "object" && "message" in error) {
|
|
4014
|
+
\t\tconst message = (error as { message?: unknown }).message;
|
|
4015
|
+
\t\tif (typeof message === "string" && message.length > 0) return message;
|
|
4016
|
+
\t}
|
|
4017
|
+
\treturn fallback;
|
|
4018
|
+
}
|
|
4019
|
+
|
|
4020
|
+
function MemberRow({
|
|
4021
|
+
\tmember,
|
|
4022
|
+
\tisSelf,
|
|
4023
|
+
\tcanManage,
|
|
4024
|
+
\tonError,
|
|
4025
|
+
}: {
|
|
4026
|
+
\tmember: WorkspaceMemberWithUser;
|
|
4027
|
+
\tisSelf: boolean;
|
|
4028
|
+
\tcanManage: boolean;
|
|
4029
|
+
\tonError: (message: string) => void;
|
|
4030
|
+
}) {
|
|
4031
|
+
\tconst queryClient = useQueryClient();
|
|
4032
|
+
|
|
4033
|
+
\tconst roleMutation = useMutation({
|
|
4034
|
+
\t\t...updateMemberRoleMutationOptions(),
|
|
4035
|
+
\t\tonSuccess: async () => {
|
|
4036
|
+
\t\t\tawait invalidateMembers(queryClient);
|
|
4037
|
+
\t\t},
|
|
4038
|
+
\t\tonError: (error) =>
|
|
4039
|
+
\t\t\tonError(errorMessage(error, "Could not change the member role.")),
|
|
4040
|
+
\t});
|
|
4041
|
+
\tconst removeMutation = useMutation({
|
|
4042
|
+
\t\t...removeMemberMutationOptions(),
|
|
4043
|
+
\t\tonSuccess: async () => {
|
|
4044
|
+
\t\t\tawait invalidateMembers(queryClient);
|
|
4045
|
+
\t\t},
|
|
4046
|
+
\t\tonError: (error) =>
|
|
4047
|
+
\t\t\tonError(errorMessage(error, "Could not remove the member.")),
|
|
4048
|
+
\t});
|
|
4049
|
+
|
|
4050
|
+
\treturn (
|
|
4051
|
+
\t\t<div className="flex flex-wrap items-center gap-3 py-2">
|
|
4052
|
+
\t\t\t<span className="inline-flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm font-medium">
|
|
4053
|
+
\t\t\t\t{(member.user.name || member.user.email || "?")
|
|
4054
|
+
\t\t\t\t\t.slice(0, 1)
|
|
4055
|
+
\t\t\t\t\t.toUpperCase()}
|
|
4056
|
+
\t\t\t</span>
|
|
4057
|
+
\t\t\t<div className="min-w-0 flex-1">
|
|
4058
|
+
\t\t\t\t<p className="truncate text-sm font-medium">
|
|
4059
|
+
\t\t\t\t\t{member.user.name}
|
|
4060
|
+
\t\t\t\t\t{isSelf ? (
|
|
4061
|
+
\t\t\t\t\t\t<span className="text-xs text-muted-foreground"> (you)</span>
|
|
4062
|
+
\t\t\t\t\t) : null}
|
|
4063
|
+
\t\t\t\t</p>
|
|
4064
|
+
\t\t\t\t<p className="truncate text-xs text-muted-foreground">
|
|
4065
|
+
\t\t\t\t\t{member.user.email}
|
|
4066
|
+
\t\t\t\t</p>
|
|
4067
|
+
\t\t\t</div>
|
|
4068
|
+
\t\t\t{canManage ? (
|
|
4069
|
+
\t\t\t\t<>
|
|
4070
|
+
\t\t\t\t\t<select
|
|
4071
|
+
\t\t\t\t\t\tclassName={roleSelectClassName}
|
|
4072
|
+
\t\t\t\t\t\taria-label="Member role"
|
|
4073
|
+
\t\t\t\t\t\tvalue={member.role}
|
|
4074
|
+
\t\t\t\t\t\tdisabled={roleMutation.isPending}
|
|
4075
|
+
\t\t\t\t\t\tonChange={(event) => {
|
|
4076
|
+
\t\t\t\t\t\t\tconst role =
|
|
4077
|
+
\t\t\t\t\t\t\t\tevent.currentTarget.value === "admin" ? "admin" : "member";
|
|
4078
|
+
\t\t\t\t\t\t\tif (role === member.role) return;
|
|
4079
|
+
\t\t\t\t\t\t\troleMutation.mutate({
|
|
4080
|
+
\t\t\t\t\t\t\t\tpath: { userId: member.userId },
|
|
4081
|
+
\t\t\t\t\t\t\t\tbody: { role },
|
|
4082
|
+
\t\t\t\t\t\t\t});
|
|
4083
|
+
\t\t\t\t\t\t}}
|
|
4084
|
+
\t\t\t\t\t>
|
|
4085
|
+
\t\t\t\t\t\t<option value="member">Member</option>
|
|
4086
|
+
\t\t\t\t\t\t<option value="admin">Admin</option>
|
|
4087
|
+
\t\t\t\t\t</select>
|
|
4088
|
+
\t\t\t\t\t<Button
|
|
4089
|
+
\t\t\t\t\t\ttype="button"
|
|
4090
|
+
\t\t\t\t\t\tvariant="destructive"
|
|
4091
|
+
\t\t\t\t\t\tsize="sm"
|
|
4092
|
+
\t\t\t\t\t\tdisabled={removeMutation.isPending}
|
|
4093
|
+
\t\t\t\t\t\tonClick={() =>
|
|
4094
|
+
\t\t\t\t\t\t\tremoveMutation.mutate({ path: { userId: member.userId } })
|
|
4095
|
+
\t\t\t\t\t\t}
|
|
4096
|
+
\t\t\t\t\t>
|
|
4097
|
+
\t\t\t\t\t\tRemove
|
|
4098
|
+
\t\t\t\t\t</Button>
|
|
4099
|
+
\t\t\t\t</>
|
|
4100
|
+
\t\t\t) : (
|
|
4101
|
+
\t\t\t\t<span className="rounded-md bg-muted px-2 py-0.5 text-xs font-medium capitalize">
|
|
4102
|
+
\t\t\t\t\t{member.role}
|
|
4103
|
+
\t\t\t\t</span>
|
|
4104
|
+
\t\t\t)}
|
|
4105
|
+
\t\t</div>
|
|
4106
|
+
\t);
|
|
4107
|
+
}
|
|
4108
|
+
|
|
4109
|
+
export function MembersSettings() {
|
|
4110
|
+
\tconst session = authClient.useSession();
|
|
4111
|
+
\tconst queryClient = useQueryClient();
|
|
4112
|
+
\tconst [actionError, setActionError] = useState<string | null>(null);
|
|
4113
|
+
|
|
4114
|
+
\tconst currentQuery = useQuery(currentWorkspaceQueryOptions());
|
|
4115
|
+
\tconst isAdmin = currentQuery.data?.membership?.role === "admin";
|
|
4116
|
+
\tconst hasWorkspace = Boolean(currentQuery.data?.workspace);
|
|
4117
|
+
\tconst currentUserId = session.data?.user.id;
|
|
4118
|
+
|
|
4119
|
+
\tconst membersQuery = useQuery(membersQueryOptions({ enabled: hasWorkspace }));
|
|
4120
|
+
\tconst invitesQuery = useQuery(
|
|
4121
|
+
\t\tinvitesQueryOptions({ enabled: hasWorkspace && isAdmin }),
|
|
4122
|
+
\t);
|
|
4123
|
+
|
|
4124
|
+
\tconst revokeMutation = useMutation({
|
|
4125
|
+
\t\t...revokeInviteMutationOptions(),
|
|
4126
|
+
\t\tonSuccess: async () => {
|
|
4127
|
+
\t\t\tawait invalidateInvites(queryClient);
|
|
4128
|
+
\t\t},
|
|
4129
|
+
\t\tonError: (error) =>
|
|
4130
|
+
\t\t\tsetActionError(errorMessage(error, "Could not revoke the invite.")),
|
|
4131
|
+
\t});
|
|
4132
|
+
|
|
4133
|
+
\tif (currentQuery.isPending) {
|
|
4134
|
+
\t\treturn <p className="text-sm text-muted-foreground">Loading...</p>;
|
|
4135
|
+
\t}
|
|
4136
|
+
|
|
4137
|
+
\tif (!hasWorkspace || !currentUserId) {
|
|
4138
|
+
\t\treturn (
|
|
4139
|
+
\t\t\t<p className="text-sm text-muted-foreground">
|
|
4140
|
+
\t\t\t\tCreate or pick a workspace to manage members.
|
|
4141
|
+
\t\t\t</p>
|
|
4142
|
+
\t\t);
|
|
4143
|
+
\t}
|
|
4144
|
+
|
|
4145
|
+
\tconst invites = invitesQuery.data?.items ?? [];
|
|
4146
|
+
\tconst pendingInvites = invites.filter(
|
|
4147
|
+
\t\t(invite) => invite.status === "pending",
|
|
4148
|
+
\t);
|
|
4149
|
+
|
|
4150
|
+
\treturn (
|
|
4151
|
+
\t\t<div className="flex max-w-2xl flex-col gap-4">
|
|
4152
|
+
\t\t\t<Card>
|
|
4153
|
+
\t\t\t\t<CardHeader>
|
|
4154
|
+
\t\t\t\t\t<CardTitle>Members</CardTitle>
|
|
4155
|
+
\t\t\t\t</CardHeader>
|
|
4156
|
+
\t\t\t\t<CardContent>
|
|
4157
|
+
\t\t\t\t\t{membersQuery.isPending ? (
|
|
4158
|
+
\t\t\t\t\t\t<p className="text-sm text-muted-foreground">
|
|
4159
|
+
\t\t\t\t\t\t\tLoading members...
|
|
4160
|
+
\t\t\t\t\t\t</p>
|
|
4161
|
+
\t\t\t\t\t) : null}
|
|
4162
|
+
\t\t\t\t\t{membersQuery.isError ? (
|
|
4163
|
+
\t\t\t\t\t\t<p className="text-sm text-destructive">
|
|
4164
|
+
\t\t\t\t\t\t\tCould not load members.
|
|
4165
|
+
\t\t\t\t\t\t</p>
|
|
4166
|
+
\t\t\t\t\t) : null}
|
|
4167
|
+
\t\t\t\t\t<div className="flex flex-col divide-y divide-border">
|
|
4168
|
+
\t\t\t\t\t\t{membersQuery.data?.items.map((member) => (
|
|
4169
|
+
\t\t\t\t\t\t\t<MemberRow
|
|
4170
|
+
\t\t\t\t\t\t\t\tkey={member.id}
|
|
4171
|
+
\t\t\t\t\t\t\t\tmember={member}
|
|
4172
|
+
\t\t\t\t\t\t\t\tisSelf={member.userId === currentUserId}
|
|
4173
|
+
\t\t\t\t\t\t\t\tcanManage={isAdmin}
|
|
4174
|
+
\t\t\t\t\t\t\t\tonError={setActionError}
|
|
4175
|
+
\t\t\t\t\t\t\t/>
|
|
4176
|
+
\t\t\t\t\t\t))}
|
|
4177
|
+
\t\t\t\t\t</div>
|
|
4178
|
+
\t\t\t\t\t{actionError ? (
|
|
4179
|
+
\t\t\t\t\t\t<p className="mt-2 text-sm text-destructive">{actionError}</p>
|
|
4180
|
+
\t\t\t\t\t) : null}
|
|
4181
|
+
\t\t\t\t</CardContent>
|
|
4182
|
+
\t\t\t</Card>
|
|
4183
|
+
|
|
4184
|
+
\t\t\t{isAdmin ? (
|
|
4185
|
+
\t\t\t\t<Card>
|
|
4186
|
+
\t\t\t\t\t<CardHeader>
|
|
4187
|
+
\t\t\t\t\t\t<CardTitle>Invites</CardTitle>
|
|
4188
|
+
\t\t\t\t\t</CardHeader>
|
|
4189
|
+
\t\t\t\t\t<CardContent className="flex flex-col gap-3">
|
|
4190
|
+
\t\t\t\t\t\t<InviteForm />
|
|
4191
|
+
\t\t\t\t\t\t<Separator />
|
|
4192
|
+
\t\t\t\t\t\t{invitesQuery.isPending ? (
|
|
4193
|
+
\t\t\t\t\t\t\t<p className="text-sm text-muted-foreground">
|
|
4194
|
+
\t\t\t\t\t\t\t\tLoading invites...
|
|
4195
|
+
\t\t\t\t\t\t\t</p>
|
|
4196
|
+
\t\t\t\t\t\t) : null}
|
|
4197
|
+
\t\t\t\t\t\t{pendingInvites.length === 0 && !invitesQuery.isPending ? (
|
|
4198
|
+
\t\t\t\t\t\t\t<p className="text-sm text-muted-foreground">
|
|
4199
|
+
\t\t\t\t\t\t\t\tNo pending invites.
|
|
4200
|
+
\t\t\t\t\t\t\t</p>
|
|
4201
|
+
\t\t\t\t\t\t) : null}
|
|
4202
|
+
\t\t\t\t\t\t{pendingInvites.map((invite) => (
|
|
4203
|
+
\t\t\t\t\t\t\t<div
|
|
4204
|
+
\t\t\t\t\t\t\t\tkey={invite.id}
|
|
4205
|
+
\t\t\t\t\t\t\t\tclassName="flex flex-wrap items-center gap-3"
|
|
4206
|
+
\t\t\t\t\t\t\t>
|
|
4207
|
+
\t\t\t\t\t\t\t\t<div className="min-w-0 flex-1">
|
|
4208
|
+
\t\t\t\t\t\t\t\t\t<p className="truncate text-sm font-medium">
|
|
4209
|
+
\t\t\t\t\t\t\t\t\t\t{invite.email}
|
|
4210
|
+
\t\t\t\t\t\t\t\t\t</p>
|
|
4211
|
+
\t\t\t\t\t\t\t\t\t<p className="text-xs text-muted-foreground">
|
|
4212
|
+
\t\t\t\t\t\t\t\t\t\t{invite.role} - expires{" "}
|
|
4213
|
+
\t\t\t\t\t\t\t\t\t\t{new Date(invite.expiresAt).toLocaleDateString()}
|
|
4214
|
+
\t\t\t\t\t\t\t\t\t</p>
|
|
4215
|
+
\t\t\t\t\t\t\t\t</div>
|
|
4216
|
+
\t\t\t\t\t\t\t\t<Button
|
|
4217
|
+
\t\t\t\t\t\t\t\t\ttype="button"
|
|
4218
|
+
\t\t\t\t\t\t\t\t\tvariant="outline"
|
|
4219
|
+
\t\t\t\t\t\t\t\t\tsize="sm"
|
|
4220
|
+
\t\t\t\t\t\t\t\t\tdisabled={revokeMutation.isPending}
|
|
4221
|
+
\t\t\t\t\t\t\t\t\tonClick={() =>
|
|
4222
|
+
\t\t\t\t\t\t\t\t\t\trevokeMutation.mutate({
|
|
4223
|
+
\t\t\t\t\t\t\t\t\t\t\tpath: { inviteId: invite.id },
|
|
4224
|
+
\t\t\t\t\t\t\t\t\t\t})
|
|
4225
|
+
\t\t\t\t\t\t\t\t\t}
|
|
4226
|
+
\t\t\t\t\t\t\t\t>
|
|
4227
|
+
\t\t\t\t\t\t\t\t\tRevoke
|
|
4228
|
+
\t\t\t\t\t\t\t\t</Button>
|
|
4229
|
+
\t\t\t\t\t\t\t</div>
|
|
4230
|
+
\t\t\t\t\t\t))}
|
|
4231
|
+
\t\t\t\t\t</CardContent>
|
|
4232
|
+
\t\t\t\t</Card>
|
|
4233
|
+
\t\t\t) : (
|
|
4234
|
+
\t\t\t\t<p className="text-sm text-muted-foreground">
|
|
4235
|
+
\t\t\t\t\tOnly workspace admins can manage roles and invites.
|
|
4236
|
+
\t\t\t\t</p>
|
|
4237
|
+
\t\t\t)}
|
|
4238
|
+
\t\t</div>
|
|
4239
|
+
\t);
|
|
4240
|
+
}
|
|
4241
|
+
`;
|
|
4242
|
+
}
|
|
4243
|
+
export function inviteAcceptComponentFile() {
|
|
4244
|
+
return `"use client";
|
|
4245
|
+
|
|
4246
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
4247
|
+
import Link from "next/link";
|
|
4248
|
+
import { useRouter } from "next/navigation";
|
|
4249
|
+
import { useState } from "react";
|
|
4250
|
+
import { authClient } from "@/client/auth-client";
|
|
4251
|
+
import { Button } from "@/components/ui/button";
|
|
4252
|
+
import {
|
|
4253
|
+
\tCard,
|
|
4254
|
+
\tCardContent,
|
|
4255
|
+
\tCardDescription,
|
|
4256
|
+
\tCardHeader,
|
|
4257
|
+
\tCardTitle,
|
|
4258
|
+
} from "@/components/ui/card";
|
|
4259
|
+
import {
|
|
4260
|
+
\tacceptInviteMutationOptions,
|
|
4261
|
+
\tinvalidateWorkspaces,
|
|
4262
|
+
\tswitchWorkspaceMutationOptions,
|
|
4263
|
+
} from "@/features/workspaces/client/queries";
|
|
4264
|
+
|
|
4265
|
+
function errorMessage(error: unknown, fallback: string): string {
|
|
4266
|
+
\tif (error && typeof error === "object" && "message" in error) {
|
|
4267
|
+
\t\tconst message = (error as { message?: unknown }).message;
|
|
4268
|
+
\t\tif (typeof message === "string" && message.length > 0) return message;
|
|
4269
|
+
\t}
|
|
4270
|
+
\treturn fallback;
|
|
4271
|
+
}
|
|
4272
|
+
|
|
4273
|
+
export function InviteAccept({ token }: { token: string }) {
|
|
4274
|
+
\tconst session = authClient.useSession();
|
|
4275
|
+
\tconst router = useRouter();
|
|
4276
|
+
\tconst queryClient = useQueryClient();
|
|
4277
|
+
\tconst [error, setError] = useState<string | null>(null);
|
|
4278
|
+
|
|
4279
|
+
\tconst switchMutation = useMutation({
|
|
4280
|
+
\t\t...switchWorkspaceMutationOptions(),
|
|
4281
|
+
\t\tonSettled: async () => {
|
|
4282
|
+
\t\t\tawait queryClient.invalidateQueries();
|
|
4283
|
+
\t\t\trouter.push("/dashboard");
|
|
4284
|
+
\t\t\trouter.refresh();
|
|
4285
|
+
\t\t},
|
|
4286
|
+
\t});
|
|
4287
|
+
|
|
4288
|
+
\tconst acceptMutation = useMutation({
|
|
4289
|
+
\t\t...acceptInviteMutationOptions(),
|
|
4290
|
+
\t\tonSuccess: async (result) => {
|
|
4291
|
+
\t\t\tawait invalidateWorkspaces(queryClient);
|
|
4292
|
+
\t\t\tswitchMutation.mutate({ body: { workspaceId: result.workspace.id } });
|
|
4293
|
+
\t\t},
|
|
4294
|
+
\t\tonError: (mutationError) =>
|
|
4295
|
+
\t\t\tsetError(errorMessage(mutationError, "Could not accept the invite.")),
|
|
4296
|
+
\t});
|
|
4297
|
+
|
|
4298
|
+
\tif (session.isPending) {
|
|
4299
|
+
\t\treturn (
|
|
4300
|
+
\t\t\t<main className="grid min-h-svh place-items-center p-6">
|
|
4301
|
+
\t\t\t\t<p className="text-sm text-muted-foreground">Loading...</p>
|
|
4302
|
+
\t\t\t</main>
|
|
4303
|
+
\t\t);
|
|
4304
|
+
\t}
|
|
4305
|
+
|
|
4306
|
+
\tif (!session.data?.user) {
|
|
4307
|
+
\t\treturn (
|
|
4308
|
+
\t\t\t<main className="grid min-h-svh place-items-center p-6">
|
|
4309
|
+
\t\t\t\t<Card className="w-full max-w-sm">
|
|
4310
|
+
\t\t\t\t\t<CardHeader>
|
|
4311
|
+
\t\t\t\t\t\t<CardTitle>Workspace invite</CardTitle>
|
|
4312
|
+
\t\t\t\t\t\t<CardDescription>
|
|
4313
|
+
\t\t\t\t\t\t\tSign in to accept this invite. New here? Create an account
|
|
4314
|
+
\t\t\t\t\t\t\tfirst, then reopen the invite link.
|
|
4315
|
+
\t\t\t\t\t\t</CardDescription>
|
|
4316
|
+
\t\t\t\t\t</CardHeader>
|
|
4317
|
+
\t\t\t\t\t<CardContent className="flex gap-2">
|
|
4318
|
+
\t\t\t\t\t\t<Button asChild>
|
|
4319
|
+
\t\t\t\t\t\t\t<Link href="/sign-in">Sign in</Link>
|
|
4320
|
+
\t\t\t\t\t\t</Button>
|
|
4321
|
+
\t\t\t\t\t\t<Button asChild variant="outline">
|
|
4322
|
+
\t\t\t\t\t\t\t<Link href="/sign-up">Create account</Link>
|
|
4323
|
+
\t\t\t\t\t\t</Button>
|
|
4324
|
+
\t\t\t\t\t</CardContent>
|
|
4325
|
+
\t\t\t\t</Card>
|
|
4326
|
+
\t\t\t</main>
|
|
4327
|
+
\t\t);
|
|
4328
|
+
\t}
|
|
4329
|
+
|
|
4330
|
+
\tconst pending = acceptMutation.isPending || switchMutation.isPending;
|
|
4331
|
+
|
|
4332
|
+
\treturn (
|
|
4333
|
+
\t\t<main className="grid min-h-svh place-items-center p-6">
|
|
4334
|
+
\t\t\t<Card className="w-full max-w-sm">
|
|
4335
|
+
\t\t\t\t<CardHeader>
|
|
4336
|
+
\t\t\t\t\t<CardTitle>Workspace invite</CardTitle>
|
|
4337
|
+
\t\t\t\t\t<CardDescription>
|
|
4338
|
+
\t\t\t\t\t\tAccept this invite to join the workspace as{" "}
|
|
4339
|
+
\t\t\t\t\t\t{session.data.user.name || session.data.user.email}.
|
|
4340
|
+
\t\t\t\t\t</CardDescription>
|
|
4341
|
+
\t\t\t\t</CardHeader>
|
|
4342
|
+
\t\t\t\t<CardContent className="flex flex-col gap-3">
|
|
4343
|
+
\t\t\t\t\t{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
|
4344
|
+
\t\t\t\t\t<Button
|
|
4345
|
+
\t\t\t\t\t\ttype="button"
|
|
4346
|
+
\t\t\t\t\t\tdisabled={pending}
|
|
4347
|
+
\t\t\t\t\t\tonClick={() => {
|
|
4348
|
+
\t\t\t\t\t\t\tsetError(null);
|
|
4349
|
+
\t\t\t\t\t\t\tacceptMutation.mutate({ path: { token } });
|
|
4350
|
+
\t\t\t\t\t\t}}
|
|
4351
|
+
\t\t\t\t\t>
|
|
4352
|
+
\t\t\t\t\t\t{pending ? "Joining..." : "Accept invite"}
|
|
4353
|
+
\t\t\t\t\t</Button>
|
|
4354
|
+
\t\t\t\t</CardContent>
|
|
4355
|
+
\t\t\t</Card>
|
|
4356
|
+
\t\t</main>
|
|
4357
|
+
\t);
|
|
4358
|
+
}
|
|
4359
|
+
`;
|
|
4360
|
+
}
|
|
4361
|
+
export function workspaceComponentsIndexFile() {
|
|
4362
|
+
return `export { InviteAccept } from "./invite-accept";
|
|
4363
|
+
export { InviteForm } from "./invite-form";
|
|
4364
|
+
export { MembersSettings } from "./members-settings";
|
|
4365
|
+
export { WorkspaceSettingsForm } from "./workspace-settings-form";
|
|
4366
|
+
export { WorkspaceSwitcher } from "./workspace-switcher";
|
|
4367
|
+
`;
|
|
4368
|
+
}
|
|
4369
|
+
export function settingsWorkspacePageFile() {
|
|
4370
|
+
return `import {
|
|
4371
|
+
\tWorkspaceSettingsForm,
|
|
4372
|
+
\tWorkspaceSwitcher,
|
|
4373
|
+
} from "@/features/workspaces/components";
|
|
4374
|
+
|
|
4375
|
+
export default function SettingsWorkspacePage() {
|
|
4376
|
+
\treturn (
|
|
4377
|
+
\t\t<div className="flex flex-col gap-4">
|
|
4378
|
+
\t\t\t<div className="max-w-xs">
|
|
4379
|
+
\t\t\t\t<WorkspaceSwitcher />
|
|
4380
|
+
\t\t\t</div>
|
|
4381
|
+
\t\t\t<WorkspaceSettingsForm />
|
|
4382
|
+
\t\t</div>
|
|
4383
|
+
\t);
|
|
4384
|
+
}
|
|
4385
|
+
`;
|
|
4386
|
+
}
|
|
4387
|
+
export function settingsMembersPageFile() {
|
|
4388
|
+
return `import { MembersSettings } from "@/features/workspaces/components";
|
|
4389
|
+
|
|
4390
|
+
export default function SettingsMembersPage() {
|
|
4391
|
+
\treturn <MembersSettings />;
|
|
4392
|
+
}
|
|
4393
|
+
`;
|
|
4394
|
+
}
|
|
4395
|
+
export function inviteTokenPageFile() {
|
|
4396
|
+
return `import { InviteAccept } from "@/features/workspaces/components";
|
|
4397
|
+
|
|
4398
|
+
export default async function InvitePage({
|
|
4399
|
+
\tparams,
|
|
4400
|
+
}: {
|
|
4401
|
+
\tparams: Promise<{ token: string }>;
|
|
4402
|
+
}) {
|
|
4403
|
+
\tconst { token } = await params;
|
|
4404
|
+
|
|
4405
|
+
\treturn <InviteAccept token={token} />;
|
|
4406
|
+
}
|
|
4407
|
+
`;
|
|
4408
|
+
}
|
|
4409
|
+
//# sourceMappingURL=tenancy.js.map
|