Read-side only — write paths land with claim_task / complete_task
in the next epic. Keyset pagination on started_at, three procedures
(listRecent, listForTask, summary), and a /settings/runs view that
mirrors the audit page's visual language.
Co-authored-by: Cursor <cursoragent@cursor.com>
Soft-delete cascade was the missing half of archive: stamping
workspaces.archived_at alone left objects visible to anyone with a
direct id. The cascade runs in one transaction so the partial state
isn't reachable, and restore inverts it for any archived row in the
workspace — provenance-blind on purpose until we have a use case
that needs to distinguish per-workspace from per-object archives.
audit_log keeps the keyset index on (workspace_id, created_at) and
the actor_user_id FK with onDelete set null. recordAudit() refuses
to write a null actor without a metadata.system_actor label so the
audit view always has something to render. workspaces and invites
mutations call recordAudit on success; objects-router instrumentation
and the markdown importer's system-actor flow are filed as P2
follow-ups because each needs a thoughtful "what's audit-worthy?"
pass, not mechanical wiring.
Settings → Audit log lives at /<slug>/settings/audit, owner-gated,
keyset-paginated. ACTION_LABELS is small on purpose; new actions
fall back to their raw key so missing a label degrades gracefully.
Co-authored-by: Cursor <cursoragent@cursor.com>
Closes Task-workspace-invites-and-roles end-to-end. Builds on the
schema + procedures from 7a55d6d (Task 2, part 1/2).
apps/web/components/teams/invite-dialog.tsx (new):
* Owner/admin-only sheet that wraps invites.create. Email input + role
select (member/admin; owner deliberately excluded — single-owner
model means ownership transfer is a separate flow, not a fresh
invite). On success surfaces the accept URL with a copy-to-clipboard
affordance and a "your email isn't wired up yet, paste this directly"
hint. Plain text input in this commit; the smart recipient
autocomplete combobox from Task 3 will swap it in via a follow-up
edit to this same file (subagent is working that in parallel).
apps/web/app/(app)/[workspaceSlug]/teams/page.tsx (rewrite):
* Replaced the placeholder "Invite coming soon" button with the new
InviteDialog. Adds:
- Pending invites section (admin/owner only) listing each open
invite with email, role, expiry-relative time, and Copy link /
Revoke actions.
- Per-member kebab menu with role-change actions and Remove. Only
owners can promote anyone to owner; admins can move people
between admin/member only. The "demote to member" item disables
on the last-owner row (the server enforces this anyway with a
clear error; UI just avoids surfacing a click that'd 400).
- "You're a member, not a manager" footer hint for non-owners/admins.
* Caller's role is derived from the members query (no extra
round-trip) — the membership row IS the source of truth for who
can manage what.
* Mutation errors surface inline at the page level with a Dismiss
action — kebab/copy actions that hit the last-owner guard, expired-
token error, etc. don't fail silently.
apps/web/app/invite/[token]/page.tsx (new):
* Public-by-token redeem page. Four phases handled cleanly:
1. No session yet -> "Sign in to continue" with callbackUrl set so
the user lands back here after auth.
2. Authenticated, accepting -> spinner.
3. Success -> redirect to the workspace's slug-rooted URL.
4. FORBIDDEN with cause.reason='email_not_owned' -> dedicated
explainer page showing both the invited email AND the user's
current sign-in email, with deep links to link the invited email
via OAuth and try again. (This is the Task 1 invariant
surfacing through the UI: we never silently accept an invite
under a mismatched identity.)
* All other accept errors (not found / revoked / expired) render the
message verbatim with a "Go home" button.
apps/web/server/trpc.ts:
* Added a small errorFormatter that exposes `error.cause` to the
client when it's a plain object. Required for the invite-accept
explainer page to read `cause.invitedEmail` off the TRPCError. The
cause-payload contract is "small, pure data, no secrets" — anything
the server throws as a cause is also visible client-side.
End-to-end behavior verified statically: type-check clean across all
6 packages. Smoke test path:
1. As admin@tasks.dev, open /<workspace>/teams.
2. Click Invite -> dialog opens -> enter an email, pick member, send.
3. See the success state with the accept URL. Copy it.
4. Open the URL in a different browser (or incognito). With no session
-> sign-in prompt. After auth -> invite accepts and you land in
the workspace. With a session whose email doesn't match -> the
email-mismatch explainer renders.
Note: the test runner shows three new tests in packages/shared
(invite-suggestions.test.ts) from the in-progress Task-3 subagent.
Those land with their own commit when the subagent finishes — they're
visible here only because they share the working tree.
Co-authored-by: Cursor <cursoragent@cursor.com>
First half of Task-multi-email-identity. Lays down everything except the
NextAuth callback wiring, which is gated on a research subagent finishing
its survey of OAuth provider behavior for the email_verified claim
across GitHub, Google, and Authentik.
Schema (packages/database):
* New user_email_identities table colocated with `users` in users.ts.
Columns: id, user_id (FK), email (lowercased), verified_at, source,
created_at, last_used_at.
* Indexes: user_id, email, unique(user_id, email), and a PARTIAL unique
index on email WHERE verified_at IS NOT NULL — a verified email
resolves to exactly one users row globally, while unverified rows
(none today; placeholder for the manual-verification follow-up) do
not share the constraint.
* Drizzle relation: users.emailIdentities -> userEmailIdentities, and
the inverse one(users) relation.
* Migration 0005 generated by db:generate, augmented with a backfill
INSERT that seeds one source='primary' identity per existing users
row using created_at as verified_at. Migration applied to dev DB;
existing admin@tasks.dev user verified as 1:1 mapped.
Server (apps/web/server):
* apps/web/server/lib/identity.ts exports two pure read helpers:
- userOwnsEmail(userId, email): boolean used by the (upcoming)
invite-accept procedure to verify the human controls the invited
address under any of their linked identities.
- findUserIdByVerifiedEmail(email): the replacement for the old
ensureUserIdByEmail lookup. Will be called from auth.ts once the
OAuth research subagent returns.
* apps/web/server/routers/identity.ts exposes identity.listMine — a
protected procedure returning the caller's identities ordered by
verifiedAt desc. Cross-user identity surface is intentionally NOT
exposed here; that lives behind the workspace-scoped autocomplete
in Task 3 with its own tenancy fence.
UI (apps/web/app):
* New route /[workspaceSlug]/settings/profile renders a read-only
"Linked emails" section with per-identity row (email, source badge,
verified state, last-used relative time) plus a hint that explains
how to add another email (sign in via that email's OAuth provider).
* Empty / loading / error states all handled. The "no identities"
branch should never fire post-backfill but renders a friendly
message instead of throwing.
What's NOT in this commit:
* auth.ts changes (ensureUserIdByEmail -> ensureUserIdByVerifiedEmail,
OAuth callback identity upsert, cross-user conflict rejection).
Waiting on subagent research to land the callback wiring correctly
on the first try across all three providers.
* Vitest tests. The pure helpers are 10-line query shims and the
behavior-relevant assertion is the auth callback path — easier to
write meaningful tests once that lands.
All three CI gates green: pnpm lint (14 pre-existing warnings,
unchanged), pnpm type-check (6/6 packages), pnpm test (14/14
existing tests across @tasks/shared, @tasks/database, @tasks/ai).
Co-authored-by: Cursor <cursoragent@cursor.com>
Path-A task 3/5. Replaces the setTimeout mock that returned the literal
"Full AI integration is coming soon!" string with a real streaming
provider call.
* apps/web/app/api/chat/route.ts (new): POST handler that runs the
same auth + resolveWorkspace pipeline workspaceProcedure uses, then
streams a response from streamText().toDataStreamResponse(). Maps
resolveWorkspace's TRPCError codes to HTTP status (401/403/404/400).
Returns a structured 503 with a human-readable hint when
OPENAI_API_KEY is unset, so the misconfiguration is surfaced rather
than masked by a fake stream.
* apps/web/app/(app)/[workspaceSlug]/ai/page.tsx: replace the local
message-state + setTimeout placeholder with useChat from
@ai-sdk/react. workspace slug is sent on every request body so the
server can enforce tenant scoping. Adds a ChatErrorBanner that
parses the JSON error body the route emits and renders amber for
the "unavailable" case, destructive for other failures.
* apps/web/package.json: pull in @ai-sdk/react as a direct dep
(previously only transitive via `ai`).
The existing aiRouter.chat tRPC mutation is left intact — it powers
the right-panel command palette via the non-streaming generateText
path, and rebuilding that as streaming was outside the scope of
making the dedicated chat page usable.
Provider selection still flows from env per packages/ai conventions:
OPENAI_API_KEY gates availability, OPENAI_BASE_URL lets operators
route through Ollama on CT 108 transparently, OPENAI_MODEL overrides
the default gpt-4o-mini.
`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-wire-ai-chat-to-trpc.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Path-A task 2/5. Replaces the hardcoded `stats` (24/8/12) and
hardcoded `recent` list on the workspace-home page with real
workspace-scoped data.
* server/routers/objects.ts: add two new procedures.
- `objects.stats` returns { openTasks, containers }. Open-task count
treats null status as open; only `done` and `closed` (per
packages/shared object-statuses) are terminal. Container count
aggregates project + space + group rows.
- `objects.listRecent({ limit })` returns the N most-recently-updated
rows, descending by updated_at. Excludes archived and excludes
`workspace`/`group` from the activity feed (containers clutter
"what did I just touch" recency).
Both go through workspaceProcedure, so the workspace_id filter
comes from the middleware-resolved ctx.workspace.id rather than
any user input.
* app/(app)/[workspaceSlug]/page.tsx: rewrite to consume the new
procedures via @trpc/react-query. Adds:
- Skeleton loading state (no flash of zeros).
- Empty state with a "New task" CTA on workspaces with no objects.
- Real "X ago" labels on the recent feed.
- Click-through links from recent rows to /{slug}/{id}.
- A locally-mounted CreateObjectDialog instance independent of the
global one in AppShell so the empty-state CTA can pre-seed
defaultType="task" without coordinating shared state.
* components/ui/skeleton.tsx: new (standard shadcn pulse skeleton).
Used by the dashboard but reusable across the app.
The scaffolded "Due this week" stat is dropped: `objects` has no
due_at column and the task explicitly preferred dropping a card to
schema-creep.
`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-wire-workspace-home-dashboard.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Block A of the EchoDo plan. Workspaces used to live as `objects(type='workspace')`,
which made it impossible to put a real RLS-friendly tenant boundary on the schema
or to give each workspace a stable URL slug. This commit:
- Adds a top-level `workspaces` table (slug unique, owner FK, plan_tier hook).
- Migrates the 8 anchor tables (objects, workspace_members, object_type_defs,
property_definitions, templates, forms, markdown_backlog_items,
cursor_sync_mappings) to FK into `workspaces.id` instead of `objects.id`,
with a hand-augmented data-copy migration that preserves IDs and slug-collision-
proofs on backfill.
- Introduces a `workspaceProcedure` tRPC middleware + `resolveWorkspace` helper
that take a UUID-or-slug `workspace` handle and expose `ctx.workspace`. All
tenant-scoped routers (objects, types, properties, templates, forms, search,
ai, relations, favorites) now flow through it.
- Updates the web app to pass `workspace` slugs from the URL (or store) instead
of the old `workspaceId`, including a workspace-sync layer that rewrites
/<UUID>/... links to /<slug>/...
- Updates the MCP tools (list_objects, create_object, search_objects) and the
workspace://{handle}/tree resource to accept either a slug or UUID so existing
agents keep working.
- Adds a Create Workspace dialog and a Workspace Settings page (rename + slug
rename with redirect, owner-only archive).
Verified locally against a fresh Postgres: migration applies cleanly, slug
uniqueness holds, tenant data is isolated by workspace_id, slug↔UUID resolution
works in both directions, and ON DELETE CASCADE cleans up child rows in the
correct workspace only.
Co-authored-by: Cursor <cursoragent@cursor.com>