@beignet/cli 0.0.25 → 0.0.27

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