Commit graph

15 commits

Author SHA1 Message Date
Randall Stillwell
7a55d6d1c6 feat(invites): workspace_invites schema + tRPC router + role management (Task 2, part 1/2)
Schema half of Task-workspace-invites-and-roles. Lands the table, the
invites router (create/list/revoke/accept), and the two new workspaces
procedures (updateMemberRole/removeMember). UI ships in part 2/2.

This is a stable checkpoint for Task 3 (invite-recipient-autocomplete)
to start building against — the procedure surface area is frozen and the
new identity helper from Task 1 is in the accept path.

Schema:
* workspace_invites: id, workspace_id, email (lowercased), role
  (owner/admin/member), invited_by_user_id, token (base64url 32B),
  expires_at (DEFAULT now() + 14d), accepted_at, revoked_at, created_at.
* Indexes: workspace_id, UNIQUE(token), and a PARTIAL UNIQUE on
  (workspace_id, email) WHERE accepted_at IS NULL AND revoked_at IS NULL.
  An open invite is unique per (workspace, email); closed invites
  (accepted or revoked) fall out of the constraint so re-invites work.
* Drizzle relations wired: workspaceInvites.workspace,
  workspaceInvites.invitedBy, workspaces.invites.
* Migration 0006_broad_lethal_legion applied to dev DB.

invites router:
* create({email, role}) on workspaceProcedure (owner/admin only).
  Generates a base64url token from 32 random bytes via node:crypto.
  Idempotent on (workspace, email) — if an open invite already exists,
  returns it instead of inserting (the partial unique would block it
  anyway). Refuses self-invite. Refuses if the email is already a
  member.
* list() returns pending (non-accepted, non-revoked) invites with
  inviter name/email joined for UI display.
* revoke({inviteId}) authorizes against the invite's workspace, not
  the caller's input (the inviteId carries its own tenant scope).
* accept({token}) is protectedProcedure (no workspace handle). Calls
  userOwnsEmail() from Task 1 — if the caller doesn't own the invited
  email under any of their verified identities, throws FORBIDDEN with
  a structured cause ({reason: "email_not_owned", invitedEmail}) so
  the redeem page can render the "link this email" explainer. Handles
  expiry, revoked, already-accepted states with clear messages.
  Idempotent on existing membership — if you've already been added by
  another flow, accept just closes the invite without re-inserting.

workspaces additions:
* updateMemberRole: admin/owner only. Three guards:
    1. Can't change your own role (avoids accidental lockout).
    2. Can't demote the only owner-role member (would leave the
       membership-level ownership empty even though workspaces.owner_user_id
       still points there — see ADR-pragmatic decision documented in the
       Task-multi-email-identity convoy discussion).
    3. Only owners can promote to owner; admins move people between
       admin/member but cannot create a new owner.
* removeMember: admin/owner OR self (the leave-workspace affordance).
  Same last-owner guard. Admins can't remove owners (only owners can,
  via demote-then-remove).

Wired both new routers into root.ts as `invites` and `identity`
(identity was landed in Task 1; this commit just keeps the registration
visible alongside invites).

All three CI gates green: 0 lint errors, 14 unchanged warnings, 6/6
type-check, 14/14 tests (no new tests yet — apps/web vitest harness is
filed as Task-bootstrap-vitest-for-apps-web P2).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:27:44 -05:00
Randall Stillwell
3a657a4aed feat(identity): OAuth-aware sign-in writes accounts + identity rows (Task 1, part 2/2)
Closes Task-multi-email-identity. Rebuilds the OAuth half of the jwt
callback around the new user_email_identities table AND the existing
(but until-now empty) accounts table, with per-provider email_verified
resolution and a cross-user conflict guard. Credentials sign-in path is
unchanged.

Per the OAuth research subagent: NextAuth has no adapter configured, so
the accounts table has been sitting empty since this app started. Rather
than leave it that way, the new resolveOAuthUser helper writes to it
on every OAuth sign-in. (provider, providerAccountId) is now the
canonical "this OAuth identity belongs to this user" record and gives
us a fast path that doesn't depend on email matching.

Sign-in resolution order for an OAuth account:

1. Lookup accounts by (provider, providerAccountId).
   Hit -> bump last_used_at on the matching identity row, return user_id.
2. Lookup user_email_identities by (email, verified_at IS NOT NULL).
   Hit AND the owner has zero existing OAuth accounts -> link this new
     OAuth account to that user (covers "Credentials user adds their
     first OAuth provider"). Insert a fresh accounts row.
   Hit AND the owner already has an OAuth account -> REFUSE.  Returning
     a token without an id field denies the session; the user lands on
     NextAuth's error page. (This is the "Bob's GitHub claims alice's
     verified email" rejection.)
3. Fall back to legacy users.email match.
   Hit -> link to that user (covers users created before migration 0005).
4. Otherwise mint a new users row + a source='primary' identity in the
   identities table, then write the accounts row.

The verified identity row is upserted only when the provider's
email_verified claim is true. The new resolveOAuthEmailVerified helper:

- Google + Authentik: read profile.email_verified directly (the Auth.js
  v5 jwt callback receives `profile` on the sign-in trigger). Authentik
  caveat documented inline: since the 2025.10 release the claim defaults
  to false unless an admin adds a custom property mapping.
- GitHub: GitHubProfile does not expose the claim. We GET /user/emails
  with the OAuth access_token and read `verified` on the entry matching
  the primary email. Failure to fetch (rate limit, network) is treated
  as unverified.

What's intentionally not in this commit:
- Vitest tests for the callback logic. apps/web has no vitest config
  yet (the test foundation only wired up the packages). Filed a
  follow-up: Task-bootstrap-vitest-for-apps-web.md (P2) under
  Epic-test-foundation. The auth-callback assertions will land against
  that harness when it's stood up.
- Race-condition transaction isolation. The current sequence (account
  lookup -> identity lookup -> account/identity upsert) has the same
  race window the old ensureUserIdByEmail had — two simultaneous OAuth
  sign-ins for a brand-new email could both pass the identity check
  before either INSERT fires. Mitigated in practice by the partial
  unique on email WHERE verified_at IS NOT NULL — postgres will reject
  the second insert — but the loser gets an opaque error. Filed as a
  follow-up if it becomes a real issue.

Task file (plans/.../Task-multi-email-identity.md) updated with the
detailed smoke-test playbook an operator needs to run before the OAuth
path goes to production (sign in fresh, sign in repeat, sign in
cross-provider, sign in cross-user-conflict). admin@tasks.dev signs in
via Credentials so the dev fixtures alone do not exercise this code.

Lint + type-check + test all green (14/14 tests, 0 lint errors, 14
unchanged warnings, 6/6 packages type-check).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 10:14:16 -05:00
Randall Stillwell
86c014cb66 feat(identity): schema + helpers + read-only profile UI (Task 1, part 1/2)
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>
2026-06-02 10:07:50 -05:00
Randall Stillwell
7ec2ede7ca feat(web): land authed users on their oldest workspace home by slug
Path-A task 4/5. The root landing logic in apps/web/app/page.tsx was
redirecting to `/${workspaceId}` (UUID, ugly) and using no ORDER BY
(so two sessions could land on different workspaces). It also looped
zero-workspace users through `/sign-in`.

Changes:

* Inner-join workspaceMembers with workspaces to fetch the slug, not
  just the id. Order by membership createdAt ascending so users
  consistently hit their oldest workspace.
* Redirect to /{slug} (slug, not UUID).
* Removed the unused `objects` / `and` imports that were lint
  warnings.
* Zero-workspace branch redirects to /sign-in?error=no_workspace as a
  defensive fallback; documented inline that this is unreachable for
  fresh sign-ins post `ensureUserHasWorkspace` in apps/web/lib/auth.ts.

The dashboard at /{slug}/ is no longer a mockup (post commit f64d307
which wired it to objects.stats and objects.listRecent), so landing
there now shows real state.

Filed plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-onboarding-zero-workspace-flow.md (P2) as the follow-up that
turns the defensive fallback into a proper welcome flow with a
shared workspace-provisioning helper.

`pnpm lint && pnpm type-check` clean. Closes
plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-pick-workspace-landing-route.md.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 00:25:48 -05:00
Randall Stillwell
a1e6c863d5 feat(web): wire AI chat page to streaming /api/chat handler
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>
2026-06-02 00:23:21 -05:00
Randall Stillwell
f64d307f72 feat(web): wire workspace-home dashboard to real tRPC queries
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>
2026-06-02 00:17:29 -05:00
Randall Stillwell
1c9deea3fd chore(lint+types): green pnpm lint && pnpm type-check from a clean clone
Path-A first task: get the repo's two repo-wide quality gates passing.
Both were failing from a clean clone in ways that were silently hiding
each other.

Headline fixes:

* apps/web: add an eslint 9 flat config (eslint.config.mjs) using
  FlatCompat against next/core-web-vitals + next/typescript, and switch
  the `lint` script from `next lint` to `eslint .`. Previously `next
  lint` fell into its interactive setup prompt because there was no
  config at all in apps/web, which made `pnpm lint` permanently fail
  before any rule ever ran.
* packages/shared/src/utils/id.ts: replace `randomUUID` from `node:crypto`
  with `globalThis.crypto.randomUUID`. `@tasks/shared` is forbidden from
  using Node-only APIs (per AGENTS.md / repo-overview.mdc) because it
  has to be importable from the browser bundle.

Adjacent fixes pulled in to make the gates actually green:

* apps/mcp-server/tsconfig.json: drop vestigial rootDir / declaration*
  / outDir / sourceMap (build is via tsup, not tsc emit) and add
  allowImportingTsExtensions. The MCP server uses `.ts`-extension
  re-export shims (db.ts / schema.ts / shared-types.ts) so tsup can
  inline workspace .ts sources into the bundle.
* apps/collab-server/tsconfig.json: same simplification.
* apps/mcp-server/package.json: add @types/node so `process.env` in
  packages/database/src/client.ts (transitively pulled into the MCP
  server's type-check) resolves.
* apps/web/components/ui/input.tsx: empty `interface InputProps extends
  React.InputHTMLAttributes<HTMLInputElement> {}` -> `type` alias.
* apps/web/server/lib/workspace-guard.ts: `from(args.table as any)` ->
  `as unknown as PgTable` with a comment. Standard drizzle escape
  hatch for structural generic tables.
* apps/web/components/whiteboard/shapes/{document,project,task}-card.tsx:
  `BaseBoxShapeUtil<any>` -> `BaseBoxShapeUtil<{Shape}>` plus inline
  `declare module "@tldraw/tlschema"` augmentation of
  TLGlobalShapePropsMap. Required adding @tldraw/tlschema as a direct
  devDep of apps/web so the augmentation target resolves; previously
  it was only present transitively under tldraw's own deps.

Result: `pnpm lint && pnpm type-check` exits 0 across all 6 packages.
16 unused-import / exhaustive-deps warnings remain; they're pre-existing
housekeeping and out of scope for this task.

Closes plans/Plan-daily-driver-finish/Epic-shipping-the-shell/
Task-fix-lint-and-shared-types.md (status: done).

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-02 00:11:52 -05:00
Randall Stillwell
875b1cfc87 auth: case-insensitive emails, Authentik SSO, first-signin workspace provisioning
Three pieces of authentication work that need to land together so OAuth
sign-ins produce a usable session.

* `ensureUserIdByEmail` upserts a `users` row on every OAuth sign-in
  matched case-insensitively on email, then stamps `token.id` with the
  resulting UUID so workspace-scoped tRPC procedures can resolve
  membership. Credentials sign-in already returned the DB id from
  `authorize`; OAuth now does the equivalent.
* `ensureUserHasWorkspace` mints a personal workspace (and `owner`
  member row) on first sign-in for any user that doesn't already
  belong to one, so fresh OAuth accounts don't land in the app with
  no tenant scope. Idempotent; slug collisions retry with a random
  suffix and cap at 5 attempts.
* Migration 0004 adds a `UNIQUE (lower(email))` index on `users` to
  match the lookup pattern and prevent two providers from minting
  rows that differ only in casing. Existing rows are normalized to
  lowercase first; the column-level UNIQUE catches any pre-existing
  duplicates so they get resolved by a human rather than silently
  merged.

Sign-in / sign-up pages add an Authentik SSO button (gated on
`AUTH_AUTHENTIK_*` env vars). Layout switches to GitHub+Google on top
with Authentik full-width below.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-01 23:45:00 -05:00
Randall Stillwell
c582d621ce multi-tenancy: promote workspaces to top-level table
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>
2026-05-06 23:02:55 -05:00
Randall Stillwell
b992db5b3e collab: replace createRequire shim with normal imports
The collab server was using createRequire() with hardcoded relative paths
(../../../packages/database/package.json, ../node_modules/@hocuspocus/server/...)
to grab `eq` from drizzle-orm and `Forbidden` from @hocuspocus/common. That
worked under tsx in dev but fell apart in the bundled prod image because
those paths don't exist there and pnpm's symlink topology in the runtime
node_modules wasn't reachable from inside the bundled dist file.

Result at runtime: Cannot find module '@hocuspocus/common' on every
collab restart, infinite crash loop.

Switch both to plain ESM imports — tsup bundles them into dist/index.mjs
directly, no runtime resolution needed. Add @hocuspocus/common and
drizzle-orm as explicit deps so they're properly tracked.

Verified locally: bundle now resolves cleanly with no createRequire calls
(actually shrinks from 304 KB to 102 KB after dead-code elimination).

Made-with: Cursor
2026-04-28 15:14:03 -05:00
Randall Stillwell
829cbdc899 collab/mcp: bundle workspace deps so runtime doesn't import .ts files
@tasks/database (and @tasks/shared) export raw .ts files via "main" /
"exports", which works fine for tsx/dev but blows up at runtime in the
production image:

  ERR_UNKNOWN_FILE_EXTENSION: Unknown file extension ".ts" for
  /app/packages/database/src/client.ts

tsup was leaving those workspace imports as externals in the output, so
the deployed dist/index.mjs still tried to resolve them at startup.
Switch each app to a tsup.config.ts that sets noExternal: [/^@tasks\\//],
which inlines workspace packages into the bundle while keeping
node_modules deps (postgres, drizzle-orm, hocuspocus, mcp-sdk, etc.)
external.

Verified locally: collab-server bundle size goes from 7 KB to 304 KB,
confirming @tasks/database is now compiled in. Also fixed the collab
start script to point at index.mjs (tsup ESM output) instead of .js.

Made-with: Cursor
2026-04-27 16:47:17 -05:00
Randall Stillwell
3fa891bf08 docker: ensure apps/web/public exists so web runtime COPY succeeds
The Next.js standalone runtime stage copies apps/web/public into the image,
but the directory was never committed since the project hasn't shipped any
static assets. BuildKit fails with "not found" on the COPY. Adding an empty
.gitkeep guarantees the directory exists at build time and lets future static
assets drop in without further build changes.

Made-with: Cursor
2026-04-27 14:46:47 -05:00
Randall Stillwell
ba519a8b78 fix(web): import @tasks/ai via workspace alias instead of relative path
Two AI components were importing from ../../../../packages/ai/src using
relative paths that escape the workspace root. This worked locally because
all packages are siblings on disk, but failed in Docker where Dockerfile.web
only copies apps/web, packages/database, and packages/shared into the
build context — packages/ai never made it in.

Changes:
- Add @tasks/ai as a workspace dependency in apps/web/package.json
- Switch both imports (command-palette.tsx, ai-block.tsx) to "@tasks/ai"
- Add @tasks/ai to transpilePackages in next.config.ts and the docker variant
- Copy packages/ai into the Docker build context (Dockerfile.web)
- Refresh pnpm-lock.yaml for the new workspace edge

Verified locally: web builds compile cleanly past the previously failing
"Module not found" errors. (Local final step hits an unrelated ENOSPC on
the dev disk; Coolify's volume has plenty of headroom.)

Made-with: Cursor
2026-04-27 12:57:27 -05:00
Randall Stillwell
663bc77afe feat: ECHODO app shell, Coolify deploy, Authentik + Umami
Bundles in-flight ECHODO work with the Coolify deployment configuration:

App
- New routes: ai, forms, planner, settings (templates/types), teams,
  doc detail, whiteboard detail
- New components: app shell rework (icon-rail, top-header), forms
  builder/renderer/responses, types manager, objects creation dialog,
  card primitive, form + overview views
- New tRPC routers: favorites, forms, types, workspaces; updates to
  health and objects routers
- Markdown backlog sync (packages/database) + cursor-sync schema/migrations
- Schema additions: forms, types, favorites, markdown_backlog, cursor_sync
- Initial Drizzle migrations checked in

Deployment
- docker/docker-compose.coolify.yml: drops bundled Postgres/Redis
  (uses CT 102 shared services), removes host port mappings, adds
  Coolify SERVICE_FQDN_* magic vars for web + collab
- .env.example rewritten as the full ECHODO/Coolify variable manifest
- NextAuth gains an Authentik OIDC provider (gated on env presence)
- Root layout injects Umami tracking script when configured;
  metadata title flipped to ECHODO

Security
- .gitignore expanded to exclude AGENT-DEPLOY.md, .env.*, secrets/,
  credentials.*, *.key, *.crt, *.pem, ssh keys

Made-with: Cursor
2026-04-26 14:34:34 -05:00
Randall Stillwell
a508ece6e7 feat: Full project management application scaffold
Complete architecture for a ClickUp/Notion/Miro-class project management app:

- Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM)
- Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards)
- NextAuth v5 authentication with credentials + OAuth providers
- tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search
- Three-panel UI: collapsible sidebar, center content area, push-in right panel
- Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS
- Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe
- TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block
- Real-time collaboration via Yjs + Hocuspocus with presence/cursors
- tldraw whiteboard with custom shape cards (task, document, project)
- MCP server exposing all app data/tools for AI agents
- AI chat panel, editor AI slash commands, Cmd+K command palette
- Template system with built-in templates (Bug Report, Meeting Notes, Sprint)
- Full-text search with result highlighting
- Docker Compose for full-stack deployment (web + collab + postgres + redis)

Made-with: Cursor
2026-03-26 22:39:16 -05:00