MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-32

1.2.4 Auto-create default workspace on user signup (Better-Auth hook)

Done
Description

Estimate: 12m · Depends on: 1.2.2, 1.1.2

Every authenticated user must have at least one workspace (Story-level AC bullet 1). Wire a Better-Auth databaseHooks.user.create.after hook that creates a default workspace named "{user.name}'s Workspace" with the user as its first member, AND a lazy self-heal backfill so a user who somehow ends up with zero workspaces is repaired on next context resolution. The hook covers the user-creating sign-up paths from Story 1.1: email/password sign-up and Google OAuth first sign-in (new user). The Google linking path — an existing email-first user signing in with Google — does NOT create a new User row (Better-Auth's native account.accountLinking with trustedProviders: ['google'] links to the existing user), so the hook correctly never fires there and the pre-existing workspace is preserved.

Why a hook PLUS a backfill, not a hook alone: the obvious design is "the hook is atomic with user creation, so the workspace always exists." That is not true for better-auth@1.6.11 with the Prisma adapter — verified in dist/db/with-hooks.mjs: the create.after hook runs sequentially after the user insert commits, with no shared transaction (the adapter exposes no tx to the hook). So a throw inside the hook leaves a committed user with no workspace — exactly the orphan we want to avoid. Rather than pretend the hook is transactional, design for the real behavior: the hook is the best-effort fast path (covers ~all signups synchronously), and a lazy ensureDefaultWorkspace backfill is the correctness backstop. This also future-proofs against any later sign-up path that bypasses the hook. (Logged as PRODECT_FINDINGS #6 — this card previously asserted false atomicity.)

Why "{user.name}'s Workspace" as the default name: mirrors Linear / Notion / Slack defaults. The user can rename in 1.2.6's settings page anytime. The slug derives from the workspace name via workspacesService.createWorkspace's existing slugify + 4-char-random-suffix-on-collision logic (from 1.2.2), so cross-user name collisions resolve automatically.

What you'll do: Extend lib/auth/index.ts's betterAuth({ ... }) config with a databaseHooks.user.create.after block that calls workspacesService.createWorkspace({ name: ${user.name}'s Workspace, ownerUserId: user.id }) and swallows-and-logs any error (the user row is already committed; throwing here would 500 an otherwise-successful signup — the backfill recovers the miss). Add workspacesService.ensureDefaultWorkspace({ userId, userName }) (idempotent: no-op if the user already has a membership; otherwise creates the default) and wire the resolver behind getWorkspaceContext() to call it when it finds no membership instead of returning null. Add GET /api/workspaces/current (app/api/workspaces/current/route.ts) — a thin route reading the session + active context, returning { workspace, membership } via a service method + DTO (no db.* in the route, per CLAUDE.md's 4-layer rule). Add a Vitest integration test signing up fresh users and asserting workspace + membership exist; extend tests/e2e/auth-credentials.spec.ts to assert /api/workspaces/current returns the auto-created workspace after sign-up.

Acceptance criteria

  • lib/auth/index.ts extended with databaseHooks.user.create.after calling workspacesService.createWorkspace with the new user's name + id. The hook is best-effort post-commit (NOT atomic with the user insert — verified against better-auth@1.6.11); it swallows-and-logs errors rather than failing the signup response.
  • workspacesService.ensureDefaultWorkspace({ userId, userName }) exists and is idempotent (no-op when the user already has a membership; creates the default otherwise). The resolver behind getWorkspaceContext() calls it when it finds no membership, so a signed-in user is never stranded with zero workspaces — this is the correctness backstop for the non-atomic hook.
  • All Story-1.1 sign-up paths behave correctly: email/password sign-up and Google OAuth new-user sign-up each produce exactly one workspace; the email-first-then-Google linking path produces NO second workspace (the pre-existing one is preserved; the hook doesn't fire because no User is created).
  • A /api/workspaces/current route handler (app/api/workspaces/current/route.ts) returns { workspace, membership } for the active workspace context, or 401 if no session. Thin transport only — no db.* / $transaction in the route (4-layer rule).
  • Default workspace name: "{user.name}'s Workspace" (e.g. "Alice's Workspace"). Slug from createWorkspace's existing slugify + 4-char-random-suffix-on-collision logic (1.2.2).
  • Vitest integration test in tests/auto-workspace-on-signup.test.ts covers: email/password sign-up creates a workspace; Google OAuth new-user creates a workspace; email-first user linking Google does NOT create a second workspace; ensureDefaultWorkspace backfills a zero-workspace user and is idempotent (calling twice yields one workspace).
  • Playwright E2E spec (tests/e2e/auth-credentials.spec.ts) extended to assert /api/workspaces/current returns the auto-created workspace after sign-up. Existing assertions stay green.
  • All 4 quality gates green; Vitest + Playwright suites green.

Context refs

  • lib/auth/index.ts — current Better-Auth config (email/password + socialProviders.google + native account.accountLinking with trustedProviders: ['google']); no databaseHooks yet — this Subtask adds the block
  • lib/services/workspacesService.tscreateWorkspace({ name, ownerUserId }) is the entry point (atomic Workspace + owner Membership, slug-collision retry). This Subtask adds ensureDefaultWorkspace here. (NOTE: the old lib/workspaces/repo.ts / lib/users/repo.ts referenced in pre-1.2.5 cards were deleted by 1.2.5's 4-layer refactor.)
  • lib/workspaces/index.ts + lib/workspaces/middleware.tsgetWorkspaceContext() and the resolver this Subtask hooks the backfill into
  • motir-core/CLAUDE.md — the 4-layer Route→Service→Repository→Prisma contract the new endpoint + service method must follow
  • tests/e2e/auth-credentials.spec.ts + tests/e2e/_helpers/{db-reset,email-capture}.ts — the spec this Subtask extends and its helpers
  • Better-Auth source (verify, don't assume): node_modules/better-auth/dist/db/with-hooks.mjs — confirms create.after runs post-commit, not in a shared transaction (the reason the backfill exists)
Status
Done
Type
Sub-task