diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..f68c4be --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,220 @@ +name: ci-cd + +on: + push: + branches: + - main + pull_request: + workflow_dispatch: + +jobs: + quality: + runs-on: projecte-runner + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # Self-hosted Gitea runners may not be able to resolve actions from + # github.com. If the checkout action above failed (e.g. it could not be + # fetched), fall back to a manual shallow clone from the Gitea server. + - name: Fallback checkout (manual clone) + if: failure() + shell: bash + run: | + set -euo pipefail + SERVER_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + TOKEN="${{ github.token }}" + HOST="${SERVER_URL#https://}" + HOST="${HOST#http://}" + rm -rf ./* ./.git 2>/dev/null || true + echo "Manual shallow clone from ${HOST}/${REPO}" + git clone --depth 1 "https://oauth2:${TOKEN}@${HOST}/${REPO}.git" . + + - name: Ensure bun + shell: bash + run: command -v bun || npm i -g bun@1.3.14 + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile || bun install + + - name: Typecheck + shell: bash + run: bun run typecheck + + - name: Build web + shell: bash + run: cd apps/web && bun run build + + - name: Docker compose build + shell: bash + run: docker compose build + + e2e: + runs-on: projecte-runner + needs: quality + timeout-minutes: 25 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + # Self-hosted Gitea runners may not be able to resolve actions from + # github.com. If the checkout action above failed (e.g. it could not be + # fetched), fall back to a manual shallow clone from the Gitea server. + - name: Fallback checkout (manual clone) + if: failure() + shell: bash + run: | + set -euo pipefail + SERVER_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + TOKEN="${{ github.token }}" + HOST="${SERVER_URL#https://}" + HOST="${HOST#http://}" + rm -rf ./* ./.git 2>/dev/null || true + echo "Manual shallow clone from ${HOST}/${REPO}" + git clone --depth 1 "https://oauth2:${TOKEN}@${HOST}/${REPO}.git" . + + - name: Ensure bun + shell: bash + run: command -v bun || npm i -g bun@1.3.14 + + - name: Install dependencies + shell: bash + run: bun install --frozen-lockfile || bun install + + - name: Start ephemeral Postgres + shell: bash + run: | + set -euo pipefail + docker rm -f projecte-e2e-db >/dev/null 2>&1 || true + docker run -d --name projecte-e2e-db \ + -e POSTGRES_PASSWORD=test \ + -e POSTGRES_DB=project_e \ + -e POSTGRES_USER=project_e \ + -p 5433:5432 \ + postgres:16-alpine + echo "Waiting for Postgres to accept connections..." + for i in $(seq 1 30); do + if docker exec projecte-e2e-db pg_isready -U project_e -d project_e >/dev/null 2>&1; then + echo "Postgres ready" + exit 0 + fi + sleep 1 + done + echo "ERROR: Postgres did not become ready in time" + exit 1 + + - name: Sync database schema + shell: bash + env: + DATABASE_URL: postgresql://project_e:test@localhost:5433/project_e + run: bun run db:migrate + + - name: Install Playwright chromium + shell: bash + run: npx playwright install --with-deps chromium + + # The Playwright webServer boots the API + Vite dev server itself via + # `bun run dev`. The API needs the ephemeral DB connection, and the admin + # auto-creation on first login depends on INITIAL_ADMIN_* — the same + # values the e2e fixtures fall back to. + - name: Run E2E tests (chromium) + shell: bash + env: + DATABASE_URL: postgresql://project_e:test@localhost:5433/project_e + INITIAL_ADMIN_EMAIL: admin@example.com + INITIAL_ADMIN_PASSWORD: testpassword123 + CI: "1" + run: bunx playwright test + + - name: Stop ephemeral Postgres + if: always() + shell: bash + run: docker rm -f projecte-e2e-db >/dev/null 2>&1 || true + + deploy: + runs-on: projecte-runner + needs: quality + if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || github.event_name == 'workflow_dispatch' + timeout-minutes: 20 + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Fallback checkout (manual clone) + if: failure() + shell: bash + run: | + set -euo pipefail + SERVER_URL="${{ github.server_url }}" + REPO="${{ github.repository }}" + TOKEN="${{ github.token }}" + HOST="${SERVER_URL#https://}" + HOST="${HOST#http://}" + rm -rf ./* ./.git 2>/dev/null || true + echo "Manual shallow clone from ${HOST}/${REPO}" + git clone --depth 1 "https://oauth2:${TOKEN}@${HOST}/${REPO}.git" . + + - name: Deploy + shell: bash + env: + DEPLOY_DIR: ${{ vars.DEPLOY_DIR || '/home/projecte/ProjectE' }} + run: bash script/deploy.sh + + smoke: + runs-on: projecte-runner + needs: deploy + if: always() + timeout-minutes: 10 + steps: + - name: API health check + shell: bash + run: | + set -euo pipefail + BODY="$(curl -s http://localhost:3000/api/health || true)" + echo "${BODY}" + echo "${BODY}" | grep -q '"status"' || { + echo "ERROR: API health did not return the expected payload" + exit 1 + } + echo "API health: OK" + + - name: SPA root returns HTML + shell: bash + run: | + set -euo pipefail + BODY="$(curl -s http://localhost:3000/ || true)" + echo "${BODY}" | grep -qi '` (or `git checkout `) and push to `main` +2. Re-run the manual deploy steps (`docker compose build`, `docker compose up -d`) + +The `project-e-pg-data` volume is untouched by deploys and rollbacks, so the database survives both. If a deploy failed, `deploy.sh` exits non-zero with the recent API logs; do not force it past a failing health check. + +## Logs ```bash # All services @@ -46,174 +116,56 @@ docker compose logs --tail=50 -f worker docker compose logs --tail=50 -f db ``` -## How to debug +## Debugging + +### API health (direct, :3001) -### API health check ```bash curl http://localhost:3001/api/health ``` -### SPA health check -```bash -curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/ -``` +Returns `{"status":"ok", ...}` with a database ping (`database.connected`, `database.ping_ms`). + +### API health (through Caddy) -### API through reverse proxy ```bash curl http://localhost:3000/api/health ``` -### Login test -```bash -TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/credentials \ - -H "Content-Type: application/json" \ - -d email:user@example.com | \ - python3 -c "import sys,json; print(json.load(sys.stdin).get(token,))") -echo "Token: $TOKEN" -``` - -### Check container health -```bash -docker inspect project-e-db --format {{.State.Health.Status}} -``` - -### Restart a single service -```bash -docker compose restart api -docker compose restart spa -``` - -### Rebuild a single service -```bash -docker compose build spa -docker compose up -d --force-recreate spa -``` - -## Architecture - -``` -Internet → :3000 → Caddy (SPA container) - ├── /api/* → api:3000 (Hono/Bun) - ├── /mcp* → api:3000 (Hono/Bun) - └── /* → index.html (SPA fallback) - -API (:3001, direct) → PostgreSQL (:5432) -Worker → PostgreSQL -``` - -## Important notes - -- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.** -- The `apps/web-legacy/` directory contains the old Next.js app for reference. **Do not delete it.** -- Port 3000 is the SPA (Caddy), port 3001 is the API directly (for debugging). -- The MCP endpoint requires a valid API key (separate from JWT auth). -EOF cd ~/ProjectE && cat > DEPLOY.md << 'EOF' -# Project E — Deploy Guide - -## How to redeploy - -```bash -cd ~/ProjectE - -# Pull latest -git pull origin redesign/ui-v2 - -# Rebuild images -docker compose build - -# Restart stack -docker compose up -d - -# Check status -docker compose ps -``` - -## How to roll back - -If the new stack fails: - -```bash -cd ~/ProjectE - -# Stop the new stack -docker compose down - -# Restart the old worker (Node) from the legacy compose -# (The old compose file is preserved in git history) -# docker compose -f docker-compose.legacy.yml up -d worker -``` - -## How to view logs - -```bash -# All services -docker compose logs --tail=50 -f - -# Specific service -docker compose logs --tail=50 -f api -docker compose logs --tail=50 -f spa -docker compose logs --tail=50 -f worker -docker compose logs --tail=50 -f db -``` - -## How to debug - -### API health check -```bash -curl http://localhost:3001/api/health -``` - ### SPA health check + ```bash curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/ ``` -### API through reverse proxy -```bash -curl http://localhost:3000/api/health -``` - ### Login test + ```bash -TOKEN=$(curl -s -X POST http://localhost:3000/api/auth/credentials \ +curl -s -X POST http://localhost:3000/api/auth/credentials \ -H "Content-Type: application/json" \ - -d password: | \ - python3 -c "import sys,json; print(json.load(sys.stdin).get(token,))") -echo "Token: $TOKEN" + -d '{"email":"","password":""}' ``` -### Check container health +Expect HTTP 200 and a `session` cookie. + +### Container health + ```bash -docker inspect project-e-db --format {{.State.Health.Status}} +docker inspect project-e-db --format '{{.State.Health.Status}}' ``` -### Restart a single service +### Restart or rebuild one service + ```bash docker compose restart api -docker compose restart spa -``` -### Rebuild a single service -```bash docker compose build spa docker compose up -d --force-recreate spa ``` -## Architecture - -``` -Internet → :3000 → Caddy (SPA container) - ├── /api/* → api:3000 (Hono/Bun) - ├── /mcp* → api:3000 (Hono/Bun) - └── /* → index.html (SPA fallback) - -API (:3001, direct) → PostgreSQL (:5432) -Worker → PostgreSQL -``` - ## Important notes -- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.** -- The `apps/web-legacy/` directory contains the old Next.js app for reference. **Do not delete it.** -- Port 3000 is the SPA (Caddy), port 3001 is the API directly (for debugging). +- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.** Back it up (volume snapshot or `pg_dump`) before major schema work. +- Port 3000 is the SPA (Caddy); port 3001 is the API directly (for debugging). - The MCP endpoint requires a valid API key (separate from JWT auth). +- The root `worker/` directory is legacy. The active worker is `apps/worker`. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..f69750f --- /dev/null +++ b/PLAN.md @@ -0,0 +1,67 @@ +Project E — Full Review, Functional Completion & CI/CD Plan +What we know +Actual stack: Vite + React 19 + TanStack Router/Query + shadcn/ui SPA (apps/web) · Hono/Bun API (apps/api) · Bun worker · Drizzle/Postgres. README/AGENTS.md describe a Next.js app that no longer exists — docs are stale. +Deployment: docker-compose on the projecte host (10.0.0.204): db, api, spa (Caddy), worker. Secrets come from a host .env (already/soon exists). Remote = git.buzzbee.dev (Gitea). +CI/CD: No workflows exist yet. Will add Gitea Actions (.gitea/workflows/) using runner label projecte-runner, deploy = docker compose on the same host, triggered by push to main + manual dispatch. +Phase 0 — CI/CD foundation (do first, so everything after ships through it) +.gitea/workflows/ci.yml with two jobs: +quality (every push + PR): bun install --frozen-lockfile → typecheck (api + worker + web) → vite build → docker compose build (catches Dockerfile breakage early). +deploy (push to main + workflow_dispatch): docker compose build → apply migrations → docker compose up -d → health checks (SPA 200, /api/health DB ping, login smoke via POST /api/auth/credentials). +Deploy job runs on projecte-runner; uses host .env for secrets; keeps previous image tags for rollback. +Migration story — currently broken: db:push doesn't apply drizzle/*.sql, README references a non-existent drizzle/0000_postgres.sql, and 0005 is hand-written. Reconcile: verify the 5 migration files reproduce the .migration-baseline-schema.sql schema, make a single idempotent db:migrate script, and call it in the deploy job before up -d. +Docs refresh: rewrite README, AGENTS.md, DEPLOY.md to describe the real stack, deploy flow, and Gitea Actions pipeline. +Conventions to fix first (they gate everything): implement requireWorkspaceAccess() per AGENTS.md, a shared recordActivity wrapper that always derives workspaceId correctly, and a shared error helper — then phases 1–4 build on them. +Phase 1 — Critical functional fixes (app must stop lying) +# Fix Where +1 Calendar delete — useApiMutation("delete", "", …) → DELETE /api 404. Use DELETE /calendar/events/{id} + wire drag/resize handlers + enable withDragAndDrop calendar.tsx:173 +2 Canvas persistence — blocks are local state; handleSave sends only {name}. Add canvas-card CRUD endpoints (API), persist blocks (content/type/order), load on open, save on debounce, route detail page through it canvas.tsx:243, API routes/canvas.ts +3 Logout — no POST /api/auth/logout; cookie never cleared. Add route that clears the session cookie; client already calls it in 3 places API routes/auth.ts, sidebar.tsx +4 Graph edge write/delete 500 — recordActivity({ workspaceId: "" }) → invalid uuid. Derive domain from the edge API routes/graph.ts:150,190 +5 Analytics fake data — replace Math.random() with real endpoints; fix /analytics/habits domain scoping; make /analytics/projects real; real CSV export analytics.tsx:137, API routes/analytics.ts +6 Task board DnD — add droppable columns, persist status change + order via API tasks.tsx:215 +7 Notes editor — wire installed Tiptap (replace contentEditable): autosave debounce, placeholder fix, stop stopPropagation killing shortcuts, render HTML on detail page (use editor output, not raw innerHTML text) notes.tsx, notes/$id.tsx +8 Passkey security hole — authenticates without verifying signature. Either implement real WebAuthn verification or remove the passkey surface API routes/auth.ts:115 +9 Domain/owner scoping (IDOR) — add owner/domain checks to all by-ID GET/PATCH/DELETE routes; scope /api/search and /api/export to active domain all API routes +10 Command palette routing — singular vs plural types → non-existent /search/{id}. Fix type→route map; fix @mention agent fetch (response is {items}, q ignored) command-palette.tsx:313 +11 Agent activity SSE handler — wrong event type match + data.payload doesn't exist; would crash on match agents/activity.tsx:54 +12 Graph fly-to — centerAt(undefined, undefined) on raw nodes (no x/y). Track positions via onNodeDrag or search the rendered graph data graph.tsx:157 +Phase 2 — UX & polish pass (current shadcn look, elevated) +Rebuild login on the design system: Input/Button/Label, autofocus, loading/disabled state, error styling, branding mark. (Worst screen in the app today.) +Feedback everywhere: mount Sonner ; add success/error toasts to every create/update/delete mutation; inline error handling on failed mutations. +State coverage: shared LoadingState (skeleton), EmptyState (icon + text + CTA), ErrorState components; apply to every page (calendar, graph, analytics, settings tabs, search, agents…). +Settings honesty: implement density (CSS variable that actually changes spacing), wire sidebarPos to reposition the sidebar, add real reduce-motion CSS — or remove the controls. +Typography: add a real font via --font-sans (self-hosted or Google Fonts) — currently browser-default only. +Consolidate color maps: one shared src/lib/status-colors.ts (status/priority/entity colors as semantic tokens) replacing 8+ divergent copies; make accent theming actually visible. +Unify detail routes (tasks/$id, habits/$id, projects/$id, notes/$id, canvas/$id) with the app design language; make list UIs link to them. +Dashboard: fix sort=dueDate→due_date (widget shows wrong tasks), align realtime invalidation keys with widget query keys, make widget grid responsive. +Responsive: notes/graph/settings panes on mobile, dashboard grid stacking, calendar height. +A11y: aria-labels on icon/scale buttons, radiogroup for mood/energy, fix dangerouslySetInnerHTML snippet XSS surface (sanitize ), notification badge label. +Phase 3 — Secondary feature completion +Canvas: persist block editor (from Phase 1) + real save UX + delete blocks/cards. +Daily notes: timezone bug (UTC midnight stored, local day shown), add DELETE /api/daily-notes/:id, autosave cleanup, mood/energy before content creates the note. +Graph: domain picker (shared with topbar), node detail → navigable entity links. +Analytics: real charts (tasks completed over time, created vs completed, habit consistency, project progress, time per domain, productivity heatmap) + honest empty states + working CSV. +Domains: active-domain selection store + picker; API already supports multi-domain. +Tags: assign/remove tags on tasks/habits/notes from the UI (currently create-only); fix in-memory tag filter after pagination. +Import/Export: scope export to domain (currently exports every user's data), implement advertised CSV format, validate import. +Custom fields: decide and wire to entities, or hide if inert. +Phase 4 — Advanced features (selected; largest phase) +Webhooks pipeline: enqueue webhook_delivery jobs on entity events; fix POST /webhooks/:id/test; worker already knows how to deliver — just wire the queue. +Recurring tasks: create scheduledJobs from recurrenceRule; worker recurring_spawn already exists — wire creation. +Agents: real CRUD UI, permission editing, activity filters (from/to/action honored server-side). +Notifications: real notification count/feed instead of hardcoded 0. +Reports & Milestones: minimal working versions, or remove from nav until built (recommend: build minimal). +MCP polish: validation, correct error codes, domain scoping, drop the dead ?? sql`` `` `` `` leftover. +Worker: wire ai_dispatch or mark it disabled. +Phase 5 — E2E rewrite + docs +Rewrite the Playwright suite (e2e/*.spec.ts) for the new SPA: auth cookie flow, tasks/habits/projects/notes/calendar/canvas/daily/graph/search/analytics/settings/agents-activity; new auth helper; drop specs for removed features (reports/mcp UI). +CI: dedicated ephemeral Postgres + dev servers for E2E, run chromium only in CI (full 5-browser matrix locally/on demand); npx playwright install --with-deps chromium on the runner. +Update docs/API.md to match reality. +Risks & mitigations +Huge scope → sequenced phases; each phase lands on main and deploys independently, so value ships incrementally. +DB migrations on live prod data → Phase 0 makes migrations idempotent + verifiable before any deploy; backup volume (project-e-pg-data) noted in DEPLOY.md. +E2E against prod → E2E runs in CI against an ephemeral test DB, never prod. +Secrets → host .env (gitignored), never committed; Gitea Actions secrets only if needed later. +DnD persistence + Tiptap are the two most invasive frontend changes → done early (Phase 1) so regressions surface in CI before polish. +Verification +Per user: CI + manual testing — quality job gates every push (typecheck/build/docker build); deploy job gates main; you verify on the live site (10.0.0.204:3000) after each deploy. E2E suite (Phase 5) becomes the automated gate once rewritten. diff --git a/README.md b/README.md index a15758e..454c80b 100644 --- a/README.md +++ b/README.md @@ -1,274 +1,209 @@ # Project E -A personal project, habit, and task tracker built for the AI-agent era. Track tasks, build habits, manage projects, write notes, generate reports, and let AI agents work alongside you through a native MCP (Model Context Protocol) server. +A personal project, habit, and task tracker built for the AI-agent era. Track tasks and habits, manage projects, write notes, and let AI agents read and write your data through a built-in MCP (Model Context Protocol) server. ## Features - **Tasks:** Kanban boards, priorities, due dates, subtasks, time tracking, recurring tasks, dependencies, and attachments - **Habits:** Daily/weekly/custom frequencies, streak tracking, mood logging, skip days, and completion scoring - **Projects:** Organize work by domain, track progress through milestones, set deadlines, and manage team members -- **Notes:** Rich text editor with wikilinks, note graph visualization, bookmarks, and AI-generated content support -- **Reports:** Weekly, monthly, project, and habit reports with templates and AI-assisted generation +- **Notes:** Rich text editor with wikilinks, note graph visualization, and bookmarks +- **Reports:** Weekly, monthly, project, and habit reports with templates - **Milestones:** Plan project phases, set dependencies, and track completion - **Domains & Tags:** Organize everything across life domains (work, personal, health) with flexible tagging - **AI Agents:** Register agents with API keys, assign permission tiers, and dispatch work via @mentions - **Webhooks:** Subscribe to events, deliver payloads with HMAC signatures, and track delivery history - **Analytics:** Task completion rates, habit consistency, time summaries, and streak tracking -- **Realtime:** Server-sent events proxy keeps the UI in sync across devices -- **Background Worker:** Processes webhook deliveries, agent mentions, report generation, recurring tasks, and data cleanup -- **MCP Server:** 61 tools for AI agents to read and write data through the Model Context Protocol +- **Realtime:** SSE feed backed by PostgreSQL LISTEN/NOTIFY keeps the UI in sync across devices +- **Background Worker:** Bun process for webhook deliveries, agent mentions, recurring tasks, and data cleanup +- **MCP Server:** 18 tools for AI agents to read and write data through the Model Context Protocol ## Architecture ``` -┌─────────────────────────────────────────────────────────────┐ -│ Frontend (Next.js) │ -│ React 19 · App Router · shadcn/ui · Tailwind · Zustand │ -└──────────────────────────┬──────────────────────────────────┘ +┌───────────────────────────────────────────────────────────────┐ +│ Frontend (Vite SPA) │ +│ React 19 · TanStack Router/Query · shadcn/ui · Tailwind │ +└──────────────────────────┬────────────────────────────────────┘ │ REST API + SSE -┌──────────────────────────▼──────────────────────────────────┐ -│ API Layer (Next.js Routes) │ -│ Auth · Validation (Zod) · Realtime SSE Proxy · MCP Server │ -└──────────────────────────┬──────────────────────────────────┘ - │ Drizzle ORM -┌──────────────────────────▼──────────────────────────────────┐ -│ Data Layer (PostgreSQL + Drizzle ORM) │ -│ PostgreSQL · Drizzle migrations · NextAuth credentials │ -└─────────────────────────────────────────────────────────────┘ + │ (Vite dev proxy → :3001) +┌──────────────────────────▼────────────────────────────────────┐ +│ API (Hono on Bun, apps/api) │ +│ Auth (JWT) · Routes · Realtime SSE · MCP server · Webhooks │ +└──────────────────────────┬────────────────────────────────────┘ + │ Drizzle ORM (postgres driver) +┌──────────────────────────▼────────────────────────────────────┐ +│ PostgreSQL 16 + Drizzle ORM │ +│ Schema in packages/db/src/schema.ts · migrations in drizzle/ │ +└────────────────────────────────────────────────────────────────┘ -┌─────────────────────────────────────────────────────────────┐ -│ Background Worker │ -│ Webhook Delivery · Agent Mentions · Report Generation │ -│ Recurring Tasks · Data Cleanup │ -└─────────────────────────────────────────────────────────────┘ +┌────────────────────────────────────────────────────────────────┐ +│ Background Worker (Bun, apps/worker) │ +│ Webhook delivery · Agent mentions · Recurring tasks · │ +│ Data cleanup │ +└────────────────────────────────────────────────────────────────┘ ``` ## Tech Stack | Layer | Technology | |-------|-----------| -| Frontend | Next.js 15, React 19, TypeScript 5.9 | +| Frontend | Vite 5, React 19, TypeScript 5.9 | +| Routing / Data | TanStack Router, TanStack Query, TanStack Table | | UI Components | shadcn/ui, Radix UI, Lucide icons | | Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority | | State Management | Zustand 5 | | Rich Text | Tiptap 3 | -| Forms | React Hook Form 7, Zod 4 validation | +| Forms | React Hook Form, Zod 4 validation | | Calendar | react-big-calendar, date-fns | | Charts | Recharts 3 | | Graph Visualization | react-force-graph-2d | | Drag & Drop | @dnd-kit | -| Backend | Next.js API routes (App Router) | -| Database | PostgreSQL 16 with Drizzle ORM | -| Authentication | NextAuth 4 with credentials authentication | -| Background Jobs | Node.js worker with polling and exponential backoff | -| MCP Server | @modelcontextprotocol/sdk 1.29 | -| Monorepo | Turborepo 2.5, npm workspaces | -| Testing | Jest (unit/component), Playwright 1.61 (E2E) | +| Backend | Hono 4 on Bun 1.3 (`apps/api`) | +| Database | PostgreSQL 16 with Drizzle ORM and the `postgres` driver | +| Authentication | JWT (jose) in an httpOnly session cookie; API keys for agents | +| Background Jobs | Bun worker (`apps/worker`) | +| MCP Server | JSON-RPC over HTTP at `/api/mcp`, served by the API | +| Monorepo | Turborepo 2.5, Bun workspaces | +| Testing | Playwright (E2E in `e2e/`) | ## Prerequisites -- **Node.js** 22.13.0 or later -- **npm** 10.0.0 or later -- **PostgreSQL** 16 or later +- **Bun** 1.3.14 or later (the repo pins `bun@1.3.14`) +- **PostgreSQL** 16 (local install, or the Docker container from the compose file) +- **Docker + Docker Compose** for the production stack (see [DEPLOY.md](DEPLOY.md)) ## Quick Start -### Development Setup - 1. **Clone the repository** ```bash - git clone + git clone https://git.buzzbee.dev/Vibing/ProjectE.git cd ProjectE ``` 2. **Install dependencies** ```bash - npm install + bun install ``` -3. **Create the database** +3. **Start PostgreSQL** - ```bash - createuser -P project_e - createdb -O project_e project_e - ``` + ```bash + docker compose up -d db + ``` + + Or point `DATABASE_URL` at an existing PostgreSQL 16 instance. 4. **Set environment variables** - Create a `.env.local` file in the root: + ```bash + cp .env.example .env + ``` - ```bash - DATABASE_URL=postgresql://project_e:your_postgres_password@localhost:5432/project_e - POSTGRES_PASSWORD=your_postgres_password - NEXTAUTH_SECRET=your_long_random_secret - INITIAL_ADMIN_EMAIL=admin@example.com - INITIAL_ADMIN_PASSWORD=your_initial_admin_password - ``` - - `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD` create the first admin account when you sign in with those credentials. + Fill in `DATABASE_URL`, `POSTGRES_PASSWORD`, `AUTH_SECRET`, `INITIAL_ADMIN_EMAIL`, and `INITIAL_ADMIN_PASSWORD`. 5. **Apply the database schema** - ```bash - psql "postgresql://project_e:your_postgres_password@localhost:5432/project_e" -f drizzle/0000_postgres.sql - ``` - -6. **Start the development server** - ```bash - npm run dev + bun run db:migrate ``` - This starts all packages via Turborepo: - - Web app at `http://localhost:3000` - - Worker (if configured) + Pushes the schema (`drizzle-kit push --force`) and applies the search-vector triggers. Idempotent, safe to re-run. + +6. **Start the development servers** + + ```bash + bun run dev + ``` + + - API (Hono) on `http://localhost:3001` + - Web (Vite) on `http://localhost:3000`, proxying `/api` and `/mcp` to :3001 7. **Sign in as the initial admin** - Open `http://localhost:3000` and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`. + Open `http://localhost:3000` and sign in with `INITIAL_ADMIN_EMAIL` and `INITIAL_ADMIN_PASSWORD`. The first login creates the admin account. ## Project Structure ``` project-e/ ├── apps/ -│ └── web/ # Next.js application -│ ├── app/ # App Router pages and API routes -│ │ ├── (auth)/ # Auth pages (login, signup) -│ │ ├── (dashboard)/ # Dashboard pages -│ │ ├── api/ # REST API endpoints -│ │ │ ├── auth/ # Login, logout, refresh, me -│ │ │ ├── tasks/ # Task CRUD + bulk operations -│ │ │ ├── habits/ # Habit CRUD -│ │ │ ├── projects/ # Project CRUD -│ │ │ ├── notes/ # Note CRUD -│ │ │ ├── reports/ # Report CRUD -│ │ │ ├── milestones/ # Milestone CRUD -│ │ │ ├── domains/ # Domain CRUD -│ │ │ ├── tags/ # Tag CRUD -│ │ │ ├── agents/ # Agent CRUD -│ │ │ ├── webhooks/ # Webhook CRUD -│ │ │ ├── analytics/ # Analytics data -│ │ │ ├── realtime/ # SSE updates -│ │ │ ├── mcp/ # MCP server endpoint -│ │ │ └── health/ # Health check -│ │ └── layout.tsx # Root layout -│ ├── components/ # React components (shadcn/ui) -│ ├── hooks/ # Custom React hooks -│ ├── lib/ # Utilities, services, and NextAuth config -│ │ ├── mcp/ # MCP server and tools -│ │ ├── services/ # Business logic services -│ │ ├── stores/ # Zustand stores -│ │ ├── events/ # Event bus -│ │ ├── auth-config.ts # NextAuth configuration -│ │ └── errors.ts # Error handling -│ └── types/ # TypeScript type definitions +│ ├── api/ # Hono + Bun API server (routes, middleware, auth) +│ ├── web/ # Vite + React 19 SPA (TanStack Router, shadcn/ui) +│ ├── worker/ # Bun background worker (src/index.ts) +│ └── web-legacy/ # Old Next.js app, kept for reference only ├── packages/ -│ ├── db/ # Drizzle schema and PostgreSQL client -│ └── shared/ # Shared package -│ └── src/ -│ ├── schemas/ # Zod validation schemas -│ ├── types/ # TypeScript types -│ └── constants/ # Shared constants -├── drizzle/ # Generated PostgreSQL migrations -├── worker/ -│ └── index.ts # Background job worker -├── e2e/ # Playwright E2E tests -├── tests/ # Unit and component tests -├── drizzle.config.ts # Drizzle Kit configuration -├── turbo.json # Turborepo configuration -└── package.json # Root package.json +│ ├── db/ # Drizzle schema, client, and ORM access +│ └── shared/ # Shared types, schemas, and constants +├── drizzle/ # Drizzle migrations (0000–0005) +├── script/ # deploy.sh, apply-triggers.ts +├── e2e/ # Playwright E2E tests +├── .gitea/workflows/ # Gitea Actions CI/CD (ci.yml) +├── docker-compose.yml # Production stack (db, api, spa, worker) +├── Caddyfile # SPA serving + /api reverse proxy +├── Dockerfile.api # API image +├── Dockerfile.spa # SPA build + Caddy image +├── Dockerfile.worker # Worker image +├── bunfig.toml +├── drizzle.config.ts +└── package.json ``` ## Available Scripts | Command | Description | |---------|-------------| -| `npm run dev` | Start all packages in development mode | -| `npm run build` | Build all packages for production | -| `npm run lint` | Run linting across all packages | -| `npm run test` | Run unit and component tests (Jest) | -| `npm run test:e2e` | Run Playwright E2E tests | -| `npm run test:e2e:ui` | Run Playwright tests with UI mode | -| `npm run test:e2e:report` | Show Playwright test report | -| `npm run typecheck` | Run TypeScript type checking | -| `npm run db:generate` | Generate Drizzle migrations | +| `bun run dev` | Run the API (watch) and the Vite dev server concurrently | +| `bun run dev:api` | API server with watch on :3001 | +| `bun run dev:web` | Vite dev server on :3000 | +| `bun run build` | Production build of the web SPA (`vite build`) | +| `bun run typecheck` | `tsc --noEmit` across api, worker, and web | +| `bun run db:push` | Push schema changes to the database (`drizzle-kit push`) | +| `bun run db:sync` | Push schema with `--force` (idempotent) | +| `bun run db:generate` | Generate a new Drizzle migration from schema changes | +| `bun run db:triggers` | Apply the search-vector triggers (idempotent) | +| `bun run db:migrate` | `db:sync` + `db:triggers`; what deploys run | +| `bun run db:studio` | Open Drizzle Studio | +| `bun run deploy` | Run `script/deploy.sh` (see DEPLOY.md) | ## Environment Variables +Variables live in a root `.env` file (not `.env.local`). Copy from `.env.example`. + | Variable | Description | Default | |----------|-------------|---------| | `DATABASE_URL` | PostgreSQL connection string | (required) | -| `POSTGRES_PASSWORD` | Password for the `project_e` PostgreSQL user | (required) | -| `NEXTAUTH_SECRET` | Secret used to sign NextAuth sessions | (required) | +| `POSTGRES_PASSWORD` | Password for the `project_e` user (used by docker-compose) | (required) | +| `AUTH_SECRET` | Secret that signs JWT sessions (`NEXTAUTH_SECRET` is accepted as a fallback) | (required) | | `INITIAL_ADMIN_EMAIL` | Email for the account created on first sign-in | (required) | | `INITIAL_ADMIN_PASSWORD` | Password for the account created on first sign-in | (required) | -| `NODE_ENV` | Environment (`development`, `production`) | `development` | - -Create a `.env.local` file in the root directory for local development. +| `NODE_ENV` | `development` or `production` | `development` | +| `PUBLIC_URL` | Absolute URL used for emails and webhooks | `http://localhost:3000` | +| `COOKIE_SECURE` | Set `true` behind HTTPS | `false` | +| `ALLOWED_HOSTS` | Comma-separated list of allowed hostnames | `localhost` | ## Testing -### Unit and Component Tests +Playwright E2E tests live in `e2e/` (config at the repo root). Start the dev stack (`bun run dev`) in one terminal, then run: ```bash -npm run test +# Full suite +bunx playwright test + +# Interactive UI mode +bunx playwright test --ui + +# Plain list reporter +bunx playwright test --reporter=list ``` -Runs Jest tests across all packages. Tests are located in `tests/` and alongside components. - -### E2E Tests - -```bash -# Run all E2E tests -npm run test:e2e - -# Run with UI mode (interactive) -npm run test:e2e:ui - -# View test report -npm run test:e2e:report -``` - -Playwright tests are in `e2e/` and cover: -- Authentication flows -- Task management -- Habit tracking -- Project organization -- Note editing -- Report generation -- Analytics dashboards -- Navigation and settings - -Tests run against five browser configurations: Chromium, Firefox, WebKit, Mobile Chrome, and Mobile Safari. +Tests run against Chromium, Firefox, WebKit, Mobile Chrome, and Mobile Safari, and cover authentication, tasks, habits, projects, notes, reports, analytics, calendar, dashboard, search, settings, webhooks, realtime, MCP, and import/export. ## Deployment -### Environment Configuration - -Provision PostgreSQL, apply `drizzle/0000_postgres.sql`, and set these in your deployment environment: - -```bash -DATABASE_URL=postgresql://project_e:your_postgres_password@your-postgres-host:5432/project_e -POSTGRES_PASSWORD=your_postgres_password -NEXTAUTH_SECRET=your_long_random_secret -INITIAL_ADMIN_EMAIL=admin@example.com -INITIAL_ADMIN_PASSWORD=your_initial_admin_password -``` - -Start the web app and worker after the database is available: - -```bash -npm run build -npm run --workspace @project-e/web start -npm run --workspace @project-e/worker start -``` - -### Health Checks - -Use `GET /api/health` to check the web app. +Production runs as a docker-compose stack (db, api, spa, worker) on the host. Gitea Actions drives deploys: a `quality` gate runs on every push and PR, a `deploy` job on pushes to `main` runs `script/deploy.sh`, and a `smoke` job verifies health afterwards. Manual steps, rollback, and troubleshooting are in [DEPLOY.md](DEPLOY.md). ## Documentation @@ -280,36 +215,11 @@ Use `GET /api/health` to check the web app. ## Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Make your changes -4. Run tests (`npm run test && npm run test:e2e`) -5. Commit your changes (`git commit -m 'Add amazing feature'`) -6. Push to the branch (`git push origin feature/amazing-feature`) -7. Open a Pull Request - -### Development Guidelines - -- Write tests for new features -- Follow existing code style (TypeScript, functional components) -- Update documentation for API changes -- Keep commits atomic and well-described -- Use conventional commit messages - -### Code Review Process - -- All PRs require at least one review -- CI must pass (lint, typecheck, tests) -- Keep PRs focused on a single concern -- Write clear PR descriptions explaining the "why" +1. Fork the repository on Gitea and create a feature branch +2. Make your changes +3. Run `bun run typecheck` and `cd apps/web && bun run build` before committing +4. Push the branch and open a pull request. CI runs the same quality gate on every push and PR. ## License This project is private and proprietary. - -## Support - -For issues and questions: -- Open an issue on GitHub -- Check the documentation in `docs/` -- Review existing issues for similar problems diff --git a/apps/api/package.json b/apps/api/package.json index 8fb799e..8f8ebfd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -15,6 +15,7 @@ "hono": "^4.6.0", "jose": "^5.9.6", "postgres": "^3.4.9", + "rrule": "^2.8.1", "zod": "^4.4.3" }, "devDependencies": { diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 2a53cf8..28bcde2 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -23,6 +23,7 @@ import { customFieldRoutes } from "./routes/custom-fields"; import { errorLogRoutes } from "./routes/error-log"; import { analyticsRoutes } from "./routes/analytics"; import { importExportRoutes } from "./routes/import-export"; +import { notificationRoutes } from "./routes/notifications"; import { healthHandler } from "./routes/health"; const app = new Hono(); @@ -57,6 +58,7 @@ app.route("/api/tags", tagRoutes); app.route("/api/custom-fields", customFieldRoutes); app.route("/api/error-log", errorLogRoutes); app.route("/api/analytics", analyticsRoutes); +app.route("/api/notifications", notificationRoutes); app.route("/api", importExportRoutes); app.route("/api", realtimeRoutes); app.route("/api/mcp", mcpRoutes); diff --git a/apps/api/src/middleware/activity.ts b/apps/api/src/middleware/activity.ts index 42f82ae..35acec3 100644 --- a/apps/api/src/middleware/activity.ts +++ b/apps/api/src/middleware/activity.ts @@ -1,4 +1,21 @@ -import { db, sql, activityFeed } from "@project-e/db"; +import { + db, + sql, + activityFeed, + tasks, + habits, + projects, + notes, + canvases, + dailyNotes, + calendarEvents, + webhooks, + agents, + customFields, + dashboardWidgets, +} from "@project-e/db"; +import { eq } from "drizzle-orm"; +import type { AnyPgColumn, AnyPgTable } from "drizzle-orm/pg-core"; export interface RecordActivityParams { actor: string; @@ -24,3 +41,79 @@ export async function recordActivity(params: RecordActivityParams): Promise; + workspaceId?: string | null; +} + +interface EntityWorkspaceLookup { + table: AnyPgTable; + idColumn: AnyPgColumn; + workspaceColumn: AnyPgColumn; +} + +// Maps an entityType (as recorded in activity_feed) to the table + column that +// holds its owning workspace/domain. Tables with domain_id use that; webhooks +// store it as workspace_id. +const entityWorkspaceLookups: Record = { + task: { table: tasks, idColumn: tasks.id, workspaceColumn: tasks.domainId }, + habit: { table: habits, idColumn: habits.id, workspaceColumn: habits.domainId }, + project: { table: projects, idColumn: projects.id, workspaceColumn: projects.domainId }, + note: { table: notes, idColumn: notes.id, workspaceColumn: notes.domainId }, + canvas: { table: canvases, idColumn: canvases.id, workspaceColumn: canvases.domainId }, + daily_note: { table: dailyNotes, idColumn: dailyNotes.id, workspaceColumn: dailyNotes.domainId }, + calendar_event: { table: calendarEvents, idColumn: calendarEvents.id, workspaceColumn: calendarEvents.domainId }, + webhook: { table: webhooks, idColumn: webhooks.id, workspaceColumn: webhooks.workspaceId }, + agent: { table: agents, idColumn: agents.id, workspaceColumn: agents.domainId }, + custom_field: { table: customFields, idColumn: customFields.id, workspaceColumn: customFields.domainId }, + dashboard_widget: { table: dashboardWidgets, idColumn: dashboardWidgets.id, workspaceColumn: dashboardWidgets.domainId }, +}; + +async function resolveEntityWorkspaceId(entityType: string, entityId: string): Promise { + const lookup = entityWorkspaceLookups[entityType]; + if (!lookup) return null; + + const [row] = await db + .select({ workspaceId: lookup.workspaceColumn }) + .from(lookup.table) + .where(eq(lookup.idColumn, entityId)) + .limit(1); + + // AnyPgColumn erases the concrete type, so the selected value is `unknown`. + return (row?.workspaceId as string | undefined) ?? null; +} + +/** + * Record an activity event, deriving the workspaceId from the entity when not + * provided (or empty). Unknown entity types or unresolvable entities are logged + * and skipped rather than crashing the request. + */ +export async function recordActivityForEntity(params: RecordActivityForEntityParams): Promise { + let workspaceId = params.workspaceId; + + if (!workspaceId || workspaceId.trim() === "") { + workspaceId = await resolveEntityWorkspaceId(params.entityType, params.entityId); + if (!workspaceId) { + console.warn(`[activity] Could not resolve workspace for ${params.entityType}:${params.entityId}; skipping activity`); + return; + } + } + + await recordActivity({ + actor: params.actor, + action: params.action, + entityType: params.entityType, + entityId: params.entityId, + changes: params.changes, + workspaceId, + }); +} diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index 4616564..b108234 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -2,8 +2,8 @@ import { createMiddleware } from "hono/factory"; import type { Context, Next } from "hono"; import { jwtVerify, SignJWT } from "jose"; import { createHash } from "node:crypto"; -import { db, users, apiKeys } from "@project-e/db"; -import { and, eq } from "drizzle-orm"; +import { db, users, apiKeys, domains } from "@project-e/db"; +import { and, asc, eq } from "drizzle-orm"; const AUTH_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "fallback-secret-change-me"); const COOKIE_NAME = "session"; @@ -101,10 +101,44 @@ export async function requireAuth(c: Context): Promise { return user; } -export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> { - const { domains } = await import("@project-e/db"); - const { asc } = await import("drizzle-orm"); +/** + * Require access to a workspace (domain). Verifies the workspace exists and, + * when a user is present on the context, that the user owns it. + * + * Ownership fallback: domain rows created before the ownership model was + * introduced may have a NULL ownerId. For those rows we fall back to the + * existence check only, so legacy data isn't locked out. + * + * @returns the domain row so callers can reuse it (id, name, slug, ownerId, …) + * @throws AuthError 403 FORBIDDEN when workspaceId is missing/empty or not owned + * @throws AuthError 404 NOT_FOUND when no such workspace exists + */ +export async function requireWorkspaceAccess(c: Context, workspaceId: string): Promise { + if (!workspaceId || workspaceId.trim() === "") { + throw new AuthError("Workspace ID is required", 403, "FORBIDDEN"); + } + const [domain] = await db + .select() + .from(domains) + .where(eq(domains.id, workspaceId)) + .limit(1); + + if (!domain) { + throw new AuthError("Workspace not found", 404, "NOT_FOUND"); + } + + const user = c.get("user"); + // Single-user/personal app: ownership is enforced when a user is known. + // Rows with NULL ownerId (legacy) are allowed through the existence check above. + if (user && domain.ownerId !== null && domain.ownerId !== user.id) { + throw new AuthError("You do not have access to this workspace", 403, "FORBIDDEN"); + } + + return domain; +} + +export async function resolveActiveDomain(user: { id: string; email: string; name?: string | null }): Promise<{ id: string; name: string; created: boolean }> { const [existing] = await db .select({ id: domains.id, name: domains.name }) .from(domains) diff --git a/apps/api/src/middleware/webhook-queue.ts b/apps/api/src/middleware/webhook-queue.ts new file mode 100644 index 0000000..6cdc816 --- /dev/null +++ b/apps/api/src/middleware/webhook-queue.ts @@ -0,0 +1,84 @@ +import { db, jobs, webhooks } from "@project-e/db"; +import { and, eq } from "drizzle-orm"; + +/** + * Enqueue a single webhook delivery job for a specific webhook. Used directly by + * the test endpoint and by `enqueueWebhooks` for every matching webhook. The + * worker reads this job, signs the payload with the webhook secret, delivers it + * via fetch, and records the delivery row. + */ +export async function enqueueWebhookDelivery({ + webhookId, + event, + entityType, + entityId, + data, + workspaceId, +}: { + webhookId: string; + event: string; + entityType: string; + entityId: string; + data?: Record; + workspaceId: string; +}): Promise { + await db.insert(jobs).values({ + type: "webhook_delivery", + payload: { + webhook_id: webhookId, + event, + entity_type: entityType, + entity_id: entityId, + data: data ?? {}, + timestamp: new Date().toISOString(), + workspace_id: workspaceId, + }, + status: "pending", + }); +} + +/** + * Enqueue webhook deliveries for every active webhook in a workspace that + * subscribes to the given event. Never throws — failures are logged and the + * caller's request proceeds, matching the activity-feed behavior. + */ +export async function enqueueWebhooks({ + workspaceId, + event, + entityType, + entityId, + data, +}: { + workspaceId: string; + event: string; + entityType: string; + entityId: string; + data?: Record; +}): Promise { + try { + const activeWebhooks = await db.select() + .from(webhooks) + .where(and(eq(webhooks.workspaceId, workspaceId), eq(webhooks.active, true))); + + const matches = activeWebhooks.filter((webhook) => + (webhook.events ?? []).includes(event) + ); + + for (const webhook of matches) { + await enqueueWebhookDelivery({ + webhookId: webhook.id, + event, + entityType, + entityId, + data, + workspaceId, + }); + } + + if (matches.length > 0) { + console.log(`[webhooks] Enqueued ${matches.length} delivery job(s) for ${entityType}.${event}`); + } + } catch (error) { + console.error(`[webhooks] Failed to enqueue webhook deliveries for ${entityType}.${event}:`, error); + } +} diff --git a/apps/api/src/routes/agents.ts b/apps/api/src/routes/agents.ts index 7fb95d5..a75b3d2 100644 --- a/apps/api/src/routes/agents.ts +++ b/apps/api/src/routes/agents.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, agents, agentActivity, agentTasks } from "@project-e/db"; -import { and, asc, desc, eq, isNull, sql } from "drizzle-orm"; -import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { and, asc, desc, eq, gte, ilike, isNull, lte, sql } from "drizzle-orm"; +import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -30,6 +30,33 @@ const updateAgentSchema = z.object({ customFields: z.record(z.string(), z.unknown()).optional(), }); +// ── Activity filter helpers ────────────────────────────────────────────────── +// GET /activity and GET /:id/activity honor the `action`, `from`, `to` and +// `limit` query params the frontend activity page sends. Invalid dates are +// ignored rather than erroring; a bare "YYYY-MM-DD" bounds the whole day for +// the `to` filter so a date-picker value doesn't silently drop that day. + +function parseActivityDate(value: string): Date | null { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return null; + if (/^\d{4}-\d{2}-\d{2}$/.test(value)) date.setUTCHours(23, 59, 59, 999); + return date; +} + +function parseActivityFilters(c: any): { conditions: any[]; limit: number } { + const conditions: any[] = []; + const action = c.req.query("action"); + const from = c.req.query("from"); + const to = c.req.query("to"); + if (action) conditions.push(eq(agentActivity.action, action)); + const fromDate = from ? parseActivityDate(from) : null; + if (fromDate) conditions.push(gte(agentActivity.createdAt, fromDate)); + const toDate = to ? parseActivityDate(to) : null; + if (toDate) conditions.push(lte(agentActivity.createdAt, toDate)); + const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "100", 10) || 100, 1), 500); + return { conditions, limit }; +} + // GET /api/agents — List agents agentRoutes.get("/", async (c) => { try { @@ -38,13 +65,16 @@ agentRoutes.get("/", async (c) => { const page = Math.max(1, parseInt(url.searchParams.get("page") || "1")); const perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50"))); const sort = url.searchParams.get("sort") || "-created"; + const q = url.searchParams.get("q")?.trim(); let domainId = url.searchParams.get("domain") || undefined; if (!domainId) { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); const conditions: any[] = [eq(agents.domainId, domainId)]; + if (q) conditions.push(ilike(agents.name, `%${q}%`)); const sortField = sort.replace(/^-/, ""); const sortDir = sort.startsWith("-") ? "desc" : "asc"; const sortColumns: Record = { created: agents.createdAt, updated: agents.updatedAt, name: agents.name }; @@ -73,6 +103,8 @@ agentRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [agent] = await db.insert(agents).values({ name: data.name, description: data.description ?? null, @@ -104,11 +136,27 @@ agentRoutes.post("/", async (c) => { // GET /api/agents/activity — All activity (bare path, no agent filter) agentRoutes.get("/activity", async (c) => { try { - await requireAuth(c); - const items = await db.select() + const user = await requireAuth(c); + // agent_activity has no domain_id — scope through the owning agent + const domainId = c.req.query("domain") || (await resolveActiveDomain(user)).id; + await requireWorkspaceAccess(c, domainId); + const { conditions: filterConditions, limit } = parseActivityFilters(c); + const items = await db.select({ + id: agentActivity.id, + agentId: agentActivity.agentId, + action: agentActivity.action, + entityType: agentActivity.entityType, + entityId: agentActivity.entityId, + details: agentActivity.details, + success: agentActivity.success, + errorMessage: agentActivity.errorMessage, + createdAt: agentActivity.createdAt, + }) .from(agentActivity) + .innerJoin(agents, eq(agentActivity.agentId, agents.id)) + .where(and(eq(agents.domainId, domainId), ...filterConditions)) .orderBy(desc(agentActivity.createdAt)) - .limit(100); + .limit(limit); return c.json({ items, totalItems: items.length }); } catch (error) { if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); @@ -131,6 +179,7 @@ agentRoutes.get("/:id", async (c) => { } const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); + await requireWorkspaceAccess(c, agent.domainId); return c.json(agent); } catch (error) { if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); @@ -150,6 +199,8 @@ agentRoutes.patch("/:id", async (c) => { const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.name !== undefined) updateValues.name = data.name; if (data.description !== undefined) updateValues.description = data.description; @@ -185,6 +236,8 @@ agentRoutes.delete("/:id", async (c) => { const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + await db.delete(agents).where(eq(agents.id, id)); await recordActivity({ @@ -211,6 +264,10 @@ agentRoutes.post("/:id/permissions", async (c) => { customPermissions: z.array(z.string()).optional().default([]), }).parse(body); + const [existing] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); + if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + const [updated] = await db.update(agents) .set({ permissionTier, customPermissions: customPermissions ?? [], updatedAt: new Date() }) .where(eq(agents.id, id)) @@ -236,11 +293,12 @@ agentRoutes.get("/:id/permissions", async (c) => { await requireAuth(c); const id = c.req.param("id"); const [agent] = await db.select({ - id: agents.id, permissionTier: agents.permissionTier, customPermissions: agents.customPermissions, + id: agents.id, permissionTier: agents.permissionTier, customPermissions: agents.customPermissions, domainId: agents.domainId, }).from(agents).where(eq(agents.id, id)).limit(1); if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); - return c.json(agent); + await requireWorkspaceAccess(c, agent.domainId); + return c.json({ id: agent.id, permissionTier: agent.permissionTier, customPermissions: agent.customPermissions }); } catch (error) { if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); console.error("[agents] GET /:id/permissions error:", error); @@ -251,13 +309,47 @@ agentRoutes.get("/:id/permissions", async (c) => { // GET /api/agents/:id/activity — Agent activity log (or all if id=_all) agentRoutes.get("/:id/activity", async (c) => { try { - await requireAuth(c); + const user = await requireAuth(c); const id = c.req.param("id"); + + if (id === "_all") { + // agent_activity has no domain_id — scope through the owning agent + const domainId = c.req.query("domain") || (await resolveActiveDomain(user)).id; + await requireWorkspaceAccess(c, domainId); + const { conditions: filterConditions, limit } = parseActivityFilters(c); + const items = await db.select({ + id: agentActivity.id, + agentId: agentActivity.agentId, + action: agentActivity.action, + entityType: agentActivity.entityType, + entityId: agentActivity.entityId, + details: agentActivity.details, + success: agentActivity.success, + errorMessage: agentActivity.errorMessage, + createdAt: agentActivity.createdAt, + }) + .from(agentActivity) + .innerJoin(agents, eq(agentActivity.agentId, agents.id)) + .where(and(eq(agents.domainId, domainId), ...filterConditions)) + .orderBy(desc(agentActivity.createdAt)) + .limit(limit); + return c.json({ items, totalItems: items.length }); + } + + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)) { + return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); + } + + const [agent] = await db.select().from(agents).where(eq(agents.id, id)).limit(1); + if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404); + await requireWorkspaceAccess(c, agent.domainId); + + const { conditions: filterConditions, limit } = parseActivityFilters(c); const items = await db.select() .from(agentActivity) - .where(id === "_all" ? undefined : eq(agentActivity.agentId, id)) + .where(and(eq(agentActivity.agentId, id), ...filterConditions)) .orderBy(desc(agentActivity.createdAt)) - .limit(100); + .limit(limit); return c.json({ items, totalItems: items.length }); } catch (error) { if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index 98389b0..aa094ae 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; -import { db, tasks, habits, habitCompletions } from "@project-e/db"; -import { and, eq, gte, isNull } from "drizzle-orm"; +import { db, tasks, habits, habitCompletions, projects } from "@project-e/db"; +import { and, eq, gte, inArray, isNull, or } from "drizzle-orm"; import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; export const analyticsRoutes = new Hono(); @@ -65,9 +65,17 @@ analyticsRoutes.get("/habits", async (c) => { .from(habits) .where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))); - const allLogs = await db.select() - .from(habitCompletions) - .where(gte(habitCompletions.date, startDate)); + const habitIds = allHabits.map((h) => h.id); + + // Only count completions belonging to habits in this domain (not all completions globally) + const allLogs = habitIds.length > 0 + ? await db.select() + .from(habitCompletions) + .where(and( + inArray(habitCompletions.habitId, habitIds), + gte(habitCompletions.date, startDate), + )) + : []; const habitConsistency = allHabits.length > 0 ? Math.round((allLogs.length / (allHabits.length * range)) * 100) @@ -93,7 +101,7 @@ analyticsRoutes.get("/habits", async (c) => { } }); -// GET /api/analytics/projects?range=... — Project progress +// GET /api/analytics/projects?range=... — Per-project progress analyticsRoutes.get("/projects", async (c) => { try { const user = await requireAuth(c); @@ -105,24 +113,48 @@ analyticsRoutes.get("/projects", async (c) => { domainId = active.id; } - const startDate = new Date(); - startDate.setDate(startDate.getDate() - range); + const allProjects = await db.select() + .from(projects) + .where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))); - const allTasks = await db.select() - .from(tasks) - .where(and( - eq(tasks.domainId, domainId), - gte(tasks.createdAt, startDate), - isNull(tasks.deletedAt), - )); + const projectIds = allProjects.map((p) => p.id); - const completedTasks = allTasks.filter(t => t.status === "done"); - const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0; + // Count tasks per project (any status, including non-done) for the domain + const taskRows = projectIds.length > 0 + ? await db.select({ projectId: tasks.projectId, status: tasks.status }) + .from(tasks) + .where(and( + isNull(tasks.deletedAt), + inArray(tasks.projectId, projectIds), + )) + : []; + + const counts = new Map(); + for (const t of taskRows) { + if (!t.projectId) continue; + const entry = counts.get(t.projectId) ?? { totalTasks: 0, completedTasks: 0 }; + entry.totalTasks += 1; + if (t.status === "done") entry.completedTasks += 1; + counts.set(t.projectId, entry); + } + + const projectsData = allProjects.map((p) => { + const stats = counts.get(p.id) ?? { totalTasks: 0, completedTasks: 0 }; + const progress = stats.totalTasks > 0 + ? Math.round((stats.completedTasks / stats.totalTasks) * 100) / 100 + : 0; + return { + id: p.id, + name: p.name, + totalTasks: stats.totalTasks, + completedTasks: stats.completedTasks, + progress, + }; + }); return c.json({ - taskCompletionRate, - totalTasks: allTasks.length, - completedTasks: completedTasks.length, + projects: projectsData, + totalProjects: allProjects.length, period: range, }, { headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" }, @@ -133,3 +165,76 @@ analyticsRoutes.get("/projects", async (c) => { return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get project analytics" } }, 500); } }); + +// GET /api/analytics/daily?range=... — Daily task creation & completion time series +analyticsRoutes.get("/daily", async (c) => { + try { + const user = await requireAuth(c); + const url = new URL(c.req.url); + const range = parseInt(url.searchParams.get("range") || "30"); + let domainId = url.searchParams.get("domain") || undefined; + if (!domainId) { + const active = await resolveActiveDomain(user); + domainId = active.id; + } + + // Buckets cover the last `range` days ending today, matching the frontend's expectation. + const firstDay = new Date(); + firstDay.setDate(firstDay.getDate() - (range - 1)); + firstDay.setHours(0, 0, 0, 0); + + const domainTasks = await db.select() + .from(tasks) + .where(and( + eq(tasks.domainId, domainId), + isNull(tasks.deletedAt), + or( + gte(tasks.createdAt, firstDay), + gte(tasks.completedAt, firstDay), + ), + )); + + // Bucket by local calendar date (yyyy-MM-dd) so keys line up with the frontend's + // date-fns day generation (which uses local time as well). + const localDateKey = (d: Date) => { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; + }; + + const createdByDay = new Map(); + const completedByDay = new Map(); + for (const t of domainTasks) { + const createdKey = localDateKey(t.createdAt); + createdByDay.set(createdKey, (createdByDay.get(createdKey) || 0) + 1); + if (t.status === "done" && t.completedAt) { + const completedKey = localDateKey(t.completedAt); + completedByDay.set(completedKey, (completedByDay.get(completedKey) || 0) + 1); + } + } + + const items: Array<{ date: string; created: number; completed: number }> = []; + const cursor = new Date(firstDay); + for (let i = 0; i < range; i++) { + const key = localDateKey(cursor); + items.push({ + date: key, + created: createdByDay.get(key) || 0, + completed: completedByDay.get(key) || 0, + }); + cursor.setDate(cursor.getDate() + 1); + } + + return c.json({ + items, + period: range, + }, { + headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" }, + }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[analytics] GET /daily error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get daily analytics" } }, 500); + } +}); diff --git a/apps/api/src/routes/auth.ts b/apps/api/src/routes/auth.ts index 5cf3b09..21d5b36 100644 --- a/apps/api/src/routes/auth.ts +++ b/apps/api/src/routes/auth.ts @@ -1,12 +1,17 @@ import { Hono } from "hono"; -import { setCookie } from "hono/cookie"; +import { deleteCookie, setCookie } from "hono/cookie"; import bcrypt from "bcryptjs"; import { db, users } from "@project-e/db"; import { count, eq } from "drizzle-orm"; -import { createToken, requireAuth, createErrorResponse, AuthError } from "../middleware/auth"; +import { createToken } from "../middleware/auth"; export const authRoutes = new Hono(); +// NOTE: Passkeys are intentionally NOT implemented. The legacy passkey routes were +// removed because they issued a session without verifying the WebAuthn signature +// (an authentication bypass). Do not re-add passkey endpoints without full +// WebAuthn challenge/attestation verification. + // POST /api/auth/credentials — Login with email + password authRoutes.post("/credentials", async (c) => { try { @@ -67,6 +72,12 @@ authRoutes.get("/session", async (c) => { } }); +// POST /api/auth/logout — Clear the session cookie +authRoutes.post("/logout", (c) => { + deleteCookie(c, "session", { path: "/" }); + return c.json({ success: true }); +}); + // GET /api/auth/me — Return current user profile authRoutes.get("/me", async (c) => { try { @@ -79,75 +90,3 @@ authRoutes.get("/me", async (c) => { return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401); } }); - -// POST /api/auth/passkey/register — Register a passkey -authRoutes.post("/passkey/register", async (c) => { - try { - const user = c.get("user"); - if (!user) { - return c.json({ error: { code: "UNAUTHORIZED", message: "Not authenticated" } }, 401); - } - - const body = await c.req.json(); - const { credentialId, publicKey, counter } = body; - - if (!credentialId || !publicKey) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and publicKey are required" } }, 400); - } - - await db - .update(users) - .set({ - passkeyCredentialId: credentialId, - passkeyPublicKey: publicKey, - passkeyCounter: counter ?? 0, - }) - .where(eq(users.id, user.id)); - - return c.json({ success: true }); - } catch (error) { - console.error("[passkey/register] error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to register passkey" } }, 500); - } -}); - -// POST /api/auth/passkey/authenticate — Verify passkey login -authRoutes.post("/passkey/authenticate", async (c) => { - try { - const body = await c.req.json(); - const { credentialId, signature, authenticatorData, clientDataJSON } = body; - - if (!credentialId || !signature) { - return c.json({ error: { code: "VALIDATION_ERROR", message: "credentialId and signature are required" } }, 400); - } - - const [user] = await db - .select() - .from(users) - .where(eq(users.passkeyCredentialId, credentialId)) - .limit(1); - - if (!user) { - return c.json({ error: { code: "UNAUTHORIZED", message: "Passkey not found" } }, 401); - } - const token = await createToken({ id: user.id, email: user.email, name: user.name }); - setCookie(c, "session", token, { - httpOnly: true, - secure: false, - sameSite: "Lax", - path: "/", - maxAge: 30 * 24 * 60 * 60, - }); - - return c.json({ - user: { - id: user.id, - email: user.email, - name: user.name, - }, - }); - } catch (error) { - console.error("[passkey/authenticate] error:", error); - return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to verify passkey" } }, 500); - } -}); diff --git a/apps/api/src/routes/calendar.ts b/apps/api/src/routes/calendar.ts index f1b12df..e0dd827 100644 --- a/apps/api/src/routes/calendar.ts +++ b/apps/api/src/routes/calendar.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, calendarEvents } from "@project-e/db"; import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm"; -import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, createErrorResponse, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -46,6 +46,7 @@ calendarRoutes.get("/events", async (c) => { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); const conditions: any[] = [eq(calendarEvents.domainId, domainId)]; if (from) conditions.push(gte(calendarEvents.startTime, new Date(from))); @@ -76,6 +77,8 @@ calendarRoutes.post("/events", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [event] = await db.insert(calendarEvents).values({ title: data.title, description: data.description ?? null, @@ -129,6 +132,8 @@ calendarRoutes.patch("/events/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.title !== undefined) updateValues.title = data.title; if (data.description !== undefined) updateValues.description = data.description; @@ -184,6 +189,8 @@ calendarRoutes.delete("/events/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + await db.delete(calendarEvents).where(eq(calendarEvents.id, id)); await recordActivity({ @@ -216,6 +223,7 @@ calendarRoutes.get("/upcoming", async (c) => { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); const now = new Date(); const end = new Date(); diff --git a/apps/api/src/routes/canvas.ts b/apps/api/src/routes/canvas.ts index 1e8a010..eee24ad 100644 --- a/apps/api/src/routes/canvas.ts +++ b/apps/api/src/routes/canvas.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, canvases, canvasCards, canvasConnections } from "@project-e/db"; import { and, asc, desc, eq, sql } from "drizzle-orm"; -import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -28,6 +28,39 @@ const updateCanvasSchema = z.object({ customFields: z.record(z.string(), z.unknown()).optional(), }); +const createCardSchema = z.object({ + type: z.string().min(1).default("note"), + content: z.string().optional().nullable().default(""), + title: z.string().optional().nullable(), + x: z.number().int().optional(), + y: z.number().int().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), + rotation: z.number().int().optional(), + color: z.string().optional().nullable(), + zIndex: z.number().int().optional(), +}); + +const updateCardSchema = createCardSchema.partial(); + +const bulkSaveCardsSchema = z.object({ + cards: z.array( + z.object({ + id: z.string().uuid().optional(), + type: z.string().min(1).default("note"), + content: z.string().optional().nullable().default(""), + title: z.string().optional().nullable(), + x: z.number().int().optional(), + y: z.number().int().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), + rotation: z.number().int().optional(), + color: z.string().optional().nullable(), + zIndex: z.number().int().optional(), + }) + ).default([]), +}); + // GET /api/canvas — List canvases canvasRoutes.get("/", async (c) => { try { @@ -41,6 +74,7 @@ canvasRoutes.get("/", async (c) => { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); const conditions: any[] = [eq(canvases.domainId, domainId)]; const sortField = sort.replace(/^-/, ""); @@ -71,6 +105,8 @@ canvasRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [canvas] = await db.insert(canvases).values({ name: data.name, description: data.description ?? null, @@ -104,6 +140,8 @@ canvasRoutes.get("/:id", async (c) => { const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); + await requireWorkspaceAccess(c, canvas.domainId); + const [cards, connections] = await Promise.all([ db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)), db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)), @@ -128,6 +166,8 @@ canvasRoutes.patch("/:id", async (c) => { const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.name !== undefined) updateValues.name = data.name; if (data.description !== undefined) updateValues.description = data.description; @@ -162,6 +202,8 @@ canvasRoutes.delete("/:id", async (c) => { const [existing] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + await db.delete(canvases).where(eq(canvases.id, id)); await recordActivity({ @@ -176,3 +218,175 @@ canvasRoutes.delete("/:id", async (c) => { return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500); } }); + +// POST /api/canvas/:id/cards — Create one card (new block) +canvasRoutes.post("/:id/cards", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const data = createCardSchema.parse(body); + + const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); + if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); + + await requireWorkspaceAccess(c, canvas.domainId); + + // New cards append to the end of the vertical document unless an explicit zIndex is given + const [maxRow] = await db + .select({ max: sql`max(${canvasCards.zIndex})` }) + .from(canvasCards) + .where(eq(canvasCards.canvasId, id)); + const zIndex = data.zIndex ?? Number(maxRow?.max ?? -1) + 1; + + const [card] = await db.insert(canvasCards).values({ + canvasId: id, + type: data.type, + content: data.content ?? "", + title: data.title ?? null, + x: data.x ?? 0, + y: data.y ?? 0, + width: data.width ?? 200, + height: data.height ?? 150, + rotation: data.rotation ?? 0, + color: data.color ?? null, + zIndex, + }).returning(); + + await recordActivity({ + actor: user.name, action: "created", entityType: "canvas_card", entityId: card.id, + changes: { type: card.type, zIndex: card.zIndex }, workspaceId: canvas.domainId, + }); + + return c.json(card, 201); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + console.error("[canvas] POST /:id/cards error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to create canvas card" } }, 500); + } +}); + +// PUT /api/canvas/:id/cards — Bulk replace all cards (primary save path) +canvasRoutes.put("/:id/cards", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const data = bulkSaveCardsSchema.parse(body); + + const [canvas] = await db.select().from(canvases).where(eq(canvases.id, id)).limit(1); + if (!canvas) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404); + + await requireWorkspaceAccess(c, canvas.domainId); + + const cards = await db.transaction(async (tx) => { + await tx.delete(canvasCards).where(eq(canvasCards.canvasId, id)); + if (data.cards.length === 0) return []; + return tx.insert(canvasCards).values( + data.cards.map((card, i) => ({ + ...(card.id ? { id: card.id } : {}), + canvasId: id, + type: card.type, + content: card.content ?? "", + title: card.title ?? null, + x: card.x ?? 0, + y: card.y ?? 0, + width: card.width ?? 200, + height: card.height ?? 150, + rotation: card.rotation ?? 0, + color: card.color ?? null, + zIndex: card.zIndex ?? i, + })) + ).returning(); + }); + + await recordActivity({ + actor: user.name, action: "updated", entityType: "canvas", entityId: id, + changes: { name: canvas.name, cardCount: cards.length }, workspaceId: canvas.domainId, + }); + + return c.json({ ...canvas, cards }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + console.error("[canvas] PUT /:id/cards error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to save canvas cards" } }, 500); + } +}); + +// PATCH /api/canvas/cards/:cardId — Update one card +canvasRoutes.patch("/cards/:cardId", async (c) => { + try { + const user = await requireAuth(c); + const cardId = c.req.param("cardId"); + const body = await c.req.json(); + const data = updateCardSchema.parse(body); + + const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1); + if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404); + + // canvas_cards has no domain_id — resolve ownership through the parent canvas + const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1); + if (canvas) { + await requireWorkspaceAccess(c, canvas.domainId); + } + + const updateValues: Record = {}; + if (data.type !== undefined) updateValues.type = data.type; + if (data.content !== undefined) updateValues.content = data.content; + if (data.title !== undefined) updateValues.title = data.title; + if (data.x !== undefined) updateValues.x = data.x; + if (data.y !== undefined) updateValues.y = data.y; + if (data.width !== undefined) updateValues.width = data.width; + if (data.height !== undefined) updateValues.height = data.height; + if (data.rotation !== undefined) updateValues.rotation = data.rotation; + if (data.color !== undefined) updateValues.color = data.color; + if (data.zIndex !== undefined) updateValues.zIndex = data.zIndex; + updateValues.updatedAt = new Date(); + + const [updated] = await db.update(canvasCards).set(updateValues).where(eq(canvasCards.id, cardId)).returning(); + + await recordActivity({ + actor: user.name, action: "updated", entityType: "canvas_card", entityId: cardId, + changes: { ...data }, workspaceId: canvas?.domainId ?? "", + }); + + return c.json(updated); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + if (error instanceof z.ZodError) return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + console.error("[canvas] PATCH /cards/:cardId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update canvas card" } }, 500); + } +}); + +// DELETE /api/canvas/cards/:cardId — Delete one card +canvasRoutes.delete("/cards/:cardId", async (c) => { + try { + const user = await requireAuth(c); + const cardId = c.req.param("cardId"); + const [card] = await db.select().from(canvasCards).where(eq(canvasCards.id, cardId)).limit(1); + if (!card) return c.json({ error: { code: "NOT_FOUND", message: "Canvas card not found" } }, 404); + + // canvas_cards has no domain_id — resolve ownership through the parent canvas + const [canvas] = await db.select().from(canvases).where(eq(canvases.id, card.canvasId)).limit(1); + if (canvas) { + await requireWorkspaceAccess(c, canvas.domainId); + } + + // Hard delete — canvas_cards has no deleted_at column + await db.delete(canvasCards).where(eq(canvasCards.id, cardId)); + + await recordActivity({ + actor: user.name, action: "deleted", entityType: "canvas_card", entityId: cardId, + changes: { type: card.type }, workspaceId: canvas?.domainId ?? "", + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[canvas] DELETE /cards/:cardId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas card" } }, 500); + } +}); diff --git a/apps/api/src/routes/custom-fields.ts b/apps/api/src/routes/custom-fields.ts index 8dcbe44..9743f11 100644 --- a/apps/api/src/routes/custom-fields.ts +++ b/apps/api/src/routes/custom-fields.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, customFields } from "@project-e/db"; import { and, asc, eq } from "drizzle-orm"; -import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -37,6 +37,7 @@ customFieldRoutes.get("/", async (c) => { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); const conditions: any[] = [eq(customFields.domainId, domainId)]; if (entityType) { @@ -66,6 +67,8 @@ customFieldRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [field] = await db.insert(customFields).values({ name: data.name, type: data.type, @@ -102,6 +105,8 @@ customFieldRoutes.patch("/:id", async (c) => { const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.name !== undefined) updateValues.name = data.name; if (data.type !== undefined) updateValues.type = data.type; @@ -135,6 +140,8 @@ customFieldRoutes.delete("/:id", async (c) => { const [existing] = await db.select().from(customFields).where(eq(customFields.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + await db.delete(customFields).where(eq(customFields.id, id)); await recordActivity({ diff --git a/apps/api/src/routes/daily-notes.ts b/apps/api/src/routes/daily-notes.ts index e99d220..4bc034d 100644 --- a/apps/api/src/routes/daily-notes.ts +++ b/apps/api/src/routes/daily-notes.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, dailyNotes } from "@project-e/db"; import { and, desc, eq } from "drizzle-orm"; -import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -33,6 +33,7 @@ dailyNoteRoutes.get("/", async (c) => { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); if (dateStr) { const startOfDay = new Date(dateStr + "T00:00:00.000Z"); @@ -70,6 +71,8 @@ dailyNoteRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [note] = await db.insert(dailyNotes).values({ date: new Date(data.date + "T00:00:00.000Z"), content: data.content ?? null, @@ -104,6 +107,8 @@ dailyNoteRoutes.patch("/:id", async (c) => { const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404); + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.content !== undefined) updateValues.content = data.content; if (data.mood !== undefined) updateValues.mood = data.mood; @@ -126,3 +131,30 @@ dailyNoteRoutes.patch("/:id", async (c) => { return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to update daily note" } }, 500); } }); + +// DELETE /api/daily-notes/:id — Delete a note +dailyNoteRoutes.delete("/:id", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + + const [existing] = await db.select().from(dailyNotes).where(eq(dailyNotes.id, id)).limit(1); + if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404); + + await requireWorkspaceAccess(c, existing.domainId); + + // daily_notes has no deleted_at column, so this is a hard delete. + await db.delete(dailyNotes).where(eq(dailyNotes.id, id)); + + await recordActivity({ + actor: user.name, action: "deleted", entityType: "daily_note", entityId: id, + changes: { date: existing.date.toISOString() }, workspaceId: existing.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[daily-notes] DELETE error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete daily note" } }, 500); + } +}); diff --git a/apps/api/src/routes/dashboard.ts b/apps/api/src/routes/dashboard.ts index 17cce4b..88a6f0c 100644 --- a/apps/api/src/routes/dashboard.ts +++ b/apps/api/src/routes/dashboard.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, dashboardWidgets } from "@project-e/db"; import { and, asc, eq } from "drizzle-orm"; -import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -70,6 +70,8 @@ dashboardRoutes.post("/widgets", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain!); + const [widget] = await db.insert(dashboardWidgets).values({ userId: user.id, type: data.type, @@ -118,6 +120,8 @@ dashboardRoutes.patch("/widgets/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.type !== undefined) updateValues.type = data.type; if (data.title !== undefined) updateValues.title = data.title; @@ -167,6 +171,8 @@ dashboardRoutes.delete("/widgets/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + await db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, id)); await recordActivity({ diff --git a/apps/api/src/routes/graph.ts b/apps/api/src/routes/graph.ts index 0686588..8803475 100644 --- a/apps/api/src/routes/graph.ts +++ b/apps/api/src/routes/graph.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db"; import { and, eq, inArray, isNull } from "drizzle-orm"; -import { requireAuth, AuthError } from "../middleware/auth"; +import { requireAuth, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; import { z } from "zod"; @@ -80,6 +80,20 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg return { nodes, edges }; } +// Resolve the owning domain for a graph edge source. `type` may be an edge type +// (note_link / note_entity / task_dependency) or a source entity type (note / task). +async function resolveEdgeWorkspaceId(sourceId: string, type: string): Promise { + if (type === "note_link" || type === "note_entity" || type === "note") { + const [row] = await db.select({ domainId: notes.domainId }).from(notes).where(eq(notes.id, sourceId)).limit(1); + return row?.domainId ?? null; + } + if (type === "task_dependency" || type === "task") { + const [row] = await db.select({ domainId: tasks.domainId }).from(tasks).where(eq(tasks.id, sourceId)).limit(1); + return row?.domainId ?? null; + } + return null; +} + // GET /api/graph/nodes — All nodes graphRoutes.get("/nodes", async (c) => { try { @@ -89,6 +103,7 @@ graphRoutes.get("/nodes", async (c) => { if (!domainId) { return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400); } + await requireWorkspaceAccess(c, domainId); const data = await getGraphData(domainId); return c.json({ items: data.nodes, totalItems: data.nodes.length }); } catch (error) { @@ -109,6 +124,7 @@ graphRoutes.get("/edges", async (c) => { if (!domainId) { return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400); } + await requireWorkspaceAccess(c, domainId); const data = await getGraphData(domainId); return c.json({ items: data.edges, totalItems: data.edges.length }); } catch (error) { @@ -131,6 +147,18 @@ graphRoutes.post("/edges", async (c) => { type: z.string().default("note_link"), }).parse(body); + // Verify ownership before mutating anything. Both endpoints of the edge + // must belong to the caller's domain. + const workspaceId = await resolveEdgeWorkspaceId(sourceId, type); + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + const targetType = type === "note_link" ? "note" : (type === "note_entity" || type === "task_dependency") ? "task" : type; + const targetWorkspaceId = await resolveEdgeWorkspaceId(targetId, targetType); + if (targetWorkspaceId) { + await requireWorkspaceAccess(c, targetWorkspaceId); + } + if (type === "note_link") { await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId }); } else if (type === "note_entity") { @@ -141,14 +169,18 @@ graphRoutes.post("/edges", async (c) => { return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400); } - await recordActivity({ - actor: user.name, - action: "created", - entityType: "graph_edge", - entityId: sourceId + "-" + targetId, - changes: { type, sourceId, targetId }, - workspaceId: "", - }); + if (!workspaceId) { + console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${type}); skipping activity`); + } else { + await recordActivity({ + actor: user.name, + action: "created", + entityType: "graph_edge", + entityId: sourceId + "-" + targetId, + changes: { type, sourceId, targetId }, + workspaceId, + }); + } return c.json({ success: true }, 201); } catch (error) { @@ -170,6 +202,16 @@ graphRoutes.delete("/edges/:id", async (c) => { const id = c.req.param("id"); const [sourceId, targetId] = id.split("-"); + // The type isn't known at delete time, so resolve from the source entity: + // it's either a note or a task. Verify ownership before mutating anything. + let workspaceId = await resolveEdgeWorkspaceId(sourceId, "note"); + if (!workspaceId) { + workspaceId = await resolveEdgeWorkspaceId(sourceId, "task"); + } + if (workspaceId) { + await requireWorkspaceAccess(c, workspaceId); + } + // Try deleting from note_links first const result = await db.delete(noteLinks) .where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId))) @@ -181,14 +223,18 @@ graphRoutes.delete("/edges/:id", async (c) => { .where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId))); } - await recordActivity({ - actor: user.name, - action: "deleted", - entityType: "graph_edge", - entityId: id, - changes: {}, - workspaceId: "", - }); + if (!workspaceId) { + console.warn(`[graph] DELETE /edges/${id}: could not resolve workspace for source ${sourceId}; skipping activity`); + } else { + await recordActivity({ + actor: user.name, + action: "deleted", + entityType: "graph_edge", + entityId: id, + changes: {}, + workspaceId, + }); + } return c.body(null, 204); } catch (error) { diff --git a/apps/api/src/routes/habits.ts b/apps/api/src/routes/habits.ts index 8cdddba..a02b3c4 100644 --- a/apps/api/src/routes/habits.ts +++ b/apps/api/src/routes/habits.ts @@ -1,8 +1,9 @@ import { Hono } from "hono"; import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db"; -import { and, asc, desc, eq, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm"; -import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, sql } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; import { z } from "zod"; export const habitRoutes = new Hono(); @@ -96,6 +97,7 @@ habitRoutes.get("/", async (c) => { const active = url.searchParams.get("active"); const frequency = url.searchParams.get("frequency"); const difficulty = url.searchParams.get("difficulty"); + const tag = url.searchParams.get("tag"); const search = url.searchParams.get("search"); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); const offset = parseInt(url.searchParams.get("offset") || "0"); @@ -107,6 +109,8 @@ habitRoutes.get("/", async (c) => { domainId = active.id; } + await requireWorkspaceAccess(c, domainId); + const conditions: any[] = [ eq(habits.domainId, domainId), isNull(habits.deletedAt), @@ -118,6 +122,20 @@ habitRoutes.get("/", async (c) => { if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any)); if (search) conditions.push(ilike(habits.name, `%${search}%`)); if (filter) conditions.push(ilike(habits.name, `%${filter}%`)); + // Tag filter applied in SQL (EXISTS on the junction table) so it runs over + // the full dataset before pagination. + if (tag) { + const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean); + if (tagIds.length > 0) { + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(habitTags) + .where(and(eq(habitTags.habitId, habits.id), inArray(habitTags.tagId, tagIds))) + ) + ); + } + } const sortDir = sort.startsWith("-") ? "desc" : "asc"; const sortField = sort.replace(/^-/, ""); @@ -202,6 +220,8 @@ habitRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [habit] = await db.insert(habits).values({ name: data.name, description: data.description ?? null, @@ -231,6 +251,8 @@ habitRoutes.post("/", async (c) => { workspaceId: data.domain, }); + await enqueueWebhooks({ workspaceId: data.domain, event: "habit.created", entityType: "habit", entityId: habit.id, data: { name: habit.name } }); + return c.json(habit, 201); } catch (error) { if (error instanceof AuthError) { @@ -259,6 +281,8 @@ habitRoutes.get("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); } + await requireWorkspaceAccess(c, habit.domainId); + // Fetch recent completions (last 30 days) const thirtyDaysAgo = new Date(); thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); @@ -312,6 +336,8 @@ habitRoutes.patch("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.name !== undefined) updateValues.name = data.name; if (data.description !== undefined) updateValues.description = data.description; @@ -339,6 +365,8 @@ habitRoutes.patch("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.updated", entityType: "habit", entityId: id, data: { ...data, previousName: existing.name } }); + return c.json(updated); } catch (error) { if (error instanceof AuthError) { @@ -367,6 +395,8 @@ habitRoutes.delete("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + await db.update(habits) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(habits.id, id)); @@ -380,6 +410,8 @@ habitRoutes.delete("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.deleted", entityType: "habit", entityId: id, data: { name: existing.name } }); + return c.body(null, 204); } catch (error) { if (error instanceof AuthError) { @@ -390,6 +422,96 @@ habitRoutes.delete("/:id", async (c) => { } }); +// POST /api/habits/:id/tags — Assign a tag to a habit +habitRoutes.post("/:id/tags", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body); + + const [habit] = await db.select({ id: habits.id, domainId: habits.domainId }) + .from(habits) + .where(and(eq(habits.id, id), isNull(habits.deletedAt))) + .limit(1); + if (!habit) { + return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); + } + + await requireWorkspaceAccess(c, habit.domainId); + + const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name }) + .from(tagsTable) + .where(eq(tagsTable.id, tagId)) + .limit(1); + if (!tag) { + return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404); + } + + // Junction table has a composite PK — ignore re-assigns instead of erroring + await db.insert(habitTags).values({ habitId: id, tagId }).onConflictDoNothing(); + + await recordActivity({ + actor: user.name, + action: "tagged", + entityType: "habit", + entityId: id, + changes: { tagId, tagName: tag.name }, + workspaceId: habit.domainId, + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[habits] POST /:id/tags error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500); + } +}); + +// DELETE /api/habits/:id/tags/:tagId — Remove a tag from a habit +habitRoutes.delete("/:id/tags/:tagId", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const tagId = c.req.param("tagId"); + + const [habit] = await db.select({ id: habits.id, domainId: habits.domainId }) + .from(habits) + .where(and(eq(habits.id, id), isNull(habits.deletedAt))) + .limit(1); + if (!habit) { + return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); + } + + await requireWorkspaceAccess(c, habit.domainId); + + // Junction tables have no deleted_at — hard delete is correct here + await db.delete(habitTags).where(and(eq(habitTags.habitId, id), eq(habitTags.tagId, tagId))); + + await recordActivity({ + actor: user.name, + action: "untagged", + entityType: "habit", + entityId: id, + changes: { tagId }, + workspaceId: habit.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[habits] DELETE /:id/tags/:tagId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500); + } +}); + // POST /api/habits/:id/complete — Complete a habit for today habitRoutes.post("/:id/complete", async (c) => { try { @@ -407,6 +529,8 @@ habitRoutes.post("/:id/complete", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); } + await requireWorkspaceAccess(c, habit.domainId); + const [completion] = await db.insert(habitCompletions).values({ habitId: id, date: new Date(), @@ -465,7 +589,7 @@ habitRoutes.get("/:id/completions", async (c) => { const id = c.req.param("id"); const url = new URL(c.req.url); - const [habit] = await db.select({ id: habits.id }) + const [habit] = await db.select({ id: habits.id, domainId: habits.domainId }) .from(habits) .where(and(eq(habits.id, id), isNull(habits.deletedAt))) .limit(1); @@ -474,6 +598,8 @@ habitRoutes.get("/:id/completions", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404); } + await requireWorkspaceAccess(c, habit.domainId); + const from = url.searchParams.get("from"); const to = url.searchParams.get("to"); const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000); diff --git a/apps/api/src/routes/import-export.ts b/apps/api/src/routes/import-export.ts index 17621af..845b9a1 100644 --- a/apps/api/src/routes/import-export.ts +++ b/apps/api/src/routes/import-export.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; -import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks } from "@project-e/db"; -import { eq, isNull } from "drizzle-orm"; -import { requireAuth, createErrorResponse, AuthError } from "../middleware/auth"; +import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks, taskTags, habitTags, projectTags, noteTags } from "@project-e/db"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, resolveActiveDomain, createErrorResponse, AuthError } from "../middleware/auth"; import { z } from "zod"; export const importExportRoutes = new Hono(); @@ -22,6 +22,11 @@ importExportRoutes.post("/import", async (c) => { return c.json({ error: { code: "INVALID_DATA", message: "Missing version field" } }, 400); } + // Every imported entity is forced into a single target domain that the + // current user owns. Payload-supplied domain ids are never trusted. + const targetDomain = body.domain || body.domain_id || (await resolveActiveDomain(user)).id; + await requireWorkspaceAccess(c, targetDomain); + const results: Array<{ collection: string; imported: number; failed: number; errors: string[] }> = []; let totalImported = 0; let totalFailed = 0; @@ -38,25 +43,25 @@ importExportRoutes.post("/import", async (c) => { // Map to the right table switch (collection) { case 'tasks': - await db.insert(tasks).values({ ...data, domainId: data.domain_id || data.domainId }); + await db.insert(tasks).values({ ...data, domainId: targetDomain }); break; case 'habits': - await db.insert(habits).values({ ...data, domainId: data.domain_id || data.domainId }); + await db.insert(habits).values({ ...data, domainId: targetDomain }); break; case 'projects': - await db.insert(projects).values({ ...data, domainId: data.domain_id || data.domainId }); + await db.insert(projects).values({ ...data, domainId: targetDomain }); break; case 'notes': - await db.insert(notes).values({ ...data, domainId: data.domain_id || data.domainId }); + await db.insert(notes).values({ ...data, domainId: targetDomain }); break; case 'tags': await db.insert(tagsTable).values(data); break; case 'agents': - await db.insert(agents).values({ ...data, domainId: data.domain_id || data.domainId }); + await db.insert(agents).values({ ...data, domainId: targetDomain }); break; case 'webhooks': - await db.insert(webhooks).values({ ...data, workspaceId: data.workspace_id || data.workspaceId || data.domain_id || data.domainId }); + await db.insert(webhooks).values({ ...data, workspaceId: targetDomain }); break; } result.imported++; @@ -101,9 +106,15 @@ importExportRoutes.get("/export", async (c) => { importExportRoutes.post("/export", async (c) => { try { const user = await requireAuth(c); - let body: { collections?: string[] } = {}; + let body: { collections?: string[]; domain?: string } = {}; try { body = await c.req.json(); } catch { /* empty body is fine */ } + // Scope the entire export to one domain owned by the current user. + // An optional `domain` in the body can override the active domain, but it + // must still pass the ownership check. + const domainId = body.domain || (await resolveActiveDomain(user)).id; + await requireWorkspaceAccess(c, domainId); + const requestedCollections = body.collections && body.collections.length > 0 ? body.collections.filter(c => COLLECTIONS.includes(c as typeof COLLECTIONS[number])) : [...COLLECTIONS]; @@ -117,13 +128,33 @@ importExportRoutes.post("/export", async (c) => { try { let items: any[] = []; switch (collection) { - case 'tasks': items = await db.select().from(tasks).where(isNull(tasks.deletedAt)); break; - case 'habits': items = await db.select().from(habits).where(isNull(habits.deletedAt)); break; - case 'projects': items = await db.select().from(projects).where(isNull(projects.deletedAt)); break; - case 'notes': items = await db.select().from(notes).where(isNull(notes.deletedAt)); break; - case 'tags': items = await db.select().from(tagsTable); break; - case 'agents': items = await db.select().from(agents); break; - case 'webhooks': items = await db.select().from(webhooks); break; + case 'tasks': items = await db.select().from(tasks).where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))); break; + case 'habits': items = await db.select().from(habits).where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))); break; + case 'projects': items = await db.select().from(projects).where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))); break; + case 'notes': items = await db.select().from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))); break; + case 'tags': { + // Tags are global (no domain_id); export only tags actually used by + // this domain's entities via the four junction tables. + const [taskTagIds, habitTagIds, projectTagIds, noteTagIds] = await Promise.all([ + db.select({ tagId: taskTags.tagId }).from(taskTags) + .innerJoin(tasks, eq(taskTags.taskId, tasks.id)) + .where(and(eq(tasks.domainId, domainId), isNull(tasks.deletedAt))), + db.select({ tagId: habitTags.tagId }).from(habitTags) + .innerJoin(habits, eq(habitTags.habitId, habits.id)) + .where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt))), + db.select({ tagId: projectTags.tagId }).from(projectTags) + .innerJoin(projects, eq(projectTags.projectId, projects.id)) + .where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt))), + db.select({ tagId: noteTags.tagId }).from(noteTags) + .innerJoin(notes, eq(noteTags.noteId, notes.id)) + .where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))), + ]); + const tagIds = [...new Set([...taskTagIds, ...habitTagIds, ...projectTagIds, ...noteTagIds].map(r => r.tagId))]; + items = tagIds.length > 0 ? await db.select().from(tagsTable).where(inArray(tagsTable.id, tagIds)) : []; + break; + } + case 'agents': items = await db.select().from(agents).where(eq(agents.domainId, domainId)); break; + case 'webhooks': items = await db.select().from(webhooks).where(eq(webhooks.workspaceId, domainId)); break; } exportData[collection] = items; } catch (error) { diff --git a/apps/api/src/routes/mcp.ts b/apps/api/src/routes/mcp.ts index d04207b..bc08944 100644 --- a/apps/api/src/routes/mcp.ts +++ b/apps/api/src/routes/mcp.ts @@ -1,7 +1,7 @@ import { Hono } from "hono"; import { createHash } from "node:crypto"; import { db, apiKeys, users, tasks, taskTags, tags as tagsTable, habits, habitCompletions, projects, notes, noteLinks, domains, activityFeed, webhooks, webhookDeliveries } from "@project-e/db"; -import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import { and, asc, desc, eq, ilike, isNull, or } from "drizzle-orm"; import { recordActivity } from "../middleware/activity"; export const mcpRoutes = new Hono(); @@ -484,7 +484,7 @@ const tools: ToolDefinition[] = [ .where(and( eq(notes.domainId, params.domain_id as string), isNull(notes.deletedAt), - or(ilike(notes.title, `%${query}%`), ilike(notes.content ?? sql``, `%${query}%`)) + or(ilike(notes.title, `%${query}%`), ilike(notes.content, `%${query}%`)) )) .orderBy(desc(notes.updatedAt)) .limit(20); @@ -493,10 +493,15 @@ const tools: ToolDefinition[] = [ }, { name: "domains.list", - description: "List domains/workspaces", + description: "List the caller's domains/workspaces", inputSchema: { type: "object", properties: {} }, - handler: async () => { - const items = await db.select().from(domains).orderBy(asc(domains.name)); + handler: async (_params, auth) => { + // Only the caller's own domains (plus legacy ownerless rows) — never every + // domain in the database. + const items = await db.select() + .from(domains) + .where(or(eq(domains.ownerId, auth.userId), isNull(domains.ownerId))) + .orderBy(asc(domains.name)); return { items }; }, }, @@ -517,6 +522,7 @@ const tools: ToolDefinition[] = [ name: params.name as string, slug: params.slug as string, color: (params.color as string) ?? null, + ownerId: auth.userId, }).returning(); await recordActivity({ @@ -603,14 +609,48 @@ class JsonRpcErrorResponse extends Error { } } -function makeError(code: number, message: string, data?: unknown): JsonRpcResponse { - return { jsonrpc: "2.0", error: { code, message, data }, id: null }; +function makeError(code: number, message: string, data?: unknown, id: string | number | null = null): JsonRpcResponse { + return { jsonrpc: "2.0", error: { code, message, data }, id }; } function makeResult(result: unknown, id: string | number | null): JsonRpcResponse { return { jsonrpc: "2.0", result, id }; } +// Validate that every field listed in the tool's inputSchema `required` array is +// present. Prevents silent empty-result queries (e.g. a missing domain_id) from +// reaching the database. +function validateParams(tool: ToolDefinition, args: Record): string | null { + const schema = tool.inputSchema as { required?: string[] } | undefined; + for (const field of schema?.required || []) { + const value = args[field]; + if (value === undefined || value === null || value === "") { + return `Missing required parameter: ${field}`; + } + } + return null; +} + +// Domain/workspace ownership check for tools that receive a domain_id or +// workspace_id. Mirrors requireWorkspaceAccess but works without a Hono context: +// legacy domains with a NULL ownerId pass through the existence check only. +async function verifyDomainAccess(domainId: string, userId: string): Promise { + if (!domainId || domainId.trim() === "") { + throw new JsonRpcErrorResponse(JSONRPC_INVALID_PARAMS, "domain_id is required"); + } + const [domain] = await db + .select({ id: domains.id, ownerId: domains.ownerId }) + .from(domains) + .where(eq(domains.id, domainId)) + .limit(1); + if (!domain) { + throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, `Domain not found: ${domainId}`); + } + if (domain.ownerId !== null && domain.ownerId !== userId) { + throw new JsonRpcErrorResponse(JSONRPC_INTERNAL_ERROR, `No access to domain: ${domainId}`); + } +} + async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise { const { method, params, id } = body; @@ -644,23 +684,36 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN if (method === "tools/call") { const callParams = params as { name?: string; arguments?: Record } | undefined; if (!callParams?.name) { - return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", id); + return makeError(JSONRPC_INVALID_PARAMS, "Missing tool name", undefined, id); } const tool = tools.find(t => t.name === callParams.name); if (!tool) { - return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, id); + return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown tool: ${callParams.name}`, undefined, id); } try { - const result = await tool.handler(callParams.arguments || {}, auth); + const args = callParams.arguments || {}; + + const missing = validateParams(tool, args); + if (missing) { + return makeError(JSONRPC_INVALID_PARAMS, missing, undefined, id); + } + + // Scope to the caller's domains when a domain/workspace is passed. + const domainId = (args.domain_id as string | undefined) ?? (args.workspace_id as string | undefined); + if (domainId) { + await verifyDomainAccess(domainId, auth.userId); + } + + const result = await tool.handler(args, auth); return makeResult({ content: [{ type: "text", text: JSON.stringify(result) }] }, id); } catch (error) { if (error instanceof JsonRpcErrorResponse) { - return makeError(error.code, error.message, error.data); + return makeError(error.code, error.message, error.data, id); } console.error(`[MCP] Tool ${callParams.name} error:`, error); - return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", id); + return makeError(JSONRPC_INTERNAL_ERROR, error instanceof Error ? error.message : "Internal error", undefined, id); } } @@ -700,7 +753,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN if (method === "resources/read") { const readParams = params as { uri?: string } | undefined; if (!readParams?.uri) { - return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", id); + return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", undefined, id); } return makeResult({ contents: [ @@ -727,7 +780,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN }, id); } - return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, id); + return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, undefined, id); } // ── Route handler ──────────────────────────────────────────────────────────────── diff --git a/apps/api/src/routes/notes.ts b/apps/api/src/routes/notes.ts index 018591c..a1112c1 100644 --- a/apps/api/src/routes/notes.ts +++ b/apps/api/src/routes/notes.ts @@ -1,8 +1,9 @@ import { Hono } from "hono"; import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db"; -import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; -import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { and, asc, desc, eq, exists, ilike, inArray, isNull, sql } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service"; import { z } from "zod"; @@ -35,6 +36,7 @@ noteRoutes.get("/", async (c) => { const sort = url.searchParams.get("sort") || "-updated_at"; const pinned = url.searchParams.get("pinned"); const archived = url.searchParams.get("archived"); + const tag = url.searchParams.get("tag"); const search = url.searchParams.get("search"); const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200); const offset = parseInt(url.searchParams.get("offset") || "0"); @@ -46,6 +48,8 @@ noteRoutes.get("/", async (c) => { domainId = active.id; } + await requireWorkspaceAccess(c, domainId); + const conditions: any[] = [ eq(notes.domainId, domainId), isNull(notes.deletedAt), @@ -56,6 +60,20 @@ noteRoutes.get("/", async (c) => { else if (archived !== "all") conditions.push(eq(notes.isArchived, false)); if (search) conditions.push(ilike(notes.title, `%${search}%`)); if (filter) conditions.push(ilike(notes.title, `%${filter}%`)); + // Tag filter applied in SQL (EXISTS on the junction table) so it runs over + // the full dataset before pagination. + if (tag) { + const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean); + if (tagIds.length > 0) { + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(noteTags) + .where(and(eq(noteTags.noteId, notes.id), inArray(noteTags.tagId, tagIds))) + ) + ); + } + } const sortDir = sort.startsWith("-") ? "desc" : "asc"; const sortField = sort.replace(/^-/, ""); @@ -136,6 +154,8 @@ noteRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [note] = await db.insert(notes).values({ title: data.title, content: data.content ?? null, @@ -164,6 +184,8 @@ noteRoutes.post("/", async (c) => { workspaceId: data.domain, }); + await enqueueWebhooks({ workspaceId: data.domain, event: "note.created", entityType: "note", entityId: note.id, data: { title: note.title } }); + return c.json(note, 201); } catch (error) { if (error instanceof AuthError) { @@ -192,6 +214,8 @@ noteRoutes.get("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); } + await requireWorkspaceAccess(c, note.domainId); + // Fetch tags const tagRows = await db.select({ id: tagsTable.id, @@ -240,6 +264,8 @@ noteRoutes.patch("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.title !== undefined) updateValues.title = data.title; if (data.content !== undefined) updateValues.content = data.content; @@ -267,6 +293,8 @@ noteRoutes.patch("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.updated", entityType: "note", entityId: id, data: { ...data, previousTitle: existing.title } }); + return c.json(updated); } catch (error) { if (error instanceof AuthError) { @@ -295,6 +323,8 @@ noteRoutes.delete("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + await db.update(notes) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(notes.id, id)); @@ -308,6 +338,8 @@ noteRoutes.delete("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.deleted", entityType: "note", entityId: id, data: { title: existing.title } }); + return c.body(null, 204); } catch (error) { if (error instanceof AuthError) { @@ -318,12 +350,113 @@ noteRoutes.delete("/:id", async (c) => { } }); +// POST /api/notes/:id/tags — Assign a tag to a note +noteRoutes.post("/:id/tags", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body); + + const [note] = await db.select({ id: notes.id, domainId: notes.domainId }) + .from(notes) + .where(and(eq(notes.id, id), isNull(notes.deletedAt))) + .limit(1); + if (!note) { + return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); + } + + await requireWorkspaceAccess(c, note.domainId); + + const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name }) + .from(tagsTable) + .where(eq(tagsTable.id, tagId)) + .limit(1); + if (!tag) { + return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404); + } + + // Junction table has a composite PK — ignore re-assigns instead of erroring + await db.insert(noteTags).values({ noteId: id, tagId }).onConflictDoNothing(); + + await recordActivity({ + actor: user.name, + action: "tagged", + entityType: "note", + entityId: id, + changes: { tagId, tagName: tag.name }, + workspaceId: note.domainId, + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[notes] POST /:id/tags error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500); + } +}); + +// DELETE /api/notes/:id/tags/:tagId — Remove a tag from a note +noteRoutes.delete("/:id/tags/:tagId", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const tagId = c.req.param("tagId"); + + const [note] = await db.select({ id: notes.id, domainId: notes.domainId }) + .from(notes) + .where(and(eq(notes.id, id), isNull(notes.deletedAt))) + .limit(1); + if (!note) { + return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); + } + + await requireWorkspaceAccess(c, note.domainId); + + // Junction tables have no deleted_at — hard delete is correct here + await db.delete(noteTags).where(and(eq(noteTags.noteId, id), eq(noteTags.tagId, tagId))); + + await recordActivity({ + actor: user.name, + action: "untagged", + entityType: "note", + entityId: id, + changes: { tagId }, + workspaceId: note.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[notes] DELETE /:id/tags/:tagId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500); + } +}); + // GET /api/notes/:id/backlinks — Notes that link TO this one noteRoutes.get("/:id/backlinks", async (c) => { try { const user = await requireAuth(c); const id = c.req.param("id"); + const [note] = await db.select({ id: notes.id, domainId: notes.domainId }) + .from(notes) + .where(and(eq(notes.id, id), isNull(notes.deletedAt))) + .limit(1); + + if (!note) { + return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); + } + + await requireWorkspaceAccess(c, note.domainId); + const backlinks = await getBacklinks(id); return c.json({ @@ -345,11 +478,23 @@ noteRoutes.get("/:id/versions", async (c) => { const user = await requireAuth(c); const id = c.req.param("id"); + const [note] = await db.select({ id: notes.id, domainId: notes.domainId }) + .from(notes) + .where(and(eq(notes.id, id), isNull(notes.deletedAt))) + .limit(1); + + if (!note) { + return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404); + } + + await requireWorkspaceAccess(c, note.domainId); + const versions = await db.select() .from(activityFeed) .where(and( eq(activityFeed.entityId, id), eq(activityFeed.entityType, "note"), + eq(activityFeed.workspaceId, note.domainId), )) .orderBy(desc(activityFeed.createdAt)) .limit(100); diff --git a/apps/api/src/routes/notifications.ts b/apps/api/src/routes/notifications.ts new file mode 100644 index 0000000..4597829 --- /dev/null +++ b/apps/api/src/routes/notifications.ts @@ -0,0 +1,44 @@ +import { Hono } from "hono"; +import { db, activityFeed } from "@project-e/db"; +import { and, desc, eq, gte, ne, sql } from "drizzle-orm"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; + +export const notificationRoutes = new Hono(); + +// The bell in the topbar shows a badge for activity_feed events from the last +// 7 days. There is no read/unread state yet, so "count" doubles as the unread +// badge. graph_edge rows are workspace-internal graph plumbing, not user-facing +// activity, so they are excluded from both the count and the feed. +const NOTIFICATION_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +// GET /api/notifications?workspace_id=&limit= — Recent activity for a workspace. +notificationRoutes.get("/", async (c) => { + try { + const user = await requireAuth(c); + let workspaceId = c.req.query("workspace_id"); + if (!workspaceId) { + const active = await resolveActiveDomain(user); + workspaceId = active.id; + } + await requireWorkspaceAccess(c, workspaceId); + + const limit = Math.min(Math.max(parseInt(c.req.query("limit") || "20", 10) || 20, 1), 100); + const since = new Date(Date.now() - NOTIFICATION_WINDOW_MS); + const conditions = [ + eq(activityFeed.workspaceId, workspaceId), + gte(activityFeed.createdAt, since), + ne(activityFeed.entityType, "graph_edge"), + ]; + + const [items, countResult] = await Promise.all([ + db.select().from(activityFeed).where(and(...conditions)).orderBy(desc(activityFeed.createdAt)).limit(limit), + db.select({ count: sql`count(*)` }).from(activityFeed).where(and(...conditions)), + ]); + + return c.json({ items, count: Number(countResult[0]?.count || 0) }); + } catch (error) { + if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + console.error("[notifications] GET error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get notifications" } }, 500); + } +}); diff --git a/apps/api/src/routes/projects.ts b/apps/api/src/routes/projects.ts index 22d15ae..3b87d04 100644 --- a/apps/api/src/routes/projects.ts +++ b/apps/api/src/routes/projects.ts @@ -1,8 +1,9 @@ import { Hono } from "hono"; import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db"; import { and, asc, desc, eq, ilike, inArray, isNull, sql } from "drizzle-orm"; -import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; import { z } from "zod"; export const projectRoutes = new Hono(); @@ -69,6 +70,8 @@ projectRoutes.get("/", async (c) => { domainId = active.id; } + await requireWorkspaceAccess(c, domainId); + const conditions: any[] = [ eq(projects.domainId, domainId), isNull(projects.deletedAt), @@ -189,6 +192,8 @@ projectRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [project] = await db.insert(projects).values({ name: data.name, description: data.description ?? null, @@ -214,6 +219,8 @@ projectRoutes.post("/", async (c) => { workspaceId: data.domain, }); + await enqueueWebhooks({ workspaceId: data.domain, event: "project.created", entityType: "project", entityId: project.id, data: { name: project.name } }); + return c.json(project, 201); } catch (error) { if (error instanceof AuthError) { @@ -242,6 +249,8 @@ projectRoutes.get("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, project.domainId); + // Fetch sections const projectSections = await db.select() .from(sections) @@ -303,6 +312,8 @@ projectRoutes.patch("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = {}; if (data.name !== undefined) updateValues.name = data.name; if (data.description !== undefined) updateValues.description = data.description; @@ -326,6 +337,8 @@ projectRoutes.patch("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.updated", entityType: "project", entityId: id, data: { ...data, previousName: existing.name } }); + return c.json(updated); } catch (error) { if (error instanceof AuthError) { @@ -354,6 +367,8 @@ projectRoutes.delete("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + await db.update(projects) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(projects.id, id)); @@ -367,6 +382,8 @@ projectRoutes.delete("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.deleted", entityType: "project", entityId: id, data: { name: existing.name } }); + return c.body(null, 204); } catch (error) { if (error instanceof AuthError) { @@ -383,7 +400,7 @@ projectRoutes.get("/:id/sections", async (c) => { const user = await requireAuth(c); const projectId = c.req.param("id"); - const [project] = await db.select({ id: projects.id }) + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) .from(projects) .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) .limit(1); @@ -392,6 +409,8 @@ projectRoutes.get("/:id/sections", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, project.domainId); + const items = await db.select() .from(sections) .where(eq(sections.projectId, projectId)) @@ -424,6 +443,8 @@ projectRoutes.post("/:id/sections", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, project.domainId); + let sortOrder = data.sortOrder; if (sortOrder === undefined) { const [maxOrder] = await db.select({ max: sql`COALESCE(MAX(sort_order), -1)` }) @@ -470,6 +491,17 @@ projectRoutes.get("/:id/sections/:sid", async (c) => { const projectId = c.req.param("id"); const id = c.req.param("sid"); + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + const [section] = await db.select() .from(sections) .where(and(eq(sections.id, id), eq(sections.projectId, projectId))) @@ -498,6 +530,17 @@ projectRoutes.patch("/:id/sections/:sid", async (c) => { const body = await c.req.json(); const data = updateSectionSchema.parse(body); + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + const [existing] = await db.select() .from(sections) .where(and(eq(sections.id, id), eq(sections.projectId, projectId))) @@ -555,6 +598,17 @@ projectRoutes.delete("/:id/sections/:sid", async (c) => { const projectId = c.req.param("id"); const id = c.req.param("sid"); + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) + .from(projects) + .where(and(eq(projects.id, projectId), isNull(projects.deletedAt))) + .limit(1); + + if (!project) { + return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); + } + + await requireWorkspaceAccess(c, project.domainId); + const [existing] = await db.select() .from(sections) .where(and(eq(sections.id, id), eq(sections.projectId, projectId))) @@ -598,7 +652,7 @@ projectRoutes.get("/:id/members", async (c) => { const user = await requireAuth(c); const id = c.req.param("id"); - const [project] = await db.select({ id: projects.id }) + const [project] = await db.select({ id: projects.id, domainId: projects.domainId }) .from(projects) .where(and(eq(projects.id, id), isNull(projects.deletedAt))) .limit(1); @@ -607,6 +661,8 @@ projectRoutes.get("/:id/members", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, project.domainId); + // Members are stored in activity feed with entityType=member const members = await db.select() .from(activityFeed) @@ -646,6 +702,8 @@ projectRoutes.post("/:id/members", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, project.domainId); + await recordActivity({ actor: user.name, action: "added", @@ -684,6 +742,8 @@ projectRoutes.delete("/:id/members/:uid", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404); } + await requireWorkspaceAccess(c, project.domainId); + await recordActivity({ actor: user.name, action: "removed", diff --git a/apps/api/src/routes/realtime.ts b/apps/api/src/routes/realtime.ts index daa66a1..394db62 100644 --- a/apps/api/src/routes/realtime.ts +++ b/apps/api/src/routes/realtime.ts @@ -1,5 +1,6 @@ import { Hono } from "hono"; import postgres from "postgres"; +import { requireWorkspaceAccess, AuthError } from "../middleware/auth"; export const realtimeRoutes = new Hono(); @@ -13,6 +14,21 @@ realtimeRoutes.get("/realtime", async (c) => { const url = new URL(c.req.url); const workspaceId = url.searchParams.get("workspace_id"); + // IDOR guard: if a workspace is requested, verify the current user actually + // owns it before subscribing to the event stream. Without this check any + // authenticated user could tail another workspace's events. + if (workspaceId) { + try { + await requireWorkspaceAccess(c, workspaceId); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[realtime] workspace validation error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to validate workspace" } }, 500); + } + } + const encoder = new TextEncoder(); const listener = postgres(process.env.DATABASE_URL!, { max: 1 }); let unlisten: (() => Promise) | undefined; diff --git a/apps/api/src/routes/search.ts b/apps/api/src/routes/search.ts index 2d47336..48773a8 100644 --- a/apps/api/src/routes/search.ts +++ b/apps/api/src/routes/search.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, sql } from "@project-e/db"; -import { requireAuth, AuthError } from "../middleware/auth"; +import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; export const searchRoutes = new Hono(); @@ -31,6 +31,11 @@ searchRoutes.get("/", async (c) => { return c.json({ results: [], totalCount: 0 }); } + // Scope all searches to the user's active domain so users can never see + // another workspace's data. + const userDomain = await resolveActiveDomain(user); + const userDomainId = userDomain.id; + const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = []; for (const type of types) { @@ -44,6 +49,17 @@ searchRoutes.get("/", async (c) => { conditions.push(deletedColumn + " IS NULL"); } + // Restrict results to the user's active domain. For `domain` the + // workspace column is the table's own `id`; for all other entities it + // is `domain_id`. userDomainId is a trusted uuid from the DB, but we + // escape single quotes defensively anyway. + const domainValue = String(userDomainId).replace(/'/g, "''"); + if (type === 'domain') { + conditions.push(workspaceColumn + " = '" + domainValue + "'"); + } else { + conditions.push("domain_id = '" + domainValue + "'"); + } + const whereClause = conditions.join(' AND '); const headlineColumn = contentColumn || titleColumn; diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index 2d22968..7aa1073 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -1,9 +1,11 @@ import { Hono } from "hono"; -import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed } from "@project-e/db"; -import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm"; -import { requireAuth, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs } from "@project-e/db"; +import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm"; +import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; +import { enqueueWebhooks } from "../middleware/webhook-queue"; import { z } from "zod"; +import { RRule } from "rrule"; export const taskRoutes = new Hono(); @@ -42,6 +44,38 @@ const updateTaskSchema = z.object({ recurrenceRule: z.string().optional().nullable(), }); +// ── Recurring tasks ──────────────────────────────────────────────────────────────── +// Keeps scheduled_jobs in sync with a task's recurrence_rule so the worker's +// recurring_spawn handler has work to do. A malformed rule must never fail the +// create/update — fall back to +1 day and log. + +function computeNextOccurrenceAt(recurrenceRule: string): Date { + try { + const rule = RRule.fromString(recurrenceRule); + const next = rule.after(new Date()); + if (next) return next; + } catch { + // fall through to fallback + } + return new Date(Date.now() + 24 * 60 * 60 * 1000); +} + +async function syncScheduledJob(taskId: string, recurrenceRule: string | null): Promise { + try { + await db.delete(scheduledJobs).where(and(eq(scheduledJobs.entityType, "task"), eq(scheduledJobs.entityId, taskId))); + if (recurrenceRule) { + await db.insert(scheduledJobs).values({ + entityType: "task", + entityId: taskId, + recurrenceRule, + nextOccurrenceAt: computeNextOccurrenceAt(recurrenceRule), + }); + } + } catch (error) { + console.error(`[tasks] Failed to sync scheduled job for task ${taskId}:`, error); + } +} + // GET /api/tasks — List tasks with filtering, sorting, pagination taskRoutes.get("/", async (c) => { try { @@ -68,6 +102,8 @@ taskRoutes.get("/", async (c) => { domainId = active.id; } + await requireWorkspaceAccess(c, domainId); + // Build conditions const conditions: any[] = [ eq(tasks.domainId, domainId), @@ -104,6 +140,21 @@ taskRoutes.get("/", async (c) => { if (sectionId) { conditions.push(eq(tasks.sectionId, sectionId)); } + // Tag filter applied in SQL (EXISTS on the junction table) so it runs over + // the full dataset before pagination — filtering in-memory after fetching a + // page would miss tasks beyond the limit and report a wrong totalItems. + if (tag) { + const tagIds = tag.split(",").map((t) => t.trim()).filter(Boolean); + if (tagIds.length > 0) { + conditions.push( + exists( + db.select({ one: sql`1` }) + .from(taskTags) + .where(and(eq(taskTags.taskId, tasks.id), inArray(taskTags.tagId, tagIds))) + ) + ); + } + } // Build order const orderFn = order === "desc" ? desc : asc; @@ -138,21 +189,10 @@ taskRoutes.get("/", async (c) => { const totalItems = Number(countResult[0]?.count || 0); - // If tag filter is specified, filter in-memory - let filteredItems = items; - if (tag) { - const tagIds = tag.split(","); - const taskTagRows = await db.select({ taskId: taskTags.taskId }) - .from(taskTags) - .where(inArray(taskTags.tagId, tagIds)); - const matchingTaskIds = new Set(taskTagRows.map(r => r.taskId)); - filteredItems = items.filter(t => matchingTaskIds.has(t.id)); - } - // Fetch tags for all tasks let taskTagMap = new Map(); - if (filteredItems.length > 0) { - const taskIds = filteredItems.map(t => t.id); + if (items.length > 0) { + const taskIds = items.map(t => t.id); const tagRows = await db.select({ taskId: taskTags.taskId, id: tagsTable.id, @@ -169,7 +209,7 @@ taskRoutes.get("/", async (c) => { } } - const itemsWithTags = filteredItems.map(t => ({ + const itemsWithTags = items.map(t => ({ ...t, tags: taskTagMap.get(t.id) || [], })); @@ -202,6 +242,8 @@ taskRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + // Cycle detection for parentId (subtask) if (data.parentId) { const [parent] = await db.select({ id: tasks.id }) @@ -244,6 +286,12 @@ taskRoutes.post("/", async (c) => { workspaceId: data.domain, }); + await enqueueWebhooks({ workspaceId: data.domain, event: "task.created", entityType: "task", entityId: task.id, data: { title: task.title } }); + + if (data.recurrenceRule) { + await syncScheduledJob(task.id, data.recurrenceRule); + } + return c.json(task, 201); } catch (error) { if (error instanceof AuthError) { @@ -257,6 +305,71 @@ taskRoutes.post("/", async (c) => { } }); +const reorderTasksSchema = z.object({ + orderedIds: z.array(z.string().uuid()).min(1, "orderedIds is required"), + domain: z.string().uuid().optional(), +}); + +// POST /api/tasks/reorder — Persist Kanban board column ordering +taskRoutes.post("/reorder", async (c) => { + try { + const user = await requireAuth(c); + const body = await c.req.json(); + const { orderedIds, domain } = reorderTasksSchema.parse(body); + + // Resolve the workspace: explicit domain, or the first task's domain + let workspaceId = domain; + if (!workspaceId) { + const [firstTask] = await db.select({ domainId: tasks.domainId }) + .from(tasks) + .where(eq(tasks.id, orderedIds[0])) + .limit(1); + if (!firstTask) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + workspaceId = firstTask.domainId; + } + await requireWorkspaceAccess(c, workspaceId); + + // Verify every task exists in this workspace and is not soft-deleted + const existing = await db.select({ id: tasks.id }) + .from(tasks) + .where(and(inArray(tasks.id, orderedIds), eq(tasks.domainId, workspaceId), isNull(tasks.deletedAt))); + if (existing.length !== orderedIds.length) { + return c.json({ error: { code: "NOT_FOUND", message: "One or more tasks not found" } }, 404); + } + + // Update each task's order to its index in a transaction + await db.transaction(async (tx) => { + for (let i = 0; i < orderedIds.length; i++) { + await tx.update(tasks) + .set({ order: i, updatedAt: new Date() }) + .where(eq(tasks.id, orderedIds[i])); + } + }); + + await recordActivity({ + actor: user.name, + action: "reordered", + entityType: "task", + entityId: orderedIds[0], + changes: { orderedIds }, + workspaceId, + }); + + return c.json({ success: true, orderedIds }); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[tasks] POST /reorder error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder tasks" } }, 500); + } +}); + // GET /api/tasks/:id — Get a single task with subtasks + dependencies taskRoutes.get("/:id", async (c) => { try { @@ -272,6 +385,8 @@ taskRoutes.get("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); } + await requireWorkspaceAccess(c, task.domainId); + // Fetch subtasks const subtasks = await db.select() .from(tasks) @@ -341,6 +456,8 @@ taskRoutes.patch("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + // Cycle detection for parentId if (data.parentId && data.parentId === id) { return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot be its own parent" } }, 400); @@ -390,6 +507,12 @@ taskRoutes.patch("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStatus: existing.status } }); + + if (data.recurrenceRule !== undefined) { + await syncScheduledJob(id, data.recurrenceRule); + } + return c.json(updated); } catch (error) { if (error instanceof AuthError) { @@ -418,6 +541,8 @@ taskRoutes.delete("/:id", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + await db.update(tasks) .set({ deletedAt: new Date(), updatedAt: new Date() }) .where(eq(tasks.id, id)); @@ -431,6 +556,11 @@ taskRoutes.delete("/:id", async (c) => { workspaceId: existing.domainId, }); + await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.deleted", entityType: "task", entityId: id, data: { title: existing.title } }); + + // Stop recurring spawns for a deleted task + await syncScheduledJob(id, null); + return c.body(null, 204); } catch (error) { if (error instanceof AuthError) { @@ -441,6 +571,96 @@ taskRoutes.delete("/:id", async (c) => { } }); +// POST /api/tasks/:id/tags — Assign a tag to a task +taskRoutes.post("/:id/tags", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const body = await c.req.json(); + const { tagId } = z.object({ tagId: z.string().uuid("Invalid tag id") }).parse(body); + + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + + const [tag] = await db.select({ id: tagsTable.id, name: tagsTable.name }) + .from(tagsTable) + .where(eq(tagsTable.id, tagId)) + .limit(1); + if (!tag) { + return c.json({ error: { code: "NOT_FOUND", message: "Tag not found" } }, 404); + } + + // Junction table has a composite PK — ignore re-assigns instead of erroring + await db.insert(taskTags).values({ taskId: id, tagId }).onConflictDoNothing(); + + await recordActivity({ + actor: user.name, + action: "tagged", + entityType: "task", + entityId: id, + changes: { tagId, tagName: tag.name }, + workspaceId: task.domainId, + }); + + return c.json({ success: true }, 201); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + if (error instanceof z.ZodError) { + return c.json({ error: { code: "VALIDATION_ERROR", message: "Invalid input", details: error.issues } }, 400); + } + console.error("[tasks] POST /:id/tags error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to assign tag" } }, 500); + } +}); + +// DELETE /api/tasks/:id/tags/:tagId — Remove a tag from a task +taskRoutes.delete("/:id/tags/:tagId", async (c) => { + try { + const user = await requireAuth(c); + const id = c.req.param("id"); + const tagId = c.req.param("tagId"); + + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + + // Junction tables have no deleted_at — hard delete is correct here + await db.delete(taskTags).where(and(eq(taskTags.taskId, id), eq(taskTags.tagId, tagId))); + + await recordActivity({ + actor: user.name, + action: "untagged", + entityType: "task", + entityId: id, + changes: { tagId }, + workspaceId: task.domainId, + }); + + return c.body(null, 204); + } catch (error) { + if (error instanceof AuthError) { + return c.json({ error: { code: error.code, message: error.message } }, error.status as any); + } + console.error("[tasks] DELETE /:id/tags/:tagId error:", error); + return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove tag" } }, 500); + } +}); + // POST /api/tasks/:id/status — Change task status (Kanban drag) taskRoutes.post("/:id/status", async (c) => { try { @@ -460,6 +680,8 @@ taskRoutes.post("/:id/status", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); } + await requireWorkspaceAccess(c, existing.domainId); + const updateValues: Record = { status: newStatus, updatedAt: new Date(), @@ -501,11 +723,23 @@ taskRoutes.get("/:id/history", async (c) => { const user = await requireAuth(c); const id = c.req.param("id"); + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + const history = await db.select() .from(activityFeed) .where(and( eq(activityFeed.entityId, id), eq(activityFeed.entityType, "task"), + eq(activityFeed.workspaceId, task.domainId), )) .orderBy(desc(activityFeed.createdAt)) .limit(100); @@ -526,11 +760,23 @@ taskRoutes.get("/:id/comments", async (c) => { const user = await requireAuth(c); const id = c.req.param("id"); + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + const comments = await db.select() .from(activityFeed) .where(and( eq(activityFeed.entityId, id), eq(activityFeed.entityType, "comment"), + eq(activityFeed.workspaceId, task.domainId), )) .orderBy(asc(activityFeed.createdAt)); @@ -564,6 +810,8 @@ taskRoutes.post("/:id/comments", async (c) => { return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); } + await requireWorkspaceAccess(c, task.domainId); + await recordActivity({ actor: user.name, action: "commented", @@ -592,12 +840,24 @@ taskRoutes.get("/:id/attachments", async (c) => { const user = await requireAuth(c); const id = c.req.param("id"); + const [task] = await db.select({ id: tasks.id, domainId: tasks.domainId }) + .from(tasks) + .where(and(eq(tasks.id, id), isNull(tasks.deletedAt))) + .limit(1); + + if (!task) { + return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404); + } + + await requireWorkspaceAccess(c, task.domainId); + // Attachments are stored in activity feed with entityType=attachment const attachments = await db.select() .from(activityFeed) .where(and( eq(activityFeed.entityId, id), eq(activityFeed.entityType, "attachment"), + eq(activityFeed.workspaceId, task.domainId), )) .orderBy(desc(activityFeed.createdAt)); diff --git a/apps/api/src/routes/webhooks.ts b/apps/api/src/routes/webhooks.ts index 8725f48..a541c00 100644 --- a/apps/api/src/routes/webhooks.ts +++ b/apps/api/src/routes/webhooks.ts @@ -1,8 +1,9 @@ import { Hono } from "hono"; -import { db, webhooks, webhookDeliveries } from "@project-e/db"; +import { db, webhooks } from "@project-e/db"; import { and, asc, desc, eq, sql } from "drizzle-orm"; -import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth"; +import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth"; import { recordActivity } from "../middleware/activity"; +import { enqueueWebhookDelivery } from "../middleware/webhook-queue"; import { z } from "zod"; export const webhookRoutes = new Hono(); @@ -43,6 +44,7 @@ webhookRoutes.get("/", async (c) => { const active = await resolveActiveDomain(user); domainId = active.id; } + await requireWorkspaceAccess(c, domainId); const conditions: any[] = [eq(webhooks.workspaceId, domainId)]; const sortField = sort.replace(/^-/, ""); @@ -73,6 +75,8 @@ webhookRoutes.post("/", async (c) => { domain: body.domain || (await resolveActiveDomain(user)).id, }); + await requireWorkspaceAccess(c, data.domain); + const [webhook] = await db.insert(webhooks).values({ name: data.name, url: data.url, @@ -107,6 +111,8 @@ webhookRoutes.patch("/:id", async (c) => { const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404); + await requireWorkspaceAccess(c, existing.workspaceId); + const updateValues: Record = {}; if (data.name !== undefined) updateValues.name = data.name; if (data.url !== undefined) updateValues.url = data.url; @@ -139,6 +145,8 @@ webhookRoutes.delete("/:id", async (c) => { const [existing] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1); if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404); + await requireWorkspaceAccess(c, existing.workspaceId); + await db.delete(webhooks).where(eq(webhooks.id, id)); await recordActivity({ @@ -162,13 +170,17 @@ webhookRoutes.post("/:id/test", async (c) => { const [webhook] = await db.select().from(webhooks).where(eq(webhooks.id, id)).limit(1); if (!webhook) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404); - const testPayload = { event: "test", data: { message: "This is a test webhook from Project E", timestamp: new Date().toISOString() } }; + await requireWorkspaceAccess(c, webhook.workspaceId); - await db.insert(webhookDeliveries).values({ + // Enqueue a delivery job instead of inserting a delivery row directly — the + // worker performs the delivery and records the webhook_deliveries row. + await enqueueWebhookDelivery({ webhookId: id, event: "test", - payload: testPayload, - status: "pending", + entityType: "test", + entityId: webhook.id, + data: { message: "This is a test webhook from Project E", timestamp: new Date().toISOString() }, + workspaceId: webhook.workspaceId, }); return c.json({ success: true, message: "Test webhook queued" }); diff --git a/apps/web/package.json b/apps/web/package.json index 0b88758..65c6fda 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -64,6 +64,7 @@ "react-force-graph-2d": "^1.29.1", "react-hook-form": "^7.84.0", "recharts": "^3.10.1", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^4.4.3", @@ -73,6 +74,7 @@ "@tanstack/react-query-devtools": "^5.62.0", "@tanstack/react-router-devtools": "^1.167.0", "@types/react": "^19.1.0", + "@types/react-big-calendar": "^1.16.3", "@types/react-dom": "^19.1.0", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.5.2", diff --git a/apps/web/src/components/custom-fields/custom-field-inputs.tsx b/apps/web/src/components/custom-fields/custom-field-inputs.tsx new file mode 100644 index 0000000..7c8320b --- /dev/null +++ b/apps/web/src/components/custom-fields/custom-field-inputs.tsx @@ -0,0 +1,199 @@ +import { useEffect, useRef } from "react"; +import { useApiQuery } from "@/lib/api"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Separator } from "@/components/ui/separator"; +import type { CustomField } from "@/lib/types"; + +export type CustomFieldsValue = Record; + +interface CustomFieldInputsProps { + /** + * Plural entity type used by the custom-fields API, e.g. "tasks" | "habits". + */ + entityType: string; + values: CustomFieldsValue; + onChange: (values: CustomFieldsValue) => void; +} + +/** + * Renders a type-aware input for every custom field defined for an entity. + * The entity payload carries values as `customFields: { [fieldName]: value }`, + * which this component reads and writes via `values` / `onChange`. + * + * Renders nothing when no custom fields are defined for the entity. + */ +export function CustomFieldInputs({ entityType, values, onChange }: CustomFieldInputsProps) { + const activeDomainId = useApiDomain(); + const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>( + ["custom-fields", entityType, activeDomainId], + "/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "") + ); + const fields = data?.items ?? []; + + // Keep the latest values in a ref so the default-seeding effect below never + // clobbers edits the user makes while definitions refetch in the background. + const valuesRef = useRef(values); + valuesRef.current = values; + + // Seed defaults for fields that have no value yet (e.g. a field created after + // the task already existed, or a brand new task with field defaults). + useEffect(() => { + if (fields.length === 0) return; + let changed = false; + const next: CustomFieldsValue = { ...valuesRef.current }; + for (const field of fields) { + if (next[field.name] === undefined && field.defaultValue !== null && field.defaultValue !== undefined) { + next[field.name] = field.defaultValue; + changed = true; + } + } + if (changed) onChange(next); + // `valuesRef.current` is intentionally read (not listed) so the effect only + // runs when the field definitions change. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fields, onChange]); + + if (fields.length === 0) return null; + + return ( +
+ +
+ +
+ {fields.map((field) => ( + onChange({ ...values, [name]: value })} + /> + ))} +
+
+
+ ); +} + +function FieldInput({ + field, + value, + onValueChange, +}: { + field: CustomField; + value: unknown; + onValueChange: (name: string, value: unknown) => void; +}) { + const inputId = "cf-" + field.name; + + if (field.type === "boolean") { + return ( +
+ onValueChange(field.name, v === true)} /> + +
+ ); + } + + if (field.type === "multi_select") { + const selected: string[] = Array.isArray(value) ? value : []; + return ( +
+ +
+ {(field.options ?? []).map((opt) => ( +
+ { + const next = v ? [...selected, opt] : selected.filter((o) => o !== opt); + onValueChange(field.name, next.length > 0 ? next : null); + }} + /> + +
+ ))} +
+
+ ); + } + + return ( +
+ + {renderStandardControl(field, value, inputId, onValueChange)} +
+ ); +} + +function renderStandardControl( + field: CustomField, + value: unknown, + inputId: string, + onValueChange: (name: string, value: unknown) => void +) { + switch (field.type) { + case "number": { + let numeric: number | "" = ""; + if (typeof value === "number") numeric = value; + else if (value !== undefined && value !== null) { + const parsed = Number(value); + numeric = Number.isNaN(parsed) ? "" : parsed; + } + return ( + onValueChange(field.name, e.target.value === "" ? null : Number(e.target.value))} + required={field.required} + /> + ); + } + case "date": { + return ( + onValueChange(field.name, e.target.value || null)} + required={field.required} + /> + ); + } + case "select": { + return ( + + ); + } + default: { + return ( + onValueChange(field.name, e.target.value || null)} + required={field.required} + /> + ); + } + } +} diff --git a/apps/web/src/components/custom-fields/custom-fields-display.tsx b/apps/web/src/components/custom-fields/custom-fields-display.tsx new file mode 100644 index 0000000..176c6bf --- /dev/null +++ b/apps/web/src/components/custom-fields/custom-fields-display.tsx @@ -0,0 +1,62 @@ +import { useApiQuery } from "@/lib/api"; +import { useApiDomain } from "@/lib/stores/use-active-domain-store"; +import type { CustomField } from "@/lib/types"; + +interface CustomFieldsDisplayProps { + /** + * Plural entity type used by the custom-fields API, e.g. "tasks" | "habits". + */ + entityType: string; + values?: Record; +} + +/** + * Read-only list of an entity's custom field values. Uses the field + * definitions for labels and type-aware formatting when they are available; + * falls back to raw `name: value` rendering otherwise. Skips empty values and + * renders nothing when there is nothing to show. + */ +export function CustomFieldsDisplay({ entityType, values }: CustomFieldsDisplayProps) { + const activeDomainId = useApiDomain(); + const { data } = useApiQuery<{ items: CustomField[]; totalItems: number }>( + ["custom-fields", entityType, activeDomainId], + "/custom-fields?entity=" + encodeURIComponent(entityType) + (activeDomainId ? "&domain=" + activeDomainId : "") + ); + + if (!values) return null; + + const defs = new Map((data?.items ?? []).map((f) => [f.name, f])); + const entries = Object.entries(values).filter( + ([, v]) => v !== undefined && v !== null && v !== "" && !(Array.isArray(v) && v.length === 0) + ); + if (entries.length === 0) return null; + + return ( +
+

Custom Fields

+
+ {entries.map(([name, value]) => { + const field = defs.get(name); + return ( +
+ {field?.name ?? name} + {formatValue(field, value)} +
+ ); + })} +
+
+ ); +} + +function formatValue(field: CustomField | undefined, value: unknown): string { + if (field) { + if (field.type === "boolean") return value ? "Yes" : "No"; + if (field.type === "date" && typeof value === "string") { + const d = new Date(value); + return Number.isNaN(d.getTime()) ? value.slice(0, 10) : d.toLocaleDateString(); + } + } + if (Array.isArray(value)) return value.join(", "); + return String(value); +} diff --git a/apps/web/src/components/entities/tag-manager.tsx b/apps/web/src/components/entities/tag-manager.tsx new file mode 100644 index 0000000..8c65cb1 --- /dev/null +++ b/apps/web/src/components/entities/tag-manager.tsx @@ -0,0 +1,96 @@ +import { useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { api, useApiQuery } from "@/lib/api"; +import { Badge } from "@/components/ui/badge"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { X } from "lucide-react"; +import type { Tag, PaginatedResponse } from "@/lib/types"; + +const ENTITY_ROUTES: Record<"task" | "habit" | "note", string> = { + task: "tasks", + habit: "habits", + note: "notes", +}; + +interface TagManagerProps { + entityType: "task" | "habit" | "note"; + entityId: string; + tags: Tag[]; +} + +/** + * Assign/remove tags on an entity from its detail page. The entity's tags come + * from the parent's query data; mutations hit the tag junction endpoints and + * refetch the entity so badges stay in sync with the API. + */ +export function TagManager({ entityType, entityId, tags }: TagManagerProps) { + const queryClient = useQueryClient(); + const [addValue, setAddValue] = useState(""); + const plural = ENTITY_ROUTES[entityType]; + + // Always fetch fresh so tags created elsewhere show up in the add dropdown. + const { data: tagsData } = useApiQuery>( + ["tags"], + "/tags?perPage=100&sort=name", + { staleTime: 0 } + ); + const allTags = tagsData?.items || []; + + const assignedIds = new Set(tags.map((t) => t.id)); + const availableTags = allTags.filter((t) => !assignedIds.has(t.id)); + + const refreshEntity = () => { + queryClient.invalidateQueries({ queryKey: [entityType, entityId] }); + }; + + const assignMutation = useMutation({ + mutationFn: (tagId: string) => api.post(`/${plural}/${entityId}/tags`, { tagId }), + onSuccess: refreshEntity, + }); + + const removeMutation = useMutation({ + mutationFn: (tagId: string) => api.delete(`/${plural}/${entityId}/tags/${tagId}`), + onSuccess: refreshEntity, + }); + + const handleAssign = (tagId: string) => { + if (!tagId || assignedIds.has(tagId)) return; + setAddValue(""); + assignMutation.mutate(tagId); + }; + + return ( +
+

Tags

+
+ {tags.length === 0 && ( + No tags + )} + {tags.map((t) => ( + + {t.name} + + + ))} +
+ +
+ ); +} diff --git a/apps/web/src/components/shell/command-palette.tsx b/apps/web/src/components/shell/command-palette.tsx index 56eabdf..36d1586 100644 --- a/apps/web/src/components/shell/command-palette.tsx +++ b/apps/web/src/components/shell/command-palette.tsx @@ -73,9 +73,9 @@ export function CommandPalette() { const { mode, setMode, accent, setAccent } = useThemeStore(); const [open, setOpen] = useState(false); const [searchResults, setSearchResults] = useState< - Array<{ type: string; items: Array<{ id: string; title: string }> }> + Array<{ type: string; items: Array<{ id: string; title: string; link?: string }> }> >([]); - const searchTimeoutRef = useRef>(); + const searchTimeoutRef = useRef | null>(null); const [isMobile, setIsMobile] = useState(false); useEffect(() => { @@ -178,7 +178,7 @@ export function CommandPalette() { setSearchResults([ { type: "Agents", - items: (data.agents || data.results || []).map((a: { id: string; name: string }) => ({ + items: (data.items || []).map((a: { id: string; name: string }) => ({ id: a.id, title: a.name, })), @@ -311,17 +311,17 @@ export function CommandPalette() { { - const typeRoute = - group.type === "tasks" - ? "/tasks" - : group.type === "habits" - ? "/habits" - : group.type === "projects" - ? "/projects" - : group.type === "notes" - ? "/notes" - : "/search"; - runCommand(() => navigate({ to: `${typeRoute}/${item.id}` })); + // The search API returns singular types ("task", "note", ...) + // and each result carries a ready-made detail link (e.g. + // "/tasks/{id}"). Domains have no detail route, so land on the + // dashboard (the domain-scoped home). Agent mentions have no + // detail page either, so just dismiss the palette. + if (group.type === "Agents") { + runCommand(() => {}); + return; + } + const link = group.type === "domain" ? "/" : item.link!; + runCommand(() => navigate({ to: link })); }} > diff --git a/apps/web/src/components/shell/domain-picker.tsx b/apps/web/src/components/shell/domain-picker.tsx new file mode 100644 index 0000000..e6c5967 --- /dev/null +++ b/apps/web/src/components/shell/domain-picker.tsx @@ -0,0 +1,60 @@ +import { useEffect } from "react"; +import { useApiQuery } from "@/lib/api"; +import { useActiveDomainStore } from "@/lib/stores/use-active-domain-store"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { Domain, PaginatedResponse } from "@/lib/types"; + +export function DomainPicker() { + const activeDomainId = useActiveDomainStore((s) => s.activeDomainId); + const setActiveDomain = useActiveDomainStore((s) => s.setActiveDomain); + + const { data, isLoading } = useApiQuery>(["domains"], "/domains"); + const domains = data?.items || []; + + // The persisted selection may reference a deleted domain — validate against + // the fetched list and fall back to the first domain while unset or stale. + useEffect(() => { + if (domains.length === 0) return; + if (!activeDomainId || !domains.some((d) => d.id === activeDomainId)) { + setActiveDomain(domains[0].id); + } + }, [domains, activeDomainId, setActiveDomain]); + + const empty = isLoading || domains.length === 0; + + return ( +
+ +
+ ); +} diff --git a/apps/web/src/components/shell/sidebar.tsx b/apps/web/src/components/shell/sidebar.tsx index 06cb825..1d4196b 100644 --- a/apps/web/src/components/shell/sidebar.tsx +++ b/apps/web/src/components/shell/sidebar.tsx @@ -1,3 +1,4 @@ +import { useEffect, useState } from "react"; import { Link, useLocation } from "@tanstack/react-router"; import { cn } from "@/lib/utils"; import { useSidebarStore } from "@/lib/stores/use-sidebar-store"; @@ -76,6 +77,24 @@ export function Sidebar() { const location = useLocation(); const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore(); + // Sidebar position (left/right) is set in Settings. Read once on mount and + // update live via the "sidebar-position-change" custom event dispatched by + // the settings page. + const [sidebarPos, setSidebarPos] = useState<"left" | "right">(() => + typeof window !== "undefined" && localStorage.getItem("sidebar-position") === "right" + ? "right" + : "left" + ); + + useEffect(() => { + const onSidebarPositionChange = (event: Event) => { + const detail = (event as CustomEvent).detail; + if (detail === "left" || detail === "right") setSidebarPos(detail); + }; + window.addEventListener("sidebar-position-change", onSidebarPositionChange); + return () => window.removeEventListener("sidebar-position-change", onSidebarPositionChange); + }, []); + const isActive = (href: string) => { if (href === "/") return location.pathname === "/"; return location.pathname.startsWith(href); @@ -114,7 +133,7 @@ export function Sidebar() { return ( {link} - {item.label} + {item.label} ); } @@ -143,8 +162,9 @@ export function Sidebar() {