feat: full plan execution - CI/CD, critical fixes, UX polish, secondary/advanced features, E2E + docs
Phase 0 (CI/CD): fix root typecheck to cover api+worker+web; reconcile migration story into idempotent db:migrate (db:sync + db:triggers); add Gitea Actions quality/deploy/smoke workflow; rewrite README/AGENTS/DEPLOY docs; add requireWorkspaceAccess + recordActivityForEntity conventions. Phase 1 (critical fixes): calendar delete + drag/resize DnD; canvas card CRUD + bulk save + debounced autosave; logout route; graph edge workspaceId derivation; real analytics endpoints (drop Math.random); task board droppable columns + reorder persistence; Tiptap notes editor with sanitized HTML rendering; remove insecure passkey auth; domain/owner scoping (IDOR) on all by-ID routes + search/ export/realtime scoping; command palette routing + agent mention fetch; agent activity SSE handler; graph fly-to with tracked positions. Phase 2 (UX polish): login on design system; Sonner toasts app-wide; shared Loading/Empty/Error state components; working density/sidebarPos/reduce-motion settings; Inter typography; consolidated status-colors lib; unified detail routes; dashboard sort/realtime/responsive fixes; mobile responsive; a11y (radiogroups, sanitized snippets, badge labels). Phase 3 (features): daily notes timezone fix + delete + autosave + mood/energy create; active-domain store + topbar picker; graph domain picker + navigable entity links; tag assign/remove UI + server-side tag filter; real CSV export + import validation; custom fields on tasks. Phase 4 (advanced): migrate job worker into apps/worker (webhook delivery with HMAC, recurring spawn, ai_dispatch disabled); webhook queue helper + entity event enqueuing + test endpoint fix; recurring scheduledJobs pipeline; agents CRUD + permission editing + activity filters; real notifications feed; MCP polish (validation, error codes, domain scoping, dead sql leftover). Phase 5 (E2E + docs): rewrite Playwright suite for the Vite SPA (15 specs, new auth helpers, chromium-only in CI); add ephemeral-Postgres e2e CI job; rewrite docs/API.md for the real Hono API.
This commit is contained in:
@@ -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 '<html' || {
|
||||||
|
echo "ERROR: SPA root did not return HTML"
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
echo "SPA root: HTML OK"
|
||||||
|
|
||||||
|
- name: Login smoke test
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
set -a
|
||||||
|
. /home/projecte/ProjectE/.env
|
||||||
|
set +a
|
||||||
|
CODE="$(curl -s -o /tmp/smoke-login.json -w '%{http_code}' \
|
||||||
|
-X POST http://localhost:3000/api/auth/credentials \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
-d "{\"email\":\"${INITIAL_ADMIN_EMAIL}\",\"password\":\"${INITIAL_ADMIN_PASSWORD}\"}" \
|
||||||
|
|| true)"
|
||||||
|
echo "Login HTTP status: ${CODE}"
|
||||||
|
cat /tmp/smoke-login.json
|
||||||
|
if [ "${CODE}" != "200" ]; then
|
||||||
|
echo "ERROR: Login smoke test failed (expected HTTP 200)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Login smoke test: OK"
|
||||||
@@ -1,5 +1,14 @@
|
|||||||
# AGENTS.md — Project E v2 Agent Contract
|
# AGENTS.md — Project E v2 Agent Contract
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Monorepo: Turborepo + Bun workspaces, `packageManager bun@1.3.14`. Use `bun` for every command. Do not use `npm`.
|
||||||
|
- `apps/web`: Vite + React 19 SPA (TanStack Router/Query, shadcn/ui, Tailwind). Dev server on :3000 proxies `/api` and `/mcp` to :3001.
|
||||||
|
- `apps/api`: Hono API server on Bun, port 3001 (Docker) / 3000 via the Vite proxy in dev.
|
||||||
|
- `apps/worker`: Bun background worker (`src/index.ts`). The root `worker/` directory is legacy and NOT used.
|
||||||
|
- `apps/web-legacy`: legacy v1 app, kept for reference only. Do not edit.
|
||||||
|
- `packages/db`: Drizzle ORM + `postgres` client. `packages/shared`: shared types, schemas, constants.
|
||||||
|
|
||||||
## Core Rules
|
## Core Rules
|
||||||
|
|
||||||
Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern:
|
Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern:
|
||||||
@@ -8,6 +17,13 @@ Every API route that writes data (INSERT/UPDATE/DELETE) MUST follow this pattern
|
|||||||
2. **Activity feed insert** — Call `recordActivity()` with actor, action, entity_type, entity_id, changes, workspace_id
|
2. **Activity feed insert** — Call `recordActivity()` with actor, action, entity_type, entity_id, changes, workspace_id
|
||||||
3. **pg_notify** — `recordActivity()` handles this automatically via `pg.notify('project_e_events', payload)`
|
3. **pg_notify** — `recordActivity()` handles this automatically via `pg.notify('project_e_events', payload)`
|
||||||
|
|
||||||
|
## Shared Helpers
|
||||||
|
|
||||||
|
Both live in `apps/api/src/middleware/`. Use them; do not re-implement.
|
||||||
|
|
||||||
|
- `requireWorkspaceAccess(c, workspaceId)` (`middleware/auth.ts`) — verifies a workspace exists and the current user owns it. Returns the domain row. Throws 403 FORBIDDEN when the id is missing/empty or not owned, 404 NOT_FOUND when no such workspace exists. Call it at the top of every workspace-scoped route.
|
||||||
|
- `recordActivityForEntity({ actor, action, entityType, entityId, changes, workspaceId })` (`middleware/activity.ts`) — same as `recordActivity()` but resolves the workspace from the entity row when `workspaceId` is omitted. Unknown entity types or unresolvable entities are logged and skipped, never fatal to the request.
|
||||||
|
|
||||||
## Soft-Delete Only
|
## Soft-Delete Only
|
||||||
|
|
||||||
- Never use SQL `DELETE` on user data tables
|
- Never use SQL `DELETE` on user data tables
|
||||||
@@ -38,9 +54,17 @@ Standard codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `C
|
|||||||
- Every entity table has a `domain_id` FK (or `workspace_id` for activity_feed/webhooks)
|
- Every entity table has a `domain_id` FK (or `workspace_id` for activity_feed/webhooks)
|
||||||
- Use `requireWorkspaceAccess(workspaceId)` to verify the workspace exists
|
- Use `requireWorkspaceAccess(workspaceId)` to verify the workspace exists
|
||||||
|
|
||||||
|
## CI/CD Contract
|
||||||
|
|
||||||
|
- CI runs on Gitea Actions (`.gitea/workflows/ci.yml`) on the self-hosted runner `projecte-runner`.
|
||||||
|
- Every push and pull request must pass the `quality` job: `bun install --frozen-lockfile` → `bun run typecheck` → web build (`cd apps/web && bun run build`) → `docker compose build`.
|
||||||
|
- A push to `main` (or a manual `workflow_dispatch`) triggers `deploy`, which runs `bash script/deploy.sh`. The `smoke` job then checks API health, SPA HTML, and login.
|
||||||
|
- Do not commit build artifacts (`.next/`, `dist/`, `.turbo/`, `*.tsbuildinfo`).
|
||||||
|
|
||||||
## Build Before Commit
|
## Build Before Commit
|
||||||
|
|
||||||
- Run `npm run build --workspace=apps/web` before committing
|
- Run `bun run typecheck` (typechecks api, worker, and web)
|
||||||
|
- Run `cd apps/web && bun run build` to verify the SPA builds
|
||||||
- Do NOT commit build artifacts (`.next/`, `dist/`, `.turbo/`)
|
- Do NOT commit build artifacts (`.next/`, `dist/`, `.turbo/`)
|
||||||
|
|
||||||
## Schema
|
## Schema
|
||||||
@@ -50,6 +74,11 @@ Standard codes: `VALIDATION_ERROR`, `NOT_FOUND`, `UNAUTHORIZED`, `FORBIDDEN`, `C
|
|||||||
- Use Drizzle ORM for all database operations
|
- Use Drizzle ORM for all database operations
|
||||||
- Never write raw SQL except for `pg_notify` calls
|
- Never write raw SQL except for `pg_notify` calls
|
||||||
|
|
||||||
|
## Migrations
|
||||||
|
|
||||||
|
- Migrations live in `drizzle/` (0000–0005). Generate a new one with `bun run db:generate` after editing the schema.
|
||||||
|
- `bun run db:migrate` is the deploy-time migration: `db:sync` (`drizzle-kit push --force`) then `db:triggers` (`script/apply-triggers.ts`, search-vector triggers). Both are idempotent; safe to run on every deploy.
|
||||||
|
|
||||||
## Realtime
|
## Realtime
|
||||||
|
|
||||||
- SSE endpoint at `/api/realtime` uses PostgreSQL LISTEN/NOTIFY
|
- SSE endpoint at `/api/realtime` uses PostgreSQL LISTEN/NOTIFY
|
||||||
|
|||||||
@@ -1,39 +1,109 @@
|
|||||||
# Project E — Deploy Guide
|
# Project E — Deploy Guide
|
||||||
|
|
||||||
## How to redeploy
|
Production runs as a docker-compose stack on the deploy host (`10.0.0.204`). The stack has four services: PostgreSQL, the Hono API, the Vite SPA served by Caddy, and the Bun worker. Deploys are normally driven by Gitea Actions, but every step below can be run by hand.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Internet → :3000 (SPA container, Caddy)
|
||||||
|
├── /api/* → api:3000 (Hono/Bun)
|
||||||
|
├── /mcp* → api:3000 (Hono/Bun)
|
||||||
|
└── /* → index.html (SPA fallback)
|
||||||
|
|
||||||
|
API (:3001, direct) → PostgreSQL (:5432)
|
||||||
|
Worker → PostgreSQL
|
||||||
|
```
|
||||||
|
|
||||||
|
Caddy (in the `spa` container) serves the built SPA and reverse-proxies `/api/*` and `/mcp*` to the `api` service. The API is also exposed directly on :3001 for debugging.
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
| Service | Container | Image / Build | Ports | Notes |
|
||||||
|
|---------|-----------|---------------|-------|-------|
|
||||||
|
| `db` | `project-e-db` | `postgres:16-alpine` | 5432 | Data in the `project-e-pg-data` volume |
|
||||||
|
| `api` | `project-e-api` | `Dockerfile.api` | 3001 → 3000 | Hono on Bun |
|
||||||
|
| `spa` | `project-e-spa` | `Dockerfile.spa` | 3000 → 80 | Built Vite SPA + Caddy |
|
||||||
|
| `worker` | `project-e-worker` | `Dockerfile.worker` | none | Bun worker |
|
||||||
|
|
||||||
|
All services share the `project-e-network` bridge and restart unless stopped.
|
||||||
|
|
||||||
|
## CI/CD pipeline (Gitea Actions)
|
||||||
|
|
||||||
|
The workflow lives at `.gitea/workflows/ci.yml` and runs on the self-hosted runner `projecte-runner`, which is colocated with the deploy host.
|
||||||
|
|
||||||
|
- **`quality`** — runs on every push and pull request: `bun install --frozen-lockfile` → `bun run typecheck` → web build (`cd apps/web && bun run build`) → `docker compose build`. A failed quality gate blocks the deploy job.
|
||||||
|
- **`deploy`** — runs on pushes to `main` and on `workflow_dispatch`. It checks out the code and runs `bash script/deploy.sh` with `DEPLOY_DIR` defaulting to `/home/projecte/ProjectE`.
|
||||||
|
- **`smoke`** — runs after `deploy` (also on deploy failure): API health at `http://localhost:3000/api/health`, SPA root returns HTML, and a login POST to `/api/auth/credentials` using `INITIAL_ADMIN_EMAIL`/`INITIAL_ADMIN_PASSWORD` from the host `.env`.
|
||||||
|
|
||||||
|
### What `script/deploy.sh` does
|
||||||
|
|
||||||
|
Idempotent, safe to re-run. It:
|
||||||
|
|
||||||
|
1. Syncs the CI checkout into `DEPLOY_DIR` (rsync, excluding `.git`, `node_modules`, build artifacts, and `.env`)
|
||||||
|
2. Loads secrets from the host `.env` (never overwrites it)
|
||||||
|
3. Installs dependencies with `bun install --frozen-lockfile`
|
||||||
|
4. Runs `bun run db:migrate`
|
||||||
|
5. Runs `docker compose build` and `docker compose up -d`
|
||||||
|
6. Waits up to 60s for `http://localhost:3000/api/health` to return HTTP 200, dumping recent API logs if it times out
|
||||||
|
|
||||||
|
## Secrets
|
||||||
|
|
||||||
|
Secrets live in the host `.env` at `/home/projecte/ProjectE/.env`. This file is gitignored; never commit it. To set it up:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ~/ProjectE
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
Fill in `POSTGRES_PASSWORD`, `DATABASE_URL`, `AUTH_SECRET` (or `NEXTAUTH_SECRET`), `INITIAL_ADMIN_EMAIL`, `INITIAL_ADMIN_PASSWORD`, and, as needed, `NODE_ENV`, `PUBLIC_URL`, `COOKIE_SECURE`, and `ALLOWED_HOSTS`. `deploy.sh` sources it, and docker-compose reads the `POSTGRES_PASSWORD` and `DATABASE_URL` values from it.
|
||||||
|
|
||||||
|
## Manual deploy
|
||||||
|
|
||||||
|
On the deploy host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/projecte/ProjectE
|
||||||
|
|
||||||
# Pull latest
|
# Pull latest
|
||||||
git pull origin redesign/ui-v2
|
git pull origin main
|
||||||
|
|
||||||
# Rebuild images
|
# Install dependencies
|
||||||
|
bun install
|
||||||
|
|
||||||
|
# Apply schema + triggers (idempotent)
|
||||||
|
bun run db:migrate
|
||||||
|
|
||||||
|
# Build images
|
||||||
docker compose build
|
docker compose build
|
||||||
|
|
||||||
# Restart stack
|
# Restart the stack
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|
||||||
|
# Wait for API health
|
||||||
|
until curl -s http://localhost:3000/api/health | grep -q '"status"'; do sleep 2; done
|
||||||
|
|
||||||
# Check status
|
# Check status
|
||||||
docker compose ps
|
docker compose ps
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to roll back
|
## Database migrations
|
||||||
|
|
||||||
If the new stack fails:
|
`bun run db:migrate` chains two idempotent steps:
|
||||||
|
|
||||||
```bash
|
- `db:sync` — `drizzle-kit push --force`, which syncs the schema in `packages/db/src/schema.ts` to the database
|
||||||
cd ~/ProjectE
|
- `db:triggers` — `script/apply-triggers.ts`, which applies the search-vector triggers from `drizzle/0005_search_vector_trigger.sql` (`CREATE OR REPLACE FUNCTION` + `DROP TRIGGER IF EXISTS`)
|
||||||
|
|
||||||
# Stop the new stack
|
Both are safe to run on every deploy. Schema changes go through `bun run db:generate` in development, then land in `drizzle/` before the next deploy.
|
||||||
docker compose down
|
|
||||||
|
|
||||||
# Restart the old worker (Node) from the legacy compose
|
## Rollback
|
||||||
# (The old compose file is preserved in git history)
|
|
||||||
# docker compose -f docker-compose.legacy.yml up -d worker
|
|
||||||
```
|
|
||||||
|
|
||||||
## How to view logs
|
Compose images are rebuilt from the checkout, so there are no pinned image tags to restore. To roll back a bad release:
|
||||||
|
|
||||||
|
1. Revert the checkout to the previous good commit: `git revert <sha>` (or `git checkout <sha>`) 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
|
```bash
|
||||||
# All services
|
# All services
|
||||||
@@ -46,174 +116,56 @@ docker compose logs --tail=50 -f worker
|
|||||||
docker compose logs --tail=50 -f db
|
docker compose logs --tail=50 -f db
|
||||||
```
|
```
|
||||||
|
|
||||||
## How to debug
|
## Debugging
|
||||||
|
|
||||||
|
### API health (direct, :3001)
|
||||||
|
|
||||||
### API health check
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3001/api/health
|
curl http://localhost:3001/api/health
|
||||||
```
|
```
|
||||||
|
|
||||||
### SPA health check
|
Returns `{"status":"ok", ...}` with a database ping (`database.connected`, `database.ping_ms`).
|
||||||
```bash
|
|
||||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
|
### API health (through Caddy)
|
||||||
```
|
|
||||||
|
|
||||||
### API through reverse proxy
|
|
||||||
```bash
|
```bash
|
||||||
curl http://localhost:3000/api/health
|
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
|
### SPA health check
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
|
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
|
### Login test
|
||||||
|
|
||||||
```bash
|
```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" \
|
-H "Content-Type: application/json" \
|
||||||
-d password:<password> | \
|
-d '{"email":"<INITIAL_ADMIN_EMAIL>","password":"<INITIAL_ADMIN_PASSWORD>"}'
|
||||||
python3 -c "import sys,json; print(json.load(sys.stdin).get(token,))")
|
|
||||||
echo "Token: $TOKEN"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check container health
|
Expect HTTP 200 and a `session` cookie.
|
||||||
|
|
||||||
|
### Container health
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
docker compose restart api
|
docker compose restart api
|
||||||
docker compose restart spa
|
|
||||||
```
|
|
||||||
|
|
||||||
### Rebuild a single service
|
|
||||||
```bash
|
|
||||||
docker compose build spa
|
docker compose build spa
|
||||||
docker compose up -d --force-recreate 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
|
## Important notes
|
||||||
|
|
||||||
- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.**
|
- 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.
|
||||||
- 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).
|
||||||
- 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 MCP endpoint requires a valid API key (separate from JWT auth).
|
||||||
|
- The root `worker/` directory is legacy. The active worker is `apps/worker`.
|
||||||
|
|||||||
@@ -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 <Toaster/>; 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 <mark>), 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.
|
||||||
@@ -1,274 +1,209 @@
|
|||||||
# Project E
|
# 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
|
## Features
|
||||||
|
|
||||||
- **Tasks:** Kanban boards, priorities, due dates, subtasks, time tracking, recurring tasks, dependencies, and attachments
|
- **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
|
- **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
|
- **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
|
- **Notes:** Rich text editor with wikilinks, note graph visualization, and bookmarks
|
||||||
- **Reports:** Weekly, monthly, project, and habit reports with templates and AI-assisted generation
|
- **Reports:** Weekly, monthly, project, and habit reports with templates
|
||||||
- **Milestones:** Plan project phases, set dependencies, and track completion
|
- **Milestones:** Plan project phases, set dependencies, and track completion
|
||||||
- **Domains & Tags:** Organize everything across life domains (work, personal, health) with flexible tagging
|
- **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
|
- **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
|
- **Webhooks:** Subscribe to events, deliver payloads with HMAC signatures, and track delivery history
|
||||||
- **Analytics:** Task completion rates, habit consistency, time summaries, and streak tracking
|
- **Analytics:** Task completion rates, habit consistency, time summaries, and streak tracking
|
||||||
- **Realtime:** Server-sent events proxy keeps the UI in sync across devices
|
- **Realtime:** SSE feed backed by PostgreSQL LISTEN/NOTIFY keeps the UI in sync across devices
|
||||||
- **Background Worker:** Processes webhook deliveries, agent mentions, report generation, recurring tasks, and data cleanup
|
- **Background Worker:** Bun process for webhook deliveries, agent mentions, recurring tasks, and data cleanup
|
||||||
- **MCP Server:** 61 tools for AI agents to read and write data through the Model Context Protocol
|
- **MCP Server:** 18 tools for AI agents to read and write data through the Model Context Protocol
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌───────────────────────────────────────────────────────────────┐
|
||||||
│ Frontend (Next.js) │
|
│ Frontend (Vite SPA) │
|
||||||
│ React 19 · App Router · shadcn/ui · Tailwind · Zustand │
|
│ React 19 · TanStack Router/Query · shadcn/ui · Tailwind │
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
└──────────────────────────┬────────────────────────────────────┘
|
||||||
│ REST API + SSE
|
│ REST API + SSE
|
||||||
┌──────────────────────────▼──────────────────────────────────┐
|
│ (Vite dev proxy → :3001)
|
||||||
│ API Layer (Next.js Routes) │
|
┌──────────────────────────▼────────────────────────────────────┐
|
||||||
│ Auth · Validation (Zod) · Realtime SSE Proxy · MCP Server │
|
│ API (Hono on Bun, apps/api) │
|
||||||
└──────────────────────────┬──────────────────────────────────┘
|
│ Auth (JWT) · Routes · Realtime SSE · MCP server · Webhooks │
|
||||||
│ Drizzle ORM
|
└──────────────────────────┬────────────────────────────────────┘
|
||||||
┌──────────────────────────▼──────────────────────────────────┐
|
│ Drizzle ORM (postgres driver)
|
||||||
│ Data Layer (PostgreSQL + Drizzle ORM) │
|
┌──────────────────────────▼────────────────────────────────────┐
|
||||||
│ PostgreSQL · Drizzle migrations · NextAuth credentials │
|
│ PostgreSQL 16 + Drizzle ORM │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
│ Schema in packages/db/src/schema.ts · migrations in drizzle/ │
|
||||||
|
└────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌────────────────────────────────────────────────────────────────┐
|
||||||
│ Background Worker │
|
│ Background Worker (Bun, apps/worker) │
|
||||||
│ Webhook Delivery · Agent Mentions · Report Generation │
|
│ Webhook delivery · Agent mentions · Recurring tasks · │
|
||||||
│ Recurring Tasks · Data Cleanup │
|
│ Data cleanup │
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Layer | Technology |
|
| 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 |
|
| UI Components | shadcn/ui, Radix UI, Lucide icons |
|
||||||
| Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority |
|
| Styling | Tailwind CSS 3.4, tailwind-merge, class-variance-authority |
|
||||||
| State Management | Zustand 5 |
|
| State Management | Zustand 5 |
|
||||||
| Rich Text | Tiptap 3 |
|
| 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 |
|
| Calendar | react-big-calendar, date-fns |
|
||||||
| Charts | Recharts 3 |
|
| Charts | Recharts 3 |
|
||||||
| Graph Visualization | react-force-graph-2d |
|
| Graph Visualization | react-force-graph-2d |
|
||||||
| Drag & Drop | @dnd-kit |
|
| Drag & Drop | @dnd-kit |
|
||||||
| Backend | Next.js API routes (App Router) |
|
| Backend | Hono 4 on Bun 1.3 (`apps/api`) |
|
||||||
| Database | PostgreSQL 16 with Drizzle ORM |
|
| Database | PostgreSQL 16 with Drizzle ORM and the `postgres` driver |
|
||||||
| Authentication | NextAuth 4 with credentials authentication |
|
| Authentication | JWT (jose) in an httpOnly session cookie; API keys for agents |
|
||||||
| Background Jobs | Node.js worker with polling and exponential backoff |
|
| Background Jobs | Bun worker (`apps/worker`) |
|
||||||
| MCP Server | @modelcontextprotocol/sdk 1.29 |
|
| MCP Server | JSON-RPC over HTTP at `/api/mcp`, served by the API |
|
||||||
| Monorepo | Turborepo 2.5, npm workspaces |
|
| Monorepo | Turborepo 2.5, Bun workspaces |
|
||||||
| Testing | Jest (unit/component), Playwright 1.61 (E2E) |
|
| Testing | Playwright (E2E in `e2e/`) |
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- **Node.js** 22.13.0 or later
|
- **Bun** 1.3.14 or later (the repo pins `bun@1.3.14`)
|
||||||
- **npm** 10.0.0 or later
|
- **PostgreSQL** 16 (local install, or the Docker container from the compose file)
|
||||||
- **PostgreSQL** 16 or later
|
- **Docker + Docker Compose** for the production stack (see [DEPLOY.md](DEPLOY.md))
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Development Setup
|
|
||||||
|
|
||||||
1. **Clone the repository**
|
1. **Clone the repository**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone <repository-url>
|
git clone https://git.buzzbee.dev/Vibing/ProjectE.git
|
||||||
cd ProjectE
|
cd ProjectE
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Install dependencies**
|
2. **Install dependencies**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
bun install
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Create the database**
|
3. **Start PostgreSQL**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
createuser -P project_e
|
docker compose up -d db
|
||||||
createdb -O project_e project_e
|
```
|
||||||
```
|
|
||||||
|
Or point `DATABASE_URL` at an existing PostgreSQL 16 instance.
|
||||||
|
|
||||||
4. **Set environment variables**
|
4. **Set environment variables**
|
||||||
|
|
||||||
Create a `.env.local` file in the root:
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
```bash
|
Fill in `DATABASE_URL`, `POSTGRES_PASSWORD`, `AUTH_SECRET`, `INITIAL_ADMIN_EMAIL`, and `INITIAL_ADMIN_PASSWORD`.
|
||||||
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.
|
|
||||||
|
|
||||||
5. **Apply the database schema**
|
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
|
```bash
|
||||||
npm run dev
|
bun run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
This starts all packages via Turborepo:
|
Pushes the schema (`drizzle-kit push --force`) and applies the search-vector triggers. Idempotent, safe to re-run.
|
||||||
- Web app at `http://localhost:3000`
|
|
||||||
- Worker (if configured)
|
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**
|
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 Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
project-e/
|
project-e/
|
||||||
├── apps/
|
├── apps/
|
||||||
│ └── web/ # Next.js application
|
│ ├── api/ # Hono + Bun API server (routes, middleware, auth)
|
||||||
│ ├── app/ # App Router pages and API routes
|
│ ├── web/ # Vite + React 19 SPA (TanStack Router, shadcn/ui)
|
||||||
│ │ ├── (auth)/ # Auth pages (login, signup)
|
│ ├── worker/ # Bun background worker (src/index.ts)
|
||||||
│ │ ├── (dashboard)/ # Dashboard pages
|
│ └── web-legacy/ # Old Next.js app, kept for reference only
|
||||||
│ │ ├── 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
|
|
||||||
├── packages/
|
├── packages/
|
||||||
│ ├── db/ # Drizzle schema and PostgreSQL client
|
│ ├── db/ # Drizzle schema, client, and ORM access
|
||||||
│ └── shared/ # Shared package
|
│ └── shared/ # Shared types, schemas, and constants
|
||||||
│ └── src/
|
├── drizzle/ # Drizzle migrations (0000–0005)
|
||||||
│ ├── schemas/ # Zod validation schemas
|
├── script/ # deploy.sh, apply-triggers.ts
|
||||||
│ ├── types/ # TypeScript types
|
├── e2e/ # Playwright E2E tests
|
||||||
│ └── constants/ # Shared constants
|
├── .gitea/workflows/ # Gitea Actions CI/CD (ci.yml)
|
||||||
├── drizzle/ # Generated PostgreSQL migrations
|
├── docker-compose.yml # Production stack (db, api, spa, worker)
|
||||||
├── worker/
|
├── Caddyfile # SPA serving + /api reverse proxy
|
||||||
│ └── index.ts # Background job worker
|
├── Dockerfile.api # API image
|
||||||
├── e2e/ # Playwright E2E tests
|
├── Dockerfile.spa # SPA build + Caddy image
|
||||||
├── tests/ # Unit and component tests
|
├── Dockerfile.worker # Worker image
|
||||||
├── drizzle.config.ts # Drizzle Kit configuration
|
├── bunfig.toml
|
||||||
├── turbo.json # Turborepo configuration
|
├── drizzle.config.ts
|
||||||
└── package.json # Root package.json
|
└── package.json
|
||||||
```
|
```
|
||||||
|
|
||||||
## Available Scripts
|
## Available Scripts
|
||||||
|
|
||||||
| Command | Description |
|
| Command | Description |
|
||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `npm run dev` | Start all packages in development mode |
|
| `bun run dev` | Run the API (watch) and the Vite dev server concurrently |
|
||||||
| `npm run build` | Build all packages for production |
|
| `bun run dev:api` | API server with watch on :3001 |
|
||||||
| `npm run lint` | Run linting across all packages |
|
| `bun run dev:web` | Vite dev server on :3000 |
|
||||||
| `npm run test` | Run unit and component tests (Jest) |
|
| `bun run build` | Production build of the web SPA (`vite build`) |
|
||||||
| `npm run test:e2e` | Run Playwright E2E tests |
|
| `bun run typecheck` | `tsc --noEmit` across api, worker, and web |
|
||||||
| `npm run test:e2e:ui` | Run Playwright tests with UI mode |
|
| `bun run db:push` | Push schema changes to the database (`drizzle-kit push`) |
|
||||||
| `npm run test:e2e:report` | Show Playwright test report |
|
| `bun run db:sync` | Push schema with `--force` (idempotent) |
|
||||||
| `npm run typecheck` | Run TypeScript type checking |
|
| `bun run db:generate` | Generate a new Drizzle migration from schema changes |
|
||||||
| `npm run db:generate` | Generate Drizzle migrations |
|
| `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
|
## Environment Variables
|
||||||
|
|
||||||
|
Variables live in a root `.env` file (not `.env.local`). Copy from `.env.example`.
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
|----------|-------------|---------|
|
||||||
| `DATABASE_URL` | PostgreSQL connection string | (required) |
|
| `DATABASE_URL` | PostgreSQL connection string | (required) |
|
||||||
| `POSTGRES_PASSWORD` | Password for the `project_e` PostgreSQL user | (required) |
|
| `POSTGRES_PASSWORD` | Password for the `project_e` user (used by docker-compose) | (required) |
|
||||||
| `NEXTAUTH_SECRET` | Secret used to sign NextAuth sessions | (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_EMAIL` | Email for the account created on first sign-in | (required) |
|
||||||
| `INITIAL_ADMIN_PASSWORD` | Password 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` |
|
| `NODE_ENV` | `development` or `production` | `development` |
|
||||||
|
| `PUBLIC_URL` | Absolute URL used for emails and webhooks | `http://localhost:3000` |
|
||||||
Create a `.env.local` file in the root directory for local development.
|
| `COOKIE_SECURE` | Set `true` behind HTTPS | `false` |
|
||||||
|
| `ALLOWED_HOSTS` | Comma-separated list of allowed hostnames | `localhost` |
|
||||||
|
|
||||||
## Testing
|
## 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
|
```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.
|
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.
|
||||||
|
|
||||||
### 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.
|
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
### Environment Configuration
|
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).
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -280,36 +215,11 @@ Use `GET /api/health` to check the web app.
|
|||||||
|
|
||||||
## Contributing
|
## Contributing
|
||||||
|
|
||||||
1. Fork the repository
|
1. Fork the repository on Gitea and create a feature branch
|
||||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
2. Make your changes
|
||||||
3. Make your changes
|
3. Run `bun run typecheck` and `cd apps/web && bun run build` before committing
|
||||||
4. Run tests (`npm run test && npm run test:e2e`)
|
4. Push the branch and open a pull request. CI runs the same quality gate on every push and PR.
|
||||||
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"
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
This project is private and proprietary.
|
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
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { customFieldRoutes } from "./routes/custom-fields";
|
|||||||
import { errorLogRoutes } from "./routes/error-log";
|
import { errorLogRoutes } from "./routes/error-log";
|
||||||
import { analyticsRoutes } from "./routes/analytics";
|
import { analyticsRoutes } from "./routes/analytics";
|
||||||
import { importExportRoutes } from "./routes/import-export";
|
import { importExportRoutes } from "./routes/import-export";
|
||||||
|
import { notificationRoutes } from "./routes/notifications";
|
||||||
import { healthHandler } from "./routes/health";
|
import { healthHandler } from "./routes/health";
|
||||||
|
|
||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
@@ -57,6 +58,7 @@ app.route("/api/tags", tagRoutes);
|
|||||||
app.route("/api/custom-fields", customFieldRoutes);
|
app.route("/api/custom-fields", customFieldRoutes);
|
||||||
app.route("/api/error-log", errorLogRoutes);
|
app.route("/api/error-log", errorLogRoutes);
|
||||||
app.route("/api/analytics", analyticsRoutes);
|
app.route("/api/analytics", analyticsRoutes);
|
||||||
|
app.route("/api/notifications", notificationRoutes);
|
||||||
app.route("/api", importExportRoutes);
|
app.route("/api", importExportRoutes);
|
||||||
app.route("/api", realtimeRoutes);
|
app.route("/api", realtimeRoutes);
|
||||||
app.route("/api/mcp", mcpRoutes);
|
app.route("/api/mcp", mcpRoutes);
|
||||||
|
|||||||
@@ -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 {
|
export interface RecordActivityParams {
|
||||||
actor: string;
|
actor: string;
|
||||||
@@ -24,3 +41,79 @@ export async function recordActivity(params: RecordActivityParams): Promise<void
|
|||||||
const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId });
|
const payload = JSON.stringify({ type: entityType, action, id: entityId, workspace_id: workspaceId });
|
||||||
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
await sql`SELECT pg_notify('project_e_events', ${payload}::text)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── recordActivityForEntity ────────────────────────────────────────────────────
|
||||||
|
// Variant that derives the workspaceId from the entity itself when the caller
|
||||||
|
// omits it. Keeps `recordActivity` unchanged so existing call sites compile as-is.
|
||||||
|
|
||||||
|
export interface RecordActivityForEntityParams {
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
changes?: Record<string, unknown>;
|
||||||
|
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<string, EntityWorkspaceLookup> = {
|
||||||
|
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<string | null> {
|
||||||
|
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<void> {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ import { createMiddleware } from "hono/factory";
|
|||||||
import type { Context, Next } from "hono";
|
import type { Context, Next } from "hono";
|
||||||
import { jwtVerify, SignJWT } from "jose";
|
import { jwtVerify, SignJWT } from "jose";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { db, users, apiKeys } from "@project-e/db";
|
import { db, users, apiKeys, domains } from "@project-e/db";
|
||||||
import { and, eq } from "drizzle-orm";
|
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 AUTH_SECRET = new TextEncoder().encode(process.env.AUTH_SECRET || process.env.NEXTAUTH_SECRET || "fallback-secret-change-me");
|
||||||
const COOKIE_NAME = "session";
|
const COOKIE_NAME = "session";
|
||||||
@@ -101,10 +101,44 @@ export async function requireAuth(c: Context): Promise<AuthUser> {
|
|||||||
return user;
|
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");
|
* Require access to a workspace (domain). Verifies the workspace exists and,
|
||||||
const { asc } = await import("drizzle-orm");
|
* 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<typeof domains.$inferSelect> {
|
||||||
|
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
|
const [existing] = await db
|
||||||
.select({ id: domains.id, name: domains.name })
|
.select({ id: domains.id, name: domains.name })
|
||||||
.from(domains)
|
.from(domains)
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
workspaceId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}): Promise<void> {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+102
-10
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, agents, agentActivity, agentTasks } from "@project-e/db";
|
import { db, agents, agentActivity, agentTasks } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, isNull, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, gte, ilike, isNull, lte, sql } 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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -30,6 +30,33 @@ const updateAgentSchema = z.object({
|
|||||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
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
|
// GET /api/agents — List agents
|
||||||
agentRoutes.get("/", async (c) => {
|
agentRoutes.get("/", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -38,13 +65,16 @@ agentRoutes.get("/", async (c) => {
|
|||||||
const page = Math.max(1, parseInt(url.searchParams.get("page") || "1"));
|
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 perPage = Math.min(100, Math.max(1, parseInt(url.searchParams.get("perPage") || "50")));
|
||||||
const sort = url.searchParams.get("sort") || "-created";
|
const sort = url.searchParams.get("sort") || "-created";
|
||||||
|
const q = url.searchParams.get("q")?.trim();
|
||||||
let domainId = url.searchParams.get("domain") || undefined;
|
let domainId = url.searchParams.get("domain") || undefined;
|
||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [eq(agents.domainId, domainId)];
|
const conditions: any[] = [eq(agents.domainId, domainId)];
|
||||||
|
if (q) conditions.push(ilike(agents.name, `%${q}%`));
|
||||||
const sortField = sort.replace(/^-/, "");
|
const sortField = sort.replace(/^-/, "");
|
||||||
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
const sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||||
const sortColumns: Record<string, any> = { created: agents.createdAt, updated: agents.updatedAt, name: agents.name };
|
const sortColumns: Record<string, any> = { created: agents.createdAt, updated: agents.updatedAt, name: agents.name };
|
||||||
@@ -73,6 +103,8 @@ agentRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [agent] = await db.insert(agents).values({
|
const [agent] = await db.insert(agents).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description ?? null,
|
description: data.description ?? null,
|
||||||
@@ -104,11 +136,27 @@ agentRoutes.post("/", async (c) => {
|
|||||||
// GET /api/agents/activity — All activity (bare path, no agent filter)
|
// GET /api/agents/activity — All activity (bare path, no agent filter)
|
||||||
agentRoutes.get("/activity", async (c) => {
|
agentRoutes.get("/activity", async (c) => {
|
||||||
try {
|
try {
|
||||||
await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const items = await db.select()
|
// 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)
|
.from(agentActivity)
|
||||||
|
.innerJoin(agents, eq(agentActivity.agentId, agents.id))
|
||||||
|
.where(and(eq(agents.domainId, domainId), ...filterConditions))
|
||||||
.orderBy(desc(agentActivity.createdAt))
|
.orderBy(desc(agentActivity.createdAt))
|
||||||
.limit(100);
|
.limit(limit);
|
||||||
return c.json({ items, totalItems: items.length });
|
return c.json({ items, totalItems: items.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
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);
|
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);
|
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||||
|
await requireWorkspaceAccess(c, agent.domainId);
|
||||||
return c.json(agent);
|
return c.json(agent);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
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);
|
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);
|
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.name !== undefined) updateValues.name = data.name;
|
if (data.name !== undefined) updateValues.name = data.name;
|
||||||
if (data.description !== undefined) updateValues.description = data.description;
|
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);
|
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);
|
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 db.delete(agents).where(eq(agents.id, id));
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -211,6 +264,10 @@ agentRoutes.post("/:id/permissions", async (c) => {
|
|||||||
customPermissions: z.array(z.string()).optional().default([]),
|
customPermissions: z.array(z.string()).optional().default([]),
|
||||||
}).parse(body);
|
}).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)
|
const [updated] = await db.update(agents)
|
||||||
.set({ permissionTier, customPermissions: customPermissions ?? [], updatedAt: new Date() })
|
.set({ permissionTier, customPermissions: customPermissions ?? [], updatedAt: new Date() })
|
||||||
.where(eq(agents.id, id))
|
.where(eq(agents.id, id))
|
||||||
@@ -236,11 +293,12 @@ agentRoutes.get("/:id/permissions", async (c) => {
|
|||||||
await requireAuth(c);
|
await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const [agent] = await db.select({
|
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);
|
}).from(agents).where(eq(agents.id, id)).limit(1);
|
||||||
|
|
||||||
if (!agent) return c.json({ error: { code: "NOT_FOUND", message: "Agent not found" } }, 404);
|
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) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
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);
|
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)
|
// GET /api/agents/:id/activity — Agent activity log (or all if id=_all)
|
||||||
agentRoutes.get("/:id/activity", async (c) => {
|
agentRoutes.get("/:id/activity", async (c) => {
|
||||||
try {
|
try {
|
||||||
await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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()
|
const items = await db.select()
|
||||||
.from(agentActivity)
|
.from(agentActivity)
|
||||||
.where(id === "_all" ? undefined : eq(agentActivity.agentId, id))
|
.where(and(eq(agentActivity.agentId, id), ...filterConditions))
|
||||||
.orderBy(desc(agentActivity.createdAt))
|
.orderBy(desc(agentActivity.createdAt))
|
||||||
.limit(100);
|
.limit(limit);
|
||||||
return c.json({ items, totalItems: items.length });
|
return c.json({ items, totalItems: items.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
if (error instanceof AuthError) return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, tasks, habits, habitCompletions } from "@project-e/db";
|
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
|
||||||
import { and, eq, gte, isNull } from "drizzle-orm";
|
import { and, eq, gte, inArray, isNull, or } from "drizzle-orm";
|
||||||
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
import { requireAuth, resolveActiveDomain, AuthError } from "../middleware/auth";
|
||||||
|
|
||||||
export const analyticsRoutes = new Hono();
|
export const analyticsRoutes = new Hono();
|
||||||
@@ -65,9 +65,17 @@ analyticsRoutes.get("/habits", async (c) => {
|
|||||||
.from(habits)
|
.from(habits)
|
||||||
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt)));
|
.where(and(eq(habits.domainId, domainId), isNull(habits.deletedAt)));
|
||||||
|
|
||||||
const allLogs = await db.select()
|
const habitIds = allHabits.map((h) => h.id);
|
||||||
.from(habitCompletions)
|
|
||||||
.where(gte(habitCompletions.date, startDate));
|
// 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
|
const habitConsistency = allHabits.length > 0
|
||||||
? Math.round((allLogs.length / (allHabits.length * range)) * 100)
|
? 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) => {
|
analyticsRoutes.get("/projects", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
@@ -105,24 +113,48 @@ analyticsRoutes.get("/projects", async (c) => {
|
|||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const startDate = new Date();
|
const allProjects = await db.select()
|
||||||
startDate.setDate(startDate.getDate() - range);
|
.from(projects)
|
||||||
|
.where(and(eq(projects.domainId, domainId), isNull(projects.deletedAt)));
|
||||||
|
|
||||||
const allTasks = await db.select()
|
const projectIds = allProjects.map((p) => p.id);
|
||||||
.from(tasks)
|
|
||||||
.where(and(
|
|
||||||
eq(tasks.domainId, domainId),
|
|
||||||
gte(tasks.createdAt, startDate),
|
|
||||||
isNull(tasks.deletedAt),
|
|
||||||
));
|
|
||||||
|
|
||||||
const completedTasks = allTasks.filter(t => t.status === "done");
|
// Count tasks per project (any status, including non-done) for the domain
|
||||||
const taskCompletionRate = allTasks.length > 0 ? Math.round((completedTasks.length / allTasks.length) * 100) : 0;
|
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<string, { totalTasks: number; completedTasks: number }>();
|
||||||
|
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({
|
return c.json({
|
||||||
taskCompletionRate,
|
projects: projectsData,
|
||||||
totalTasks: allTasks.length,
|
totalProjects: allProjects.length,
|
||||||
completedTasks: completedTasks.length,
|
|
||||||
period: range,
|
period: range,
|
||||||
}, {
|
}, {
|
||||||
headers: { "Cache-Control": "private, max-age=300, stale-while-revalidate=600" },
|
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);
|
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<string, number>();
|
||||||
|
const completedByDay = new Map<string, number>();
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
+13
-74
@@ -1,12 +1,17 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { setCookie } from "hono/cookie";
|
import { deleteCookie, setCookie } from "hono/cookie";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
import { db, users } from "@project-e/db";
|
import { db, users } from "@project-e/db";
|
||||||
import { count, eq } from "drizzle-orm";
|
import { count, eq } from "drizzle-orm";
|
||||||
import { createToken, requireAuth, createErrorResponse, AuthError } from "../middleware/auth";
|
import { createToken } from "../middleware/auth";
|
||||||
|
|
||||||
export const authRoutes = new Hono();
|
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
|
// POST /api/auth/credentials — Login with email + password
|
||||||
authRoutes.post("/credentials", async (c) => {
|
authRoutes.post("/credentials", async (c) => {
|
||||||
try {
|
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
|
// GET /api/auth/me — Return current user profile
|
||||||
authRoutes.get("/me", async (c) => {
|
authRoutes.get("/me", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -79,75 +90,3 @@ authRoutes.get("/me", async (c) => {
|
|||||||
return c.json({ error: { code: "AUTH_ERROR", message: "Invalid or expired token" } }, 401);
|
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, calendarEvents } from "@project-e/db";
|
import { db, calendarEvents } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, gte, lte, isNull } from "drizzle-orm";
|
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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ calendarRoutes.get("/events", async (c) => {
|
|||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [eq(calendarEvents.domainId, domainId)];
|
const conditions: any[] = [eq(calendarEvents.domainId, domainId)];
|
||||||
if (from) conditions.push(gte(calendarEvents.startTime, new Date(from)));
|
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,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [event] = await db.insert(calendarEvents).values({
|
const [event] = await db.insert(calendarEvents).values({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
description: data.description ?? null,
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Event not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.title !== undefined) updateValues.title = data.title;
|
if (data.title !== undefined) updateValues.title = data.title;
|
||||||
if (data.description !== undefined) updateValues.description = data.description;
|
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);
|
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 db.delete(calendarEvents).where(eq(calendarEvents.id, id));
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -216,6 +223,7 @@ calendarRoutes.get("/upcoming", async (c) => {
|
|||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const end = new Date();
|
const end = new Date();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
|
import { db, canvases, canvasCards, canvasConnections } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, sql } from "drizzle-orm";
|
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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -28,6 +28,39 @@ const updateCanvasSchema = z.object({
|
|||||||
customFields: z.record(z.string(), z.unknown()).optional(),
|
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
|
// GET /api/canvas — List canvases
|
||||||
canvasRoutes.get("/", async (c) => {
|
canvasRoutes.get("/", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -41,6 +74,7 @@ canvasRoutes.get("/", async (c) => {
|
|||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [eq(canvases.domainId, domainId)];
|
const conditions: any[] = [eq(canvases.domainId, domainId)];
|
||||||
const sortField = sort.replace(/^-/, "");
|
const sortField = sort.replace(/^-/, "");
|
||||||
@@ -71,6 +105,8 @@ canvasRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [canvas] = await db.insert(canvases).values({
|
const [canvas] = await db.insert(canvases).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description ?? null,
|
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);
|
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);
|
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([
|
const [cards, connections] = await Promise.all([
|
||||||
db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)),
|
db.select().from(canvasCards).where(eq(canvasCards.canvasId, id)).orderBy(asc(canvasCards.zIndex)),
|
||||||
db.select().from(canvasConnections).where(eq(canvasConnections.canvasId, id)),
|
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);
|
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);
|
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Canvas not found" } }, 404);
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.name !== undefined) updateValues.name = data.name;
|
if (data.name !== undefined) updateValues.name = data.name;
|
||||||
if (data.description !== undefined) updateValues.description = data.description;
|
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);
|
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);
|
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 db.delete(canvases).where(eq(canvases.id, id));
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -176,3 +218,175 @@ canvasRoutes.delete("/:id", async (c) => {
|
|||||||
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to delete canvas" } }, 500);
|
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<number>`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<string, unknown> = {};
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, customFields } from "@project-e/db";
|
import { db, customFields } from "@project-e/db";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ customFieldRoutes.get("/", async (c) => {
|
|||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [eq(customFields.domainId, domainId)];
|
const conditions: any[] = [eq(customFields.domainId, domainId)];
|
||||||
if (entityType) {
|
if (entityType) {
|
||||||
@@ -66,6 +67,8 @@ customFieldRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [field] = await db.insert(customFields).values({
|
const [field] = await db.insert(customFields).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
type: data.type,
|
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);
|
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);
|
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Custom field not found" } }, 404);
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.name !== undefined) updateValues.name = data.name;
|
if (data.name !== undefined) updateValues.name = data.name;
|
||||||
if (data.type !== undefined) updateValues.type = data.type;
|
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);
|
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);
|
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 db.delete(customFields).where(eq(customFields.id, id));
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, dailyNotes } from "@project-e/db";
|
import { db, dailyNotes } from "@project-e/db";
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -33,6 +33,7 @@ dailyNoteRoutes.get("/", async (c) => {
|
|||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
if (dateStr) {
|
if (dateStr) {
|
||||||
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
|
const startOfDay = new Date(dateStr + "T00:00:00.000Z");
|
||||||
@@ -70,6 +71,8 @@ dailyNoteRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [note] = await db.insert(dailyNotes).values({
|
const [note] = await db.insert(dailyNotes).values({
|
||||||
date: new Date(data.date + "T00:00:00.000Z"),
|
date: new Date(data.date + "T00:00:00.000Z"),
|
||||||
content: data.content ?? null,
|
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);
|
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);
|
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Daily note not found" } }, 404);
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.content !== undefined) updateValues.content = data.content;
|
if (data.content !== undefined) updateValues.content = data.content;
|
||||||
if (data.mood !== undefined) updateValues.mood = data.mood;
|
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);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, dashboardWidgets } from "@project-e/db";
|
import { db, dashboardWidgets } from "@project-e/db";
|
||||||
import { and, asc, eq } from "drizzle-orm";
|
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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -70,6 +70,8 @@ dashboardRoutes.post("/widgets", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain!);
|
||||||
|
|
||||||
const [widget] = await db.insert(dashboardWidgets).values({
|
const [widget] = await db.insert(dashboardWidgets).values({
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
type: data.type,
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Widget not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.type !== undefined) updateValues.type = data.type;
|
if (data.type !== undefined) updateValues.type = data.type;
|
||||||
if (data.title !== undefined) updateValues.title = data.title;
|
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);
|
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 db.delete(dashboardWidgets).where(eq(dashboardWidgets.id, id));
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, domains, notes, noteLinks, noteEntityLinks, tasks, taskDependencies, habits, projects, sections, tags as tagsTable } from "@project-e/db";
|
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 { 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 { recordActivity } from "../middleware/activity";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -80,6 +80,20 @@ async function getGraphData(domainId: string): Promise<{ nodes: GraphNode[]; edg
|
|||||||
return { nodes, edges };
|
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<string | null> {
|
||||||
|
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
|
// GET /api/graph/nodes — All nodes
|
||||||
graphRoutes.get("/nodes", async (c) => {
|
graphRoutes.get("/nodes", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -89,6 +103,7 @@ graphRoutes.get("/nodes", async (c) => {
|
|||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
const data = await getGraphData(domainId);
|
const data = await getGraphData(domainId);
|
||||||
return c.json({ items: data.nodes, totalItems: data.nodes.length });
|
return c.json({ items: data.nodes, totalItems: data.nodes.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -109,6 +124,7 @@ graphRoutes.get("/edges", async (c) => {
|
|||||||
if (!domainId) {
|
if (!domainId) {
|
||||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "domain parameter is required" } }, 400);
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
const data = await getGraphData(domainId);
|
const data = await getGraphData(domainId);
|
||||||
return c.json({ items: data.edges, totalItems: data.edges.length });
|
return c.json({ items: data.edges, totalItems: data.edges.length });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -131,6 +147,18 @@ graphRoutes.post("/edges", async (c) => {
|
|||||||
type: z.string().default("note_link"),
|
type: z.string().default("note_link"),
|
||||||
}).parse(body);
|
}).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") {
|
if (type === "note_link") {
|
||||||
await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId });
|
await db.insert(noteLinks).values({ sourceNoteId: sourceId, targetNoteId: targetId });
|
||||||
} else if (type === "note_entity") {
|
} 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);
|
return c.json({ error: { code: "VALIDATION_ERROR", message: "Unknown edge type: " + type } }, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordActivity({
|
if (!workspaceId) {
|
||||||
actor: user.name,
|
console.warn(`[graph] POST /edges: could not resolve workspace for source ${sourceId} (type ${type}); skipping activity`);
|
||||||
action: "created",
|
} else {
|
||||||
entityType: "graph_edge",
|
await recordActivity({
|
||||||
entityId: sourceId + "-" + targetId,
|
actor: user.name,
|
||||||
changes: { type, sourceId, targetId },
|
action: "created",
|
||||||
workspaceId: "",
|
entityType: "graph_edge",
|
||||||
});
|
entityId: sourceId + "-" + targetId,
|
||||||
|
changes: { type, sourceId, targetId },
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.json({ success: true }, 201);
|
return c.json({ success: true }, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -170,6 +202,16 @@ graphRoutes.delete("/edges/:id", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const [sourceId, targetId] = id.split("-");
|
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
|
// Try deleting from note_links first
|
||||||
const result = await db.delete(noteLinks)
|
const result = await db.delete(noteLinks)
|
||||||
.where(and(eq(noteLinks.sourceNoteId, sourceId), eq(noteLinks.targetNoteId, targetId)))
|
.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)));
|
.where(and(eq(taskDependencies.taskId, sourceId), eq(taskDependencies.dependsOnTaskId, targetId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
await recordActivity({
|
if (!workspaceId) {
|
||||||
actor: user.name,
|
console.warn(`[graph] DELETE /edges/${id}: could not resolve workspace for source ${sourceId}; skipping activity`);
|
||||||
action: "deleted",
|
} else {
|
||||||
entityType: "graph_edge",
|
await recordActivity({
|
||||||
entityId: id,
|
actor: user.name,
|
||||||
changes: {},
|
action: "deleted",
|
||||||
workspaceId: "",
|
entityType: "graph_edge",
|
||||||
});
|
entityId: id,
|
||||||
|
changes: {},
|
||||||
|
workspaceId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, habits, habitCompletions, habitTags, tags as tagsTable } from "@project-e/db";
|
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 { and, asc, desc, eq, exists, gte, ilike, inArray, isNull, lte, 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 { recordActivity } from "../middleware/activity";
|
||||||
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const habitRoutes = new Hono();
|
export const habitRoutes = new Hono();
|
||||||
@@ -96,6 +97,7 @@ habitRoutes.get("/", async (c) => {
|
|||||||
const active = url.searchParams.get("active");
|
const active = url.searchParams.get("active");
|
||||||
const frequency = url.searchParams.get("frequency");
|
const frequency = url.searchParams.get("frequency");
|
||||||
const difficulty = url.searchParams.get("difficulty");
|
const difficulty = url.searchParams.get("difficulty");
|
||||||
|
const tag = url.searchParams.get("tag");
|
||||||
const search = url.searchParams.get("search");
|
const search = url.searchParams.get("search");
|
||||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||||
@@ -107,6 +109,8 @@ habitRoutes.get("/", async (c) => {
|
|||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [
|
const conditions: any[] = [
|
||||||
eq(habits.domainId, domainId),
|
eq(habits.domainId, domainId),
|
||||||
isNull(habits.deletedAt),
|
isNull(habits.deletedAt),
|
||||||
@@ -118,6 +122,20 @@ habitRoutes.get("/", async (c) => {
|
|||||||
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
|
if (difficulty) conditions.push(eq(habits.difficulty, difficulty as any));
|
||||||
if (search) conditions.push(ilike(habits.name, `%${search}%`));
|
if (search) conditions.push(ilike(habits.name, `%${search}%`));
|
||||||
if (filter) conditions.push(ilike(habits.name, `%${filter}%`));
|
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 sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||||
const sortField = sort.replace(/^-/, "");
|
const sortField = sort.replace(/^-/, "");
|
||||||
@@ -202,6 +220,8 @@ habitRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [habit] = await db.insert(habits).values({
|
const [habit] = await db.insert(habits).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description ?? null,
|
description: data.description ?? null,
|
||||||
@@ -231,6 +251,8 @@ habitRoutes.post("/", async (c) => {
|
|||||||
workspaceId: data.domain,
|
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);
|
return c.json(habit, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, habit.domainId);
|
||||||
|
|
||||||
// Fetch recent completions (last 30 days)
|
// Fetch recent completions (last 30 days)
|
||||||
const thirtyDaysAgo = new Date();
|
const thirtyDaysAgo = new Date();
|
||||||
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.name !== undefined) updateValues.name = data.name;
|
if (data.name !== undefined) updateValues.name = data.name;
|
||||||
if (data.description !== undefined) updateValues.description = data.description;
|
if (data.description !== undefined) updateValues.description = data.description;
|
||||||
@@ -339,6 +365,8 @@ habitRoutes.patch("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.updated", entityType: "habit", entityId: id, data: { ...data, previousName: existing.name } });
|
||||||
|
|
||||||
return c.json(updated);
|
return c.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
await db.update(habits)
|
await db.update(habits)
|
||||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(habits.id, id));
|
.where(eq(habits.id, id));
|
||||||
@@ -380,6 +410,8 @@ habitRoutes.delete("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "habit.deleted", entityType: "habit", entityId: id, data: { name: existing.name } });
|
||||||
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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
|
// POST /api/habits/:id/complete — Complete a habit for today
|
||||||
habitRoutes.post("/:id/complete", async (c) => {
|
habitRoutes.post("/:id/complete", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -407,6 +529,8 @@ habitRoutes.post("/:id/complete", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, habit.domainId);
|
||||||
|
|
||||||
const [completion] = await db.insert(habitCompletions).values({
|
const [completion] = await db.insert(habitCompletions).values({
|
||||||
habitId: id,
|
habitId: id,
|
||||||
date: new Date(),
|
date: new Date(),
|
||||||
@@ -465,7 +589,7 @@ habitRoutes.get("/:id/completions", async (c) => {
|
|||||||
const id = c.req.param("id");
|
const id = c.req.param("id");
|
||||||
const url = new URL(c.req.url);
|
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)
|
.from(habits)
|
||||||
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
.where(and(eq(habits.id, id), isNull(habits.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -474,6 +598,8 @@ habitRoutes.get("/:id/completions", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Habit not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, habit.domainId);
|
||||||
|
|
||||||
const from = url.searchParams.get("from");
|
const from = url.searchParams.get("from");
|
||||||
const to = url.searchParams.get("to");
|
const to = url.searchParams.get("to");
|
||||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000);
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "365"), 1000);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks } from "@project-e/db";
|
import { db, tasks, habits, projects, notes, tags as tagsTable, agents, webhooks, taskTags, habitTags, projectTags, noteTags } from "@project-e/db";
|
||||||
import { eq, isNull } from "drizzle-orm";
|
import { and, eq, inArray, isNull } from "drizzle-orm";
|
||||||
import { requireAuth, createErrorResponse, AuthError } from "../middleware/auth";
|
import { requireAuth, requireWorkspaceAccess, resolveActiveDomain, createErrorResponse, AuthError } from "../middleware/auth";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const importExportRoutes = new Hono();
|
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);
|
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[] }> = [];
|
const results: Array<{ collection: string; imported: number; failed: number; errors: string[] }> = [];
|
||||||
let totalImported = 0;
|
let totalImported = 0;
|
||||||
let totalFailed = 0;
|
let totalFailed = 0;
|
||||||
@@ -38,25 +43,25 @@ importExportRoutes.post("/import", async (c) => {
|
|||||||
// Map to the right table
|
// Map to the right table
|
||||||
switch (collection) {
|
switch (collection) {
|
||||||
case 'tasks':
|
case 'tasks':
|
||||||
await db.insert(tasks).values({ ...data, domainId: data.domain_id || data.domainId });
|
await db.insert(tasks).values({ ...data, domainId: targetDomain });
|
||||||
break;
|
break;
|
||||||
case 'habits':
|
case 'habits':
|
||||||
await db.insert(habits).values({ ...data, domainId: data.domain_id || data.domainId });
|
await db.insert(habits).values({ ...data, domainId: targetDomain });
|
||||||
break;
|
break;
|
||||||
case 'projects':
|
case 'projects':
|
||||||
await db.insert(projects).values({ ...data, domainId: data.domain_id || data.domainId });
|
await db.insert(projects).values({ ...data, domainId: targetDomain });
|
||||||
break;
|
break;
|
||||||
case 'notes':
|
case 'notes':
|
||||||
await db.insert(notes).values({ ...data, domainId: data.domain_id || data.domainId });
|
await db.insert(notes).values({ ...data, domainId: targetDomain });
|
||||||
break;
|
break;
|
||||||
case 'tags':
|
case 'tags':
|
||||||
await db.insert(tagsTable).values(data);
|
await db.insert(tagsTable).values(data);
|
||||||
break;
|
break;
|
||||||
case 'agents':
|
case 'agents':
|
||||||
await db.insert(agents).values({ ...data, domainId: data.domain_id || data.domainId });
|
await db.insert(agents).values({ ...data, domainId: targetDomain });
|
||||||
break;
|
break;
|
||||||
case 'webhooks':
|
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;
|
break;
|
||||||
}
|
}
|
||||||
result.imported++;
|
result.imported++;
|
||||||
@@ -101,9 +106,15 @@ importExportRoutes.get("/export", async (c) => {
|
|||||||
importExportRoutes.post("/export", async (c) => {
|
importExportRoutes.post("/export", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
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 */ }
|
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
|
const requestedCollections = body.collections && body.collections.length > 0
|
||||||
? body.collections.filter(c => COLLECTIONS.includes(c as typeof COLLECTIONS[number]))
|
? body.collections.filter(c => COLLECTIONS.includes(c as typeof COLLECTIONS[number]))
|
||||||
: [...COLLECTIONS];
|
: [...COLLECTIONS];
|
||||||
@@ -117,13 +128,33 @@ importExportRoutes.post("/export", async (c) => {
|
|||||||
try {
|
try {
|
||||||
let items: any[] = [];
|
let items: any[] = [];
|
||||||
switch (collection) {
|
switch (collection) {
|
||||||
case 'tasks': items = await db.select().from(tasks).where(isNull(tasks.deletedAt)); 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(isNull(habits.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(isNull(projects.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(isNull(notes.deletedAt)); break;
|
case 'notes': items = await db.select().from(notes).where(and(eq(notes.domainId, domainId), isNull(notes.deletedAt))); break;
|
||||||
case 'tags': items = await db.select().from(tagsTable); break;
|
case 'tags': {
|
||||||
case 'agents': items = await db.select().from(agents); break;
|
// Tags are global (no domain_id); export only tags actually used by
|
||||||
case 'webhooks': items = await db.select().from(webhooks); break;
|
// 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;
|
exportData[collection] = items;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
+67
-14
@@ -1,7 +1,7 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { createHash } from "node:crypto";
|
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 { 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";
|
import { recordActivity } from "../middleware/activity";
|
||||||
|
|
||||||
export const mcpRoutes = new Hono();
|
export const mcpRoutes = new Hono();
|
||||||
@@ -484,7 +484,7 @@ const tools: ToolDefinition[] = [
|
|||||||
.where(and(
|
.where(and(
|
||||||
eq(notes.domainId, params.domain_id as string),
|
eq(notes.domainId, params.domain_id as string),
|
||||||
isNull(notes.deletedAt),
|
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))
|
.orderBy(desc(notes.updatedAt))
|
||||||
.limit(20);
|
.limit(20);
|
||||||
@@ -493,10 +493,15 @@ const tools: ToolDefinition[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "domains.list",
|
name: "domains.list",
|
||||||
description: "List domains/workspaces",
|
description: "List the caller's domains/workspaces",
|
||||||
inputSchema: { type: "object", properties: {} },
|
inputSchema: { type: "object", properties: {} },
|
||||||
handler: async () => {
|
handler: async (_params, auth) => {
|
||||||
const items = await db.select().from(domains).orderBy(asc(domains.name));
|
// 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 };
|
return { items };
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -517,6 +522,7 @@ const tools: ToolDefinition[] = [
|
|||||||
name: params.name as string,
|
name: params.name as string,
|
||||||
slug: params.slug as string,
|
slug: params.slug as string,
|
||||||
color: (params.color as string) ?? null,
|
color: (params.color as string) ?? null,
|
||||||
|
ownerId: auth.userId,
|
||||||
}).returning();
|
}).returning();
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
@@ -603,14 +609,48 @@ class JsonRpcErrorResponse extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeError(code: number, message: string, data?: unknown): JsonRpcResponse {
|
function makeError(code: number, message: string, data?: unknown, id: string | number | null = null): JsonRpcResponse {
|
||||||
return { jsonrpc: "2.0", error: { code, message, data }, id: null };
|
return { jsonrpc: "2.0", error: { code, message, data }, id };
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeResult(result: unknown, id: string | number | null): JsonRpcResponse {
|
function makeResult(result: unknown, id: string | number | null): JsonRpcResponse {
|
||||||
return { jsonrpc: "2.0", result, id };
|
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, unknown>): 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<void> {
|
||||||
|
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<JsonRpcResponse> {
|
async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userName: string }): Promise<JsonRpcResponse> {
|
||||||
const { method, params, id } = body;
|
const { method, params, id } = body;
|
||||||
|
|
||||||
@@ -644,23 +684,36 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
|
|||||||
if (method === "tools/call") {
|
if (method === "tools/call") {
|
||||||
const callParams = params as { name?: string; arguments?: Record<string, unknown> } | undefined;
|
const callParams = params as { name?: string; arguments?: Record<string, unknown> } | undefined;
|
||||||
if (!callParams?.name) {
|
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);
|
const tool = tools.find(t => t.name === callParams.name);
|
||||||
if (!tool) {
|
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 {
|
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);
|
return makeResult({ content: [{ type: "text", text: JSON.stringify(result) }] }, id);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof JsonRpcErrorResponse) {
|
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);
|
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") {
|
if (method === "resources/read") {
|
||||||
const readParams = params as { uri?: string } | undefined;
|
const readParams = params as { uri?: string } | undefined;
|
||||||
if (!readParams?.uri) {
|
if (!readParams?.uri) {
|
||||||
return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", id);
|
return makeError(JSONRPC_INVALID_PARAMS, "Missing resource URI", undefined, id);
|
||||||
}
|
}
|
||||||
return makeResult({
|
return makeResult({
|
||||||
contents: [
|
contents: [
|
||||||
@@ -727,7 +780,7 @@ async function handleRequest(body: JsonRpcRequest, auth: { userId: string; userN
|
|||||||
}, id);
|
}, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, id);
|
return makeError(JSONRPC_METHOD_NOT_FOUND, `Unknown method: ${method}`, undefined, id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Route handler ────────────────────────────────────────────────────────────────
|
// ── Route handler ────────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, notes, noteTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
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 { and, asc, desc, eq, exists, 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 { recordActivity } from "../middleware/activity";
|
||||||
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
|
import { syncNoteLinks, getBacklinks, getOutgoingLinks } from "./note-link-service";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ noteRoutes.get("/", async (c) => {
|
|||||||
const sort = url.searchParams.get("sort") || "-updated_at";
|
const sort = url.searchParams.get("sort") || "-updated_at";
|
||||||
const pinned = url.searchParams.get("pinned");
|
const pinned = url.searchParams.get("pinned");
|
||||||
const archived = url.searchParams.get("archived");
|
const archived = url.searchParams.get("archived");
|
||||||
|
const tag = url.searchParams.get("tag");
|
||||||
const search = url.searchParams.get("search");
|
const search = url.searchParams.get("search");
|
||||||
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
const limit = Math.min(parseInt(url.searchParams.get("limit") || "50"), 200);
|
||||||
const offset = parseInt(url.searchParams.get("offset") || "0");
|
const offset = parseInt(url.searchParams.get("offset") || "0");
|
||||||
@@ -46,6 +48,8 @@ noteRoutes.get("/", async (c) => {
|
|||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [
|
const conditions: any[] = [
|
||||||
eq(notes.domainId, domainId),
|
eq(notes.domainId, domainId),
|
||||||
isNull(notes.deletedAt),
|
isNull(notes.deletedAt),
|
||||||
@@ -56,6 +60,20 @@ noteRoutes.get("/", async (c) => {
|
|||||||
else if (archived !== "all") conditions.push(eq(notes.isArchived, false));
|
else if (archived !== "all") conditions.push(eq(notes.isArchived, false));
|
||||||
if (search) conditions.push(ilike(notes.title, `%${search}%`));
|
if (search) conditions.push(ilike(notes.title, `%${search}%`));
|
||||||
if (filter) conditions.push(ilike(notes.title, `%${filter}%`));
|
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 sortDir = sort.startsWith("-") ? "desc" : "asc";
|
||||||
const sortField = sort.replace(/^-/, "");
|
const sortField = sort.replace(/^-/, "");
|
||||||
@@ -136,6 +154,8 @@ noteRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [note] = await db.insert(notes).values({
|
const [note] = await db.insert(notes).values({
|
||||||
title: data.title,
|
title: data.title,
|
||||||
content: data.content ?? null,
|
content: data.content ?? null,
|
||||||
@@ -164,6 +184,8 @@ noteRoutes.post("/", async (c) => {
|
|||||||
workspaceId: data.domain,
|
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);
|
return c.json(note, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, note.domainId);
|
||||||
|
|
||||||
// Fetch tags
|
// Fetch tags
|
||||||
const tagRows = await db.select({
|
const tagRows = await db.select({
|
||||||
id: tagsTable.id,
|
id: tagsTable.id,
|
||||||
@@ -240,6 +264,8 @@ noteRoutes.patch("/:id", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.title !== undefined) updateValues.title = data.title;
|
if (data.title !== undefined) updateValues.title = data.title;
|
||||||
if (data.content !== undefined) updateValues.content = data.content;
|
if (data.content !== undefined) updateValues.content = data.content;
|
||||||
@@ -267,6 +293,8 @@ noteRoutes.patch("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.updated", entityType: "note", entityId: id, data: { ...data, previousTitle: existing.title } });
|
||||||
|
|
||||||
return c.json(updated);
|
return c.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Note not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
await db.update(notes)
|
await db.update(notes)
|
||||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(notes.id, id));
|
.where(eq(notes.id, id));
|
||||||
@@ -308,6 +338,8 @@ noteRoutes.delete("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "note.deleted", entityType: "note", entityId: id, data: { title: existing.title } });
|
||||||
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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
|
// GET /api/notes/:id/backlinks — Notes that link TO this one
|
||||||
noteRoutes.get("/:id/backlinks", async (c) => {
|
noteRoutes.get("/:id/backlinks", async (c) => {
|
||||||
try {
|
try {
|
||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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);
|
const backlinks = await getBacklinks(id);
|
||||||
|
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -345,11 +478,23 @@ noteRoutes.get("/:id/versions", async (c) => {
|
|||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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()
|
const versions = await db.select()
|
||||||
.from(activityFeed)
|
.from(activityFeed)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(activityFeed.entityId, id),
|
eq(activityFeed.entityId, id),
|
||||||
eq(activityFeed.entityType, "note"),
|
eq(activityFeed.entityType, "note"),
|
||||||
|
eq(activityFeed.workspaceId, note.domainId),
|
||||||
))
|
))
|
||||||
.orderBy(desc(activityFeed.createdAt))
|
.orderBy(desc(activityFeed.createdAt))
|
||||||
.limit(100);
|
.limit(100);
|
||||||
|
|||||||
@@ -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<number>`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);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, projects, tasks, sections, projectTags, tags as tagsTable, activityFeed } from "@project-e/db";
|
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 { 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 { recordActivity } from "../middleware/activity";
|
||||||
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const projectRoutes = new Hono();
|
export const projectRoutes = new Hono();
|
||||||
@@ -69,6 +70,8 @@ projectRoutes.get("/", async (c) => {
|
|||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [
|
const conditions: any[] = [
|
||||||
eq(projects.domainId, domainId),
|
eq(projects.domainId, domainId),
|
||||||
isNull(projects.deletedAt),
|
isNull(projects.deletedAt),
|
||||||
@@ -189,6 +192,8 @@ projectRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [project] = await db.insert(projects).values({
|
const [project] = await db.insert(projects).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
description: data.description ?? null,
|
description: data.description ?? null,
|
||||||
@@ -214,6 +219,8 @@ projectRoutes.post("/", async (c) => {
|
|||||||
workspaceId: data.domain,
|
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);
|
return c.json(project, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
// Fetch sections
|
// Fetch sections
|
||||||
const projectSections = await db.select()
|
const projectSections = await db.select()
|
||||||
.from(sections)
|
.from(sections)
|
||||||
@@ -303,6 +312,8 @@ projectRoutes.patch("/:id", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.name !== undefined) updateValues.name = data.name;
|
if (data.name !== undefined) updateValues.name = data.name;
|
||||||
if (data.description !== undefined) updateValues.description = data.description;
|
if (data.description !== undefined) updateValues.description = data.description;
|
||||||
@@ -326,6 +337,8 @@ projectRoutes.patch("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.updated", entityType: "project", entityId: id, data: { ...data, previousName: existing.name } });
|
||||||
|
|
||||||
return c.json(updated);
|
return c.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
await db.update(projects)
|
await db.update(projects)
|
||||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(projects.id, id));
|
.where(eq(projects.id, id));
|
||||||
@@ -367,6 +382,8 @@ projectRoutes.delete("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
workspaceId: existing.domainId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await enqueueWebhooks({ workspaceId: existing.domainId, event: "project.deleted", entityType: "project", entityId: id, data: { name: existing.name } });
|
||||||
|
|
||||||
return c.body(null, 204);
|
return c.body(null, 204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
@@ -383,7 +400,7 @@ projectRoutes.get("/:id/sections", async (c) => {
|
|||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const projectId = c.req.param("id");
|
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)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
|
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -392,6 +409,8 @@ projectRoutes.get("/:id/sections", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
const items = await db.select()
|
const items = await db.select()
|
||||||
.from(sections)
|
.from(sections)
|
||||||
.where(eq(sections.projectId, projectId))
|
.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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
let sortOrder = data.sortOrder;
|
let sortOrder = data.sortOrder;
|
||||||
if (sortOrder === undefined) {
|
if (sortOrder === undefined) {
|
||||||
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
|
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
|
||||||
@@ -470,6 +491,17 @@ projectRoutes.get("/:id/sections/:sid", async (c) => {
|
|||||||
const projectId = c.req.param("id");
|
const projectId = c.req.param("id");
|
||||||
const id = c.req.param("sid");
|
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()
|
const [section] = await db.select()
|
||||||
.from(sections)
|
.from(sections)
|
||||||
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
|
.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 body = await c.req.json();
|
||||||
const data = updateSectionSchema.parse(body);
|
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()
|
const [existing] = await db.select()
|
||||||
.from(sections)
|
.from(sections)
|
||||||
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
|
.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 projectId = c.req.param("id");
|
||||||
const id = c.req.param("sid");
|
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()
|
const [existing] = await db.select()
|
||||||
.from(sections)
|
.from(sections)
|
||||||
.where(and(eq(sections.id, id), eq(sections.projectId, projectId)))
|
.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 user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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)
|
.from(projects)
|
||||||
.where(and(eq(projects.id, id), isNull(projects.deletedAt)))
|
.where(and(eq(projects.id, id), isNull(projects.deletedAt)))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
@@ -607,6 +661,8 @@ projectRoutes.get("/:id/members", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
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
|
// Members are stored in activity feed with entityType=member
|
||||||
const members = await db.select()
|
const members = await db.select()
|
||||||
.from(activityFeed)
|
.from(activityFeed)
|
||||||
@@ -646,6 +702,8 @@ projectRoutes.post("/:id/members", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
actor: user.name,
|
actor: user.name,
|
||||||
action: "added",
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, project.domainId);
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
actor: user.name,
|
actor: user.name,
|
||||||
action: "removed",
|
action: "removed",
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import postgres from "postgres";
|
import postgres from "postgres";
|
||||||
|
import { requireWorkspaceAccess, AuthError } from "../middleware/auth";
|
||||||
|
|
||||||
export const realtimeRoutes = new Hono();
|
export const realtimeRoutes = new Hono();
|
||||||
|
|
||||||
@@ -13,6 +14,21 @@ realtimeRoutes.get("/realtime", async (c) => {
|
|||||||
const url = new URL(c.req.url);
|
const url = new URL(c.req.url);
|
||||||
const workspaceId = url.searchParams.get("workspace_id");
|
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 encoder = new TextEncoder();
|
||||||
const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
|
const listener = postgres(process.env.DATABASE_URL!, { max: 1 });
|
||||||
let unlisten: (() => Promise<void>) | undefined;
|
let unlisten: (() => Promise<void>) | undefined;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, sql } from "@project-e/db";
|
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();
|
export const searchRoutes = new Hono();
|
||||||
|
|
||||||
@@ -31,6 +31,11 @@ searchRoutes.get("/", async (c) => {
|
|||||||
return c.json({ results: [], totalCount: 0 });
|
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 }> = [];
|
const results: Array<{ id: string; type: string; title: string; snippet: string; score: number; workspaceId: string; link: string }> = [];
|
||||||
|
|
||||||
for (const type of types) {
|
for (const type of types) {
|
||||||
@@ -44,6 +49,17 @@ searchRoutes.get("/", async (c) => {
|
|||||||
conditions.push(deletedColumn + " IS NULL");
|
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 whereClause = conditions.join(' AND ');
|
||||||
const headlineColumn = contentColumn || titleColumn;
|
const headlineColumn = contentColumn || titleColumn;
|
||||||
|
|
||||||
|
|||||||
+277
-17
@@ -1,9 +1,11 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed } from "@project-e/db";
|
import { db, tasks, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs } from "@project-e/db";
|
||||||
import { and, asc, desc, eq, ilike, inArray, isNull, or, sql } from "drizzle-orm";
|
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, 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 { recordActivity } from "../middleware/activity";
|
||||||
|
import { enqueueWebhooks } from "../middleware/webhook-queue";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
import { RRule } from "rrule";
|
||||||
|
|
||||||
export const taskRoutes = new Hono();
|
export const taskRoutes = new Hono();
|
||||||
|
|
||||||
@@ -42,6 +44,38 @@ const updateTaskSchema = z.object({
|
|||||||
recurrenceRule: z.string().optional().nullable(),
|
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<void> {
|
||||||
|
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
|
// GET /api/tasks — List tasks with filtering, sorting, pagination
|
||||||
taskRoutes.get("/", async (c) => {
|
taskRoutes.get("/", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -68,6 +102,8 @@ taskRoutes.get("/", async (c) => {
|
|||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
// Build conditions
|
// Build conditions
|
||||||
const conditions: any[] = [
|
const conditions: any[] = [
|
||||||
eq(tasks.domainId, domainId),
|
eq(tasks.domainId, domainId),
|
||||||
@@ -104,6 +140,21 @@ taskRoutes.get("/", async (c) => {
|
|||||||
if (sectionId) {
|
if (sectionId) {
|
||||||
conditions.push(eq(tasks.sectionId, 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
|
// Build order
|
||||||
const orderFn = order === "desc" ? desc : asc;
|
const orderFn = order === "desc" ? desc : asc;
|
||||||
@@ -138,21 +189,10 @@ taskRoutes.get("/", async (c) => {
|
|||||||
|
|
||||||
const totalItems = Number(countResult[0]?.count || 0);
|
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
|
// Fetch tags for all tasks
|
||||||
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
let taskTagMap = new Map<string, { id: string; name: string; color: string | null }[]>();
|
||||||
if (filteredItems.length > 0) {
|
if (items.length > 0) {
|
||||||
const taskIds = filteredItems.map(t => t.id);
|
const taskIds = items.map(t => t.id);
|
||||||
const tagRows = await db.select({
|
const tagRows = await db.select({
|
||||||
taskId: taskTags.taskId,
|
taskId: taskTags.taskId,
|
||||||
id: tagsTable.id,
|
id: tagsTable.id,
|
||||||
@@ -169,7 +209,7 @@ taskRoutes.get("/", async (c) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const itemsWithTags = filteredItems.map(t => ({
|
const itemsWithTags = items.map(t => ({
|
||||||
...t,
|
...t,
|
||||||
tags: taskTagMap.get(t.id) || [],
|
tags: taskTagMap.get(t.id) || [],
|
||||||
}));
|
}));
|
||||||
@@ -202,6 +242,8 @@ taskRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
// Cycle detection for parentId (subtask)
|
// Cycle detection for parentId (subtask)
|
||||||
if (data.parentId) {
|
if (data.parentId) {
|
||||||
const [parent] = await db.select({ id: tasks.id })
|
const [parent] = await db.select({ id: tasks.id })
|
||||||
@@ -244,6 +286,12 @@ taskRoutes.post("/", async (c) => {
|
|||||||
workspaceId: data.domain,
|
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);
|
return c.json(task, 201);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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
|
// GET /api/tasks/:id — Get a single task with subtasks + dependencies
|
||||||
taskRoutes.get("/:id", async (c) => {
|
taskRoutes.get("/:id", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -272,6 +385,8 @@ taskRoutes.get("/:id", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, task.domainId);
|
||||||
|
|
||||||
// Fetch subtasks
|
// Fetch subtasks
|
||||||
const subtasks = await db.select()
|
const subtasks = await db.select()
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
@@ -341,6 +456,8 @@ taskRoutes.patch("/:id", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
// Cycle detection for parentId
|
// Cycle detection for parentId
|
||||||
if (data.parentId && data.parentId === id) {
|
if (data.parentId && data.parentId === id) {
|
||||||
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot be its own parent" } }, 400);
|
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,
|
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);
|
return c.json(updated);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
await db.update(tasks)
|
await db.update(tasks)
|
||||||
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
.set({ deletedAt: new Date(), updatedAt: new Date() })
|
||||||
.where(eq(tasks.id, id));
|
.where(eq(tasks.id, id));
|
||||||
@@ -431,6 +556,11 @@ taskRoutes.delete("/:id", async (c) => {
|
|||||||
workspaceId: existing.domainId,
|
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);
|
return c.body(null, 204);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
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)
|
// POST /api/tasks/:id/status — Change task status (Kanban drag)
|
||||||
taskRoutes.post("/:id/status", async (c) => {
|
taskRoutes.post("/:id/status", async (c) => {
|
||||||
try {
|
try {
|
||||||
@@ -460,6 +680,8 @@ taskRoutes.post("/:id/status", async (c) => {
|
|||||||
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.domainId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {
|
const updateValues: Record<string, unknown> = {
|
||||||
status: newStatus,
|
status: newStatus,
|
||||||
updatedAt: new Date(),
|
updatedAt: new Date(),
|
||||||
@@ -501,11 +723,23 @@ taskRoutes.get("/:id/history", async (c) => {
|
|||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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()
|
const history = await db.select()
|
||||||
.from(activityFeed)
|
.from(activityFeed)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(activityFeed.entityId, id),
|
eq(activityFeed.entityId, id),
|
||||||
eq(activityFeed.entityType, "task"),
|
eq(activityFeed.entityType, "task"),
|
||||||
|
eq(activityFeed.workspaceId, task.domainId),
|
||||||
))
|
))
|
||||||
.orderBy(desc(activityFeed.createdAt))
|
.orderBy(desc(activityFeed.createdAt))
|
||||||
.limit(100);
|
.limit(100);
|
||||||
@@ -526,11 +760,23 @@ taskRoutes.get("/:id/comments", async (c) => {
|
|||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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()
|
const comments = await db.select()
|
||||||
.from(activityFeed)
|
.from(activityFeed)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(activityFeed.entityId, id),
|
eq(activityFeed.entityId, id),
|
||||||
eq(activityFeed.entityType, "comment"),
|
eq(activityFeed.entityType, "comment"),
|
||||||
|
eq(activityFeed.workspaceId, task.domainId),
|
||||||
))
|
))
|
||||||
.orderBy(asc(activityFeed.createdAt));
|
.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);
|
return c.json({ error: { code: "NOT_FOUND", message: "Task not found" } }, 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, task.domainId);
|
||||||
|
|
||||||
await recordActivity({
|
await recordActivity({
|
||||||
actor: user.name,
|
actor: user.name,
|
||||||
action: "commented",
|
action: "commented",
|
||||||
@@ -592,12 +840,24 @@ taskRoutes.get("/:id/attachments", async (c) => {
|
|||||||
const user = await requireAuth(c);
|
const user = await requireAuth(c);
|
||||||
const id = c.req.param("id");
|
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
|
// Attachments are stored in activity feed with entityType=attachment
|
||||||
const attachments = await db.select()
|
const attachments = await db.select()
|
||||||
.from(activityFeed)
|
.from(activityFeed)
|
||||||
.where(and(
|
.where(and(
|
||||||
eq(activityFeed.entityId, id),
|
eq(activityFeed.entityId, id),
|
||||||
eq(activityFeed.entityType, "attachment"),
|
eq(activityFeed.entityType, "attachment"),
|
||||||
|
eq(activityFeed.workspaceId, task.domainId),
|
||||||
))
|
))
|
||||||
.orderBy(desc(activityFeed.createdAt));
|
.orderBy(desc(activityFeed.createdAt));
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { Hono } from "hono";
|
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 { 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 { recordActivity } from "../middleware/activity";
|
||||||
|
import { enqueueWebhookDelivery } from "../middleware/webhook-queue";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const webhookRoutes = new Hono();
|
export const webhookRoutes = new Hono();
|
||||||
@@ -43,6 +44,7 @@ webhookRoutes.get("/", async (c) => {
|
|||||||
const active = await resolveActiveDomain(user);
|
const active = await resolveActiveDomain(user);
|
||||||
domainId = active.id;
|
domainId = active.id;
|
||||||
}
|
}
|
||||||
|
await requireWorkspaceAccess(c, domainId);
|
||||||
|
|
||||||
const conditions: any[] = [eq(webhooks.workspaceId, domainId)];
|
const conditions: any[] = [eq(webhooks.workspaceId, domainId)];
|
||||||
const sortField = sort.replace(/^-/, "");
|
const sortField = sort.replace(/^-/, "");
|
||||||
@@ -73,6 +75,8 @@ webhookRoutes.post("/", async (c) => {
|
|||||||
domain: body.domain || (await resolveActiveDomain(user)).id,
|
domain: body.domain || (await resolveActiveDomain(user)).id,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, data.domain);
|
||||||
|
|
||||||
const [webhook] = await db.insert(webhooks).values({
|
const [webhook] = await db.insert(webhooks).values({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
url: data.url,
|
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);
|
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);
|
if (!existing) return c.json({ error: { code: "NOT_FOUND", message: "Webhook not found" } }, 404);
|
||||||
|
|
||||||
|
await requireWorkspaceAccess(c, existing.workspaceId);
|
||||||
|
|
||||||
const updateValues: Record<string, unknown> = {};
|
const updateValues: Record<string, unknown> = {};
|
||||||
if (data.name !== undefined) updateValues.name = data.name;
|
if (data.name !== undefined) updateValues.name = data.name;
|
||||||
if (data.url !== undefined) updateValues.url = data.url;
|
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);
|
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);
|
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 db.delete(webhooks).where(eq(webhooks.id, id));
|
||||||
|
|
||||||
await recordActivity({
|
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);
|
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);
|
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,
|
webhookId: id,
|
||||||
event: "test",
|
event: "test",
|
||||||
payload: testPayload,
|
entityType: "test",
|
||||||
status: "pending",
|
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" });
|
return c.json({ success: true, message: "Test webhook queued" });
|
||||||
|
|||||||
@@ -64,6 +64,7 @@
|
|||||||
"react-force-graph-2d": "^1.29.1",
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
@@ -73,6 +74,7 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.62.0",
|
"@tanstack/react-query-devtools": "^5.62.0",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-big-calendar": "^1.16.3",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.5.2",
|
"autoprefixer": "^10.5.2",
|
||||||
|
|||||||
@@ -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<string, unknown>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Separator />
|
||||||
|
<div>
|
||||||
|
<Label className="text-sm font-semibold text-muted-foreground">Custom Fields</Label>
|
||||||
|
<div className="mt-3 space-y-4">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<FieldInput
|
||||||
|
key={field.id}
|
||||||
|
field={field}
|
||||||
|
value={values[field.name]}
|
||||||
|
onValueChange={(name, value) => onChange({ ...values, [name]: value })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox id={inputId} checked={Boolean(value)} onCheckedChange={(v) => onValueChange(field.name, v === true)} />
|
||||||
|
<Label htmlFor={inputId} className="font-normal">{field.name}</Label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "multi_select") {
|
||||||
|
const selected: string[] = Array.isArray(value) ? value : [];
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label>{field.name}</Label>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{(field.options ?? []).map((opt) => (
|
||||||
|
<div key={opt} className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={inputId + "-" + opt}
|
||||||
|
checked={selected.includes(opt)}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
const next = v ? [...selected, opt] : selected.filter((o) => o !== opt);
|
||||||
|
onValueChange(field.name, next.length > 0 ? next : null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label htmlFor={inputId + "-" + opt} className="font-normal">{opt}</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor={inputId}>
|
||||||
|
{field.name}
|
||||||
|
{field.required && <span className="text-destructive"> *</span>}
|
||||||
|
</Label>
|
||||||
|
{renderStandardControl(field, value, inputId, onValueChange)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="number"
|
||||||
|
value={numeric}
|
||||||
|
onChange={(e) => onValueChange(field.name, e.target.value === "" ? null : Number(e.target.value))}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "date": {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="date"
|
||||||
|
value={value !== undefined && value !== null ? String(value).slice(0, 10) : ""}
|
||||||
|
onChange={(e) => onValueChange(field.name, e.target.value || null)}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
case "select": {
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
value={typeof value === "string" ? value : ""}
|
||||||
|
onValueChange={(v) => onValueChange(field.name, v || null)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={inputId}><SelectValue placeholder="Select..." /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{(field.options ?? []).map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>{opt}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
id={inputId}
|
||||||
|
type="text"
|
||||||
|
value={value !== undefined && value !== null ? String(value) : ""}
|
||||||
|
onChange={(e) => onValueChange(field.name, e.target.value || null)}
|
||||||
|
required={field.required}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Custom Fields</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{entries.map(([name, value]) => {
|
||||||
|
const field = defs.get(name);
|
||||||
|
return (
|
||||||
|
<div key={name} className="flex items-start gap-2 text-sm">
|
||||||
|
<span className="w-40 shrink-0 text-muted-foreground">{field?.name ?? name}</span>
|
||||||
|
<span className="flex-1 min-w-0 break-words">{formatValue(field, value)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -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<PaginatedResponse<Tag>>(
|
||||||
|
["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 (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 mb-3">
|
||||||
|
{tags.length === 0 && (
|
||||||
|
<span className="text-sm text-muted-foreground">No tags</span>
|
||||||
|
)}
|
||||||
|
{tags.map((t) => (
|
||||||
|
<Badge key={t.id} variant="secondary" style={t.color ? { borderColor: t.color } : undefined}>
|
||||||
|
{t.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeMutation.mutate(t.id)}
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
aria-label={"Remove tag " + t.name}
|
||||||
|
className="ml-1.5 rounded-full p-0.5 text-muted-foreground hover:text-foreground hover:bg-foreground/10"
|
||||||
|
>
|
||||||
|
<X className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Select value={addValue} onValueChange={handleAssign} disabled={availableTags.length === 0}>
|
||||||
|
<SelectTrigger className="h-8 w-64 text-xs" aria-label="Add tag">
|
||||||
|
<SelectValue placeholder={availableTags.length === 0 ? "No more tags to add" : "Add tag..."} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableTags.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>{t.name}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -73,9 +73,9 @@ export function CommandPalette() {
|
|||||||
const { mode, setMode, accent, setAccent } = useThemeStore();
|
const { mode, setMode, accent, setAccent } = useThemeStore();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<
|
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<ReturnType<typeof setTimeout>>();
|
const searchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const [isMobile, setIsMobile] = useState(false);
|
const [isMobile, setIsMobile] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -178,7 +178,7 @@ export function CommandPalette() {
|
|||||||
setSearchResults([
|
setSearchResults([
|
||||||
{
|
{
|
||||||
type: "Agents",
|
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,
|
id: a.id,
|
||||||
title: a.name,
|
title: a.name,
|
||||||
})),
|
})),
|
||||||
@@ -311,17 +311,17 @@ export function CommandPalette() {
|
|||||||
<CommandItem
|
<CommandItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onSelect={() => {
|
onSelect={() => {
|
||||||
const typeRoute =
|
// The search API returns singular types ("task", "note", ...)
|
||||||
group.type === "tasks"
|
// and each result carries a ready-made detail link (e.g.
|
||||||
? "/tasks"
|
// "/tasks/{id}"). Domains have no detail route, so land on the
|
||||||
: group.type === "habits"
|
// dashboard (the domain-scoped home). Agent mentions have no
|
||||||
? "/habits"
|
// detail page either, so just dismiss the palette.
|
||||||
: group.type === "projects"
|
if (group.type === "Agents") {
|
||||||
? "/projects"
|
runCommand(() => {});
|
||||||
: group.type === "notes"
|
return;
|
||||||
? "/notes"
|
}
|
||||||
: "/search";
|
const link = group.type === "domain" ? "/" : item.link!;
|
||||||
runCommand(() => navigate({ to: `${typeRoute}/${item.id}` }));
|
runCommand(() => navigate({ to: link }));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Search className="mr-2 h-4 w-4" />
|
<Search className="mr-2 h-4 w-4" />
|
||||||
|
|||||||
@@ -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<PaginatedResponse<Domain>>(["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 (
|
||||||
|
<div className="hidden md:block">
|
||||||
|
<Select
|
||||||
|
value={activeDomainId ?? undefined}
|
||||||
|
onValueChange={setActiveDomain}
|
||||||
|
disabled={empty}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-40 text-xs" aria-label="Switch domain">
|
||||||
|
<SelectValue placeholder="Domain" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{domains.map((domain) => (
|
||||||
|
<SelectItem key={domain.id} value={domain.id}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{domain.color && (
|
||||||
|
<span
|
||||||
|
className="h-2 w-2 shrink-0 rounded-full"
|
||||||
|
style={{ backgroundColor: domain.color }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{domain.name}</span>
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useLocation } from "@tanstack/react-router";
|
import { Link, useLocation } from "@tanstack/react-router";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||||
@@ -76,6 +77,24 @@ export function Sidebar() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { collapsed, toggle, mobileOpen, setMobileOpen } = useSidebarStore();
|
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<string>).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) => {
|
const isActive = (href: string) => {
|
||||||
if (href === "/") return location.pathname === "/";
|
if (href === "/") return location.pathname === "/";
|
||||||
return location.pathname.startsWith(href);
|
return location.pathname.startsWith(href);
|
||||||
@@ -114,7 +133,7 @@ export function Sidebar() {
|
|||||||
return (
|
return (
|
||||||
<Tooltip key={item.href}>
|
<Tooltip key={item.href}>
|
||||||
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
<TooltipTrigger asChild>{link}</TooltipTrigger>
|
||||||
<TooltipContent side="right">{item.label}</TooltipContent>
|
<TooltipContent side={sidebarPos === "right" ? "left" : "right"}>{item.label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -143,8 +162,9 @@ export function Sidebar() {
|
|||||||
<TooltipProvider delayDuration={0}>
|
<TooltipProvider delayDuration={0}>
|
||||||
<aside
|
<aside
|
||||||
className={cn(
|
className={cn(
|
||||||
"hidden flex-col border-r bg-card transition-all duration-200 md:flex",
|
"hidden flex-col bg-card transition-all duration-200 md:flex",
|
||||||
collapsed ? "w-16" : "w-60"
|
collapsed ? "w-16" : "w-60",
|
||||||
|
sidebarPos === "right" ? "order-last border-l" : "border-r"
|
||||||
)}
|
)}
|
||||||
aria-label="Main navigation"
|
aria-label="Main navigation"
|
||||||
>
|
>
|
||||||
@@ -188,7 +208,7 @@ export function Sidebar() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="right" align="start" className="w-48">
|
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
|
||||||
<DropdownMenuItem onClick={() => {}}>
|
<DropdownMenuItem onClick={() => {}}>
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Profile
|
Profile
|
||||||
@@ -214,7 +234,7 @@ export function Sidebar() {
|
|||||||
<span className="text-sm font-medium">User</span>
|
<span className="text-sm font-medium">User</span>
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent side="right" align="start" className="w-48">
|
<DropdownMenuContent side={sidebarPos === "right" ? "left" : "right"} align="start" className="w-48">
|
||||||
<DropdownMenuItem onClick={() => {}}>
|
<DropdownMenuItem onClick={() => {}}>
|
||||||
<User className="mr-2 h-4 w-4" />
|
<User className="mr-2 h-4 w-4" />
|
||||||
Profile
|
Profile
|
||||||
@@ -236,7 +256,7 @@ export function Sidebar() {
|
|||||||
|
|
||||||
{/* Mobile sheet */}
|
{/* Mobile sheet */}
|
||||||
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
<Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
|
||||||
<SheetContent side="left" className="flex w-72 flex-col p-0 md:hidden">
|
<SheetContent side={sidebarPos === "right" ? "right" : "left"} className="flex w-72 flex-col p-0 md:hidden">
|
||||||
<SheetHeader className="border-b px-4 py-4 pr-12">
|
<SheetHeader className="border-b px-4 py-4 pr-12">
|
||||||
<SheetTitle>Project E</SheetTitle>
|
<SheetTitle>Project E</SheetTitle>
|
||||||
<SheetDescription>Navigate your workspace.</SheetDescription>
|
<SheetDescription>Navigate your workspace.</SheetDescription>
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Search, Bell, Plus, Menu } from "lucide-react";
|
import { Search, Bell, Plus, Menu, RefreshCw } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
import { useSidebarStore } from "@/lib/stores/use-sidebar-store";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { DomainPicker } from "@/components/shell/domain-picker";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
@@ -15,14 +19,45 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { formatDistanceToNow } from "date-fns";
|
||||||
|
import type { NotificationsResponse } from "@/lib/types";
|
||||||
|
|
||||||
|
const ACTION_LABELS: Record<string, string> = {
|
||||||
|
created: "created",
|
||||||
|
updated: "updated",
|
||||||
|
deleted: "deleted",
|
||||||
|
completed: "completed",
|
||||||
|
};
|
||||||
|
|
||||||
|
function readableEntityType(entityType: string): string {
|
||||||
|
return entityType
|
||||||
|
.replace(/_/g, " ")
|
||||||
|
.replace(/\b\w/g, (ch) => ch.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
export function Topbar() {
|
export function Topbar() {
|
||||||
const { setMobileOpen } = useSidebarStore();
|
const { setMobileOpen } = useSidebarStore();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const domainId = useApiDomain();
|
||||||
|
|
||||||
const openPalette = () => {
|
const openPalette = () => {
|
||||||
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
document.dispatchEvent(new CustomEvent("open-command-palette"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const { data: notificationsData } = useApiQuery<NotificationsResponse>(
|
||||||
|
["notifications", domainId],
|
||||||
|
"/notifications?workspace_id=" + encodeURIComponent(domainId),
|
||||||
|
{ enabled: !!domainId, refetchInterval: 60_000 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const notifications = notificationsData?.items || [];
|
||||||
|
const count = notificationsData?.count || 0;
|
||||||
|
const badgeLabel = count > 99 ? "99+" : String(count);
|
||||||
|
const tooltipText =
|
||||||
|
count === 0
|
||||||
|
? "No notifications"
|
||||||
|
: `${count} unread notification${count === 1 ? "" : "s"}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
className="sticky top-0 z-10 flex h-14 items-center gap-4 border-b bg-card px-6"
|
||||||
@@ -55,6 +90,9 @@ export function Topbar() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Domain picker */}
|
||||||
|
<DomainPicker />
|
||||||
|
|
||||||
{/* Right side */}
|
{/* Right side */}
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
{/* Quick add */}
|
{/* Quick add */}
|
||||||
@@ -76,17 +114,57 @@ export function Topbar() {
|
|||||||
|
|
||||||
{/* Notifications bell */}
|
{/* Notifications bell */}
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<DropdownMenu>
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
<TooltipTrigger asChild>
|
||||||
<Bell className="h-5 w-5" />
|
<DropdownMenuTrigger asChild>
|
||||||
<span className="absolute -right-0.5 -top-0.5 flex h-4 w-4 items-center justify-center rounded-full bg-destructive text-[10px] font-medium text-destructive-foreground">
|
<Button variant="ghost" size="icon" className="relative" aria-label="Notifications">
|
||||||
0
|
<Bell className="h-5 w-5" />
|
||||||
</span>
|
{count > 0 && (
|
||||||
</Button>
|
<span
|
||||||
</TooltipTrigger>
|
aria-hidden="true"
|
||||||
<TooltipContent>No notifications</TooltipContent>
|
className="absolute -right-0.5 -top-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-[10px] font-medium text-destructive-foreground"
|
||||||
</Tooltip>
|
>
|
||||||
|
{badgeLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>{tooltipText}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<DropdownMenuContent align="end" className="w-80">
|
||||||
|
<div className="flex items-center justify-between px-2 py-1.5">
|
||||||
|
<span className="text-sm font-medium">Notifications</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 px-2 text-xs"
|
||||||
|
onClick={() => queryClient.invalidateQueries({ queryKey: ["notifications"] })}
|
||||||
|
>
|
||||||
|
<RefreshCw className="h-3 w-3 mr-1" />Refresh
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{notifications.length === 0 ? (
|
||||||
|
<div className="px-3 py-8 text-center text-sm text-muted-foreground">
|
||||||
|
No notifications
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
notifications.slice(0, 10).map((n) => (
|
||||||
|
<DropdownMenuItem key={n.id} className="flex cursor-default flex-col items-start gap-0.5 py-2">
|
||||||
|
<span className="text-sm capitalize">
|
||||||
|
{readableEntityType(n.entityType)}{" "}
|
||||||
|
{ACTION_LABELS[n.action] || n.action}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{n.actor} · {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
||||||
{/* User avatar */}
|
{/* User avatar */}
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { Loader2, Inbox, AlertCircle, RotateCw, type LucideIcon } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered loading state with a spinner and optional label.
|
||||||
|
* Drop-in replacement for inline `<div className="py-12 text-center">Loading...</div>` markup.
|
||||||
|
*/
|
||||||
|
export function LoadingState({ label }: { label?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-12 text-muted-foreground">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin" />
|
||||||
|
{label ? <p className="text-sm">{label}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered empty state: icon, title, optional description and optional CTA.
|
||||||
|
* `action` is rendered below the text (e.g. a Button that opens a create dialog).
|
||||||
|
*/
|
||||||
|
export function EmptyState({
|
||||||
|
icon: Icon = Inbox,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
action,
|
||||||
|
}: {
|
||||||
|
icon?: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
action?: ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||||
|
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-muted">
|
||||||
|
<Icon className="h-6 w-6 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">{title}</p>
|
||||||
|
{description ? <p className="text-sm text-muted-foreground">{description}</p> : null}
|
||||||
|
</div>
|
||||||
|
{action ? <div className="pt-1">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centered error state with a destructive-styled message and an optional
|
||||||
|
* retry button that re-runs the failed query.
|
||||||
|
*/
|
||||||
|
export function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||||
|
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||||
|
<p className="text-sm font-medium text-destructive">{message}</p>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
<RotateCw className="h-4 w-4" />
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
|||||||
const { workspaceId, enabled = true } = options;
|
const { workspaceId, enabled = true } = options;
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const eventSourceRef = useRef<EventSource | null>(null);
|
const eventSourceRef = useRef<EventSource | null>(null);
|
||||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const reconnectAttempts = useRef(0);
|
const reconnectAttempts = useRef(0);
|
||||||
|
|
||||||
const handleEvent = useCallback(
|
const handleEvent = useCallback(
|
||||||
@@ -23,19 +23,22 @@ export function useRealtime(options: UseRealtimeOptions = {}) {
|
|||||||
|
|
||||||
switch (entityType) {
|
switch (entityType) {
|
||||||
case "task":
|
case "task":
|
||||||
queryKeys.push(["tasks"]);
|
queryKeys.push(["tasks"], ["tasks-due"], ["stats"], ["productivity-chart"]);
|
||||||
break;
|
break;
|
||||||
case "habit":
|
case "habit":
|
||||||
queryKeys.push(["habits"]);
|
queryKeys.push(["habits"], ["habits-today"], ["streaks"]);
|
||||||
break;
|
break;
|
||||||
case "project":
|
case "project":
|
||||||
queryKeys.push(["projects"]);
|
queryKeys.push(["projects"], ["active-projects"]);
|
||||||
break;
|
break;
|
||||||
case "note":
|
case "note":
|
||||||
queryKeys.push(["notes"]);
|
queryKeys.push(["notes"], ["recent-notes"]);
|
||||||
break;
|
break;
|
||||||
case "calendar_event":
|
case "calendar_event":
|
||||||
queryKeys.push(["calendar-events"]);
|
queryKeys.push(["calendar-events"], ["upcoming-events"]);
|
||||||
|
break;
|
||||||
|
case "dashboard_widget":
|
||||||
|
queryKeys.push(["dashboard-widgets"]);
|
||||||
break;
|
break;
|
||||||
case "graph_edge":
|
case "graph_edge":
|
||||||
queryKeys.push(["graph"]);
|
queryKeys.push(["graph"]);
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
|
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap");
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
|
/* Typography — Inter via Google Fonts @import above; falls back to system-ui */
|
||||||
|
--font-sans: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
"Helvetica Neue", Arial, sans-serif;
|
||||||
|
|
||||||
|
/* Density — scales content spacing (see the density utilities below and the
|
||||||
|
calc() padding on <main> in the app layout). 1 = comfortable. */
|
||||||
|
--density-scale: 1;
|
||||||
|
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 222.2 84% 4.9%;
|
--foreground: 222.2 84% 4.9%;
|
||||||
--card: 0 0% 100%;
|
--card: 0 0% 100%;
|
||||||
@@ -27,6 +36,17 @@
|
|||||||
--accent-hsl: 217 91% 60%;
|
--accent-hsl: 217 91% 60%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Density modes: toggled on <html> by the settings page (and applied from
|
||||||
|
localStorage on app load). They only flip --density-scale, which the
|
||||||
|
layout padding and the space-y utilities below multiply by. */
|
||||||
|
.density-compact {
|
||||||
|
--density-scale: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.density-spacious {
|
||||||
|
--density-scale: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 222.2 84% 4.9%;
|
--background: 222.2 84% 4.9%;
|
||||||
--foreground: 210 40% 98%;
|
--foreground: 210 40% 98%;
|
||||||
@@ -57,5 +77,55 @@
|
|||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@apply bg-background text-foreground;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
text-rendering: optimizeLegibility;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Real reduced-motion support: the settings page toggles `.reduce-motion` on
|
||||||
|
<html>. This standard override collapses animation/transition durations so
|
||||||
|
users who enable it get an effectively static UI. */
|
||||||
|
.reduce-motion *,
|
||||||
|
.reduce-motion *::before,
|
||||||
|
.reduce-motion *::after {
|
||||||
|
animation-duration: 0.001s !important;
|
||||||
|
transition-duration: 0.001s !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer utilities {
|
||||||
|
/* Density: multiply the vertical rhythm between stacked sections inside page
|
||||||
|
content. Mirrors Tailwind's own `.space-y-*` selector shape and is scoped
|
||||||
|
to <main> so the sidebar and topbar stay fixed. Combined with the calc()
|
||||||
|
padding on <main> in the app layout, the Density setting now visibly
|
||||||
|
changes spacing (0.85x compact, 1.15x spacious). */
|
||||||
|
.density-compact main .space-y-1 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-1 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(0.25rem * var(--density-scale));
|
||||||
|
}
|
||||||
|
.density-compact main .space-y-2 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-2 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(0.5rem * var(--density-scale));
|
||||||
|
}
|
||||||
|
.density-compact main .space-y-3 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-3 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(0.75rem * var(--density-scale));
|
||||||
|
}
|
||||||
|
.density-compact main .space-y-4 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-4 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(1rem * var(--density-scale));
|
||||||
|
}
|
||||||
|
.density-compact main .space-y-5 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-5 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(1.25rem * var(--density-scale));
|
||||||
|
}
|
||||||
|
.density-compact main .space-y-6 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-6 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(1.5rem * var(--density-scale));
|
||||||
|
}
|
||||||
|
.density-compact main .space-y-8 > :not([hidden]) ~ :not([hidden]),
|
||||||
|
.density-spacious main .space-y-8 > :not([hidden]) ~ :not([hidden]) {
|
||||||
|
margin-top: calc(2rem * var(--density-scale));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ async function apiFetch<T>(
|
|||||||
throw new Error(error.message);
|
throw new Error(error.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some routes return 204 No Content (e.g. deletes). res.json() throws on an
|
||||||
|
// empty body, so return undefined for those instead.
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Shared semantic color tokens for entities (status, priority, type).
|
||||||
|
*
|
||||||
|
* Single source of truth — pages must import these instead of defining local
|
||||||
|
* color maps. All values are Tailwind classes unless the comment says otherwise.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface StatusToken {
|
||||||
|
label: string;
|
||||||
|
/** Tailwind class for a small colored dot (e.g. `w-2 h-2 rounded-full`). */
|
||||||
|
dot: string;
|
||||||
|
/** Tailwind classes for a filled Badge (bg + text). */
|
||||||
|
badge: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Task workflow statuses (board column dots + badges). */
|
||||||
|
export const TASK_STATUS: Record<string, StatusToken> = {
|
||||||
|
todo: { label: "Todo", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
||||||
|
in_progress: { label: "In Progress", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
||||||
|
done: { label: "Done", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
||||||
|
cancelled: { label: "Cancelled", dot: "bg-red-500", badge: "bg-red-500 text-white" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Task priority. Badges use a soft tint (matching text + translucent bg). */
|
||||||
|
export const PRIORITY: Record<string, { label: string; badge: string }> = {
|
||||||
|
low: { label: "Low", badge: "text-slate-500 bg-slate-500/10" },
|
||||||
|
medium: { label: "Medium", badge: "text-blue-500 bg-blue-500/10" },
|
||||||
|
high: { label: "High", badge: "text-orange-500 bg-orange-500/10" },
|
||||||
|
urgent: { label: "Urgent", badge: "text-red-500 bg-red-500/10" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Project lifecycle statuses. */
|
||||||
|
export const PROJECT_STATUS: Record<string, StatusToken> = {
|
||||||
|
active: { label: "Active", dot: "bg-green-500", badge: "bg-green-500 text-white" },
|
||||||
|
paused: { label: "Paused", dot: "bg-amber-500", badge: "bg-amber-500 text-white" },
|
||||||
|
completed: { label: "Completed", dot: "bg-blue-500", badge: "bg-blue-500 text-white" },
|
||||||
|
archived: { label: "Archived", dot: "bg-slate-500", badge: "bg-slate-500 text-white" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Graph node colors by entity type (hex).
|
||||||
|
*
|
||||||
|
* These are consumed by the canvas renderer (`ctx.fillStyle`) and inline
|
||||||
|
* `style={{ backgroundColor }}` props. CSS variables don't work in canvas
|
||||||
|
* `fillStyle`, so keep literal hex values here.
|
||||||
|
*/
|
||||||
|
export const ENTITY: Record<string, string> = {
|
||||||
|
task: "#3b82f6",
|
||||||
|
habit: "#10b981",
|
||||||
|
project: "#8b5cf6",
|
||||||
|
note: "#f59e0b",
|
||||||
|
section: "#ec4899",
|
||||||
|
tag: "#6b7280",
|
||||||
|
domain: "#6366f1",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calendar event colors by entity type.
|
||||||
|
*
|
||||||
|
* Values are HSL triplets consumed as `hsl(${hue})` / `hsla(${hue}, 0.15)`.
|
||||||
|
* `task` resolves through the theme accent (`var(--accent-hsl)`), so events
|
||||||
|
* pick up the user's chosen accent color.
|
||||||
|
*/
|
||||||
|
export const CALENDAR_EVENT: Record<string, string> = {
|
||||||
|
task: "var(--accent-hsl, 217 91% 60%)",
|
||||||
|
habit: "142 71% 45%",
|
||||||
|
project: "271 81% 56%",
|
||||||
|
note: "24 95% 53%",
|
||||||
|
default: "215 16% 47%",
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { persist } from "zustand/middleware";
|
||||||
|
import { useApiQuery } from "@/lib/api";
|
||||||
|
|
||||||
|
interface ActiveDomainState {
|
||||||
|
activeDomainId: string | null;
|
||||||
|
setActiveDomain: (id: string | null) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useActiveDomainStore = create<ActiveDomainState>()(
|
||||||
|
persist(
|
||||||
|
(set) => ({
|
||||||
|
activeDomainId: null,
|
||||||
|
setActiveDomain: (id) => set({ activeDomainId: id }),
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
name: "project-e-active-domain",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Raw selector: reads the persisted value (may reference a deleted domain).
|
||||||
|
export function useActiveDomainId(): string | null {
|
||||||
|
return useActiveDomainStore((s) => s.activeDomainId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolved active domain id for API calls. Validates the persisted value
|
||||||
|
// against the user's domains (falling back to the first domain) so consumers
|
||||||
|
// never query a domain that no longer exists. Returns "" until domains load.
|
||||||
|
// Shares the ["domains"] query cache, so it adds no extra network requests.
|
||||||
|
export function useApiDomain(): string {
|
||||||
|
const activeDomainId = useActiveDomainId();
|
||||||
|
const { data } = useApiQuery<{ items: { id: string }[] }>(["domains"], "/domains");
|
||||||
|
const items = data?.items || [];
|
||||||
|
if (items.length === 0) return "";
|
||||||
|
if (activeDomainId && items.some((d) => d.id === activeDomainId)) return activeDomainId;
|
||||||
|
return items[0].id;
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ export interface Task {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
deletedAt: string | null;
|
deletedAt: string | null;
|
||||||
tags: Tag[];
|
tags: Tag[];
|
||||||
|
customFields?: Record<string, unknown>;
|
||||||
subtasks?: Task[];
|
subtasks?: Task[];
|
||||||
dependencies?: { id: string; title: string; status: string }[];
|
dependencies?: { id: string; title: string; status: string }[];
|
||||||
dependents?: { id: string; title: string; status: string }[];
|
dependents?: { id: string; title: string; status: string }[];
|
||||||
@@ -133,6 +134,8 @@ export interface GraphNode {
|
|||||||
label: string;
|
label: string;
|
||||||
type: string;
|
type: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
x?: number;
|
||||||
|
y?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GraphEdge {
|
export interface GraphEdge {
|
||||||
@@ -259,10 +262,27 @@ export interface Agent {
|
|||||||
domainId: string;
|
domainId: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
|
apiKey?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
actor: string;
|
||||||
|
action: string;
|
||||||
|
entityType: string;
|
||||||
|
entityId: string;
|
||||||
|
changes: Record<string, unknown> | null;
|
||||||
|
workspaceId: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NotificationsResponse {
|
||||||
|
items: Notification[];
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AgentActivity {
|
export interface AgentActivity {
|
||||||
id: string;
|
id: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
@@ -343,10 +363,28 @@ export interface HabitAnalytics {
|
|||||||
period: number;
|
period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectAnalytics {
|
export interface DailyAnalyticsItem {
|
||||||
taskCompletionRate: number;
|
date: string;
|
||||||
totalTasks: number;
|
created: number;
|
||||||
completedTasks: number;
|
completed: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyAnalytics {
|
||||||
|
items: DailyAnalyticsItem[];
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectProgress {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
totalTasks: number;
|
||||||
|
completedTasks: number;
|
||||||
|
progress: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectAnalytics {
|
||||||
|
projects: ProjectProgress[];
|
||||||
|
totalProjects: number;
|
||||||
period: number;
|
period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||||
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
export const Route = createRootRoute({
|
export const Route = createRootRoute({
|
||||||
component: () => (
|
component: () => (
|
||||||
<div className="min-h-screen bg-background text-foreground">
|
<div className="min-h-screen bg-background text-foreground">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
<Toaster />
|
||||||
{import.meta.env.DEV && <TanStackRouterDevtools />}
|
{import.meta.env.DEV && <TanStackRouterDevtools />}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
import { createRoute, Outlet } from "@tanstack/react-router";
|
import { createRoute, Outlet } from "@tanstack/react-router";
|
||||||
import { Route as rootRoute } from "./__root";
|
import { Route as rootRoute } from "./__root";
|
||||||
import { Sidebar } from "@/components/shell/sidebar";
|
import { Sidebar } from "@/components/shell/sidebar";
|
||||||
@@ -9,6 +10,18 @@ import { useKeyboardShortcuts } from "@/hooks/use-keyboard-shortcuts";
|
|||||||
function AppLayout() {
|
function AppLayout() {
|
||||||
useKeyboardShortcuts();
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
|
// Apply persisted appearance preferences (density, reduced motion) right
|
||||||
|
// after the first paint. The settings page updates these live while open;
|
||||||
|
// this covers reloads where the settings page was never visited.
|
||||||
|
useEffect(() => {
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("density-compact", "density-spacious");
|
||||||
|
const density = localStorage.getItem("density");
|
||||||
|
if (density === "compact") root.classList.add("density-compact");
|
||||||
|
if (density === "spacious") root.classList.add("density-spacious");
|
||||||
|
if (localStorage.getItem("reduced-motion") === "true") root.classList.add("reduce-motion");
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen">
|
<div className="flex min-h-screen">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -16,7 +29,7 @@ function AppLayout() {
|
|||||||
<Topbar />
|
<Topbar />
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
className="flex-1 overflow-auto p-4 md:p-6"
|
className="flex-1 overflow-auto p-[calc(1rem*var(--density-scale))] md:p-[calc(1.5rem*var(--density-scale))]"
|
||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
|
import { Bot, Filter, Calendar, RefreshCw, ExternalLink, Clock, Activity } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LoadingState, EmptyState } from "@/components/state";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
@@ -57,8 +58,20 @@ function AgentActivityPage() {
|
|||||||
es.onmessage = (event) => {
|
es.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
if (data.type === "agent_activity" || data.type === "activity") {
|
// Realtime events are flat: { type: entityType, action, id, workspace_id }.
|
||||||
setLiveActivities((prev) => [data.payload, ...prev].slice(0, 5));
|
// Match only agent events so unrelated task/habit/etc. activity doesn't leak in.
|
||||||
|
if (data.type === "agent") {
|
||||||
|
const entry: AgentActivity = {
|
||||||
|
id: `${data.id}-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
agentId: data.id,
|
||||||
|
action: data.action,
|
||||||
|
description: `Live update: ${data.action}`,
|
||||||
|
entityType: "agent",
|
||||||
|
entityId: data.id,
|
||||||
|
metadata: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
setLiveActivities((prev) => [entry, ...prev].slice(0, 5));
|
||||||
}
|
}
|
||||||
} catch {}
|
} catch {}
|
||||||
};
|
};
|
||||||
@@ -117,9 +130,9 @@ function AgentActivityPage() {
|
|||||||
|
|
||||||
{/* Timeline */}
|
{/* Timeline */}
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading activity...</div>
|
<LoadingState label="Loading activity..." />
|
||||||
) : activities.length === 0 ? (
|
) : activities.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">No activity found</div>
|
<EmptyState title="No activity found" description="Agent activity will appear here as agents run" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{activities.map((a, idx) => (
|
{activities.map((a, idx) => (
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
import { useState, useMemo } from "react";
|
import { useState, useMemo } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useApiQuery } from "@/lib/api";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { Download, Calendar, TrendingUp, BarChart3, PieChart, Activity, Grid3X3 } from "lucide-react";
|
import { Download, Calendar } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LoadingState, ErrorState } from "@/components/state";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { ProductivityData, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
import type { DailyAnalytics, HabitAnalytics, ProjectAnalytics } from "@/lib/types";
|
||||||
import { format, subDays, parseISO, startOfMonth, eachDayOfInterval } from "date-fns";
|
import { format, subDays } from "date-fns";
|
||||||
|
|
||||||
// Simple SVG-based charts (no recharts dependency needed for basic charts)
|
// Simple SVG-based charts (no recharts dependency needed for basic charts)
|
||||||
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
||||||
@@ -34,19 +34,27 @@ function LineChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function BarChart({ data, xKey, yKey, color = "#3b82f6", height = 120 }: { data: any[]; xKey: string; yKey: string; color?: string; height?: number }) {
|
function BarChart({ data, xKey, yKey, yKey2, color = "#3b82f6", color2 = "#f97316", height = 120 }: { data: any[]; xKey: string; yKey: string; yKey2?: string; color?: string; color2?: string; height?: number }) {
|
||||||
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
if (!data.length) return <p className="text-sm text-muted-foreground text-center py-8">No data</p>;
|
||||||
const maxVal = Math.max(...data.map((d) => d[yKey]), 1);
|
const valOf = (d: any, key?: string) => (key ? ((d[key] as number) ?? 0) : 0);
|
||||||
const barWidth = Math.max(20, Math.min(40, (300 / data.length)));
|
const maxVal = Math.max(...data.map((d) => Math.max(valOf(d, yKey), valOf(d, yKey2))), 1);
|
||||||
const width = Math.max(data.length * (barWidth + 4) + 40, 200);
|
const series = yKey2 ? 2 : 1;
|
||||||
|
const barWidth = Math.max(20, Math.min(40, (300 / data.length) / series));
|
||||||
|
const width = Math.max(data.length * (barWidth * series + 4) + 40, 200);
|
||||||
return (
|
return (
|
||||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full h-auto" aria-label="Bar chart">
|
||||||
{data.map((d, i) => {
|
{data.map((d, i) => {
|
||||||
const barH = (d[yKey] / maxVal) * (height - 30);
|
const barH = (valOf(d, yKey) / maxVal) * (height - 30);
|
||||||
const x = i * (barWidth + 4) + 20;
|
const x = i * (barWidth * series + 4) + 20;
|
||||||
const y = height - 20 - barH;
|
const y = height - 20 - barH;
|
||||||
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
|
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color} rx="2" />;
|
||||||
})}
|
})}
|
||||||
|
{yKey2 && data.map((d, i) => {
|
||||||
|
const barH = (valOf(d, yKey2) / maxVal) * (height - 30);
|
||||||
|
const x = i * (barWidth * series + 4) + 20 + barWidth;
|
||||||
|
const y = height - 20 - barH;
|
||||||
|
return <rect key={i} x={x} y={y} width={barWidth} height={barH} fill={color2} rx="2" />;
|
||||||
|
})}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -129,23 +137,22 @@ function CalendarHeatmap({ data, days = 30 }: { data: any[]; days?: number }) {
|
|||||||
|
|
||||||
function AnalyticsPage() {
|
function AnalyticsPage() {
|
||||||
const [range, setRange] = useState("30");
|
const [range, setRange] = useState("30");
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
const domainSuffix = activeDomainId ? "&domain=" + activeDomainId : "";
|
||||||
|
|
||||||
const { data: prodData } = useApiQuery<ProductivityData>(["analytics-productivity", range], "/analytics/productivity?range=" + range);
|
const { data: habitData, isLoading: habitsLoading, error: habitsError, refetch: refetchHabits } = useApiQuery<HabitAnalytics>(["analytics-habits", activeDomainId, range], "/analytics/habits?range=" + range + domainSuffix);
|
||||||
const { data: habitData } = useApiQuery<HabitAnalytics>(["analytics-habits", range], "/analytics/habits?range=" + range);
|
const { data: projectData, isLoading: projectsLoading, error: projectsError, refetch: refetchProjects } = useApiQuery<ProjectAnalytics>(["analytics-projects", activeDomainId, range], "/analytics/projects?range=" + range + domainSuffix);
|
||||||
const { data: projectData } = useApiQuery<ProjectAnalytics>(["analytics-projects", range], "/analytics/projects?range=" + range);
|
const { data: dailyData, isLoading: dailyLoading, error: dailyError, refetch: refetchDaily } = useApiQuery<DailyAnalytics>(["analytics-daily", activeDomainId, range], "/analytics/daily?range=" + range + domainSuffix);
|
||||||
|
|
||||||
// Generate mock daily data for charts (real API returns aggregated, we simulate daily breakdown)
|
const analyticsLoading = habitsLoading || projectsLoading || dailyLoading;
|
||||||
const dailyData = useMemo(() => {
|
const analyticsError = habitsError || projectsError || dailyError;
|
||||||
const days = parseInt(range);
|
const refetchAnalytics = () => {
|
||||||
return Array.from({ length: days }, (_, i) => {
|
refetchHabits();
|
||||||
const d = subDays(new Date(), days - 1 - i);
|
refetchProjects();
|
||||||
return {
|
refetchDaily();
|
||||||
date: format(d, "yyyy-MM-dd"),
|
};
|
||||||
completed: Math.floor(Math.random() * 5),
|
|
||||||
created: Math.floor(Math.random() * 8) + 1,
|
const dailyItems = dailyData?.items || [];
|
||||||
};
|
|
||||||
});
|
|
||||||
}, [range]);
|
|
||||||
|
|
||||||
const habitRateData = useMemo(() => {
|
const habitRateData = useMemo(() => {
|
||||||
return [
|
return [
|
||||||
@@ -182,15 +189,20 @@ function AnalyticsPage() {
|
|||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{analyticsLoading ? (
|
||||||
|
<LoadingState label="Loading analytics..." />
|
||||||
|
) : analyticsError ? (
|
||||||
|
<ErrorState message={analyticsError.message || "Failed to load analytics"} onRetry={refetchAnalytics} />
|
||||||
|
) : (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{/* Tasks completed per day */}
|
{/* Tasks completed per day */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
|
<CardTitle className="text-sm font-semibold">Tasks Completed</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-completed.csv", [["Date", "Completed"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<LineChart data={dailyData} xKey="date" yKey="completed" />
|
<LineChart data={dailyItems} xKey="date" yKey="completed" />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -198,10 +210,10 @@ function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
<CardTitle className="text-sm font-semibold">Created vs Completed</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyData.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-created-vs-completed.csv", [["Date", "Created", "Completed"], ...dailyItems.map((d) => [d.date, String(d.created), String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<BarChart data={dailyData} xKey="date" yKey="created" color="#f97316" />
|
<BarChart data={dailyItems} xKey="date" yKey="created" yKey2="completed" color="#f97316" color2="#3b82f6" />
|
||||||
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-4 mt-2 text-xs text-muted-foreground">
|
||||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span>
|
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-orange-500" /> Created</span>
|
||||||
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
|
<span className="flex items-center gap-1"><div className="w-2.5 h-2.5 rounded bg-blue-500" /> Completed</span>
|
||||||
@@ -224,34 +236,37 @@ function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
<CardTitle className="text-sm font-semibold">Project Progress</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Metric", "Value"], ["Rate", String(projectData?.taskCompletionRate || 0)], ["Total", String(projectData?.totalTasks || 0)], ["Completed", String(projectData?.completedTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("project-progress.csv", [["Project", "Total Tasks", "Completed", "Progress"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks), String(p.completedTasks), String(Math.round(p.progress * 100)) + "%"])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<div className="grid grid-cols-3 gap-2 text-center">
|
{projectData?.projects.length ? (
|
||||||
<div className="p-2 bg-muted/50 rounded">
|
<div className="space-y-2">
|
||||||
<p className="text-lg font-bold">{projectData?.taskCompletionRate || 0}%</p>
|
<p className="text-xs text-muted-foreground">{projectData.totalProjects} project{projectData.totalProjects === 1 ? "" : "s"} · progress is % of tasks done</p>
|
||||||
<p className="text-[10px] text-muted-foreground">Rate</p>
|
<HorizontalBar
|
||||||
|
data={projectData.projects.map((p) => ({ name: p.name, progress: Math.round(p.progress * 100) }))}
|
||||||
|
xKey="name"
|
||||||
|
yKey="progress"
|
||||||
|
height={Math.max(100, projectData.projects.length * 26)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-2 bg-muted/50 rounded">
|
) : (
|
||||||
<p className="text-lg font-bold">{projectData?.totalTasks || 0}</p>
|
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||||
<p className="text-[10px] text-muted-foreground">Total</p>
|
)}
|
||||||
</div>
|
|
||||||
<div className="p-2 bg-muted/50 rounded">
|
|
||||||
<p className="text-lg font-bold text-green-500">{projectData?.completedTasks || 0}</p>
|
|
||||||
<p className="text-[10px] text-muted-foreground">Done</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Time spent per domain (pie) */}
|
{/* Tasks by project (pie) */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Time per Domain</CardTitle>
|
<CardTitle className="text-sm font-semibold">Tasks by Project</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("time-per-domain.csv", [["Domain", "Tasks"], ["Default", String(prodData?.totalTasks || 0)]])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("tasks-by-project.csv", [["Project", "Total Tasks"], ...(projectData?.projects || []).map((p) => [p.name, String(p.totalTasks)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<PieChartSimple data={[{ name: "Default", value: prodData?.totalTasks || 1 }]} labelKey="name" valueKey="value" />
|
{projectData?.projects.length ? (
|
||||||
|
<PieChartSimple data={projectData.projects.map((p) => ({ name: p.name, value: p.totalTasks }))} labelKey="name" valueKey="value" />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-8">No projects yet</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
@@ -259,13 +274,14 @@ function AnalyticsPage() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
<CardHeader className="p-4 pb-0 flex flex-row items-center justify-between">
|
||||||
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
|
<CardTitle className="text-sm font-semibold">Productivity Heatmap</CardTitle>
|
||||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyData.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={() => downloadCSV("heatmap.csv", [["Date", "Count"], ...dailyItems.map((d) => [d.date, String(d.completed)])])}><Download className="h-3.5 w-3.5" /></Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="p-4">
|
<CardContent className="p-4">
|
||||||
<CalendarHeatmap data={dailyData.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
<CalendarHeatmap data={dailyItems.map((d) => ({ date: d.date, count: d.completed }))} days={parseInt(range)} />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
import { useState, useMemo, useCallback, useEffect, useRef } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient, useMutation } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
import { Plus, Trash2, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LoadingState, EmptyState } from "@/components/state";
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
@@ -14,9 +17,10 @@ import type { CalendarEvent } from "@/lib/types";
|
|||||||
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
|
import { format, parseISO, addDays, startOfWeek, getDay } from "date-fns";
|
||||||
|
|
||||||
// react-big-calendar
|
// react-big-calendar
|
||||||
import { Calendar, dateFnsLocalizer, Views, Navigate } from "react-big-calendar";
|
import { Calendar, dateFnsLocalizer, Navigate, type View, type stringOrDate } from "react-big-calendar";
|
||||||
// import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
import withDragAndDrop from "react-big-calendar/lib/addons/dragAndDrop";
|
||||||
import "react-big-calendar/lib/css/react-big-calendar.css";
|
import "react-big-calendar/lib/css/react-big-calendar.css";
|
||||||
|
import "react-big-calendar/lib/addons/dragAndDrop/styles.css";
|
||||||
|
|
||||||
const localizer = dateFnsLocalizer({
|
const localizer = dateFnsLocalizer({
|
||||||
startOfWeek,
|
startOfWeek,
|
||||||
@@ -25,7 +29,10 @@ const localizer = dateFnsLocalizer({
|
|||||||
locales: {},
|
locales: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
// const DragAndDropCalendar = withDragAndDrop(Calendar);
|
// Mapped calendar event shape used by react-big-calendar (adds Date start/end accessors)
|
||||||
|
type CalendarViewEvent = CalendarEvent & { start: Date; end: Date };
|
||||||
|
|
||||||
|
const DragAndDropCalendar = withDragAndDrop<CalendarViewEvent>(Calendar);
|
||||||
|
|
||||||
// Color palette: tasks=accent, events by domain
|
// Color palette: tasks=accent, events by domain
|
||||||
const EVENT_COLORS: Record<string, string> = {
|
const EVENT_COLORS: Record<string, string> = {
|
||||||
@@ -95,11 +102,21 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
|||||||
const [color, setColor] = useState(event?.color || "#3b82f6");
|
const [color, setColor] = useState(event?.color || "#3b82f6");
|
||||||
|
|
||||||
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
|
const createMutation = useApiMutation<CalendarEvent, any>("post", "/calendar/events", {
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
toast.success("Event created");
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to create event"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
|
const updateMutation = useApiMutation<CalendarEvent, any>("patch", event ? `/calendar/events/${event.id}` : "", {
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); onClose(); },
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
toast.success("Event updated");
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to update event"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
@@ -147,7 +164,7 @@ function EventForm({ event, onClose }: { event?: CalendarEvent; onClose: () => v
|
|||||||
function CalendarPage() {
|
function CalendarPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [date, setDate] = useState(new Date());
|
const [date, setDate] = useState(new Date());
|
||||||
const [view, setView] = useState<string>("month");
|
const [view, setView] = useState<View>("month");
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
const [selectedEvent, setSelectedEvent] = useState<CalendarEvent | null>(null);
|
||||||
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
const [eventDetailOpen, setEventDetailOpen] = useState(false);
|
||||||
@@ -163,19 +180,27 @@ function CalendarPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const { data: eventsData } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
const activeDomainId = useApiDomain();
|
||||||
["calendar-events", date.toISOString()],
|
|
||||||
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}`
|
const { data: eventsData, isLoading: eventsLoading } = useApiQuery<{ items: CalendarEvent[]; totalItems: number }>(
|
||||||
|
["calendar-events", activeDomainId, date.toISOString()],
|
||||||
|
`/calendar/events?from=${new Date(0).toISOString()}&to=${new Date("2100-01-01").toISOString()}` + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const events = eventsData?.items || [];
|
const events = eventsData?.items || [];
|
||||||
|
|
||||||
const deleteMutation = useApiMutation<any, string>("delete", "", {
|
const deleteMutation = useMutation({
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["calendar-events"] }); setEventDetailOpen(false); },
|
mutationFn: (id: string) => api.delete("/calendar/events/" + id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
|
setEventDetailOpen(false);
|
||||||
|
toast.success("Event deleted");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to delete event"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Map API events to react-big-calendar format
|
// Map API events to react-big-calendar format
|
||||||
const calendarEvents = useMemo(() => {
|
const calendarEvents = useMemo<CalendarViewEvent[]>(() => {
|
||||||
return events.map((evt) => ({
|
return events.map((evt) => ({
|
||||||
...evt,
|
...evt,
|
||||||
start: parseISO(evt.startTime),
|
start: parseISO(evt.startTime),
|
||||||
@@ -193,10 +218,10 @@ function CalendarPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleEventDrop = useCallback(
|
const handleEventDrop = useCallback(
|
||||||
({ event, start, end }: { event: any; start: Date; end: Date }) => {
|
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
||||||
api.patch(`/calendar/events/${event.id}`, {
|
api.patch(`/calendar/events/${event.id}`, {
|
||||||
startTime: start.toISOString(),
|
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
||||||
endTime: end.toISOString(),
|
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
});
|
});
|
||||||
@@ -205,10 +230,10 @@ function CalendarPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleEventResize = useCallback(
|
const handleEventResize = useCallback(
|
||||||
({ event, start, end }: { event: any; start: Date; end: Date }) => {
|
({ event, start, end }: { event: any; start: stringOrDate; end: stringOrDate }) => {
|
||||||
api.patch(`/calendar/events/${event.id}`, {
|
api.patch(`/calendar/events/${event.id}`, {
|
||||||
startTime: start.toISOString(),
|
startTime: start instanceof Date ? start.toISOString() : new Date(start).toISOString(),
|
||||||
endTime: end.toISOString(),
|
endTime: end instanceof Date ? end.toISOString() : new Date(end).toISOString(),
|
||||||
}).then(() => {
|
}).then(() => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
queryClient.invalidateQueries({ queryKey: ["calendar-events"] });
|
||||||
});
|
});
|
||||||
@@ -221,7 +246,7 @@ function CalendarPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleViewChange = useCallback((newView: string) => {
|
const handleViewChange = useCallback((newView: string) => {
|
||||||
setView(newView);
|
setView(newView as View);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -245,38 +270,54 @@ function CalendarPage() {
|
|||||||
key={name}
|
key={name}
|
||||||
variant={view === name ? "default" : "outline"}
|
variant={view === name ? "default" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setView(name)}
|
onClick={() => setView(name as View)}
|
||||||
className="capitalize"
|
className="capitalize"
|
||||||
>
|
>
|
||||||
{name}
|
{name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
|
{eventsLoading && events.length === 0 ? (
|
||||||
<Calendar
|
<LoadingState label="Loading events..." />
|
||||||
key={view}
|
) : !eventsLoading && events.length === 0 ? (
|
||||||
localizer={localizer}
|
<EmptyState
|
||||||
events={calendarEvents}
|
title="No events yet"
|
||||||
startAccessor="start"
|
description="Create your first event to get started"
|
||||||
endAccessor="end"
|
action={
|
||||||
date={date}
|
<Button onClick={() => setCreateOpen(true)}>
|
||||||
view={view}
|
<Plus className="h-4 w-4 mr-2" />New Event
|
||||||
onNavigate={handleNavigate}
|
</Button>
|
||||||
onSelectEvent={handleSelectEvent}
|
}
|
||||||
onSelectSlot={handleSelectSlot}
|
|
||||||
selectable
|
|
||||||
popup
|
|
||||||
showMultiDayTimes
|
|
||||||
components={{
|
|
||||||
event: EventComponent,
|
|
||||||
toolbar: (props: any) => <CustomToolbar {...props} />,
|
|
||||||
}}
|
|
||||||
views={["month", "week", "work_week", "day", "agenda"]}
|
|
||||||
step={30}
|
|
||||||
timeslots={2}
|
|
||||||
style={{ height: isMobile ? 400 : 600 }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<div className="rbc-calendar-container" style={{ minHeight: isMobile ? 400 : 600 }}>
|
||||||
|
<DragAndDropCalendar
|
||||||
|
key={view}
|
||||||
|
localizer={localizer}
|
||||||
|
events={calendarEvents}
|
||||||
|
startAccessor="start"
|
||||||
|
endAccessor="end"
|
||||||
|
date={date}
|
||||||
|
view={view}
|
||||||
|
onNavigate={handleNavigate}
|
||||||
|
onSelectEvent={handleSelectEvent}
|
||||||
|
onSelectSlot={handleSelectSlot}
|
||||||
|
onEventDrop={handleEventDrop}
|
||||||
|
onEventResize={handleEventResize}
|
||||||
|
selectable
|
||||||
|
popup
|
||||||
|
showMultiDayTimes
|
||||||
|
components={{
|
||||||
|
event: EventComponent,
|
||||||
|
toolbar: (props: any) => <CustomToolbar {...props} />,
|
||||||
|
}}
|
||||||
|
views={["month", "week", "work_week", "day", "agenda"]}
|
||||||
|
step={30}
|
||||||
|
timeslots={2}
|
||||||
|
style={{ height: isMobile ? 400 : 600 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Event detail dialog */}
|
{/* Event detail dialog */}
|
||||||
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
|
<Dialog open={eventDetailOpen} onOpenChange={setEventDetailOpen}>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useCallback, useRef, useEffect } from "react";
|
import { useState, useCallback, useRef, useEffect } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
|
import { Plus, Trash2, GripVertical, Type, Heading1, Heading2, List, CheckSquare, Code, Image, FileText, ArrowUp, ArrowDown, Bold, Italic } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -186,13 +186,26 @@ function BlockEditor({ block, onChange, onDelete, onMoveUp, onMoveDown }: {
|
|||||||
|
|
||||||
// ─── Canvas Editor ────────────────────────────────────────────────────────
|
// ─── Canvas Editor ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
type Block = { id: string; type: string; content: string };
|
||||||
|
|
||||||
|
export function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [blocks, setBlocks] = useState<Array<{ id: string; type: string; content: string }>>(
|
const [blocks, setBlocks] = useState<Block[]>(
|
||||||
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
|
canvas.cards?.map((c) => ({ id: c.id, type: c.type, content: c.content })) || [{ id: "new-1", type: "text", content: "" }]
|
||||||
);
|
);
|
||||||
const [title, setTitle] = useState(canvas.name);
|
const [title, setTitle] = useState(canvas.name);
|
||||||
|
const [saveState, setSaveState] = useState<"idle" | "saving" | "saved" | "error">("idle");
|
||||||
const blockIdCounter = useRef(blocks.length + 1);
|
const blockIdCounter = useRef(blocks.length + 1);
|
||||||
|
const lastSavedTitle = useRef(canvas.name);
|
||||||
|
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const isDirty = useRef(false);
|
||||||
|
const lastSeen = useRef<{ blocks: Block[]; title: string }>({ blocks, title });
|
||||||
|
|
||||||
|
// Keep latest values reachable from the debounced save without stale closures
|
||||||
|
const blocksRef = useRef(blocks);
|
||||||
|
blocksRef.current = blocks;
|
||||||
|
const titleRef = useRef(title);
|
||||||
|
titleRef.current = title;
|
||||||
|
|
||||||
// Listen for block type changes
|
// Listen for block type changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -204,10 +217,84 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
|||||||
return () => window.removeEventListener("change-block-type", handler);
|
return () => window.removeEventListener("change-block-type", handler);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const updateMutation = useMutation({
|
// Persist the full block list via the bulk endpoint, then adopt the
|
||||||
mutationFn: (data: any) => api.patch("/canvas/" + canvas.id, data),
|
// server-generated ids for newly created blocks (content stays local).
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
const persist = useCallback(async () => {
|
||||||
});
|
const currentBlocks = blocksRef.current;
|
||||||
|
const currentTitle = titleRef.current;
|
||||||
|
setSaveState("saving");
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
cards: currentBlocks.map((b, i) => ({
|
||||||
|
...(b.id.startsWith("new-") || b.id.startsWith("block-") ? {} : { id: b.id }),
|
||||||
|
type: b.type,
|
||||||
|
content: b.content,
|
||||||
|
zIndex: i,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
const result = await api.put<{ cards: { id: string }[] }>("/canvas/" + canvas.id + "/cards", payload);
|
||||||
|
setBlocks((prev) => {
|
||||||
|
if (result.cards.length !== prev.length) return prev;
|
||||||
|
let changed = false;
|
||||||
|
const next = prev.map((b, i) => {
|
||||||
|
const newId = result.cards[i]?.id;
|
||||||
|
if (newId && b.id !== newId) {
|
||||||
|
changed = true;
|
||||||
|
return { ...b, id: newId };
|
||||||
|
}
|
||||||
|
return b;
|
||||||
|
});
|
||||||
|
return changed ? next : prev;
|
||||||
|
});
|
||||||
|
if (currentTitle !== lastSavedTitle.current) {
|
||||||
|
await api.patch("/canvas/" + canvas.id, { name: currentTitle });
|
||||||
|
lastSavedTitle.current = currentTitle;
|
||||||
|
}
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||||
|
isDirty.current = false;
|
||||||
|
setSaveState("saved");
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[canvas] save failed:", error);
|
||||||
|
setSaveState("error");
|
||||||
|
}
|
||||||
|
}, [canvas.id, queryClient]);
|
||||||
|
|
||||||
|
// Debounced autosave: persist shortly after blocks/title stop changing
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastSeen.current.blocks === blocks && lastSeen.current.title === title) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastSeen.current = { blocks, title };
|
||||||
|
isDirty.current = true;
|
||||||
|
if (saveTimer.current) clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = setTimeout(() => {
|
||||||
|
saveTimer.current = null;
|
||||||
|
persist();
|
||||||
|
}, 800);
|
||||||
|
return () => {
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [blocks, title, persist]);
|
||||||
|
|
||||||
|
// Flush any unsaved edits when leaving the editor
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (isDirty.current) {
|
||||||
|
persist();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [persist]);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (saveTimer.current) {
|
||||||
|
clearTimeout(saveTimer.current);
|
||||||
|
saveTimer.current = null;
|
||||||
|
}
|
||||||
|
persist();
|
||||||
|
};
|
||||||
|
|
||||||
const handleBlockChange = (id: string, content: string) => {
|
const handleBlockChange = (id: string, content: string) => {
|
||||||
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
|
setBlocks((prev) => prev.map((b) => b.id === id ? { ...b, content } : b));
|
||||||
@@ -240,10 +327,6 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
|||||||
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
|
setBlocks((prev) => [...prev, { id: "block-" + blockIdCounter.current, type, content: "" }]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = () => {
|
|
||||||
updateMutation.mutate({ name: title });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl mx-auto space-y-4">
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -252,11 +335,15 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
|||||||
<Input
|
<Input
|
||||||
value={title}
|
value={title}
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
onBlur={handleSave}
|
|
||||||
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
|
className="text-xl font-bold border-none bg-transparent h-auto px-0 focus-visible:ring-0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button size="sm" onClick={handleSave} disabled={updateMutation.isPending}>Save</Button>
|
<div className="flex items-center gap-2">
|
||||||
|
{saveState === "saving" && <span className="text-xs text-muted-foreground">Saving…</span>}
|
||||||
|
{saveState === "saved" && <span className="text-xs text-muted-foreground">Saved</span>}
|
||||||
|
{saveState === "error" && <span className="text-xs text-destructive">Save failed</span>}
|
||||||
|
<Button size="sm" onClick={handleSave} disabled={saveState === "saving"}>Save</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -292,7 +379,7 @@ function CanvasEditor({ canvas, onBack }: { canvas: Canvas; onBack: () => void }
|
|||||||
|
|
||||||
function CanvasList() {
|
function CanvasList() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [selectedCanvas, setSelectedCanvas] = useState<Canvas | null>(null);
|
const navigate = useNavigate();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [newName, setNewName] = useState("");
|
const [newName, setNewName] = useState("");
|
||||||
|
|
||||||
@@ -305,7 +392,7 @@ function CanvasList() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
queryClient.invalidateQueries({ queryKey: ["canvas"] });
|
||||||
setCreateOpen(false);
|
setCreateOpen(false);
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setSelectedCanvas(canvas);
|
navigate({ to: "/canvas/$id", params: { id: canvas.id } });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -314,20 +401,6 @@ function CanvasList() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["canvas"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const openCanvas = async (id: string) => {
|
|
||||||
try {
|
|
||||||
const detail = await api.get<Canvas>("/canvas/" + id);
|
|
||||||
setSelectedCanvas(detail);
|
|
||||||
} catch {
|
|
||||||
const c = canvases.find((c) => c.id === id);
|
|
||||||
if (c) setSelectedCanvas(c);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (selectedCanvas) {
|
|
||||||
return <CanvasEditor canvas={selectedCanvas} onBack={() => setSelectedCanvas(null)} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -356,7 +429,7 @@ function CanvasList() {
|
|||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{canvases.map((c) => (
|
{canvases.map((c) => (
|
||||||
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => openCanvas(c.id)}>
|
<Card key={c.id} className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => navigate({ to: "/canvas/$id", params: { id: c.id } })}>
|
||||||
<CardHeader className="p-4 pb-2">
|
<CardHeader className="p-4 pb-2">
|
||||||
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
<CardTitle className="text-sm font-semibold truncate">{c.name}</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -1,13 +1,8 @@
|
|||||||
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useParams, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../../_app";
|
import { Route as appRoute } from "../../_app";
|
||||||
import { useApiQuery } from "@/lib/api";
|
import { useApiQuery } from "@/lib/api";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
|
||||||
import { ArrowLeft, Layout, Layers } from "lucide-react";
|
|
||||||
import type { Canvas } from "@/lib/types";
|
import type { Canvas } from "@/lib/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { CanvasEditor } from "../canvas";
|
||||||
|
|
||||||
function CanvasDetail() {
|
function CanvasDetail() {
|
||||||
const { id } = useParams({ from: Route.id });
|
const { id } = useParams({ from: Route.id });
|
||||||
@@ -17,50 +12,7 @@ function CanvasDetail() {
|
|||||||
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
if (isLoading) return <div className="p-8 text-center text-muted-foreground">Loading...</div>;
|
||||||
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
|
if (!canvas) return <div className="p-8 text-center text-muted-foreground">Canvas not found</div>;
|
||||||
|
|
||||||
const blockCount = canvas.cards?.length || 0;
|
return <CanvasEditor key={canvas.id} canvas={canvas} onBack={() => navigate({ to: "/canvas" })} />;
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
|
||||||
<Button variant="ghost" onClick={() => navigate({ to: "/canvas" })} className="w-fit">
|
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Canvas
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Layout className="h-6 w-6 text-muted-foreground" />
|
|
||||||
<CardTitle className="text-2xl">{canvas.name}</CardTitle>
|
|
||||||
<Badge variant="secondary">{canvas.mode}</Badge>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-4">
|
|
||||||
{canvas.description && (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
|
||||||
<p className="text-sm whitespace-pre-wrap">{canvas.description}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<Separator />
|
|
||||||
<div className="grid grid-cols-2 gap-4 text-center">
|
|
||||||
<div className="p-3 bg-muted/50 rounded-lg">
|
|
||||||
<Layers className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
|
|
||||||
<p className="text-2xl font-bold">{blockCount}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Blocks</p>
|
|
||||||
</div>
|
|
||||||
<div className="p-3 bg-muted/50 rounded-lg">
|
|
||||||
<Layout className="h-5 w-5 mx-auto mb-1 text-muted-foreground" />
|
|
||||||
<p className="text-2xl font-bold capitalize">{canvas.mode}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">Mode</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator />
|
|
||||||
<div className="text-xs text-muted-foreground space-y-1">
|
|
||||||
<p>Created: {format(parseISO(canvas.createdAt), "MMM d, yyyy HH:mm")}</p>
|
|
||||||
<p>Updated: {format(parseISO(canvas.updatedAt), "MMM d, yyyy HH:mm")}</p>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Route = createRoute({
|
export const Route = createRoute({
|
||||||
|
|||||||
@@ -3,17 +3,18 @@ import { createRoute } from "@tanstack/react-router";
|
|||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Smile, Zap } from "lucide-react";
|
import { Calendar, ChevronLeft, ChevronRight, Plus, Save, Trash2 } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { DailyNote } from "@/lib/types";
|
import type { DailyNote } from "@/lib/types";
|
||||||
import { format, parseISO, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
import { format, startOfMonth, endOfMonth, eachDayOfInterval, getDay, isSameDay, isToday, addMonths, subMonths } from "date-fns";
|
||||||
|
|
||||||
// ─── Calendar Sidebar ────────────────────────────────────────────────────
|
// ─── Calendar Sidebar ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -27,7 +28,12 @@ function CalendarSidebar({ selectedDate, onSelectDate }: { selectedDate: Date; o
|
|||||||
// Check which dates have notes
|
// Check which dates have notes
|
||||||
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
|
const { data } = useApiQuery<{ items: DailyNote[]; totalItems: number }>(["daily-notes-list"], "/daily-notes");
|
||||||
const notes = data?.items || [];
|
const notes = data?.items || [];
|
||||||
const noteDates = new Set(notes.map((n) => format(parseISO(n.date), "yyyy-MM-dd")));
|
// The API stores daily notes at UTC midnight (YYYY-MM-DDT00:00:00.000Z).
|
||||||
|
// Slicing off the time portion yields the calendar date the note belongs to
|
||||||
|
// regardless of the browser's timezone. parseISO + format would re-render the
|
||||||
|
// UTC instant in the local zone and shift the marker to the previous day for
|
||||||
|
// users west of UTC.
|
||||||
|
const noteDates = new Set(notes.map((n) => n.date.slice(0, 10)));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-64 shrink-0">
|
<div className="w-64 shrink-0">
|
||||||
@@ -88,8 +94,16 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
const [energy, setEnergy] = useState<number | null>(null);
|
const [energy, setEnergy] = useState<number | null>(null);
|
||||||
const [noteId, setNoteId] = useState<string | null>(null);
|
const [noteId, setNoteId] = useState<string | null>(null);
|
||||||
const [isNew, setIsNew] = useState(false);
|
const [isNew, setIsNew] = useState(false);
|
||||||
const [saveTimer, setSaveTimer] = useState<ReturnType<typeof setTimeout> | null>(null);
|
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const noteIdRef = useRef<string | null>(null);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const prevDateStrRef = useRef(dateStr);
|
||||||
|
|
||||||
|
// Mirror noteId into a ref so a pending autosave timer can always read the
|
||||||
|
// latest id. Without this, a timer scheduled while no note existed yet would
|
||||||
|
// fire with a stale null and double-create the note once createMutation
|
||||||
|
// resolves (noteId is set asynchronously in onSuccess).
|
||||||
|
noteIdRef.current = noteId;
|
||||||
|
|
||||||
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
|
const { data: note, isLoading } = useApiQuery<DailyNote | null>(
|
||||||
["daily-note", dateStr],
|
["daily-note", dateStr],
|
||||||
@@ -97,6 +111,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Switching days must cancel any pending autosave so it can't fire against
|
||||||
|
// the newly loaded note (or with the previous day's closure state). The
|
||||||
|
// guard on prevDateStrRef keeps refetches of the same day from wiping a
|
||||||
|
// debounce that is still in flight.
|
||||||
|
if (prevDateStrRef.current !== dateStr) {
|
||||||
|
prevDateStrRef.current = dateStr;
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (note) {
|
if (note) {
|
||||||
setContent(note.content || "");
|
setContent(note.content || "");
|
||||||
setMood(note.mood);
|
setMood(note.mood);
|
||||||
@@ -112,6 +137,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
}
|
}
|
||||||
}, [note, isLoading, dateStr]);
|
}, [note, isLoading, dateStr]);
|
||||||
|
|
||||||
|
// Clear any pending autosave when the editor unmounts so a stale timer can't
|
||||||
|
// fire after navigation away from the page.
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
|
mutationFn: (data: any) => api.post<DailyNote>("/daily-notes", data),
|
||||||
onSuccess: (saved) => {
|
onSuccess: (saved) => {
|
||||||
@@ -131,16 +167,17 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
|
const autoSave = useCallback((newContent: string, newMood: number | null, newEnergy: number | null) => {
|
||||||
if (saveTimer) clearTimeout(saveTimer);
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
const timer = setTimeout(() => {
|
saveTimerRef.current = setTimeout(() => {
|
||||||
if (noteId) {
|
saveTimerRef.current = null;
|
||||||
updateMutation.mutate({ id: noteId, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
const id = noteIdRef.current;
|
||||||
|
if (id) {
|
||||||
|
updateMutation.mutate({ id, data: { content: newContent, mood: newMood, energy: newEnergy } });
|
||||||
} else if (newContent.trim()) {
|
} else if (newContent.trim()) {
|
||||||
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
|
createMutation.mutate({ date: dateStr, content: newContent, mood: newMood, energy: newEnergy });
|
||||||
}
|
}
|
||||||
}, 1500);
|
}, 1500);
|
||||||
setSaveTimer(timer);
|
}, [dateStr, updateMutation, createMutation]);
|
||||||
}, [noteId, dateStr, saveTimer]);
|
|
||||||
|
|
||||||
const handleContentChange = (value: string) => {
|
const handleContentChange = (value: string) => {
|
||||||
setContent(value);
|
setContent(value);
|
||||||
@@ -151,6 +188,10 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
setMood(value);
|
setMood(value);
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
updateMutation.mutate({ id: noteId, data: { mood: value } });
|
updateMutation.mutate({ id: noteId, data: { mood: value } });
|
||||||
|
} else if (isNew && !createMutation.isPending) {
|
||||||
|
// No note exists for this day yet — create it so the mood is recorded
|
||||||
|
// even before any content is typed.
|
||||||
|
createMutation.mutate({ date: dateStr, content: content, mood: value, energy: energy });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -158,18 +199,68 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
setEnergy(value);
|
setEnergy(value);
|
||||||
if (noteId) {
|
if (noteId) {
|
||||||
updateMutation.mutate({ id: noteId, data: { energy: value } });
|
updateMutation.mutate({ id: noteId, data: { energy: value } });
|
||||||
|
} else if (isNew && !createMutation.isPending) {
|
||||||
|
// No note exists for this day yet — create it so the energy is recorded
|
||||||
|
// even before any content is typed.
|
||||||
|
createMutation.mutate({ date: dateStr, content: content, mood: mood, energy: value });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: async (id: string) => {
|
||||||
|
try {
|
||||||
|
await api.delete("/daily-notes/" + id);
|
||||||
|
} catch (error) {
|
||||||
|
// The API responds 204 No Content, which has no JSON body, so api.delete
|
||||||
|
// (which resolves res.json()) rejects with a SyntaxError on the empty
|
||||||
|
// body even though the server-side delete succeeded. Re-throw anything
|
||||||
|
// else (real HTTP/network failures).
|
||||||
|
if (!(error instanceof SyntaxError)) throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
setContent("");
|
||||||
|
setMood(null);
|
||||||
|
setEnergy(null);
|
||||||
|
setNoteId(null);
|
||||||
|
setIsNew(true);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["daily-note", dateStr] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["daily-notes-list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDelete = () => {
|
||||||
|
if (noteId) deleteMutation.mutate(noteId);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 space-y-4">
|
<div className="flex-1 space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
<h2 className="text-xl font-bold">{format(date, "EEEE, MMMM d, yyyy")}</h2>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{noteId && (
|
{noteId && (
|
||||||
<Badge variant="secondary" className="text-[10px]">
|
<>
|
||||||
<Save className="h-3 w-3 mr-1" />Saved
|
<Badge variant="secondary" className="text-[10px]">
|
||||||
</Badge>
|
<Save className="h-3 w-3 mr-1" />Saved
|
||||||
|
</Badge>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive" aria-label="Delete daily note">
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>Delete Daily Note</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>Are you sure you want to delete this daily note? This cannot be undone.</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} className="bg-destructive text-destructive-foreground">Delete</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -178,10 +269,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
<p className="text-xs text-muted-foreground mb-1">Mood</p>
|
||||||
<div className="flex gap-1">
|
<div role="radiogroup" aria-label="Mood" className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={mood === v}
|
||||||
|
aria-label={`Mood ${v}`}
|
||||||
onClick={() => handleMoodChange(v)}
|
onClick={() => handleMoodChange(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||||
@@ -195,10 +289,13 @@ function DailyNoteEditor({ date }: { date: Date }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
<p className="text-xs text-muted-foreground mb-1">Energy</p>
|
||||||
<div className="flex gap-1">
|
<div role="radiogroup" aria-label="Energy" className="flex gap-1">
|
||||||
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((v) => (
|
||||||
<button
|
<button
|
||||||
key={v}
|
key={v}
|
||||||
|
role="radio"
|
||||||
|
aria-checked={energy === v}
|
||||||
|
aria-label={`Energy ${v}`}
|
||||||
onClick={() => handleEnergyChange(v)}
|
onClick={() => handleEnergyChange(v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
"w-6 h-6 rounded text-[10px] font-medium transition-colors",
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
import { useState, useRef, useCallback, useEffect, useMemo } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
|
import { useActiveDomainId } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
|
import { Search, ZoomIn, ZoomOut, RotateCcw, Filter, X } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { LoadingState, EmptyState } from "@/components/state";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
@@ -30,12 +32,26 @@ const ENTITY_COLORS: Record<string, string> = {
|
|||||||
domain: "#6366f1",
|
domain: "#6366f1",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Graph node types that have a detail page. section/tag/domain nodes appear in
|
||||||
|
// the graph but have no detail route, so they are intentionally absent.
|
||||||
|
const NODE_TYPE_ROUTES: Record<string, string> = {
|
||||||
|
task: "/tasks/$id",
|
||||||
|
habit: "/habits/$id",
|
||||||
|
project: "/projects/$id",
|
||||||
|
note: "/notes/$id",
|
||||||
|
};
|
||||||
|
|
||||||
const MAX_NODES = 500;
|
const MAX_NODES = 500;
|
||||||
|
|
||||||
function GraphPage() {
|
function GraphPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const graphRef = useRef<any>(undefined);
|
const graphRef = useRef<any>(undefined);
|
||||||
|
// Simulated node positions, keyed by node id. react-force-graph assigns x/y to
|
||||||
|
// the node objects it renders during the simulation; the raw API nodes
|
||||||
|
// (displayNodes) never gain coordinates, so fly-to must look here instead.
|
||||||
|
const positionsRef = useRef(new Map<string, { x: number; y: number }>());
|
||||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
|
||||||
const [search, setSearch] = useState("");
|
const [search, setSearch] = useState("");
|
||||||
const [filterOpen, setFilterOpen] = useState(false);
|
const [filterOpen, setFilterOpen] = useState(false);
|
||||||
@@ -70,9 +86,16 @@ function GraphPage() {
|
|||||||
["domains"],
|
["domains"],
|
||||||
"/domains"
|
"/domains"
|
||||||
);
|
);
|
||||||
const activeDomainId = domainsData?.items?.[0]?.id || "";
|
// Use the active domain from the store, falling back to the first domain
|
||||||
|
// while unset. Validate against the fetched list so a persisted id that no
|
||||||
|
// longer exists doesn't produce a query for a deleted domain.
|
||||||
|
const storedDomainId = useActiveDomainId();
|
||||||
|
const activeDomainId =
|
||||||
|
(storedDomainId && domainsData?.items?.some((d) => d.id === storedDomainId) ? storedDomainId : null) ||
|
||||||
|
domainsData?.items?.[0]?.id ||
|
||||||
|
"";
|
||||||
|
|
||||||
const { data: nodesData } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
const { data: nodesData, isLoading: nodesLoading } = useApiQuery<{ items: GraphNode[]; totalItems: number }>(
|
||||||
["graph", "nodes", activeDomainId],
|
["graph", "nodes", activeDomainId],
|
||||||
"/graph/nodes?domain=" + activeDomainId,
|
"/graph/nodes?domain=" + activeDomainId,
|
||||||
{ enabled: !!activeDomainId }
|
{ enabled: !!activeDomainId }
|
||||||
@@ -154,14 +177,34 @@ function GraphPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Search: fly to node
|
// Search: fly to node
|
||||||
|
const trackNodePosition = useCallback((node: any) => {
|
||||||
|
if (node && typeof node.x === "number" && typeof node.y === "number") {
|
||||||
|
positionsRef.current.set(String(node.id), { x: node.x, y: node.y });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Snapshot every simulated node's position once the force simulation settles.
|
||||||
|
const snapshotPositions = useCallback(() => {
|
||||||
|
const nodes = graphRef.current?.graphData()?.nodes;
|
||||||
|
if (!nodes) return;
|
||||||
|
for (const node of nodes) {
|
||||||
|
trackNodePosition(node);
|
||||||
|
}
|
||||||
|
}, [trackNodePosition]);
|
||||||
|
|
||||||
const handleSearch = useCallback(() => {
|
const handleSearch = useCallback(() => {
|
||||||
if (!search.trim() || !graphRef.current) return;
|
if (!search.trim() || !graphRef.current) return;
|
||||||
const found = displayNodes.find(
|
const found = displayNodes.find(
|
||||||
(n) => n.label.toLowerCase().includes(search.toLowerCase())
|
(n) => n.label.toLowerCase().includes(search.toLowerCase())
|
||||||
);
|
);
|
||||||
if (found) {
|
if (!found) return;
|
||||||
graphRef.current.centerAt(found.x, found.y, 1000);
|
const pos = positionsRef.current.get(found.id);
|
||||||
|
if (pos) {
|
||||||
|
graphRef.current.centerAt(pos.x, pos.y, 1000);
|
||||||
graphRef.current.zoom(3, 1000);
|
graphRef.current.zoom(3, 1000);
|
||||||
|
} else {
|
||||||
|
// No coordinates yet (e.g. simulation still warming up) — fit the view instead.
|
||||||
|
graphRef.current.zoomToFit(1000, 50);
|
||||||
}
|
}
|
||||||
}, [search, displayNodes]);
|
}, [search, displayNodes]);
|
||||||
|
|
||||||
@@ -175,6 +218,15 @@ function GraphPage() {
|
|||||||
setDetailOpen(true);
|
setDetailOpen(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Close the detail panel and navigate to the node's detail page when one
|
||||||
|
// exists (task/habit/project/note). No-ops for types without a detail route.
|
||||||
|
const handleOpenEntity = useCallback((node: GraphNode) => {
|
||||||
|
const to = NODE_TYPE_ROUTES[node.type];
|
||||||
|
if (!to) return;
|
||||||
|
setDetailOpen(false);
|
||||||
|
navigate({ to, params: { id: node.id } });
|
||||||
|
}, [navigate]);
|
||||||
|
|
||||||
// Node hover → highlight
|
// Node hover → highlight
|
||||||
const handleNodeHover = useCallback((node: any | null) => {
|
const handleNodeHover = useCallback((node: any | null) => {
|
||||||
setHoveredNode(node as GraphNode | null);
|
setHoveredNode(node as GraphNode | null);
|
||||||
@@ -273,7 +325,7 @@ function GraphPage() {
|
|||||||
{/* Graph canvas area */}
|
{/* Graph canvas area */}
|
||||||
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
<div ref={containerRef} className="flex-1 relative bg-muted/20">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="absolute top-4 left-4 z-10 flex items-center gap-2">
|
<div className="absolute top-4 left-4 z-10 flex flex-wrap items-center gap-2">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
@@ -281,7 +333,7 @@ function GraphPage() {
|
|||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
onKeyDown={handleSearchKeyDown}
|
onKeyDown={handleSearchKeyDown}
|
||||||
className="pl-8 w-64 bg-background/90 backdrop-blur"
|
className="pl-8 w-40 sm:w-64 bg-background/90 backdrop-blur"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
<Button variant="outline" size="icon" onClick={() => setFilterOpen(true)} aria-label="Filters">
|
||||||
@@ -312,24 +364,35 @@ function GraphPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Force graph */}
|
{/* Force graph */}
|
||||||
<ForceGraph2D
|
{activeDomainId && nodesLoading ? (
|
||||||
ref={graphRef}
|
<LoadingState label="Loading graph..." />
|
||||||
graphData={graphData}
|
) : displayNodes.length === 0 ? (
|
||||||
width={dimensions.width}
|
<EmptyState
|
||||||
height={dimensions.height}
|
title="No graph data yet"
|
||||||
nodeCanvasObject={nodeCanvasObject}
|
description="Create tasks, habits, or projects to see them connected here"
|
||||||
linkCanvasObject={linkCanvasObject}
|
/>
|
||||||
linkDirectionalArrowLength={0}
|
) : (
|
||||||
linkDirectionalArrowRelPos={0.5}
|
<ForceGraph2D
|
||||||
onNodeClick={handleNodeClick}
|
ref={graphRef}
|
||||||
onNodeHover={handleNodeHover}
|
graphData={graphData}
|
||||||
nodeRelSize={6}
|
width={dimensions.width}
|
||||||
d3AlphaDecay={0.02}
|
height={dimensions.height}
|
||||||
d3VelocityDecay={0.3}
|
nodeCanvasObject={nodeCanvasObject}
|
||||||
cooldownTicks={100}
|
linkCanvasObject={linkCanvasObject}
|
||||||
warmupTicks={40}
|
linkDirectionalArrowLength={0}
|
||||||
backgroundColor="transparent"
|
linkDirectionalArrowRelPos={0.5}
|
||||||
/>
|
onNodeClick={handleNodeClick}
|
||||||
|
onNodeHover={handleNodeHover}
|
||||||
|
onNodeDrag={trackNodePosition}
|
||||||
|
onEngineStop={snapshotPositions}
|
||||||
|
nodeRelSize={6}
|
||||||
|
d3AlphaDecay={0.02}
|
||||||
|
d3VelocityDecay={0.3}
|
||||||
|
cooldownTicks={100}
|
||||||
|
warmupTicks={40}
|
||||||
|
backgroundColor="transparent"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter panel */}
|
{/* Filter panel */}
|
||||||
@@ -391,6 +454,19 @@ function GraphPage() {
|
|||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
|
<p className="text-sm text-muted-foreground">ID: {selectedNode.id}</p>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
{NODE_TYPE_ROUTES[selectedNode.type] ? (
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
onClick={() => handleOpenEntity(selectedNode)}
|
||||||
|
>
|
||||||
|
Open {selectedNode.type.charAt(0).toUpperCase() + selectedNode.type.slice(1)}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
No detail page for {selectedNode.type}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<Separator />
|
||||||
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
<h4 className="text-sm font-semibold">Connected nodes</h4>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{displayEdges
|
{displayEdges
|
||||||
@@ -401,7 +477,22 @@ function GraphPage() {
|
|||||||
return connected ? (
|
return connected ? (
|
||||||
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
<div key={i} className="flex items-center gap-2 text-sm py-1">
|
||||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
|
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: connected.color }} />
|
||||||
<span className="truncate flex-1">{connected.label}</span>
|
{NODE_TYPE_ROUTES[connected.type] ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleOpenEntity(connected)}
|
||||||
|
className="truncate flex-1 text-left hover:underline"
|
||||||
|
>
|
||||||
|
{connected.label}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="truncate flex-1">{connected.label}</span>
|
||||||
|
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||||
|
No detail page for {connected.type}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
<Badge variant="outline" className="text-[10px]">{e.type.replace(/_/g, " ")}</Badge>
|
||||||
</div>
|
</div>
|
||||||
) : null;
|
) : null;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createRoute } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Plus, Flame, Trash2, Check, Calendar, TrendingUp } from "lucide-react";
|
import { Plus, Flame, Trash2, Check, Pencil } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -15,6 +16,7 @@ import { Label } from "@/components/ui/label";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||||
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
import type { Habit, HabitCompletion, PaginatedResponse } from "@/lib/types";
|
||||||
@@ -58,7 +60,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="freq">Frequency</Label>
|
<Label htmlFor="freq">Frequency</Label>
|
||||||
<Select value={frequency} onValueChange={setFrequency}>
|
<Select value={frequency} onValueChange={(v) => setFrequency(v as "daily" | "weekly" | "custom")}>
|
||||||
<SelectTrigger id="freq"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="freq"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="daily">Daily</SelectItem>
|
<SelectItem value="daily">Daily</SelectItem>
|
||||||
@@ -69,7 +71,7 @@ function HabitForm({ habit, onClose }: { habit?: Habit; onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="diff">Difficulty</Label>
|
<Label htmlFor="diff">Difficulty</Label>
|
||||||
<Select value={difficulty} onValueChange={setDifficulty}>
|
<Select value={difficulty} onValueChange={(v) => setDifficulty(v as "easy" | "medium" | "hard")}>
|
||||||
<SelectTrigger id="diff"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="diff"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="easy">Easy</SelectItem>
|
<SelectItem value="easy">Easy</SelectItem>
|
||||||
@@ -113,6 +115,7 @@ function MiniGrid({ completions, days = 7 }: { completions: HabitCompletion[]; d
|
|||||||
}
|
}
|
||||||
|
|
||||||
function HabitsPage() {
|
function HabitsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
|
const [selectedHabit, setSelectedHabit] = useState<Habit | null>(null);
|
||||||
@@ -121,9 +124,11 @@ function HabitsPage() {
|
|||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
const { data: habitsData, isLoading } = useApiQuery<PaginatedResponse<Habit>>(
|
const activeDomainId = useApiDomain();
|
||||||
["habits"],
|
|
||||||
"/habits?limit=200"
|
const { data: habitsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Habit>>(
|
||||||
|
["habits", activeDomainId],
|
||||||
|
"/habits?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const habits = habitsData?.items || [];
|
const habits = habitsData?.items || [];
|
||||||
@@ -138,7 +143,11 @@ function HabitsPage() {
|
|||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["habits"] }); setPanelOpen(false); },
|
||||||
});
|
});
|
||||||
|
|
||||||
const openHabitDetail = async (habit: Habit) => {
|
const openHabitDetail = (habit: Habit) => {
|
||||||
|
navigate({ to: "/habits/$id", params: { id: habit.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openHabitPanel = async (habit: Habit) => {
|
||||||
try {
|
try {
|
||||||
const detail = await api.get<Habit>("/habits/" + habit.id);
|
const detail = await api.get<Habit>("/habits/" + habit.id);
|
||||||
setSelectedHabit(detail);
|
setSelectedHabit(detail);
|
||||||
@@ -164,9 +173,11 @@ function HabitsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading habits...</div>
|
<LoadingState label="Loading habits..." />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message="Failed to load habits." onRetry={() => refetch()} />
|
||||||
) : habits.length === 0 ? (
|
) : habits.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">No habits yet. Create your first one!</div>
|
<EmptyState icon={Flame} title="No habits yet" description="Create your first one!" />
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{habits.map((habit) => (
|
{habits.map((habit) => (
|
||||||
@@ -197,6 +208,15 @@ function HabitsPage() {
|
|||||||
>
|
>
|
||||||
<Check className="h-4 w-4 mr-1" />Complete
|
<Check className="h-4 w-4 mr-1" />Complete
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openHabitPanel(habit); }}
|
||||||
|
aria-label={"Edit " + habit.name}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { ArrowLeft, Flame, Calendar } from "lucide-react";
|
import { TagManager } from "@/components/entities/tag-manager";
|
||||||
|
import { ArrowLeft, Flame, Calendar, Clock } from "lucide-react";
|
||||||
import type { Habit, HabitCompletion } from "@/lib/types";
|
import type { Habit, HabitCompletion } from "@/lib/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
@@ -33,6 +34,10 @@ function HabitDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(habit.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(habit.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
</div>
|
||||||
{habit.description && (
|
{habit.description && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
@@ -55,6 +60,8 @@ function HabitDetail() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
|
<TagManager entityType="habit" entityId={habit.id} tags={habit.tags || []} />
|
||||||
|
<Separator />
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Recent Completions</h3>
|
||||||
{completions.length === 0 ? (
|
{completions.length === 0 ? (
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
|
import { Plus, Settings2, Trash2, ListTodo, Flame, FileText, FolderKanban, Calendar, Zap, TrendingUp, Target } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -30,7 +31,7 @@ const WIDGET_TYPES = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function TasksDueWidget() {
|
function TasksDueWidget() {
|
||||||
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=dueDate");
|
const { data } = useApiQuery<PaginatedResponse<Task>>(["tasks-due"], "/tasks?limit=10&status=todo,in_progress&sort=due_date");
|
||||||
const tasks = data?.items || [];
|
const tasks = data?.items || [];
|
||||||
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
const today = tasks.filter((t) => t.dueDate && isToday(parseISO(t.dueDate)));
|
||||||
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
const overdue = tasks.filter((t) => t.dueDate && isPast(parseISO(t.dueDate)) && t.status !== "done");
|
||||||
@@ -74,7 +75,12 @@ function HabitsTodayWidget() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const completeMutation = useMutation({
|
const completeMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
|
mutationFn: (id: string) => api.post("/habits/" + id + "/complete", {}),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["habits-today"] }),
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["habits-today"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["streaks"] });
|
||||||
|
toast.success("Habit completed");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to complete habit"),
|
||||||
});
|
});
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@@ -197,11 +203,21 @@ function QuickCaptureWidget() {
|
|||||||
const [type, setType] = useState<"task" | "note">("task");
|
const [type, setType] = useState<"task" | "note">("task");
|
||||||
const createTask = useMutation({
|
const createTask = useMutation({
|
||||||
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
|
mutationFn: (title: string) => api.post<Task>("/tasks", { title, status: "todo", priority: "medium" }),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tasks-due"] }); setText(""); },
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks-due"] });
|
||||||
|
setText("");
|
||||||
|
toast.success("Task added");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to create task"),
|
||||||
});
|
});
|
||||||
const createNote = useMutation({
|
const createNote = useMutation({
|
||||||
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
|
mutationFn: (title: string) => api.post<Note>("/notes", { title, content: "" }),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["recent-notes"] }); setText(""); },
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["recent-notes"] });
|
||||||
|
setText("");
|
||||||
|
toast.success("Note added");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to create note"),
|
||||||
});
|
});
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -293,7 +309,10 @@ function WidgetCard({ widget, onConfigure, onDelete }: { widget: DashboardWidget
|
|||||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
|
const typeInfo = WIDGET_TYPES.find((t) => t.id === widget.type);
|
||||||
const Icon = typeInfo?.icon || Target;
|
const Icon = typeInfo?.icon || Target;
|
||||||
return (
|
return (
|
||||||
<Card className="h-full flex flex-col group" style={{ gridColumn: "span " + (widget.layout.w || 2), gridRow: "span " + (widget.layout.h || 2) }}>
|
<Card
|
||||||
|
className="h-full flex flex-col group lg:[grid-column:span_var(--w)] lg:[grid-row:span_var(--h)]"
|
||||||
|
style={{ "--w": Math.min(widget.layout.w || 2, 4), "--h": widget.layout.h || 2 } as React.CSSProperties}
|
||||||
|
>
|
||||||
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
<CardHeader className="p-3 pb-0 flex flex-row items-center justify-between gap-2">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||||
@@ -397,15 +416,27 @@ function DashboardPage() {
|
|||||||
const widgets = widgetsData?.items || [];
|
const widgets = widgetsData?.items || [];
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data),
|
mutationFn: (data: any) => api.post<DashboardWidget>("/dashboard/widgets", data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
|
||||||
|
toast.success("Widget added");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to add widget"),
|
||||||
});
|
});
|
||||||
const updateMutation = useMutation({
|
const updateMutation = useMutation({
|
||||||
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data),
|
mutationFn: ({ id, data }: { id: string; data: any }) => api.patch<DashboardWidget>("/dashboard/widgets/" + id, data),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
|
||||||
|
toast.success("Widget updated");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to update widget"),
|
||||||
});
|
});
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id),
|
mutationFn: (id: string) => api.delete("/dashboard/widgets/" + id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] }),
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["dashboard-widgets"] });
|
||||||
|
toast.success("Widget removed");
|
||||||
|
},
|
||||||
|
onError: (err) => toast.error(err.message || "Failed to remove widget"),
|
||||||
});
|
});
|
||||||
const handleAddWidget = (type: string) => {
|
const handleAddWidget = (type: string) => {
|
||||||
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
const typeInfo = WIDGET_TYPES.find((t) => t.id === type);
|
||||||
@@ -431,7 +462,7 @@ function DashboardPage() {
|
|||||||
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
<Button onClick={() => handleAddWidget("tasks_due")}><Plus className="h-4 w-4 mr-2" />Add Default Widgets</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-4" style={{ gridTemplateColumns: "repeat(12, 1fr)", gridAutoRows: "minmax(120px, auto)" }}>
|
<div className="grid grid-cols-1 gap-4 auto-rows-[minmax(120px,auto)] sm:grid-cols-2 lg:grid-cols-4">
|
||||||
{widgets.map((w) => (
|
{widgets.map((w) => (
|
||||||
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
<WidgetCard key={w.id} widget={w} onConfigure={() => handleConfigure(w)} onDelete={() => handleDelete(w.id)} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { createRoute } from "@tanstack/react-router";
|
|||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery } from "@/lib/api";
|
import { api, useApiQuery } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
|
import { Plus, Trash2, Search, Pin, FileText, Link as LinkIcon, History } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -11,38 +12,92 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Note, PaginatedResponse } from "@/lib/types";
|
import type { Note, PaginatedResponse } from "@/lib/types";
|
||||||
|
import { useEditor, EditorContent } from "@tiptap/react";
|
||||||
|
import StarterKit from "@tiptap/starter-kit";
|
||||||
|
import Link from "@tiptap/extension-link";
|
||||||
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
|
|
||||||
// Simple TipTap-like editor using contentEditable - saves on blur only
|
const AUTOSAVE_DEBOUNCE_MS = 800;
|
||||||
|
|
||||||
|
// TipTap-based note editor. Autosaves with a debounce (plus a save-on-blur and a
|
||||||
|
// flush-on-unmount safety net) and deliberately does NOT stop propagation of
|
||||||
|
// key/mouse events, so global shortcuts (command palette, etc.) keep working.
|
||||||
const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) {
|
const NoteEditor = memo(function NoteEditor({ initialContent, onSave, placeholder = "Start writing..." }: { initialContent: string; onSave: (html: string) => void; placeholder?: string }) {
|
||||||
const editorRef = useRef<HTMLDivElement>(null);
|
const latestHtmlRef = useRef(initialContent || "");
|
||||||
const [isPlaceholder, setIsPlaceholder] = useState(!initialContent);
|
const dirtyRef = useRef(false);
|
||||||
|
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const editor = useEditor(
|
||||||
|
{
|
||||||
|
extensions: [
|
||||||
|
StarterKit.configure({ link: false }),
|
||||||
|
Link.configure({ openOnClick: false }),
|
||||||
|
Placeholder.configure({ placeholder }),
|
||||||
|
],
|
||||||
|
content: initialContent || "",
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
class: "focus:outline-none min-h-[300px] p-3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
[placeholder, initialContent]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (editorRef.current && !editorRef.current.innerHTML) {
|
if (!editor) return;
|
||||||
editorRef.current.innerHTML = initialContent || "";
|
|
||||||
}
|
|
||||||
setIsPlaceholder(!initialContent);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBlur = () => {
|
const flushSave = () => {
|
||||||
const html = editorRef.current?.innerHTML || "";
|
if (saveTimerRef.current) {
|
||||||
onSave(html);
|
clearTimeout(saveTimerRef.current);
|
||||||
};
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (!dirtyRef.current) return;
|
||||||
|
dirtyRef.current = false;
|
||||||
|
onSave(latestHtmlRef.current);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = () => {
|
||||||
|
latestHtmlRef.current = editor.getHTML();
|
||||||
|
dirtyRef.current = true;
|
||||||
|
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = setTimeout(flushSave, AUTOSAVE_DEBOUNCE_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.on("update", handleUpdate);
|
||||||
|
editor.on("blur", flushSave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
editor.off("update", handleUpdate);
|
||||||
|
editor.off("blur", flushSave);
|
||||||
|
if (saveTimerRef.current) {
|
||||||
|
clearTimeout(saveTimerRef.current);
|
||||||
|
saveTimerRef.current = null;
|
||||||
|
}
|
||||||
|
// Flush any unsaved edits on unmount so switching notes doesn't drop typing.
|
||||||
|
if (dirtyRef.current) {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
onSave(latestHtmlRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [editor, onSave]);
|
||||||
|
|
||||||
|
if (!editor) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative min-h-[300px]">
|
<div className="note-editor relative min-h-[300px]">
|
||||||
{isPlaceholder && (
|
{/* Placeholder needs its ::before styling; the @tailwindcss/typography plugin
|
||||||
<div className="absolute top-0 left-0 text-muted-foreground pointer-events-none p-3 text-sm">{placeholder}</div>
|
is not installed, so this is scoped CSS for the empty-editor state. */}
|
||||||
)}
|
<style>{`
|
||||||
<div
|
.note-editor p.is-editor-empty:first-child::before {
|
||||||
ref={editorRef}
|
content: attr(data-placeholder);
|
||||||
contentEditable
|
color: hsl(var(--muted-foreground));
|
||||||
suppressContentEditableWarning
|
float: left;
|
||||||
className="prose prose-sm dark:prose-invert max-w-none p-3 focus:outline-none min-h-[300px]"
|
height: 0;
|
||||||
onMouseDown={(e) => e.stopPropagation()}
|
pointer-events: none;
|
||||||
onKeyDown={(e) => e.stopPropagation()}
|
}
|
||||||
onBlur={handleBlur}
|
`}</style>
|
||||||
/>
|
<EditorContent editor={editor} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -159,9 +214,11 @@ function NotesPage() {
|
|||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
|
const activeDomainId = useApiDomain();
|
||||||
|
|
||||||
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
|
const { data: notesData, isLoading } = useApiQuery<PaginatedResponse<Note>>(
|
||||||
["notes", search],
|
["notes", activeDomainId, search],
|
||||||
"/notes?limit=200" + (search ? "&search=" + encodeURIComponent(search) : "")
|
"/notes?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "") + (search ? "&search=" + encodeURIComponent(search) : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const notes = notesData?.items || [];
|
const notes = notesData?.items || [];
|
||||||
@@ -201,9 +258,9 @@ function NotesPage() {
|
|||||||
}, [deleteMutation]);
|
}, [deleteMutation]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
<div className="flex flex-col md:flex-row h-auto min-h-[calc(100vh-8rem)] md:h-[calc(100vh-8rem)] -m-4 md:-m-6">
|
||||||
{/* Left pane - note list */}
|
{/* Left pane - note list */}
|
||||||
<div className="w-72 border-r flex flex-col shrink-0">
|
<div className="w-full md:w-72 h-64 md:h-auto border-b md:border-b-0 md:border-r flex flex-col shrink-0">
|
||||||
<div className="p-3 border-b">
|
<div className="p-3 border-b">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
@@ -246,7 +303,7 @@ function NotesPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
|
{/* Right pane - editor (memoized, won't re-render on parent state changes) */}
|
||||||
<div className="flex-1 flex flex-col">
|
<div className="flex-1 flex flex-col min-h-64 md:min-h-0">
|
||||||
{selectedNote ? (
|
{selectedNote ? (
|
||||||
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
|
<NoteEditorPane key={selectedNote.id} note={selectedNote} onDelete={handleDeleteNote} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,10 +5,21 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { TagManager } from "@/components/entities/tag-manager";
|
||||||
import { ArrowLeft, FileText, Clock } from "lucide-react";
|
import { ArrowLeft, FileText, Clock } from "lucide-react";
|
||||||
import type { Note } from "@/lib/types";
|
import type { Note } from "@/lib/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
|
||||||
|
// Note content is Tiptap-generated HTML stored by the API. Lightweight sanitizer
|
||||||
|
// applied before rendering via dangerouslySetInnerHTML: drops script/style
|
||||||
|
// blocks, inline event handlers, and javascript: URLs.
|
||||||
|
const sanitizeNoteHtml = (html: string): string =>
|
||||||
|
html
|
||||||
|
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
|
||||||
|
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/gi, "")
|
||||||
|
.replace(/\son[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, "")
|
||||||
|
.replace(/(\shref|\ssrc)\s*=\s*(?:"|')\s*javascript:[^"']*(?:"|')/gi, ' $1=""');
|
||||||
|
|
||||||
function NoteDetail() {
|
function NoteDetail() {
|
||||||
const { id } = useParams({ from: Route.id });
|
const { id } = useParams({ from: Route.id });
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -36,19 +47,38 @@ function NoteDetail() {
|
|||||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(note.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="prose prose-sm dark:prose-invert max-w-none">
|
{/* The @tailwindcss/typography plugin isn't installed, so style the
|
||||||
<p className="text-sm whitespace-pre-wrap">{note.content || "No content"}</p>
|
Tiptap output with scoped CSS instead of `prose` classes. */}
|
||||||
|
<div className="note-detail-content max-w-none">
|
||||||
|
<style>{`
|
||||||
|
.note-detail-content { line-height: 1.75; }
|
||||||
|
.note-detail-content h1 { font-size: 1.75rem; font-weight: 700; line-height: 1.25; margin: 1.5rem 0 0.75rem; }
|
||||||
|
.note-detail-content h2 { font-size: 1.5rem; font-weight: 700; line-height: 1.3; margin: 1.5rem 0 0.75rem; }
|
||||||
|
.note-detail-content h3 { font-size: 1.25rem; font-weight: 600; line-height: 1.4; margin: 1.25rem 0 0.5rem; }
|
||||||
|
.note-detail-content h4 { font-size: 1.125rem; font-weight: 600; line-height: 1.4; margin: 1rem 0 0.5rem; }
|
||||||
|
.note-detail-content h5, .note-detail-content h6 { font-size: 1rem; font-weight: 600; margin: 1rem 0 0.5rem; }
|
||||||
|
.note-detail-content p { margin: 0.75rem 0; }
|
||||||
|
.note-detail-content a { color: hsl(var(--primary)); text-decoration: underline; }
|
||||||
|
.note-detail-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||||
|
.note-detail-content ol { list-style: decimal; padding-left: 1.5rem; margin: 0.75rem 0; }
|
||||||
|
.note-detail-content li { margin: 0.25rem 0; }
|
||||||
|
.note-detail-content li p { margin: 0; }
|
||||||
|
.note-detail-content blockquote { border-left: 3px solid hsl(var(--border)); padding-left: 1rem; margin: 1rem 0; color: hsl(var(--muted-foreground)); }
|
||||||
|
.note-detail-content hr { border: 0; border-top: 1px solid hsl(var(--border)); margin: 1.5rem 0; }
|
||||||
|
.note-detail-content code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.875em; background: hsl(var(--muted)); padding: 0.125rem 0.375rem; border-radius: 0.25rem; }
|
||||||
|
.note-detail-content pre { background: hsl(var(--muted)); padding: 1rem; border-radius: 0.5rem; overflow-x: auto; margin: 1rem 0; }
|
||||||
|
.note-detail-content pre code { background: transparent; padding: 0; font-size: 0.875rem; }
|
||||||
|
.note-detail-content ul[data-type="taskList"] { list-style: none; padding-left: 0.25rem; }
|
||||||
|
.note-detail-content ul[data-type="taskList"] li { display: flex; align-items: flex-start; gap: 0.5rem; }
|
||||||
|
.note-detail-content ul[data-type="taskList"] li p { flex: 1; }
|
||||||
|
`}</style>
|
||||||
|
{note.content ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: sanitizeNoteHtml(note.content) }} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No content</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{note.tags && note.tags.length > 0 && (
|
<TagManager entityType="note" entityId={note.id} tags={note.tags || []} />
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{note.tags.map((t: any) => (
|
|
||||||
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
|
|||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { Plus, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
import { Plus, Pencil, Trash2, FolderKanban, Users, Calendar, ListTodo, GripVertical } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -18,6 +19,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||||
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
|
import { PROJECT_STATUS, TASK_STATUS } from "@/lib/status-colors";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
|
import type { Project, Section, Task, PaginatedResponse } from "@/lib/types";
|
||||||
|
|
||||||
@@ -61,7 +64,7 @@ function ProjectForm({ project, onClose }: { project?: Project; onClose: () => v
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="status">Status</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select value={status} onValueChange={setStatus}>
|
<Select value={status} onValueChange={(v) => setStatus(v as "active" | "paused" | "completed" | "archived")}>
|
||||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="active">Active</SelectItem>
|
<SelectItem value="active">Active</SelectItem>
|
||||||
@@ -100,9 +103,11 @@ function ProjectsPage() {
|
|||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
const { data: projectsData, isLoading } = useApiQuery<PaginatedResponse<Project>>(
|
const activeDomainId = useApiDomain();
|
||||||
["projects"],
|
|
||||||
"/projects?limit=200"
|
const { data: projectsData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Project>>(
|
||||||
|
["projects", activeDomainId],
|
||||||
|
"/projects?limit=200" + (activeDomainId ? "&domain=" + activeDomainId : "")
|
||||||
);
|
);
|
||||||
|
|
||||||
const projects = projectsData?.items || [];
|
const projects = projectsData?.items || [];
|
||||||
@@ -112,7 +117,11 @@ function ProjectsPage() {
|
|||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["projects"] }); setPanelOpen(false); },
|
||||||
});
|
});
|
||||||
|
|
||||||
const openProjectDetail = async (project: Project) => {
|
const openProjectDetail = (project: Project) => {
|
||||||
|
navigate({ to: "/projects/$id", params: { id: project.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openProjectPanel = async (project: Project) => {
|
||||||
try {
|
try {
|
||||||
const detail = await api.get<Project>("/projects/" + project.id);
|
const detail = await api.get<Project>("/projects/" + project.id);
|
||||||
setSelectedProject(detail);
|
setSelectedProject(detail);
|
||||||
@@ -138,9 +147,11 @@ function ProjectsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading projects...</div>
|
<LoadingState label="Loading projects..." />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message="Failed to load projects." onRetry={() => refetch()} />
|
||||||
) : projects.length === 0 ? (
|
) : projects.length === 0 ? (
|
||||||
<div className="text-center py-12 text-muted-foreground">No projects yet.</div>
|
<EmptyState title="No projects yet" description="Create your first project to get started." />
|
||||||
) : (
|
) : (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
@@ -149,7 +160,18 @@ function ProjectsPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
<div className="w-3 h-3 rounded-full shrink-0" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||||
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
<CardTitle className="text-base truncate">{project.name}</CardTitle>
|
||||||
<Badge variant="secondary" className="ml-auto text-[10px]">{project.status}</Badge>
|
<Badge className={cn("ml-auto text-[10px]", PROJECT_STATUS[project.status]?.badge)}>
|
||||||
|
{PROJECT_STATUS[project.status]?.label ?? project.status}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 shrink-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); openProjectPanel(project); }}
|
||||||
|
aria-label={"Edit " + project.name}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -208,7 +230,7 @@ function ProjectsPage() {
|
|||||||
{selectedProject.tasks.map((task: any) => (
|
{selectedProject.tasks.map((task: any) => (
|
||||||
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
<div key={task.id} className="flex items-center justify-between text-sm py-1.5 border-b last:border-0">
|
||||||
<span className="truncate">{task.title}</span>
|
<span className="truncate">{task.title}</span>
|
||||||
<Badge variant="secondary" className="text-[10px] shrink-0">{task.status}</Badge>
|
<Badge className={cn("text-[10px] shrink-0", TASK_STATUS[task.status]?.badge)}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,18 +5,11 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { ArrowLeft, Calendar, ListTodo, Activity } from "lucide-react";
|
import { ArrowLeft, Calendar, Clock, ListTodo, Activity } from "lucide-react";
|
||||||
import type { Project } from "@/lib/types";
|
import type { Project } from "@/lib/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { PROJECT_STATUS } from "@/lib/status-colors";
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
active: "bg-green-500",
|
|
||||||
paused: "bg-amber-500",
|
|
||||||
completed: "bg-blue-500",
|
|
||||||
archived: "bg-slate-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
function ProjectDetail() {
|
function ProjectDetail() {
|
||||||
const { id } = useParams({ from: Route.id });
|
const { id } = useParams({ from: Route.id });
|
||||||
@@ -27,7 +20,7 @@ function ProjectDetail() {
|
|||||||
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
|
if (!project) return <div className="p-8 text-center text-muted-foreground">Project not found</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6 space-y-6">
|
<div className="max-w-2xl mx-auto p-6 space-y-6">
|
||||||
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
|
<Button variant="ghost" onClick={() => navigate({ to: "/projects" })} className="w-fit">
|
||||||
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
|
<ArrowLeft className="h-4 w-4 mr-2" /> Back to Projects
|
||||||
</Button>
|
</Button>
|
||||||
@@ -36,10 +29,14 @@ function ProjectDetail() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
<div className="w-4 h-4 rounded-full" style={{ backgroundColor: project.color || "#3b82f6" }} />
|
||||||
<CardTitle className="text-2xl">{project.name}</CardTitle>
|
<CardTitle className="text-2xl">{project.name}</CardTitle>
|
||||||
<Badge className={STATUS_COLORS[project.status] || "bg-slate-500"}>{project.status}</Badge>
|
<Badge className={PROJECT_STATUS[project.status]?.badge}>{PROJECT_STATUS[project.status]?.label ?? project.status}</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(project.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(project.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
</div>
|
||||||
{project.description && (
|
{project.description && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
|
|||||||
@@ -20,6 +20,18 @@ const SEARCH_TYPES = [
|
|||||||
{ id: "domain", label: "Domains", color: "bg-indigo-500" },
|
{ id: "domain", label: "Domains", color: "bg-indigo-500" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Sanitize snippet HTML before it hits dangerouslySetInnerHTML. The API's
|
||||||
|
// ts_headline output is safe text with matches wrapped in <mark>...</mark>.
|
||||||
|
// Allow ONLY <mark> open/close tags (and only without event handler / href /
|
||||||
|
// src attributes) so no other element, script, or attribute can be injected.
|
||||||
|
const sanitizeSnippet = (html: string) =>
|
||||||
|
html
|
||||||
|
.replace(/<script\b[^>]*>[\s\S]*?<\/script\s*>/gi, "")
|
||||||
|
.replace(/<\/?([a-zA-Z][a-zA-Z0-9-]*)(\s[^<>]*)?>/g, (full, tag) => {
|
||||||
|
if (tag.toLowerCase() === "mark" && !/<[^>]*(?:on\w+=|href=|src=)/i.test(full)) return full;
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
function SearchPage() {
|
function SearchPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
@@ -161,7 +173,7 @@ function SearchPage() {
|
|||||||
{result.snippet && (
|
{result.snippet && (
|
||||||
<p
|
<p
|
||||||
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
className="text-xs text-muted-foreground mt-0.5 line-clamp-2"
|
||||||
dangerouslySetInnerHTML={{ __html: result.snippet }}
|
dangerouslySetInnerHTML={{ __html: sanitizeSnippet(result.snippet) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { Route as appRoute } from "../_app";
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } from "lucide-react";
|
import { Plus, Trash2, Pencil, Palette, Sun, Moon, Monitor, Type, Maximize, Sidebar, Eye, Globe, Tag, List, Key, Bot, Webhook, Upload, Download, AlertCircle, Check, X, RefreshCw, TestTube } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
@@ -13,7 +14,6 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog
|
|||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
|
||||||
@@ -62,8 +62,21 @@ function AppearanceTab() {
|
|||||||
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
|
const [reducedMotion, setReducedMotion] = useState(localStorage.getItem("reduced-motion") === "true");
|
||||||
|
|
||||||
useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
|
useEffect(() => { localStorage.setItem("font-size", fontSize); document.documentElement.style.fontSize = fontSize === "large" ? "18px" : fontSize === "small" ? "13px" : "16px"; }, [fontSize]);
|
||||||
useEffect(() => { localStorage.setItem("density", density); }, [density]);
|
// Density actually changes spacing now: toggle the density-* classes on <html>
|
||||||
useEffect(() => { localStorage.setItem("sidebar-position", sidebarPos); }, [sidebarPos]);
|
// (see the density utilities + --density-scale in index.css) and persist.
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("density", density);
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.remove("density-compact", "density-spacious");
|
||||||
|
if (density === "compact") root.classList.add("density-compact");
|
||||||
|
if (density === "spacious") root.classList.add("density-spacious");
|
||||||
|
}, [density]);
|
||||||
|
// sidebarPos actually repositions the sidebar now: persist it and tell the
|
||||||
|
// app shell (sidebar.tsx) to re-read it without a reload.
|
||||||
|
useEffect(() => {
|
||||||
|
localStorage.setItem("sidebar-position", sidebarPos);
|
||||||
|
window.dispatchEvent(new CustomEvent("sidebar-position-change", { detail: sidebarPos }));
|
||||||
|
}, [sidebarPos]);
|
||||||
useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]);
|
useEffect(() => { localStorage.setItem("reduced-motion", String(reducedMotion)); document.documentElement.classList.toggle("reduce-motion", reducedMotion); }, [reducedMotion]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -420,16 +433,63 @@ function AgentsTab() {
|
|||||||
const agents = data?.items || [];
|
const agents = data?.items || [];
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
|
const [form, setForm] = useState({ name: "", description: "", permissionTier: "read_only" });
|
||||||
|
const [editAgent, setEditAgent] = useState<Agent | null>(null);
|
||||||
|
const [editForm, setEditForm] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
status: "active",
|
||||||
|
permissionTier: "read_only",
|
||||||
|
customPermissions: "",
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (d: any) => api.post<Agent>("/agents", d),
|
mutationFn: (d: any) => api.post<Agent>("/agents", d),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setCreateOpen(false); },
|
||||||
});
|
});
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: ({ id, data: d }: { id: string; data: any }) => api.patch<Agent>("/agents/" + id, d),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["agents"] }); setEditAgent(null); },
|
||||||
|
});
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (id: string) => api.delete("/agents/" + id),
|
mutationFn: (id: string) => api.delete("/agents/" + id),
|
||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["agents"] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const openEdit = (a: Agent) => {
|
||||||
|
setEditAgent(a);
|
||||||
|
setEditForm({
|
||||||
|
name: a.name,
|
||||||
|
description: a.description || "",
|
||||||
|
status: a.status,
|
||||||
|
permissionTier: a.permissionTier,
|
||||||
|
customPermissions: (a.customPermissions || []).join(", "),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveEdit = () => {
|
||||||
|
if (!editAgent) return;
|
||||||
|
const data: any = {
|
||||||
|
name: editForm.name.trim(),
|
||||||
|
description: editForm.description || null,
|
||||||
|
status: editForm.status,
|
||||||
|
permissionTier: editForm.permissionTier,
|
||||||
|
};
|
||||||
|
// customPermissions only apply to the "custom" tier; clear them otherwise so
|
||||||
|
// a downgrade doesn't leave stale permissions in the database.
|
||||||
|
data.customPermissions = editForm.permissionTier === "custom"
|
||||||
|
? editForm.customPermissions.split(",").map((s) => s.trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
updateMutation.mutate({ id: editAgent.id, data });
|
||||||
|
};
|
||||||
|
|
||||||
|
const permissionLabels: Record<string, string> = {
|
||||||
|
full_access: "Full Access",
|
||||||
|
read_only: "Read Only",
|
||||||
|
content_creator: "Content Creator",
|
||||||
|
task_manager: "Task Manager",
|
||||||
|
custom: "Custom",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -444,11 +504,9 @@ function AgentsTab() {
|
|||||||
<div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}>
|
<div><Label>Permission Tier</Label><Select value={form.permissionTier} onValueChange={(v) => setForm({ ...form, permissionTier: v })}>
|
||||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="full_access">Full Access</SelectItem>
|
{Object.entries(permissionLabels).map(([value, label]) => (
|
||||||
<SelectItem value="read_only">Read Only</SelectItem>
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||||
<SelectItem value="content_creator">Content Creator</SelectItem>
|
))}
|
||||||
<SelectItem value="task_manager">Task Manager</SelectItem>
|
|
||||||
<SelectItem value="custom">Custom</SelectItem>
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select></div>
|
</Select></div>
|
||||||
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
|
<Button onClick={() => createMutation.mutate(form)} disabled={!form.name.trim() || createMutation.isPending}>Create</Button>
|
||||||
@@ -459,20 +517,73 @@ function AgentsTab() {
|
|||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{agents.map((a) => (
|
{agents.map((a) => (
|
||||||
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border">
|
<div key={a.id} className="flex items-center justify-between p-3 rounded-lg border">
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="font-medium">{a.name}</p>
|
<p className="font-medium truncate">{a.name}</p>
|
||||||
<p className="text-xs text-muted-foreground">{a.permissionTier.replace(/_/g, " ")} · {a.status}</p>
|
{a.description && <p className="text-xs text-muted-foreground truncate">{a.description}</p>}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{permissionLabels[a.permissionTier] || a.permissionTier} · {a.status}
|
||||||
|
</p>
|
||||||
|
{a.permissionTier === "custom" && a.customPermissions.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{a.customPermissions.map((p) => <Badge key={p} variant="secondary" className="text-[10px]">{p}</Badge>)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => openEdit(a)} aria-label={"Edit " + a.name}><Pencil className="h-4 w-4" /></Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="h-8 w-8 text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
||||||
|
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
<AlertDialog>
|
|
||||||
<AlertDialogTrigger asChild><Button variant="ghost" size="icon" className="text-destructive"><Trash2 className="h-4 w-4" /></Button></AlertDialogTrigger>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader><AlertDialogTitle>Delete Agent</AlertDialogTitle><AlertDialogDescription>Are you sure?</AlertDialogDescription></AlertDialogHeader>
|
|
||||||
<AlertDialogFooter><AlertDialogCancel>Cancel</AlertDialogCancel><AlertDialogAction onClick={() => deleteMutation.mutate(a.id)} className="bg-destructive">Delete</AlertDialogAction></AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{editAgent && (
|
||||||
|
<Dialog open={!!editAgent} onOpenChange={(o) => { if (!o) setEditAgent(null); }}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader><DialogTitle>Edit Agent</DialogTitle></DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div><Label>Name</Label><Input value={editForm.name} onChange={(e) => setEditForm({ ...editForm, name: e.target.value })} /></div>
|
||||||
|
<div><Label>Description</Label><Textarea value={editForm.description} onChange={(e) => setEditForm({ ...editForm, description: e.target.value })} /></div>
|
||||||
|
<div><Label>Status</Label><Select value={editForm.status} onValueChange={(v) => setEditForm({ ...editForm, status: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="active">Active</SelectItem>
|
||||||
|
<SelectItem value="disabled">Disabled</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select></div>
|
||||||
|
<div><Label>Permission Tier</Label><Select value={editForm.permissionTier} onValueChange={(v) => setEditForm({ ...editForm, permissionTier: v })}>
|
||||||
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(permissionLabels).map(([value, label]) => (
|
||||||
|
<SelectItem key={value} value={value}>{label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select></div>
|
||||||
|
{editForm.permissionTier === "custom" && (
|
||||||
|
<div><Label>Custom Permissions (comma-separated)</Label><Input value={editForm.customPermissions} onChange={(e) => setEditForm({ ...editForm, customPermissions: e.target.value })} placeholder="tasks.write, notes.read, ..." /></div>
|
||||||
|
)}
|
||||||
|
{editAgent.apiKey && (
|
||||||
|
<div>
|
||||||
|
<Label>API Key</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input value={editAgent.apiKey} readOnly className="font-mono text-xs" aria-label="Agent API key" />
|
||||||
|
<Button variant="outline" size="sm" className="h-9 shrink-0" onClick={() => { navigator.clipboard.writeText(editAgent.apiKey as string); toast.success("API key copied"); }}>
|
||||||
|
Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">Shown only here — store it securely before rotating.</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSaveEdit} disabled={!editForm.name.trim() || updateMutation.isPending}>Save</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,7 +598,7 @@ function WebhooksTab() {
|
|||||||
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
|
const [form, setForm] = useState({ name: "", url: "", events: "task.created,note.created" });
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (d: any) => api.post<Webhook>("/webhooks", d),
|
mutationFn: (d: any) => api.post<WebhookType>("/webhooks", d),
|
||||||
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); },
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["webhooks"] }); setCreateOpen(false); },
|
||||||
});
|
});
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -546,6 +657,46 @@ function WebhooksTab() {
|
|||||||
|
|
||||||
// ─── Import & Export Tab ─────────────────────────────────────────────────
|
// ─── Import & Export Tab ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CSV export helpers. The export API always returns JSON; when the user picks
|
||||||
|
// CSV we convert each collection client-side. Nested values (tags arrays,
|
||||||
|
// customFields objects, etc.) are JSON-stringified into a single cell.
|
||||||
|
|
||||||
|
/** Escape a single CSV field per RFC 4180: wrap in quotes when needed, double inner quotes. */
|
||||||
|
function csvEscape(value: string): string {
|
||||||
|
if (/[",\n\r]/.test(value)) {
|
||||||
|
return '"' + value.replace(/"/g, '""') + '"';
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render one cell: null/undefined → empty, scalars pass through, objects/arrays get JSON-stringified. */
|
||||||
|
function csvCell(value: unknown): string {
|
||||||
|
if (value === null || value === undefined) return "";
|
||||||
|
if (typeof value === "string") return csvEscape(value);
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
return csvEscape(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a CSV document (header + one row per object) from an array of flat rows. */
|
||||||
|
function rowsToCSV(rows: Record<string, unknown>[]): string {
|
||||||
|
if (rows.length === 0) return "";
|
||||||
|
const columns = [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
||||||
|
const header = columns.map(csvEscape).join(",");
|
||||||
|
const body = rows.map((r) => columns.map((c) => csvCell(r[c])).join(","));
|
||||||
|
return [header, ...body].join("\r\n") + "\r\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(blob: Blob, filename: string) {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
function ImportExportTab() {
|
function ImportExportTab() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [importData, setImportData] = useState("");
|
const [importData, setImportData] = useState("");
|
||||||
@@ -554,22 +705,76 @@ function ImportExportTab() {
|
|||||||
const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]);
|
const [exportCollections, setExportCollections] = useState<string[]>(["tasks", "habits", "projects", "notes"]);
|
||||||
|
|
||||||
const importMutation = useMutation({
|
const importMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post("/import", data),
|
mutationFn: (data: any) => api.post<any>("/import", data),
|
||||||
onSuccess: (res) => { setImportResult(res); queryClient.invalidateQueries(); },
|
onSuccess: (res) => {
|
||||||
|
setImportResult(res);
|
||||||
|
queryClient.invalidateQueries();
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("Imported " + res.imported + " items");
|
||||||
|
} else {
|
||||||
|
toast.error("Imported " + (res.imported ?? 0) + " items, " + (res.failed ?? 0) + " failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
|
const message = err.message || "Import failed";
|
||||||
|
setImportResult({ success: false, error: message });
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const handleImport = () => {
|
||||||
|
let parsed: any;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(importData);
|
||||||
|
} catch {
|
||||||
|
const message = "Invalid JSON in import data";
|
||||||
|
setImportResult({ success: false, error: message });
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
const message = "Invalid format: expected an object with a version field";
|
||||||
|
setImportResult({ success: false, error: message });
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!parsed.version) {
|
||||||
|
const message = "Invalid format: missing version";
|
||||||
|
setImportResult({ success: false, error: message });
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
importMutation.mutate(parsed);
|
||||||
|
};
|
||||||
|
|
||||||
const handleExport = async () => {
|
const handleExport = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await api.post<any>("/export", { collections: exportCollections });
|
const data = await api.post<any>("/export", { collections: exportCollections });
|
||||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
|
||||||
const url = URL.createObjectURL(blob);
|
if (exportFormat === "csv") {
|
||||||
const a = document.createElement("a");
|
// One CSV file per selected collection; empty collections are skipped.
|
||||||
a.href = url;
|
let exported = 0;
|
||||||
a.download = "project-e-export.json";
|
for (const collection of exportCollections) {
|
||||||
a.click();
|
const rows = Array.isArray(data[collection]) ? data[collection] : [];
|
||||||
URL.revokeObjectURL(url);
|
if (rows.length === 0) continue;
|
||||||
|
const csv = rowsToCSV(rows);
|
||||||
|
downloadBlob(new Blob([csv], { type: "text/csv;charset=utf-8" }), "project-e-export-" + collection + ".csv");
|
||||||
|
exported++;
|
||||||
|
}
|
||||||
|
if (exported === 0) {
|
||||||
|
toast.error("No data to export for the selected collections");
|
||||||
|
} else {
|
||||||
|
toast.success("Exported " + exported + " CSV file" + (exported === 1 ? "" : "s"));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// JSON export — unchanged.
|
||||||
|
downloadBlob(new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }), "project-e-export.json");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : "Export failed";
|
||||||
console.error("Export failed", e);
|
console.error("Export failed", e);
|
||||||
|
toast.error(message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -583,12 +788,16 @@ function ImportExportTab() {
|
|||||||
<h3 className="text-lg font-semibold mb-3">Import</h3>
|
<h3 className="text-lg font-semibold mb-3">Import</h3>
|
||||||
<p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p>
|
<p className="text-sm text-muted-foreground mb-3">Paste JSON data to import. Format: {"{"} "version": "1.0", "tasks": [...], "habits": [...], "projects": [...], "notes": [...] {"}"}</p>
|
||||||
<Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" />
|
<Textarea value={importData} onChange={(e) => setImportData(e.target.value)} placeholder='{"version": "1.0", "tasks": [...]}' rows={6} className="font-mono text-sm" />
|
||||||
<Button className="mt-2" onClick={() => { try { importMutation.mutate(JSON.parse(importData)); } catch { setImportResult({ success: false, error: "Invalid JSON" }); } }} disabled={!importData.trim() || importMutation.isPending}>
|
<Button className="mt-2" onClick={handleImport} disabled={!importData.trim() || importMutation.isPending}>
|
||||||
<Upload className="h-4 w-4 mr-2" />Import
|
<Upload className="h-4 w-4 mr-2" />Import
|
||||||
</Button>
|
</Button>
|
||||||
{importResult && (
|
{importResult && (
|
||||||
<div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}>
|
<div className={cn("mt-3 p-3 rounded-lg text-sm", importResult.success ? "bg-green-500/10 text-green-600" : "bg-red-500/10 text-red-600")}>
|
||||||
{importResult.success ? "Imported " + importResult.imported + " items" : "Import failed: " + (importResult.error || "Unknown error")}
|
{importResult.success
|
||||||
|
? "Imported " + importResult.imported + " items"
|
||||||
|
: importResult.error
|
||||||
|
? "Import failed: " + importResult.error
|
||||||
|
: "Imported " + (importResult.imported ?? 0) + " items, " + (importResult.failed ?? 0) + " failed"}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -691,9 +900,9 @@ function SettingsPage() {
|
|||||||
const [activeTab, setActiveTab] = useState("appearance");
|
const [activeTab, setActiveTab] = useState("appearance");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex gap-6 h-[calc(100vh-5rem)]">
|
<div className="flex flex-col md:flex-row gap-2 md:gap-6 h-auto md:h-[calc(100vh-5rem)]">
|
||||||
{/* Sidebar tabs */}
|
{/* Tab bar - horizontal scrollable on mobile, vertical sidebar on md+ */}
|
||||||
<div className="w-56 shrink-0 space-y-1">
|
<div className="flex md:flex-col gap-1 md:gap-0 overflow-x-auto md:overflow-visible pb-1 md:pb-0 md:w-56 md:shrink-0 md:space-y-1 shrink-0">
|
||||||
{SETTINGS_TABS.map((tab) => {
|
{SETTINGS_TABS.map((tab) => {
|
||||||
const Icon = tab.icon;
|
const Icon = tab.icon;
|
||||||
return (
|
return (
|
||||||
@@ -701,7 +910,8 @@ function SettingsPage() {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
onClick={() => setActiveTab(tab.id)}
|
onClick={() => setActiveTab(tab.id)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left",
|
"flex items-center gap-2.5 px-3 py-2 rounded-lg text-sm font-medium transition-colors text-left whitespace-nowrap shrink-0",
|
||||||
|
"md:w-full",
|
||||||
activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
activeTab === tab.id ? "bg-primary/10 text-primary" : "text-muted-foreground hover:text-foreground hover:bg-muted/50"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -711,7 +921,8 @@ function SettingsPage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<Separator orientation="vertical" />
|
<Separator className="md:hidden" />
|
||||||
|
<Separator orientation="vertical" className="hidden md:block" />
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-auto">
|
<div className="flex-1 overflow-auto">
|
||||||
<ScrollArea className="h-full pr-4">
|
<ScrollArea className="h-full pr-4">
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { createRoute, useNavigate } from "@tanstack/react-router";
|
|||||||
import { Route as appRoute } from "../_app";
|
import { Route as appRoute } from "../_app";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
import { api, useApiQuery, useApiMutation } from "@/lib/api";
|
||||||
|
import { useApiDomain } from "@/lib/stores/use-active-domain-store";
|
||||||
import { useRealtime } from "@/hooks/use-realtime";
|
import { useRealtime } from "@/hooks/use-realtime";
|
||||||
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
|
import { DndContext, DragOverlay, closestCorners, KeyboardSensor, PointerSensor, useSensor, useSensors, useDroppable, type DragEndEvent, type DragStartEvent } from "@dnd-kit/core";
|
||||||
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
import { SortableContext, verticalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { Plus, GripVertical, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
|
import { Plus, GripVertical, Pencil, Trash2, Calendar, Clock, ListTodo, Layout as LayoutIcon, Search, Filter, MoreHorizontal } from "lucide-react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -23,24 +24,20 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/comp
|
|||||||
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
import { EntityDetailPanel } from "@/components/entities/entity-detail-panel";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
|
||||||
|
import { LoadingState, EmptyState, ErrorState } from "@/components/state";
|
||||||
|
import { CustomFieldInputs } from "@/components/custom-fields/custom-field-inputs";
|
||||||
|
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||||
import type { Task, PaginatedResponse } from "@/lib/types";
|
import type { Task, PaginatedResponse } from "@/lib/types";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const STATUS_COLUMNS = [
|
const STATUS_COLUMNS = [
|
||||||
{ id: "todo", label: "Todo", color: "bg-slate-500" },
|
{ id: "todo", label: "Todo" },
|
||||||
{ id: "in_progress", label: "In Progress", color: "bg-blue-500" },
|
{ id: "in_progress", label: "In Progress" },
|
||||||
{ id: "done", label: "Done", color: "bg-green-500" },
|
{ id: "done", label: "Done" },
|
||||||
{ id: "cancelled", label: "Cancelled", color: "bg-red-500" },
|
{ id: "cancelled", label: "Cancelled" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
function SortableTaskCard({ task, onClick, onEdit }: { task: Task; onClick: () => void; onEdit?: () => void }) {
|
||||||
urgent: "text-red-500 bg-red-500/10",
|
|
||||||
high: "text-orange-500 bg-orange-500/10",
|
|
||||||
medium: "text-blue-500 bg-blue-500/10",
|
|
||||||
low: "text-slate-500 bg-slate-500/10",
|
|
||||||
};
|
|
||||||
|
|
||||||
function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }) {
|
|
||||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: task.id });
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
@@ -64,8 +61,8 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
|
|||||||
{new Date(task.dueDate).toLocaleDateString()}
|
{new Date(task.dueDate).toLocaleDateString()}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>
|
<Badge variant="secondary" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>
|
||||||
{task.priority}
|
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
{task.tags?.slice(0, 2).map((tag) => (
|
{task.tags?.slice(0, 2).map((tag) => (
|
||||||
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
<Badge key={tag.id} variant="outline" className="text-[10px]" style={{ borderColor: tag.color || undefined }}>
|
||||||
@@ -74,6 +71,17 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{onEdit && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-7 w-7 -mt-1 -mr-1 shrink-0"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit(); }}
|
||||||
|
aria-label={"Edit " + task.title}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -81,6 +89,15 @@ function SortableTaskCard({ task, onClick }: { task: Task; onClick: () => void }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ColumnDroppable({ id, className, children }: { id: string; className?: string; children: React.ReactNode }) {
|
||||||
|
const { setNodeRef, isOver } = useDroppable({ id });
|
||||||
|
return (
|
||||||
|
<div ref={setNodeRef} className={cn(className, isOver && "ring-2 ring-primary/40 bg-primary/10")}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [title, setTitle] = useState(task?.title || "");
|
const [title, setTitle] = useState(task?.title || "");
|
||||||
@@ -88,6 +105,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
const [status, setStatus] = useState(task?.status || "todo");
|
const [status, setStatus] = useState(task?.status || "todo");
|
||||||
const [priority, setPriority] = useState(task?.priority || "medium");
|
const [priority, setPriority] = useState(task?.priority || "medium");
|
||||||
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
const [dueDate, setDueDate] = useState(task?.dueDate ? task.dueDate.slice(0, 10) : "");
|
||||||
|
const [customFieldValues, setCustomFieldValues] = useState<Record<string, unknown>>(() => ({ ...(task?.customFields ?? {}) }));
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
mutationFn: (data: any) => api.post<Task>("/tasks", data),
|
||||||
@@ -110,6 +128,8 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
if (!title.trim()) return;
|
if (!title.trim()) return;
|
||||||
const data: any = { title: title.trim(), description: description || null, status, priority };
|
const data: any = { title: title.trim(), description: description || null, status, priority };
|
||||||
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
if (dueDate) data.dueDate = new Date(dueDate).toISOString();
|
||||||
|
const customFields = { ...customFieldValues };
|
||||||
|
if (Object.keys(customFields).length > 0) data.customFields = customFields;
|
||||||
if (task) {
|
if (task) {
|
||||||
updateMutation.mutate(data);
|
updateMutation.mutate(data);
|
||||||
} else {
|
} else {
|
||||||
@@ -130,7 +150,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="status">Status</Label>
|
<Label htmlFor="status">Status</Label>
|
||||||
<Select value={status} onValueChange={setStatus}>
|
<Select value={status} onValueChange={(v) => setStatus(v as "todo" | "in_progress" | "done" | "cancelled")}>
|
||||||
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="status"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="todo">Todo</SelectItem>
|
<SelectItem value="todo">Todo</SelectItem>
|
||||||
@@ -142,7 +162,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="priority">Priority</Label>
|
<Label htmlFor="priority">Priority</Label>
|
||||||
<Select value={priority} onValueChange={setPriority}>
|
<Select value={priority} onValueChange={(v) => setPriority(v as "low" | "medium" | "high" | "urgent")}>
|
||||||
<SelectTrigger id="priority"><SelectValue /></SelectTrigger>
|
<SelectTrigger id="priority"><SelectValue /></SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="low">Low</SelectItem>
|
<SelectItem value="low">Low</SelectItem>
|
||||||
@@ -157,6 +177,7 @@ function TaskForm({ task, onClose }: { task?: Task; onClose: () => void }) {
|
|||||||
<Label htmlFor="dueDate">Due Date</Label>
|
<Label htmlFor="dueDate">Due Date</Label>
|
||||||
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
<Input id="dueDate" type="date" value={dueDate} onChange={(e) => setDueDate(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<CustomFieldInputs entityType="tasks" values={customFieldValues} onChange={setCustomFieldValues} />
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
<Button type="button" variant="outline" onClick={onClose}>Cancel</Button>
|
||||||
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
<Button type="submit" disabled={createMutation.isPending || updateMutation.isPending}>
|
||||||
@@ -180,9 +201,11 @@ function TasksPage() {
|
|||||||
|
|
||||||
useRealtime({ enabled: true });
|
useRealtime({ enabled: true });
|
||||||
|
|
||||||
const { data: tasksData, isLoading } = useApiQuery<PaginatedResponse<Task>>(
|
const activeDomainId = useApiDomain();
|
||||||
["tasks", search, statusFilter],
|
|
||||||
"/tasks?" + new URLSearchParams({ limit: "200", ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
|
const { data: tasksData, isLoading, isError, refetch } = useApiQuery<PaginatedResponse<Task>>(
|
||||||
|
["tasks", activeDomainId, search, statusFilter],
|
||||||
|
"/tasks?" + new URLSearchParams({ limit: "200", ...(activeDomainId ? { domain: activeDomainId } : {}), ...(search ? { search } : {}), ...(statusFilter ? { status: statusFilter } : {}) }).toString()
|
||||||
);
|
);
|
||||||
|
|
||||||
const tasks = tasksData?.items || [];
|
const tasks = tasksData?.items || [];
|
||||||
@@ -193,6 +216,20 @@ function TasksPage() {
|
|||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
},
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const reorderMutation = useMutation({
|
||||||
|
mutationFn: ({ orderedIds }: { orderedIds: string[] }) =>
|
||||||
|
api.post("/tasks/reorder", { orderedIds }),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tasks"] });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
@@ -218,14 +255,78 @@ function TasksPage() {
|
|||||||
if (!over) return;
|
if (!over) return;
|
||||||
|
|
||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
const targetColumn = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
if (STATUS_COLUMNS.some((c) => c.id === targetColumn)) {
|
const draggedTask = tasks.find((t) => t.id === taskId);
|
||||||
|
if (!draggedTask) return;
|
||||||
|
|
||||||
|
// Tasks of a column in persisted order
|
||||||
|
const columnTasks = (status: string) =>
|
||||||
|
tasks
|
||||||
|
.filter((t) => t.status === status)
|
||||||
|
.sort((a, b) => a.order - b.order);
|
||||||
|
|
||||||
|
// Decide the target column and insertion index:
|
||||||
|
// - over a column id => drop at the end of that column (handles empty columns)
|
||||||
|
// - over a task id => drop at that task's position within its column
|
||||||
|
let targetColumn: string;
|
||||||
|
let insertIndex: number;
|
||||||
|
if (STATUS_COLUMNS.some((c) => c.id === overId)) {
|
||||||
|
targetColumn = overId;
|
||||||
|
insertIndex = -1;
|
||||||
|
} else {
|
||||||
|
const overTask = tasks.find((t) => t.id === overId);
|
||||||
|
if (!overTask) return;
|
||||||
|
targetColumn = overTask.status;
|
||||||
|
const overIndex = columnTasks(targetColumn).findIndex((t) => t.id === overId);
|
||||||
|
insertIndex = overIndex === -1 ? -1 : overIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the new ordered id list for the target column
|
||||||
|
const targetIds = columnTasks(targetColumn)
|
||||||
|
.map((t) => t.id)
|
||||||
|
.filter((id) => id !== taskId);
|
||||||
|
if (insertIndex === -1) {
|
||||||
|
targetIds.push(taskId);
|
||||||
|
} else {
|
||||||
|
targetIds.splice(Math.min(insertIndex, targetIds.length), 0, taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// No-op when the task is already in that exact spot
|
||||||
|
const currentIds = columnTasks(targetColumn).map((t) => t.id);
|
||||||
|
const unchanged =
|
||||||
|
currentIds.length === targetIds.length &&
|
||||||
|
currentIds.every((id, i) => id === targetIds[i]);
|
||||||
|
if (unchanged) return;
|
||||||
|
|
||||||
|
// Optimistic local update so the board reorders immediately
|
||||||
|
const statusChanged = draggedTask.status !== targetColumn;
|
||||||
|
const orderById = new Map(targetIds.map((id, i) => [id, i]));
|
||||||
|
queryClient.setQueryData<PaginatedResponse<Task>>(["tasks", activeDomainId, search, statusFilter], (old) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
items: old.items.map((t) => {
|
||||||
|
if (t.id === taskId && statusChanged) {
|
||||||
|
return { ...t, status: targetColumn as Task["status"], order: orderById.get(t.id) ?? t.order };
|
||||||
|
}
|
||||||
|
const order = orderById.get(t.id);
|
||||||
|
return order !== undefined ? { ...t, order } : t;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusChanged) {
|
||||||
statusMutation.mutate({ id: taskId, status: targetColumn });
|
statusMutation.mutate({ id: taskId, status: targetColumn });
|
||||||
}
|
}
|
||||||
|
reorderMutation.mutate({ orderedIds: targetIds });
|
||||||
};
|
};
|
||||||
|
|
||||||
const openTaskDetail = (task: Task) => {
|
const openTaskDetail = (task: Task) => {
|
||||||
|
navigate({ to: "/tasks/$id", params: { id: task.id } });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openTaskPanel = (task: Task) => {
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
setPanelOpen(true);
|
setPanelOpen(true);
|
||||||
};
|
};
|
||||||
@@ -233,7 +334,10 @@ function TasksPage() {
|
|||||||
const columns = useMemo(() => {
|
const columns = useMemo(() => {
|
||||||
return STATUS_COLUMNS.map((col) => ({
|
return STATUS_COLUMNS.map((col) => ({
|
||||||
...col,
|
...col,
|
||||||
tasks: tasks.filter((t) => t.status === col.id),
|
color: TASK_STATUS[col.id].dot,
|
||||||
|
tasks: tasks
|
||||||
|
.filter((t) => t.status === col.id)
|
||||||
|
.sort((a, b) => a.order - b.order),
|
||||||
}));
|
}));
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
@@ -280,12 +384,14 @@ function TasksPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="flex items-center justify-center py-12 text-muted-foreground">Loading tasks...</div>
|
<LoadingState label="Loading tasks..." />
|
||||||
|
) : isError ? (
|
||||||
|
<ErrorState message="Failed to load tasks." onRetry={() => refetch()} />
|
||||||
) : view === "board" ? (
|
) : view === "board" ? (
|
||||||
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
<DndContext sensors={sensors} collisionDetection={closestCorners} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
{columns.map((col) => (
|
{columns.map((col) => (
|
||||||
<div key={col.id} className="bg-muted/50 rounded-lg p-3">
|
<ColumnDroppable key={col.id} id={col.id} className="bg-muted/50 rounded-lg p-3">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className={cn("w-2 h-2 rounded-full", col.color)} />
|
<div className={cn("w-2 h-2 rounded-full", col.color)} />
|
||||||
@@ -296,14 +402,14 @@ function TasksPage() {
|
|||||||
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
<SortableContext items={col.tasks.map((t) => t.id)} strategy={verticalListSortingStrategy}>
|
||||||
<div className="space-y-2 min-h-[100px]">
|
<div className="space-y-2 min-h-[100px]">
|
||||||
{col.tasks.map((task) => (
|
{col.tasks.map((task) => (
|
||||||
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} />
|
<SortableTaskCard key={task.id} task={task} onClick={() => openTaskDetail(task)} onEdit={() => openTaskPanel(task)} />
|
||||||
))}
|
))}
|
||||||
{col.tasks.length === 0 && (
|
{col.tasks.length === 0 && (
|
||||||
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
|
<p className="text-xs text-muted-foreground text-center py-4">No tasks</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</div>
|
</ColumnDroppable>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<DragOverlay>
|
<DragOverlay>
|
||||||
@@ -325,16 +431,18 @@ function TasksPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{tasks.length === 0 ? (
|
{tasks.length === 0 ? (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">No tasks found</TableCell>
|
<TableCell colSpan={5}>
|
||||||
|
<EmptyState title="No tasks found" />
|
||||||
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
) : tasks.map((task) => (
|
) : tasks.map((task) => (
|
||||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
<TableRow key={task.id} className="cursor-pointer" onClick={() => openTaskDetail(task)}>
|
||||||
<TableCell className="font-medium">{task.title}</TableCell>
|
<TableCell className="font-medium">{task.title}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="secondary" className="text-[10px]">{task.status.replace("_", " ")}</Badge>
|
<Badge className={cn("text-[10px]", TASK_STATUS[task.status]?.badge)}>{task.status.replace("_", " ")}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline" className={cn("text-[10px]", PRIORITY_COLORS[task.priority])}>{task.priority}</Badge>
|
<Badge variant="outline" className={cn("text-[10px]", PRIORITY[task.priority]?.badge)}>{task.priority}</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-sm text-muted-foreground">
|
<TableCell className="text-sm text-muted-foreground">
|
||||||
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
{task.dueDate ? new Date(task.dueDate).toLocaleDateString() : "-"}
|
||||||
@@ -345,7 +453,7 @@ function TasksPage() {
|
|||||||
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
<Button variant="ghost" size="icon" className="h-8 w-8"><MoreHorizontal className="h-4 w-4" /></Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem onClick={() => openTaskDetail(task)}>Edit</DropdownMenuItem>
|
<DropdownMenuItem onClick={() => openTaskPanel(task)}>Edit</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
<DropdownMenuItem className="text-destructive" onClick={() => deleteMutation.mutate(task.id)}>Delete</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
|
|||||||
@@ -5,23 +5,12 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
|
import { TagManager } from "@/components/entities/tag-manager";
|
||||||
|
import { CustomFieldsDisplay } from "@/components/custom-fields/custom-fields-display";
|
||||||
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
|
import { ArrowLeft, Calendar, Clock, ListTodo } from "lucide-react";
|
||||||
import type { Task } from "@/lib/types";
|
import type { Task } from "@/lib/types";
|
||||||
import { format, parseISO } from "date-fns";
|
import { format, parseISO } from "date-fns";
|
||||||
|
import { TASK_STATUS, PRIORITY } from "@/lib/status-colors";
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
|
||||||
todo: "bg-slate-500",
|
|
||||||
in_progress: "bg-blue-500",
|
|
||||||
done: "bg-green-500",
|
|
||||||
cancelled: "bg-red-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
const PRIORITY_COLORS: Record<string, string> = {
|
|
||||||
low: "bg-slate-400",
|
|
||||||
medium: "bg-amber-500",
|
|
||||||
high: "bg-orange-500",
|
|
||||||
urgent: "bg-red-500",
|
|
||||||
};
|
|
||||||
|
|
||||||
function TaskDetail() {
|
function TaskDetail() {
|
||||||
const { id } = useParams({ from: Route.id });
|
const { id } = useParams({ from: Route.id });
|
||||||
@@ -39,14 +28,19 @@ function TaskDetail() {
|
|||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<ListTodo className="h-6 w-6 text-muted-foreground" />
|
||||||
<CardTitle className="text-2xl">{task.title}</CardTitle>
|
<CardTitle className="text-2xl">{task.title}</CardTitle>
|
||||||
<Badge className={STATUS_COLORS[task.status] || "bg-slate-500"}>{task.status.replace("_", " ")}</Badge>
|
<Badge className={TASK_STATUS[task.status]?.badge}>{TASK_STATUS[task.status]?.label ?? task.status}</Badge>
|
||||||
<Badge variant="outline" className={PRIORITY_COLORS[task.priority]}>
|
<Badge variant="outline" className={PRIORITY[task.priority]?.badge}>
|
||||||
{task.priority}
|
{PRIORITY[task.priority]?.label ?? task.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Created {format(parseISO(task.createdAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
<span className="flex items-center gap-1"><Clock className="h-3 w-3" /> Updated {format(parseISO(task.updatedAt), "MMM d, yyyy HH:mm")}</span>
|
||||||
|
</div>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
<h3 className="text-sm font-semibold text-muted-foreground mb-1">Description</h3>
|
||||||
@@ -72,16 +66,8 @@ function TaskDetail() {
|
|||||||
<span>Status: {task.status.replace("_", " ")}</span>
|
<span>Status: {task.status.replace("_", " ")}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{task.tags && task.tags.length > 0 && (
|
<CustomFieldsDisplay entityType="tasks" values={task.customFields} />
|
||||||
<div>
|
<TagManager entityType="task" entityId={task.id} tags={task.tags || []} />
|
||||||
<h3 className="text-sm font-semibold text-muted-foreground mb-2">Tags</h3>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{task.tags.map((t: any) => (
|
|
||||||
<Badge key={t.id || t.name} variant="secondary">{t.name || t}</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { createRoute, useNavigate } from "@tanstack/react-router";
|
import { createRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { Route as rootRoute } from "./__root";
|
import { Route as rootRoute } from "./__root";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Loader2, Sparkles } from "lucide-react";
|
||||||
|
|
||||||
function LoginPage() {
|
function LoginPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("");
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (isSubmitting) return;
|
||||||
setError("");
|
setError("");
|
||||||
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/auth/credentials", {
|
const res = await fetch("/api/auth/credentials", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -25,46 +33,80 @@ function LoginPage() {
|
|||||||
navigate({ to: "/" });
|
navigate({ to: "/" });
|
||||||
} catch {
|
} catch {
|
||||||
setError("Network error");
|
setError("Network error");
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center min-h-screen">
|
<div className="flex min-h-screen items-center justify-center bg-background px-4">
|
||||||
<form
|
<div className="w-full max-w-sm">
|
||||||
onSubmit={handleSubmit}
|
<div className="mb-6 flex flex-col items-center gap-3 text-center">
|
||||||
className="w-full max-w-sm p-8 space-y-4 border rounded-lg"
|
<span
|
||||||
>
|
className="flex h-12 w-12 items-center justify-center rounded-xl shadow-md"
|
||||||
<h1 className="text-2xl font-bold text-center">Sign In</h1>
|
style={{ backgroundColor: "hsl(var(--accent-hsl))" }}
|
||||||
{error && (
|
>
|
||||||
<p className="text-sm text-destructive text-center">{error}</p>
|
<Sparkles className="h-6 w-6 text-white" aria-hidden="true" />
|
||||||
)}
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Email</label>
|
<h1 className="text-2xl font-bold tracking-tight">Project E</h1>
|
||||||
<input
|
<p className="mt-1 text-sm text-muted-foreground">
|
||||||
type="email"
|
Sign in to your workspace
|
||||||
value={email}
|
</p>
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
</div>
|
||||||
className="w-full px-3 py-2 border rounded-md bg-background"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium mb-1">Password</label>
|
<Card className="shadow-md">
|
||||||
<input
|
<CardHeader>
|
||||||
type="password"
|
<CardTitle>Sign in</CardTitle>
|
||||||
value={password}
|
<CardDescription>
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
Enter your credentials to continue
|
||||||
className="w-full px-3 py-2 border rounded-md bg-background"
|
</CardDescription>
|
||||||
required
|
</CardHeader>
|
||||||
/>
|
<CardContent>
|
||||||
</div>
|
{error && (
|
||||||
<button
|
<div
|
||||||
type="submit"
|
role="alert"
|
||||||
className="w-full py-2 px-4 bg-primary text-primary-foreground rounded-md hover:opacity-90"
|
className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive"
|
||||||
>
|
>
|
||||||
Sign In
|
{error}
|
||||||
</button>
|
</div>
|
||||||
</form>
|
)}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="email">Email</Label>
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
autoComplete="email"
|
||||||
|
autoFocus
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<Label htmlFor="password">Password</Label>
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button type="submit" className="w-full" disabled={isSubmitting}>
|
||||||
|
{isSubmitting && (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
{isSubmitting ? "Signing in..." : "Sign in"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -6,6 +6,9 @@ export default {
|
|||||||
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
content: ["./index.html", "./src/**/*.{ts,tsx}"],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
fontFamily: {
|
||||||
|
sans: ["var(--font-sans)"],
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
border: "hsl(var(--border))",
|
border: "hsl(var(--border))",
|
||||||
input: "hsl(var(--input))",
|
input: "hsl(var(--input))",
|
||||||
|
|||||||
@@ -11,7 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@project-e/db": "^0.1.0",
|
"@project-e/db": "^0.1.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"postgres": "^3.4.9"
|
"postgres": "^3.4.9",
|
||||||
|
"rrule": "^2.8.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.19.0",
|
"@types/node": "^22.19.0",
|
||||||
|
|||||||
+372
-25
@@ -1,35 +1,382 @@
|
|||||||
import { db, sql } from "@project-e/db/client";
|
import { db, jobs, webhooks, webhookDeliveries, scheduledJobs, tasks } from '@project-e/db';
|
||||||
|
import { and, eq, lte, isNull, or } from 'drizzle-orm';
|
||||||
|
import { createHmac } from 'node:crypto';
|
||||||
|
import { RRule } from 'rrule';
|
||||||
|
|
||||||
let running = true;
|
const POLL_INTERVAL_BASE = 5000; // 5 seconds base
|
||||||
|
const POLL_INTERVAL_MAX = 60000; // 60 seconds max
|
||||||
|
const MAX_RETRIES = 6;
|
||||||
|
|
||||||
process.on("SIGTERM", async () => {
|
let currentPollInterval = POLL_INTERVAL_BASE;
|
||||||
console.log("worker: SIGTERM received, shutting down gracefully...");
|
let isProcessing = false;
|
||||||
running = false;
|
let shutdownRequested = false;
|
||||||
await sql.end();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
process.on("SIGINT", async () => {
|
// ── Job processing ───────────────────────────────────────────────────────────────
|
||||||
console.log("worker: SIGINT received, shutting down gracefully...");
|
|
||||||
running = false;
|
async function poll(): Promise<void> {
|
||||||
await sql.end();
|
if (isProcessing || shutdownRequested) return;
|
||||||
process.exit(0);
|
isProcessing = true;
|
||||||
});
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
try {
|
try {
|
||||||
// Test DB connection
|
// Enqueue recurring_spawn jobs for due scheduled jobs before polling the
|
||||||
await sql`SELECT 1`;
|
// queue so they are picked up in the same iteration.
|
||||||
console.log("worker ready");
|
const scheduledEnqueued = await processDueScheduledJobs();
|
||||||
} catch (err) {
|
|
||||||
console.error("worker: failed to connect to database:", err);
|
const now = new Date();
|
||||||
process.exit(1);
|
|
||||||
|
// Get pending jobs that are due.
|
||||||
|
// nextRetryAt is NULL for freshly-queued jobs, which are due immediately.
|
||||||
|
const pendingJobs = await db.select()
|
||||||
|
.from(jobs)
|
||||||
|
.where(and(
|
||||||
|
eq(jobs.status, 'pending'),
|
||||||
|
or(isNull(jobs.nextRetryAt), lte(jobs.nextRetryAt, now)),
|
||||||
|
))
|
||||||
|
.orderBy(jobs.createdAt)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
if (pendingJobs.length > 0) {
|
||||||
|
console.log(`[Worker] Processing ${pendingJobs.length} job(s)`);
|
||||||
|
|
||||||
|
for (const job of pendingJobs) {
|
||||||
|
if (shutdownRequested) break;
|
||||||
|
await processJob(job);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset poll interval on success
|
||||||
|
currentPollInterval = POLL_INTERVAL_BASE;
|
||||||
|
} else if (scheduledEnqueued > 0) {
|
||||||
|
// Scheduled jobs were enqueued but their recurring_spawn jobs will be
|
||||||
|
// processed next iteration — keep the poll fast.
|
||||||
|
currentPollInterval = POLL_INTERVAL_BASE;
|
||||||
|
} else {
|
||||||
|
// No jobs — increase poll interval (backoff)
|
||||||
|
currentPollInterval = Math.min(currentPollInterval * 1.5, POLL_INTERVAL_MAX);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Worker] Poll error:', error);
|
||||||
|
} finally {
|
||||||
|
isProcessing = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stub loop: sleep forever, handle signals
|
if (!shutdownRequested) {
|
||||||
while (running) {
|
setTimeout(poll, currentPollInterval);
|
||||||
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
main();
|
async function processJob(job: typeof jobs.$inferSelect): Promise<void> {
|
||||||
|
// Mark as processing
|
||||||
|
await db.update(jobs)
|
||||||
|
.set({ status: 'processing', updatedAt: new Date() })
|
||||||
|
.where(eq(jobs.id, job.id));
|
||||||
|
|
||||||
|
try {
|
||||||
|
switch (job.type) {
|
||||||
|
case 'webhook_delivery':
|
||||||
|
await handleWebhookDelivery(job);
|
||||||
|
break;
|
||||||
|
case 'recurring_spawn':
|
||||||
|
await handleRecurringSpawn(job);
|
||||||
|
break;
|
||||||
|
case 'ai_dispatch':
|
||||||
|
await handleAiDispatch(job);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
console.warn(`[Worker] Unknown job type: ${job.type}`);
|
||||||
|
await db.update(jobs)
|
||||||
|
.set({ status: 'failed', lastError: `Unknown job type: ${job.type}`, updatedAt: new Date() })
|
||||||
|
.where(eq(jobs.id, job.id));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as completed
|
||||||
|
await db.update(jobs)
|
||||||
|
.set({ status: 'completed', updatedAt: new Date() })
|
||||||
|
.where(eq(jobs.id, job.id));
|
||||||
|
|
||||||
|
console.log(`[Worker] Job ${job.id} (${job.type}) completed`);
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||||
|
const attempts = (job.attempts || 0) + 1;
|
||||||
|
const maxAttempts = job.maxAttempts || MAX_RETRIES;
|
||||||
|
|
||||||
|
if (attempts >= maxAttempts) {
|
||||||
|
// Max retries reached — mark as failed
|
||||||
|
await db.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: 'failed',
|
||||||
|
attempts,
|
||||||
|
lastError: errorMessage,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(jobs.id, job.id));
|
||||||
|
console.error(`[Worker] Job ${job.id} (${job.type}) failed after ${attempts} attempts: ${errorMessage}`);
|
||||||
|
} else {
|
||||||
|
// Schedule retry with exponential backoff
|
||||||
|
const backoffMs = Math.min(2000 * Math.pow(2, attempts), 300000); // Max 5 minutes
|
||||||
|
const nextRetry = new Date(Date.now() + backoffMs);
|
||||||
|
|
||||||
|
await db.update(jobs)
|
||||||
|
.set({
|
||||||
|
status: 'pending',
|
||||||
|
attempts,
|
||||||
|
lastError: errorMessage,
|
||||||
|
nextRetryAt: nextRetry,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(jobs.id, job.id));
|
||||||
|
console.log(`[Worker] Job ${job.id} (${job.type}) retry ${attempts}/${MAX_RETRIES} scheduled for ${nextRetry.toISOString()}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recurring schedule advancement ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find scheduled jobs whose next occurrence is due, enqueue a `recurring_spawn`
|
||||||
|
* job for each, and immediately advance (or remove) the scheduled job so it is
|
||||||
|
* never enqueued twice. `handleRecurringSpawn` only spawns the entity — the
|
||||||
|
* schedule bookkeeping happens here so the enqueue and the advance are atomic
|
||||||
|
* within the same poll iteration.
|
||||||
|
*/
|
||||||
|
async function processDueScheduledJobs(): Promise<number> {
|
||||||
|
try {
|
||||||
|
const now = new Date();
|
||||||
|
const dueScheduled = await db.select()
|
||||||
|
.from(scheduledJobs)
|
||||||
|
.where(lte(scheduledJobs.nextOccurrenceAt, now))
|
||||||
|
.orderBy(scheduledJobs.nextOccurrenceAt)
|
||||||
|
.limit(10);
|
||||||
|
|
||||||
|
for (const scheduled of dueScheduled) {
|
||||||
|
if (shutdownRequested) break;
|
||||||
|
|
||||||
|
await db.insert(jobs).values({
|
||||||
|
type: 'recurring_spawn',
|
||||||
|
payload: { scheduled_job_id: scheduled.id },
|
||||||
|
status: 'pending',
|
||||||
|
});
|
||||||
|
console.log(`[Worker] Enqueued recurring spawn for scheduled job ${scheduled.id} (${scheduled.entityType}:${scheduled.entityId})`);
|
||||||
|
|
||||||
|
await advanceScheduledJob(scheduled);
|
||||||
|
}
|
||||||
|
|
||||||
|
return dueScheduled.length;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Worker] processDueScheduledJobs error:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function advanceScheduledJob(scheduled: typeof scheduledJobs.$inferSelect): Promise<void> {
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
const rule = RRule.fromString(scheduled.recurrenceRule);
|
||||||
|
// Compute from the due occurrence so no occurrence is skipped even if the
|
||||||
|
// spawned job is processed later than the original due time.
|
||||||
|
const base = scheduled.nextOccurrenceAt > now ? scheduled.nextOccurrenceAt : now;
|
||||||
|
const nextOccurrence = rule.after(base);
|
||||||
|
|
||||||
|
if (nextOccurrence) {
|
||||||
|
await db.update(scheduledJobs)
|
||||||
|
.set({
|
||||||
|
nextOccurrenceAt: nextOccurrence,
|
||||||
|
lastSpawnedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(scheduledJobs.id, scheduled.id));
|
||||||
|
console.log(`[Worker] Next occurrence for ${scheduled.entityId} at ${nextOccurrence.toISOString()}`);
|
||||||
|
} else {
|
||||||
|
// No more occurrences — remove the scheduled job
|
||||||
|
await db.delete(scheduledJobs).where(eq(scheduledJobs.id, scheduled.id));
|
||||||
|
console.log(`[Worker] No more occurrences for ${scheduled.entityId}, removing scheduled job`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`[Worker] Failed to compute next occurrence for ${scheduled.entityId}:`, error);
|
||||||
|
// If rrule parsing fails, just advance by 1 day as fallback
|
||||||
|
const nextDay = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||||
|
await db.update(scheduledJobs)
|
||||||
|
.set({
|
||||||
|
nextOccurrenceAt: nextDay,
|
||||||
|
lastSpawnedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(scheduledJobs.id, scheduled.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Webhook delivery ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleWebhookDelivery(job: typeof jobs.$inferSelect): Promise<void> {
|
||||||
|
const payload = job.payload as {
|
||||||
|
webhook_id: string;
|
||||||
|
event: string;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: string;
|
||||||
|
data: unknown;
|
||||||
|
timestamp: string;
|
||||||
|
workspace_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [webhook] = await db.select()
|
||||||
|
.from(webhooks)
|
||||||
|
.where(eq(webhooks.id, payload.webhook_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!webhook) {
|
||||||
|
throw new Error(`Webhook ${payload.webhook_id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!webhook.active) {
|
||||||
|
console.log(`[Worker] Webhook ${webhook.id} is inactive, skipping delivery`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deliveryPayload = {
|
||||||
|
event: payload.event,
|
||||||
|
entity_type: payload.entity_type,
|
||||||
|
entity_id: payload.entity_id,
|
||||||
|
data: payload.data,
|
||||||
|
timestamp: payload.timestamp || new Date().toISOString(),
|
||||||
|
workspace_id: payload.workspace_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Event-Type': payload.event,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (webhook.secret) {
|
||||||
|
const body = JSON.stringify(deliveryPayload);
|
||||||
|
const signature = createHmac('sha256', webhook.secret)
|
||||||
|
.update(body)
|
||||||
|
.digest('hex');
|
||||||
|
headers['X-ProjectE-Signature'] = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
let responseStatus = 0;
|
||||||
|
let responseBody = '';
|
||||||
|
let success = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(webhook.url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers,
|
||||||
|
body: JSON.stringify(deliveryPayload),
|
||||||
|
signal: AbortSignal.timeout(10000),
|
||||||
|
});
|
||||||
|
|
||||||
|
responseStatus = response.status;
|
||||||
|
responseBody = await response.text();
|
||||||
|
success = response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
responseBody = error instanceof Error ? error.message : String(error);
|
||||||
|
success = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record delivery
|
||||||
|
await db.insert(webhookDeliveries).values({
|
||||||
|
webhookId: webhook.id,
|
||||||
|
event: payload.event,
|
||||||
|
payload: deliveryPayload as Record<string, unknown>,
|
||||||
|
status: success ? 'success' : 'failed',
|
||||||
|
statusCode: responseStatus,
|
||||||
|
responseBody: responseBody.substring(0, 1000),
|
||||||
|
attempts: job.attempts || 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!success) {
|
||||||
|
throw new Error(`Webhook delivery failed: ${responseStatus} ${responseBody}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Recurring spawn ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleRecurringSpawn(job: typeof jobs.$inferSelect): Promise<void> {
|
||||||
|
const payload = job.payload as {
|
||||||
|
scheduled_job_id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [scheduled] = await db.select()
|
||||||
|
.from(scheduledJobs)
|
||||||
|
.where(eq(scheduledJobs.id, payload.scheduled_job_id))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!scheduled) {
|
||||||
|
throw new Error(`Scheduled job ${payload.scheduled_job_id} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (scheduled.entityType === 'task') {
|
||||||
|
// Fetch the original task to clone
|
||||||
|
const [originalTask] = await db.select()
|
||||||
|
.from(tasks)
|
||||||
|
.where(and(eq(tasks.id, scheduled.entityId), isNull(tasks.deletedAt)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (originalTask) {
|
||||||
|
// Create a new task instance
|
||||||
|
await db.insert(tasks).values({
|
||||||
|
title: originalTask.title,
|
||||||
|
description: originalTask.description,
|
||||||
|
status: 'todo',
|
||||||
|
priority: originalTask.priority,
|
||||||
|
domainId: originalTask.domainId,
|
||||||
|
projectId: originalTask.projectId,
|
||||||
|
sectionId: originalTask.sectionId,
|
||||||
|
dueDate: originalTask.dueDate,
|
||||||
|
estimatedMinutes: originalTask.estimatedMinutes,
|
||||||
|
recurrenceRule: originalTask.recurrenceRule,
|
||||||
|
order: originalTask.order,
|
||||||
|
customFields: originalTask.customFields,
|
||||||
|
});
|
||||||
|
console.log(`[Worker] Spawned new task instance for ${scheduled.entityId}`);
|
||||||
|
}
|
||||||
|
} else if (scheduled.entityType === 'habit') {
|
||||||
|
// For habits, we just log — habit completions are user-driven
|
||||||
|
console.log(`[Worker] Habit ${scheduled.entityId} recurrence tick (user-driven)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── AI Dispatch (disabled) ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function handleAiDispatch(job: typeof jobs.$inferSelect): Promise<void> {
|
||||||
|
const payload = job.payload as {
|
||||||
|
entity_type?: string;
|
||||||
|
entity_id?: string;
|
||||||
|
instruction?: string;
|
||||||
|
user_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log(`[Worker] ai_dispatch is disabled — job ${job.id} completed without action`, JSON.stringify(payload));
|
||||||
|
// DISABLED: ai_dispatch is not wired up yet. Jobs of this type are allowed to
|
||||||
|
// complete (non-destructive) so the queue does not stall on them. Future:
|
||||||
|
// connect to actual AI agent.
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Graceful shutdown ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function setupGracefulShutdown(): void {
|
||||||
|
process.on('SIGTERM', () => {
|
||||||
|
console.log('[Worker] SIGTERM received, shutting down gracefully...');
|
||||||
|
shutdownRequested = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
console.log('[Worker] Forced exit after timeout');
|
||||||
|
process.exit(0);
|
||||||
|
}, 10000).unref();
|
||||||
|
});
|
||||||
|
|
||||||
|
process.on('SIGINT', () => {
|
||||||
|
console.log('[Worker] SIGINT received, shutting down...');
|
||||||
|
shutdownRequested = true;
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Start ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
console.log('[Worker] Starting Project E worker...');
|
||||||
|
console.log('[Worker] PostgreSQL queue via Drizzle ORM');
|
||||||
|
console.log(`[Worker] Poll interval: ${POLL_INTERVAL_BASE}ms (base), ${POLL_INTERVAL_MAX}ms (max)`);
|
||||||
|
|
||||||
|
setupGracefulShutdown();
|
||||||
|
|
||||||
|
// Start polling
|
||||||
|
setTimeout(poll, POLL_INTERVAL_BASE);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
"hono": "^4.6.0",
|
"hono": "^4.6.0",
|
||||||
"jose": "^5.9.6",
|
"jose": "^5.9.6",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -90,6 +91,7 @@
|
|||||||
"react-force-graph-2d": "^1.29.1",
|
"react-force-graph-2d": "^1.29.1",
|
||||||
"react-hook-form": "^7.84.0",
|
"react-hook-form": "^7.84.0",
|
||||||
"recharts": "^3.10.1",
|
"recharts": "^3.10.1",
|
||||||
|
"sonner": "^2.0.8",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
@@ -99,6 +101,7 @@
|
|||||||
"@tanstack/react-query-devtools": "^5.62.0",
|
"@tanstack/react-query-devtools": "^5.62.0",
|
||||||
"@tanstack/react-router-devtools": "^1.167.0",
|
"@tanstack/react-router-devtools": "^1.167.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-big-calendar": "^1.16.3",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.5.2",
|
"autoprefixer": "^10.5.2",
|
||||||
@@ -198,6 +201,7 @@
|
|||||||
"@project-e/db": "^0.1.0",
|
"@project-e/db": "^0.1.0",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
|
"rrule": "^2.8.1",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.19.0",
|
"@types/node": "^22.19.0",
|
||||||
@@ -2114,7 +2118,7 @@
|
|||||||
|
|
||||||
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
"slash": ["slash@3.0.0", "", {}, "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q=="],
|
||||||
|
|
||||||
"sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
"sonner": ["sonner@2.0.8", "", { "peerDependencies": { "@types/react": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-UM/ByIoFra8yzV75n1o0Puu0bw5U/9UNnDacrJNspekBewIfsQ3D6ez1nvlWpt7aTsO6rujQtifBpycwIivqlg=="],
|
||||||
|
|
||||||
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
"source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
|
||||||
|
|
||||||
@@ -2332,6 +2336,8 @@
|
|||||||
|
|
||||||
"@project-e/web-legacy/react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
|
"@project-e/web-legacy/react-hook-form": ["react-hook-form@7.83.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-AXt8cMCmx5a7u4uvpb2uRFVrWQhllI4pV+LSykxIac/hjt44TnQkmX9BKuQi2i+LDC62esmiLpilkav+kjVf/A=="],
|
||||||
|
|
||||||
|
"@project-e/web-legacy/sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="],
|
||||||
|
|
||||||
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
"@tailwindcss/node/jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
|
||||||
|
|
||||||
"@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
"@tailwindcss/node/tailwindcss": ["tailwindcss@4.3.3", "", {}, "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ=="],
|
||||||
|
|||||||
+847
-761
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('Agent Activity', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto('/agents/activity');
|
||||||
|
await expect(page.getByRole('heading', { name: /agent activity/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the filter bar and refresh button', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('combobox', { name: /all agents/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('combobox', { name: /all actions/i })).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('From')).toBeVisible();
|
||||||
|
await expect(page.getByPlaceholder('To')).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /refresh/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the timeline area', async ({ page }) => {
|
||||||
|
// The initial activity query settles to either the empty state or a row of
|
||||||
|
// live/fetched activity entries.
|
||||||
|
const emptyState = page.getByText(/no activity found/i);
|
||||||
|
const activityEntry = page.getByText(/created|updated|deleted|completed/i).last();
|
||||||
|
|
||||||
|
await expect(emptyState.or(activityEntry)).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('filters by action type', async ({ page }) => {
|
||||||
|
await page.getByRole('combobox', { name: /all actions/i }).click();
|
||||||
|
await page.getByRole('option', { name: 'Created', exact: true }).click();
|
||||||
|
|
||||||
|
// The trigger now shows the selected action and the list refetches.
|
||||||
|
await expect(page.getByRole('combobox', { name: 'Created' })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-75
@@ -5,87 +5,22 @@ test.describe('Analytics', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/analytics');
|
await page.goto('/analytics');
|
||||||
// Wait for the analytics page to load
|
|
||||||
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Analytics page', () => {
|
test('renders the analytics cards', async ({ page }) => {
|
||||||
test('should display analytics heading and tagline', async ({ page }) => {
|
await expect(page.getByText(/tasks completed/i)).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByRole('heading', { name: /analytics/i })).toBeVisible();
|
await expect(page.getByText(/created vs completed/i)).toBeVisible();
|
||||||
await expect(page.getByText(/patterns behind your progress/i)).toBeVisible();
|
await expect(page.getByText(/habit completion/i)).toBeVisible();
|
||||||
});
|
await expect(page.getByText(/productivity heatmap/i)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Summary cards', () => {
|
test('switches the reporting range', async ({ page }) => {
|
||||||
test('should display summary stat cards', async ({ page }) => {
|
await page.getByRole('combobox', { name: /last 30 days/i }).click();
|
||||||
// Should show 4 stat cards: Task Completion, Habit Consistency, Time Tracked, Active Streaks
|
await page.getByRole('option', { name: /last 90 days/i }).click();
|
||||||
await expect(page.getByText(/task completion/i)).toBeVisible();
|
|
||||||
await expect(page.getByText(/habit consistency/i)).toBeVisible();
|
|
||||||
await expect(page.getByText(/time tracked/i)).toBeVisible();
|
|
||||||
await expect(page.getByText(/active streaks/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display percentage values', async ({ page }) => {
|
await expect(page.getByRole('combobox', { name: /last 90 days/i })).toBeVisible({
|
||||||
// Task completion and habit consistency should show percentages
|
timeout: 10_000,
|
||||||
const percentElements = page.locator('text=/\\d+%/');
|
|
||||||
const count = await percentElements.count();
|
|
||||||
expect(count).toBeGreaterThanOrEqual(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Analytics tabs', () => {
|
|
||||||
test('should show Trends, Habits, and Time tabs', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('tab', { name: /trends/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('tab', { name: /habits/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('tab', { name: /time/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should default to Trends tab', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should switch to Habits tab', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /habits/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /habits/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Wait for chart to load
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should switch to Time tab', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /time/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /time/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Wait for chart to load
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should switch between all tabs', async ({ page }) => {
|
|
||||||
// Start on Trends
|
|
||||||
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Switch to Habits
|
|
||||||
await page.getByRole('tab', { name: /habits/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /habits/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Switch to Time
|
|
||||||
await page.getByRole('tab', { name: /time/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /time/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Switch back to Trends
|
|
||||||
await page.getByRole('tab', { name: /trends/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /trends/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Analytics charts', () => {
|
|
||||||
test('should load chart components for each tab', async ({ page }) => {
|
|
||||||
// Wait for charts to render (recharts is lazy loaded)
|
|
||||||
await page.waitForTimeout(3_000);
|
|
||||||
|
|
||||||
// Verify the page has rendered without errors
|
|
||||||
const mainContent = page.locator('main');
|
|
||||||
await expect(mainContent).toBeVisible();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-80
@@ -3,103 +3,63 @@ import { login, logout, goToLogin } from './helpers/auth';
|
|||||||
import { TEST_USER, INVALID_USER } from './helpers/fixtures';
|
import { TEST_USER, INVALID_USER } from './helpers/fixtures';
|
||||||
|
|
||||||
test.describe('Authentication Flow', () => {
|
test.describe('Authentication Flow', () => {
|
||||||
test.describe('Login', () => {
|
test('logs in with valid credentials and redirects to the dashboard', async ({ page }) => {
|
||||||
test('should login with valid credentials and redirect to dashboard', async ({ page }) => {
|
await goToLogin(page);
|
||||||
await goToLogin(page);
|
|
||||||
|
|
||||||
// Verify login page loaded
|
// The login page renders a Card titled "Sign in".
|
||||||
await expect(page.getByRole('heading', { name: /project e/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /sign in/i })).toBeVisible();
|
||||||
|
|
||||||
// Fill in credentials
|
await page.getByLabel('Email').fill(TEST_USER.email);
|
||||||
await page.getByLabel('Email').fill(TEST_USER.email);
|
await page.getByLabel('Password').fill(TEST_USER.password);
|
||||||
await page.getByLabel('Password').fill(TEST_USER.password);
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
|
|
||||||
// Should redirect to dashboard
|
// Successful login navigates to the "/" dashboard.
|
||||||
await page.waitForURL('**/dashboard', { timeout: 15_000 });
|
await page.waitForURL('**/', { timeout: 15_000 });
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
// Dashboard should be visible
|
test('shows an error for invalid credentials', async ({ page }) => {
|
||||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
await goToLogin(page);
|
||||||
});
|
|
||||||
|
|
||||||
test('should show error with invalid credentials', async ({ page }) => {
|
await page.getByLabel('Email').fill(INVALID_USER.email);
|
||||||
await goToLogin(page);
|
await page.getByLabel('Password').fill(INVALID_USER.password);
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
|
||||||
await page.getByLabel('Email').fill(INVALID_USER.email);
|
// Stay on the login page and surface the API error in a role="alert" box.
|
||||||
await page.getByLabel('Password').fill(INVALID_USER.password);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
await expect(page.getByRole('alert')).toContainText('Invalid email or password', {
|
||||||
|
timeout: 10_000,
|
||||||
// Should still be on login page
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
|
|
||||||
// Should show error message (toast or inline)
|
|
||||||
// The app uses sonner toasts for errors
|
|
||||||
await expect(
|
|
||||||
page.getByText(/login failed|invalid/i),
|
|
||||||
).toBeVisible({ timeout: 10_000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should prevent submitting empty form', async ({ page }) => {
|
|
||||||
await goToLogin(page);
|
|
||||||
|
|
||||||
const submitButton = page.getByRole('button', { name: /sign in/i });
|
|
||||||
await expect(submitButton).toBeVisible();
|
|
||||||
|
|
||||||
// HTML5 required attribute should prevent submission
|
|
||||||
// The email field is required
|
|
||||||
await page.getByLabel('Password').fill('somepassword');
|
|
||||||
await submitButton.click();
|
|
||||||
|
|
||||||
// Should stay on login page (HTML5 validation prevents submit)
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Logout', () => {
|
test('logs out from the user menu and returns to the login page', async ({ page }) => {
|
||||||
test('should logout and redirect to login page', async ({ page }) => {
|
await login(page);
|
||||||
// First login
|
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||||
await login(page);
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
|
|
||||||
// Clear cookies to simulate logout
|
// The topbar avatar dropdown works on every viewport (the sidebar user menu
|
||||||
await logout(page);
|
// is desktop-only), so use it for the logout flow.
|
||||||
|
const avatarButton = page.getByRole('banner').getByRole('button').last();
|
||||||
|
await avatarButton.click();
|
||||||
|
await page.getByRole('menuitem', { name: /log out/i }).click();
|
||||||
|
|
||||||
// Navigate to dashboard – should redirect to login
|
await page.waitForURL('**/login', { timeout: 15_000 });
|
||||||
await page.goto('/dashboard');
|
await expect(page.getByRole('heading', { name: 'Sign in' })).toBeVisible();
|
||||||
await page.waitForURL('**/login', { timeout: 10_000 });
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Session persistence', () => {
|
test('keeps the session after a page reload', async ({ page }) => {
|
||||||
test('should stay logged in after page refresh', async ({ page }) => {
|
await login(page);
|
||||||
await login(page);
|
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
|
|
||||||
// Refresh the page
|
await page.reload();
|
||||||
await page.reload();
|
|
||||||
|
|
||||||
// Should still be on dashboard
|
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||||
await expect(page).toHaveURL(/\/dashboard/);
|
|
||||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Protected routes', () => {
|
test('redirects unauthenticated users from protected pages to login', async ({ page }) => {
|
||||||
test('should redirect unauthenticated users from dashboard to login', async ({ page }) => {
|
await logout(page);
|
||||||
await logout(page);
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
await page.waitForURL('**/login', { timeout: 10_000 });
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should redirect unauthenticated users from tasks to login', async ({ page }) => {
|
await page.goto('/tasks');
|
||||||
await logout(page);
|
await page.waitForURL('**/login', { timeout: 15_000 });
|
||||||
await page.goto('/tasks');
|
await expect(page).toHaveURL(/\/login/);
|
||||||
await page.waitForURL('**/login', { timeout: 10_000 });
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+56
-35
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { login } from './helpers/auth';
|
import { login } from './helpers/auth';
|
||||||
|
import { testCalendarEvents } from './helpers/fixtures';
|
||||||
|
|
||||||
test.describe('Calendar', () => {
|
test.describe('Calendar', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -8,47 +9,67 @@ test.describe('Calendar', () => {
|
|||||||
await expect(page.getByRole('heading', { name: /calendar/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /calendar/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display calendar with month view by default', async ({ page }) => {
|
/** Fill the New Event dialog and submit it, returning the POST response. */
|
||||||
// Month view should be visible
|
async function createEvent(page: import('@playwright/test').Page, title: string) {
|
||||||
await expect(page.getByText(/today/i)).toBeVisible();
|
await page.getByRole('button', { name: /new event/i }).first().click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'New Event' })).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByLabel('Title').fill(title);
|
||||||
|
|
||||||
|
// The API requires an ISO start time; schedule the event for today so it
|
||||||
|
// lands in the current month view.
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0');
|
||||||
|
const start = new Date();
|
||||||
|
const end = new Date(start.getTime() + 60 * 60 * 1000);
|
||||||
|
const toLocal = (d: Date) =>
|
||||||
|
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
await dialog.getByLabel('Start').fill(toLocal(start));
|
||||||
|
await dialog.getByLabel('End').fill(toLocal(end));
|
||||||
|
|
||||||
|
const createResponse = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/calendar/events') && resp.request().method() === 'POST',
|
||||||
|
);
|
||||||
|
await dialog.getByRole('button', { name: /create event/i }).click();
|
||||||
|
await createResponse;
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renders the calendar page', async ({ page }) => {
|
||||||
|
// react-big-calendar only mounts once events exist; otherwise a friendly
|
||||||
|
// empty state is shown. Accept either so a fresh DB still passes.
|
||||||
|
await expect(
|
||||||
|
page.getByText(/no events yet/i).or(page.locator('.rbc-calendar-container')),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should switch between month, week, and day views', async ({ page }) => {
|
test('creates an event via the New Event dialog', async ({ page }) => {
|
||||||
// Click week view
|
await createEvent(page, testCalendarEvents.title);
|
||||||
const weekBtn = page.getByRole('button', { name: /week/i });
|
|
||||||
if (await weekBtn.isVisible()) {
|
|
||||||
await weekBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Click day view
|
// The calendar now renders and the event shows up on it.
|
||||||
const dayBtn = page.getByRole('button', { name: /day/i });
|
await expect(page.locator('.rbc-calendar-container')).toBeVisible({ timeout: 10_000 });
|
||||||
if (await dayBtn.isVisible()) {
|
await expect(
|
||||||
await dayBtn.click();
|
page.locator('.rbc-event').filter({ hasText: testCalendarEvents.title }),
|
||||||
await page.waitForTimeout(500);
|
).toBeVisible({ timeout: 10_000 });
|
||||||
}
|
|
||||||
|
|
||||||
// Click month view
|
|
||||||
const monthBtn = page.getByRole('button', { name: /month/i });
|
|
||||||
if (await monthBtn.isVisible()) {
|
|
||||||
await monthBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate between months', async ({ page }) => {
|
test('switches between month, week, and day views', async ({ page }) => {
|
||||||
// Click next month
|
// The toolbar only exists when the calendar is rendered, so make sure at
|
||||||
const nextBtn = page.getByRole('button', { name: /next/i });
|
// least one event exists first.
|
||||||
if (await nextBtn.isVisible()) {
|
if (!(await page.locator('.rbc-calendar-container').isVisible().catch(() => false))) {
|
||||||
await nextBtn.click();
|
await createEvent(page, `${testCalendarEvents.title} view`);
|
||||||
await page.waitForTimeout(500);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click previous month
|
await expect(page.locator('.rbc-calendar-container')).toBeVisible({ timeout: 10_000 });
|
||||||
const prevBtn = page.getByRole('button', { name: /prev/i });
|
await page.getByRole('button', { name: 'Week', exact: true }).click();
|
||||||
if (await prevBtn.isVisible()) {
|
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
|
||||||
await prevBtn.click();
|
|
||||||
await page.waitForTimeout(500);
|
await page.getByRole('button', { name: 'Day', exact: true }).click();
|
||||||
}
|
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Month', exact: true }).click();
|
||||||
|
await expect(page.locator('.rbc-calendar-container')).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
import { testCanvas } from './helpers/fixtures';
|
||||||
|
|
||||||
|
test.describe('Canvas', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto('/canvas');
|
||||||
|
await expect(page.getByRole('heading', { name: /canvas/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the canvas list', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('button', { name: /new canvas/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a canvas and opens the editor', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: /new canvas/i }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'New Canvas' })).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByLabel('Name').fill(testCanvas.name);
|
||||||
|
|
||||||
|
const createResponse = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/canvas') && resp.request().method() === 'POST',
|
||||||
|
);
|
||||||
|
await dialog.getByRole('button', { name: /^create$/i }).click();
|
||||||
|
expect((await createResponse).ok()).toBeTruthy();
|
||||||
|
|
||||||
|
// Creating a canvas navigates straight into the editor at /canvas/<id>.
|
||||||
|
await page.waitForURL('**/canvas/*', { timeout: 10_000 });
|
||||||
|
await expect(page.getByPlaceholder(/type \/ for commands/i)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('Daily Notes', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto('/daily');
|
||||||
|
// Wait for the editor (mood/energy controls) to finish loading.
|
||||||
|
await expect(page.getByRole('radiogroup', { name: 'Mood' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the calendar sidebar and editor', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('button', { name: /today/i })).toBeVisible();
|
||||||
|
await expect(page.getByText('Sun')).toBeVisible();
|
||||||
|
await expect(page.getByRole('radiogroup', { name: 'Energy' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('selecting a mood rating creates the daily note', async ({ page }) => {
|
||||||
|
await page.getByRole('radio', { name: 'Mood 5' }).click();
|
||||||
|
await expect(page.getByRole('radio', { name: 'Mood 5' })).toHaveAttribute('aria-checked', 'true');
|
||||||
|
|
||||||
|
// Creating a mood on a day with no note materializes the editor.
|
||||||
|
await expect(page.getByPlaceholder(/write your daily note/i)).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('writes a daily note and it autosaves', async ({ page }) => {
|
||||||
|
const textarea = page.getByPlaceholder(/write your daily note/i);
|
||||||
|
|
||||||
|
if (!(await textarea.isVisible().catch(() => false))) {
|
||||||
|
await page.getByRole('radio', { name: 'Mood 5' }).click();
|
||||||
|
await expect(textarea).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await textarea.fill('E2E daily note content');
|
||||||
|
|
||||||
|
// Once a note exists the header shows the "Saved" badge.
|
||||||
|
await expect(page.getByText('Saved', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
});
|
||||||
|
});
|
||||||
+14
-11
@@ -4,20 +4,23 @@ import { login } from './helpers/auth';
|
|||||||
test.describe('Dashboard', () => {
|
test.describe('Dashboard', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/dashboard');
|
});
|
||||||
|
|
||||||
|
test('renders the dashboard with the widget area', async ({ page }) => {
|
||||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
||||||
|
|
||||||
|
// Either the widget grid renders or the "add widget" entry point shows.
|
||||||
|
await expect(page.getByRole('button', { name: /add widget/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display dashboard with widgets', async ({ page }) => {
|
test('adds the default widget set from an empty dashboard', async ({ page }) => {
|
||||||
// Dashboard should show at least one widget area
|
const addDefaults = page.getByRole('button', { name: /add default widgets/i });
|
||||||
await expect(page.locator('[class*="grid"]').first()).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show today tasks widget', async ({ page }) => {
|
// Only the empty dashboard offers the default set; a dashboard that already
|
||||||
await expect(page.getByText(/today/i).first()).toBeVisible();
|
// has widgets (from earlier runs) skips this.
|
||||||
});
|
if (await addDefaults.isVisible().catch(() => false)) {
|
||||||
|
await addDefaults.click();
|
||||||
test('should show activity feed widget', async ({ page }) => {
|
await expect(page.getByText(/tasks due today/i)).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByText(/activity/i).first()).toBeVisible();
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/**
|
||||||
|
* Minimal ambient declarations for the E2E suite.
|
||||||
|
*
|
||||||
|
* The suite reads a handful of env vars for test credentials. Node type
|
||||||
|
* definitions are not installed at the repo root (they only live under
|
||||||
|
* apps/api/node_modules), so declare just the surface we use instead of
|
||||||
|
* depending on @types/node.
|
||||||
|
*/
|
||||||
|
declare const process: {
|
||||||
|
env: {
|
||||||
|
CI?: string;
|
||||||
|
E2E_EMAIL?: string;
|
||||||
|
E2E_PASSWORD?: string;
|
||||||
|
INITIAL_ADMIN_EMAIL?: string;
|
||||||
|
INITIAL_ADMIN_PASSWORD?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
import { login } from './helpers/auth';
|
||||||
|
|
||||||
|
test.describe('Graph', () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await page.goto('/graph');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders the graph toolbar with search and zoom controls', async ({ page }) => {
|
||||||
|
await expect(page.getByPlaceholder(/find a node/i)).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /filters/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /zoom in/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /zoom out/i })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /reset view/i })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens the filters panel with entity type toggles', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: /filters/i }).click();
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading', { name: 'Filters' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Tasks' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Habits' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Projects' })).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
+32
-70
@@ -2,87 +2,49 @@ import { test, expect } from '@playwright/test';
|
|||||||
import { login } from './helpers/auth';
|
import { login } from './helpers/auth';
|
||||||
import { testHabits } from './helpers/fixtures';
|
import { testHabits } from './helpers/fixtures';
|
||||||
|
|
||||||
test.describe('Habit Tracking', () => {
|
test.describe('Habits', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/habits');
|
await page.goto('/habits');
|
||||||
// Wait for the habits page to load
|
|
||||||
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Habits page', () => {
|
test('shows the empty state when there are no habits', async ({ page }) => {
|
||||||
test('should display habits page with summary banner', async ({ page }) => {
|
if (await page.getByText('No habits yet').isVisible().catch(() => false)) {
|
||||||
// Summary banner should show today's progress
|
await expect(page.getByText(/create your first one/i)).toBeVisible();
|
||||||
await expect(page.getByText(/today's progress/i)).toBeVisible();
|
}
|
||||||
await expect(page.getByText(/completion rate/i)).toBeVisible();
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('should show "New habit" button', async ({ page }) => {
|
test('creates a habit via the New Habit dialog', async ({ page }) => {
|
||||||
await expect(page.getByRole('button', { name: /new habit/i })).toBeVisible();
|
await page.getByRole('button', { name: /new habit/i }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'New Habit' })).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByLabel('Name').fill(testHabits.name);
|
||||||
|
await dialog.getByLabel('Description').fill(testHabits.description);
|
||||||
|
await dialog.getByRole('button', { name: /create habit/i }).click();
|
||||||
|
|
||||||
|
// The dialog closes and the habit appears in the list.
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByText(testHabits.name, { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Create habit', () => {
|
test('marks a habit complete from the list', async ({ page }) => {
|
||||||
test('should open new habit dialog when clicking "New habit"', async ({ page }) => {
|
// Ensure the fixture habit exists.
|
||||||
|
if (!(await page.getByText(testHabits.name, { exact: true }).isVisible().catch(() => false))) {
|
||||||
await page.getByRole('button', { name: /new habit/i }).click();
|
await page.getByRole('button', { name: /new habit/i }).click();
|
||||||
// A dialog or form should appear
|
const dialog = page.getByRole('dialog');
|
||||||
// The habit creation might be a dialog or inline form
|
await dialog.getByLabel('Name').fill(testHabits.name);
|
||||||
await page.waitForTimeout(500);
|
await dialog.getByRole('button', { name: /create habit/i }).click();
|
||||||
});
|
await expect(page.getByText(testHabits.name, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
}
|
||||||
|
|
||||||
test.describe('Habit completion', () => {
|
// Completing today's habit bumps the streak to 1 day.
|
||||||
test('should display habit cards in a grid', async ({ page }) => {
|
await page.getByRole('button', { name: /mark .* complete/i }).first().click();
|
||||||
// Habit cards should be in a grid layout
|
await expect(page.getByText('1 day streak', { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
const habitCards = page.locator('.grid > div, [class*="habit"]');
|
|
||||||
// Verify the grid container exists
|
|
||||||
await expect(page.locator('.grid')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show consistency heatmap section', async ({ page }) => {
|
|
||||||
// The heatmap section should exist
|
|
||||||
await expect(page.getByText(/consistency overview/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Quick completion mode', () => {
|
|
||||||
test('should complete a quick-mode habit with a single click', async ({ page }) => {
|
|
||||||
// Find a habit card with a complete button
|
|
||||||
const completeButtons = page.locator('button:has-text("Complete"), button[aria-label*="complete"]');
|
|
||||||
const count = await completeButtons.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
await completeButtons.first().click();
|
|
||||||
// Should update without showing a dialog (quick mode)
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Detailed completion mode', () => {
|
|
||||||
test('should open completion dialog for detailed habits', async ({ page }) => {
|
|
||||||
// Detailed mode habits open a dialog with mood/quantity fields
|
|
||||||
const detailedButtons = page.locator('button:has-text("Complete")');
|
|
||||||
const count = await detailedButtons.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
// Try clicking - if it's detailed mode, a dialog should open
|
|
||||||
await detailedButtons.first().click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Habit streaks', () => {
|
|
||||||
test('should display streak information on habit cards', async ({ page }) => {
|
|
||||||
// Habit cards should show streak/fire icons
|
|
||||||
const streakElements = page.locator('[class*="streak"], [class*="fire"], [class*="flame"]');
|
|
||||||
// Just verify the page loaded properly
|
|
||||||
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-7
@@ -4,8 +4,13 @@ import { TEST_USER } from './fixtures';
|
|||||||
/**
|
/**
|
||||||
* Log the test user in via the login page.
|
* Log the test user in via the login page.
|
||||||
*
|
*
|
||||||
* This hits the real UI flow (fill form → submit) so the auth cookie is
|
* This exercises the real UI flow (fill form → submit) so the `session` cookie
|
||||||
* set exactly as a real user would experience it.
|
* is set exactly as a real user would experience it. Successful logins navigate
|
||||||
|
* to the "/" dashboard.
|
||||||
|
*
|
||||||
|
* The very first login on an empty database auto-creates the admin user; when
|
||||||
|
* several parallel workers race that first login one of them can hit a
|
||||||
|
* transient server error, so the submit is retried once.
|
||||||
*/
|
*/
|
||||||
export async function login(
|
export async function login(
|
||||||
page: Page,
|
page: Page,
|
||||||
@@ -16,17 +21,30 @@ export async function login(
|
|||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
await page.getByLabel('Email').fill(email);
|
await page.getByLabel('Email').fill(email);
|
||||||
await page.getByLabel('Password').fill(password);
|
await page.getByLabel('Password').fill(password);
|
||||||
await page.getByRole('button', { name: /sign in/i }).click();
|
|
||||||
|
|
||||||
// Wait for navigation away from login page
|
let lastError: unknown;
|
||||||
await page.waitForURL('**/dashboard', { timeout: 15_000 });
|
for (let attempt = 0; attempt < 2; attempt++) {
|
||||||
|
await page.getByRole('button', { name: /sign in/i }).click();
|
||||||
|
try {
|
||||||
|
// Wait for navigation away from the login page to the "/" dashboard.
|
||||||
|
await page.waitForURL('**/', { timeout: 10_000 });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
// A transient failure leaves the form on the login page — refill and retry.
|
||||||
|
await page.getByLabel('Email').fill(email);
|
||||||
|
await page.getByLabel('Password').fill(password);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw lastError instanceof Error ? lastError : new Error('Login failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the user is logged out by clearing cookies.
|
* Log the user out by hitting the logout endpoint, then land on the login page.
|
||||||
*/
|
*/
|
||||||
export async function logout(page: Page) {
|
export async function logout(page: Page) {
|
||||||
await page.context().clearCookies();
|
await page.request.post('/api/auth/logout');
|
||||||
|
await page.goto('/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+21
-12
@@ -1,9 +1,14 @@
|
|||||||
import type { Page } from '@playwright/test';
|
/**
|
||||||
|
* Shared test fixtures.
|
||||||
/** Test user credentials – matches PocketBase seed data or test fixtures. */
|
*
|
||||||
|
* The first login against a fresh database auto-creates the admin user from
|
||||||
|
* INITIAL_ADMIN_EMAIL / INITIAL_ADMIN_PASSWORD (see apps/api/src/routes/auth.ts),
|
||||||
|
* so the E2E suite prefers those env vars and only falls back to hardcoded
|
||||||
|
* defaults when running against a pre-seeded local database.
|
||||||
|
*/
|
||||||
export const TEST_USER = {
|
export const TEST_USER = {
|
||||||
email: 'test@example.com',
|
email: process.env.E2E_EMAIL || process.env.INITIAL_ADMIN_EMAIL || 'test@example.com',
|
||||||
password: 'testpassword123',
|
password: process.env.E2E_PASSWORD || process.env.INITIAL_ADMIN_PASSWORD || 'testpassword123',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Fake credentials that should always fail login. */
|
/** Fake credentials that should always fail login. */
|
||||||
@@ -17,10 +22,7 @@ const ts = Date.now();
|
|||||||
|
|
||||||
export const testTasks = {
|
export const testTasks = {
|
||||||
title: `E2E Test Task ${ts}`,
|
title: `E2E Test Task ${ts}`,
|
||||||
editedTitle: `E2E Test Task Edited ${ts}`,
|
|
||||||
description: 'This task was created by the E2E test suite.',
|
description: 'This task was created by the E2E test suite.',
|
||||||
domain: 'personal',
|
|
||||||
priority: 'high' as const,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const testHabits = {
|
export const testHabits = {
|
||||||
@@ -36,14 +38,21 @@ export const testProjects = {
|
|||||||
export const testNotes = {
|
export const testNotes = {
|
||||||
title: `E2E Test Note ${ts}`,
|
title: `E2E Test Note ${ts}`,
|
||||||
content: 'This note was created by the E2E test suite.',
|
content: 'This note was created by the E2E test suite.',
|
||||||
domain: 'personal',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const testReports = {
|
export const testCalendarEvents = {
|
||||||
title: `E2E Test Report ${ts}`,
|
title: `E2E Test Event ${ts}`,
|
||||||
content: 'This report was created by the E2E test suite.',
|
};
|
||||||
|
|
||||||
|
export const testCanvas = {
|
||||||
|
name: `E2E Test Canvas ${ts}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const testDomains = {
|
export const testDomains = {
|
||||||
name: `e2e-domain-${ts}`,
|
name: `e2e-domain-${ts}`,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const testWebhooks = {
|
||||||
|
name: `E2E Test Webhook ${ts}`,
|
||||||
|
url: `https://example.com/hooks/${ts}`,
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { login } from './helpers/auth';
|
|
||||||
import * as fs from 'fs';
|
|
||||||
import * as path from 'path';
|
|
||||||
import * as os from 'os';
|
|
||||||
|
|
||||||
test.describe('Import/Export', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
await page.goto('/settings');
|
|
||||||
// Wait for the settings page to load
|
|
||||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
|
||||||
|
|
||||||
// Switch to Import & Export tab
|
|
||||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Export', () => {
|
|
||||||
test('should show export section with collection selection', async ({ page }) => {
|
|
||||||
await expect(page.getByText(/export data/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/select all/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /export to json/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have all collection checkboxes', async ({ page }) => {
|
|
||||||
const collections = [
|
|
||||||
'tasks', 'habits', 'projects', 'notes', 'reports',
|
|
||||||
'milestones', 'domains', 'tags', 'agents', 'webhooks',
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const collection of collections) {
|
|
||||||
await expect(page.getByLabel(new RegExp(`export ${collection}`, 'i'))).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should toggle select all checkbox', async ({ page }) => {
|
|
||||||
const selectAll = page.getByLabel(/select all/i);
|
|
||||||
await expect(selectAll).toBeChecked();
|
|
||||||
|
|
||||||
// Uncheck select all
|
|
||||||
await selectAll.click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Individual checkboxes should be unchecked
|
|
||||||
const tasksCheckbox = page.getByLabel(/export tasks/i);
|
|
||||||
await expect(tasksCheckbox).not.toBeChecked();
|
|
||||||
|
|
||||||
// Check select all again
|
|
||||||
await selectAll.click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
await expect(tasksCheckbox).toBeChecked();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should export data as JSON', async ({ page }) => {
|
|
||||||
// Set up download handler
|
|
||||||
const downloadPromise = page.waitForEvent('download');
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /export to json/i }).click();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const download = await downloadPromise;
|
|
||||||
// Verify download started
|
|
||||||
const filename = download.suggestedFilename();
|
|
||||||
expect(filename).toMatch(/project-e-export.*\.json/);
|
|
||||||
} catch {
|
|
||||||
// Export might fail if PocketBase isn't running – that's OK for structure test
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Import', () => {
|
|
||||||
test('should show import section', async ({ page }) => {
|
|
||||||
await expect(page.getByText(/import data/i)).toBeVisible();
|
|
||||||
await expect(page.getByText(/restore from a previously exported/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /import from json/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have file upload input', async ({ page }) => {
|
|
||||||
// The file input is hidden, triggered by the button
|
|
||||||
const fileInput = page.locator('input[type="file"][accept=".json"]');
|
|
||||||
await expect(fileInput).toBeAttached();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should import data from JSON file', async ({ page }) => {
|
|
||||||
// Create a minimal valid import file
|
|
||||||
const importData = {
|
|
||||||
version: 1,
|
|
||||||
exported_at: new Date().toISOString(),
|
|
||||||
collections: {
|
|
||||||
tasks: [],
|
|
||||||
habits: [],
|
|
||||||
projects: [],
|
|
||||||
notes: [],
|
|
||||||
reports: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const tempDir = os.tmpdir();
|
|
||||||
const importFile = path.join(tempDir, `e2e-import-${Date.now()}.json`);
|
|
||||||
fs.writeFileSync(importFile, JSON.stringify(importData, null, 2));
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Trigger file upload
|
|
||||||
const fileInput = page.locator('input[type="file"][accept=".json"]');
|
|
||||||
await fileInput.setInputFiles(importFile);
|
|
||||||
|
|
||||||
// Confirmation dialog should appear
|
|
||||||
await expect(page.getByRole('dialog', { name: /confirm import/i })).toBeVisible({ timeout: 5_000 });
|
|
||||||
|
|
||||||
// Click Import button in dialog
|
|
||||||
await page.getByRole('button', { name: /import$/i }).click();
|
|
||||||
|
|
||||||
// Wait for import to complete
|
|
||||||
await page.waitForTimeout(3_000);
|
|
||||||
} finally {
|
|
||||||
// Cleanup temp file
|
|
||||||
try { fs.unlinkSync(importFile); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show import confirmation dialog', async ({ page }) => {
|
|
||||||
const importData = {
|
|
||||||
version: 1,
|
|
||||||
exported_at: new Date().toISOString(),
|
|
||||||
collections: { tasks: [], habits: [], projects: [], notes: [], reports: [] },
|
|
||||||
};
|
|
||||||
|
|
||||||
const tempDir = os.tmpdir();
|
|
||||||
const importFile = path.join(tempDir, `e2e-import-dialog-${Date.now()}.json`);
|
|
||||||
fs.writeFileSync(importFile, JSON.stringify(importData, null, 2));
|
|
||||||
|
|
||||||
try {
|
|
||||||
const fileInput = page.locator('input[type="file"][accept=".json"]');
|
|
||||||
await fileInput.setInputFiles(importFile);
|
|
||||||
|
|
||||||
// Confirmation dialog
|
|
||||||
const dialog = page.getByRole('dialog', { name: /confirm import/i });
|
|
||||||
await expect(dialog).toBeVisible({ timeout: 5_000 });
|
|
||||||
|
|
||||||
// Should have Cancel and Import buttons
|
|
||||||
await expect(dialog.getByRole('button', { name: /cancel/i })).toBeVisible();
|
|
||||||
await expect(dialog.getByRole('button', { name: /import/i })).toBeVisible();
|
|
||||||
|
|
||||||
// Cancel the import
|
|
||||||
await dialog.getByRole('button', { name: /cancel/i }).click();
|
|
||||||
await expect(dialog).not.toBeVisible({ timeout: 3_000 });
|
|
||||||
} finally {
|
|
||||||
try { fs.unlinkSync(importFile); } catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { login } from './helpers/auth';
|
|
||||||
|
|
||||||
test.describe('MCP Server', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('server/discover should return all expected tools', async ({ page }) => {
|
|
||||||
const response = await page.request.post('/api/mcp', {
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'server/discover',
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.jsonrpc).toBe('2.0');
|
|
||||||
expect(body.result).toBeDefined();
|
|
||||||
expect(body.result.name).toBe('project-e');
|
|
||||||
expect(body.result.tools).toBeDefined();
|
|
||||||
expect(Array.isArray(body.result.tools)).toBeTruthy();
|
|
||||||
|
|
||||||
// Verify expected tools exist
|
|
||||||
const toolNames = body.result.tools.map((t: { name: string }) => t.name);
|
|
||||||
expect(toolNames).toContain('tasks.list');
|
|
||||||
expect(toolNames).toContain('tasks.create');
|
|
||||||
expect(toolNames).toContain('tasks.update');
|
|
||||||
expect(toolNames).toContain('tasks.delete');
|
|
||||||
expect(toolNames).toContain('tasks.complete');
|
|
||||||
expect(toolNames).toContain('habits.list');
|
|
||||||
expect(toolNames).toContain('habits.create');
|
|
||||||
expect(toolNames).toContain('habits.complete');
|
|
||||||
expect(toolNames).toContain('projects.list');
|
|
||||||
expect(toolNames).toContain('projects.create');
|
|
||||||
expect(toolNames).toContain('notes.list');
|
|
||||||
expect(toolNames).toContain('notes.create');
|
|
||||||
expect(toolNames).toContain('notes.update');
|
|
||||||
expect(toolNames).toContain('notes.search');
|
|
||||||
expect(toolNames).toContain('domains.list');
|
|
||||||
expect(toolNames).toContain('domains.create');
|
|
||||||
expect(toolNames).toContain('search.query');
|
|
||||||
expect(toolNames).toContain('activity.list');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject unauthenticated requests', async ({ page }) => {
|
|
||||||
const response = await page.request.post('/api/mcp', {
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'server/discover',
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Without API key, should return 401
|
|
||||||
expect(response.status()).toBe(401);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should return error for unknown method', async ({ page }) => {
|
|
||||||
const response = await page.request.post('/api/mcp', {
|
|
||||||
data: {
|
|
||||||
jsonrpc: '2.0',
|
|
||||||
method: 'unknown.method',
|
|
||||||
id: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.error).toBeDefined();
|
|
||||||
expect(body.error.code).toBe(-32601); // METHOD_NOT_FOUND
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should reject invalid JSON-RPC request', async ({ page }) => {
|
|
||||||
const response = await page.request.post('/api/mcp', {
|
|
||||||
data: { invalid: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(response.status()).toBe(400);
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body.error).toBeDefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('GET should return 405', async ({ page }) => {
|
|
||||||
const response = await page.request.get('/api/mcp');
|
|
||||||
expect(response.status()).toBe(405);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+35
-28
@@ -6,40 +6,47 @@ test.describe('Navigation', () => {
|
|||||||
await login(page);
|
await login(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should navigate between all main pages via sidebar', async ({ page }) => {
|
test('every primary route renders its page', async ({ page }) => {
|
||||||
const pages = [
|
const pages = [
|
||||||
{ href: '/dashboard', name: /dashboard/i },
|
{ url: '/', check: page.getByRole('heading', { name: /dashboard/i }) },
|
||||||
{ href: '/tasks', name: /tasks/i },
|
{ url: '/tasks', check: page.getByRole('heading', { name: /tasks/i }) },
|
||||||
{ href: '/habits', name: /habits/i },
|
{ url: '/habits', check: page.getByRole('heading', { name: /habits/i }) },
|
||||||
{ href: '/projects', name: /projects/i },
|
{ url: '/projects', check: page.getByRole('heading', { name: /projects/i }) },
|
||||||
{ href: '/notes', name: /notes/i },
|
{ url: '/notes', check: page.getByRole('button', { name: /new note/i }) },
|
||||||
{ href: '/graph', name: /graph/i },
|
{ url: '/calendar', check: page.getByRole('heading', { name: /calendar/i }) },
|
||||||
{ href: '/calendar', name: /calendar/i },
|
{ url: '/graph', check: page.getByPlaceholder(/find a node/i) },
|
||||||
{ href: '/search', name: /search/i },
|
{ url: '/search', check: page.getByPlaceholder(/search tasks, notes/i) },
|
||||||
|
{ url: '/analytics', check: page.getByRole('heading', { name: /analytics/i }) },
|
||||||
|
{ url: '/agents/activity', check: page.getByRole('heading', { name: /agent activity/i }) },
|
||||||
|
{ url: '/canvas', check: page.getByRole('heading', { name: /canvas/i }) },
|
||||||
|
{ url: '/daily', check: page.getByRole('button', { name: /today/i }) },
|
||||||
|
{ url: '/settings', check: page.getByRole('button', { name: 'Appearance' }) },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const { href, name } of pages) {
|
for (const { url, check } of pages) {
|
||||||
await page.goto(href);
|
await page.goto(url);
|
||||||
await page.waitForURL(`**${href}`, { timeout: 10_000 });
|
await page.waitForURL(`**${url}`, { timeout: 10_000 });
|
||||||
await expect(page.locator('h1, h2').filter({ hasText: name }).first()).toBeVisible();
|
await expect(check).toBeVisible({ timeout: 10_000 });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should open command palette with Cmd+K', async ({ page }) => {
|
test('navigates via the sidebar links', async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
// The sidebar is desktop-only; on narrow viewports it lives behind the
|
||||||
await page.keyboard.press('Meta+k');
|
// topbar menu button.
|
||||||
// Command palette should be visible
|
test.skip((page.viewportSize()?.width ?? 0) < 768, 'sidebar is hidden on mobile');
|
||||||
await expect(page.getByPlaceholder(/type a command/i)).toBeVisible({ timeout: 5_000 });
|
|
||||||
// Close with Escape
|
|
||||||
await page.keyboard.press('Escape');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should open keyboard shortcuts help with ?', async ({ page }) => {
|
const nav = page.getByRole('navigation', { name: 'Primary' });
|
||||||
await page.goto('/dashboard');
|
|
||||||
await page.keyboard.press('?');
|
await nav.getByRole('link', { name: 'Tasks' }).click();
|
||||||
// Shortcuts help dialog should be visible
|
await page.waitForURL('**/tasks', { timeout: 10_000 });
|
||||||
await expect(page.getByText(/keyboard shortcuts/i)).toBeVisible({ timeout: 5_000 });
|
await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
|
||||||
// Close with Escape
|
|
||||||
await page.keyboard.press('Escape');
|
await nav.getByRole('link', { name: 'Habits' }).click();
|
||||||
|
await page.waitForURL('**/habits', { timeout: 10_000 });
|
||||||
|
await expect(page.getByRole('heading', { name: /habits/i })).toBeVisible();
|
||||||
|
|
||||||
|
await nav.getByRole('link', { name: 'Settings' }).click();
|
||||||
|
await page.waitForURL('**/settings', { timeout: 10_000 });
|
||||||
|
await expect(page.getByRole('button', { name: 'Appearance' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+42
-99
@@ -6,118 +6,61 @@ test.describe('Notes', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/notes');
|
await page.goto('/notes');
|
||||||
// Wait for the notes page to load
|
// The notes page has no h1; the "New Note" button marks it as loaded.
|
||||||
await expect(page.getByRole('heading', { name: /notes/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: /new note/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Notes page layout', () => {
|
test('creates a note and opens it in the editor', async ({ page }) => {
|
||||||
test('should display three-panel layout', async ({ page }) => {
|
const createResponse = page.waitForResponse(
|
||||||
// Notes page has: notes list | editor | backlinks/graph
|
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||||
await expect(page.getByText(/connect ideas/i)).toBeVisible();
|
);
|
||||||
await expect(page.getByRole('button', { name: /new note/i })).toBeVisible();
|
await page.getByRole('button', { name: /new note/i }).click();
|
||||||
await expect(page.getByRole('button', { name: /daily note/i })).toBeVisible();
|
expect((await createResponse).ok()).toBeTruthy();
|
||||||
});
|
|
||||||
|
|
||||||
test('should show empty state when no notes exist', async ({ page }) => {
|
// The new note is auto-selected and the TipTap editor becomes editable.
|
||||||
const noteItems = page.locator('button[aria-label^="Open note:"]');
|
await expect(page.locator('.note-editor [contenteditable="true"]')).toBeVisible({
|
||||||
const count = await noteItems.count();
|
timeout: 10_000,
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
await expect(page.getByText(/no notes yet/i)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Create note', () => {
|
test('typing in the editor autosaves the content', async ({ page }) => {
|
||||||
test('should create a new note when clicking "New note"', async ({ page }) => {
|
// Ensure a note is open first.
|
||||||
// Intercept the API call
|
const createResponse = page.waitForResponse(
|
||||||
const createResponsePromise = page.waitForResponse(
|
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||||
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
);
|
||||||
);
|
await page.getByRole('button', { name: /new note/i }).click();
|
||||||
|
await createResponse;
|
||||||
|
|
||||||
await page.getByRole('button', { name: /new note/i }).click();
|
const editor = page.locator('.note-editor [contenteditable="true"]');
|
||||||
|
await expect(editor).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Wait for the API response
|
// Typing triggers the debounced autosave PATCH.
|
||||||
const response = await createResponsePromise;
|
const patchResponse = page.waitForResponse(
|
||||||
expect(response.ok()).toBeTruthy();
|
(resp) => /^\/api\/notes\/[^/?]+$/.test(new URL(resp.url()).pathname) &&
|
||||||
|
resp.request().method() === 'PATCH',
|
||||||
// The new note should appear in the list and be selected
|
);
|
||||||
await page.waitForTimeout(1_000);
|
await editor.fill(testNotes.content);
|
||||||
});
|
expect((await patchResponse).ok()).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Note editor', () => {
|
test('renames a note via the editor title input', async ({ page }) => {
|
||||||
test('should show editor when a note is selected', async ({ page }) => {
|
// Ensure a fresh note is open.
|
||||||
const noteItems = page.locator('button[aria-label^="Open note:"]');
|
const createResponse = page.waitForResponse(
|
||||||
const count = await noteItems.count();
|
(resp) => resp.url().includes('/api/notes') && resp.request().method() === 'POST',
|
||||||
|
);
|
||||||
|
await page.getByRole('button', { name: /new note/i }).click();
|
||||||
|
await createResponse;
|
||||||
|
|
||||||
if (count > 0) {
|
// The new note is titled "Untitled"; rename it via the uncontrolled input.
|
||||||
// Click first note to select it
|
const titleInput = page.locator('input[value="Untitled"]');
|
||||||
await noteItems.first().click();
|
await expect(titleInput).toBeVisible({ timeout: 10_000 });
|
||||||
|
|
||||||
// Editor should be visible (title input at minimum)
|
await titleInput.fill(testNotes.title);
|
||||||
await expect(page.getByLabel('Note title')).toBeVisible();
|
await titleInput.blur();
|
||||||
} else {
|
|
||||||
// Create a note first
|
|
||||||
await page.getByRole('button', { name: /new note/i }).click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
await expect(page.getByLabel('Note title')).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should update note title when edited', async ({ page }) => {
|
// The updated title shows up in the note list.
|
||||||
// Ensure a note is selected
|
await expect(page.getByText(testNotes.title, { exact: true })).toBeVisible({
|
||||||
const noteItems = page.locator('button[aria-label^="Open note:"]');
|
timeout: 10_000,
|
||||||
const count = await noteItems.count();
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
await page.getByRole('button', { name: /new note/i }).click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleInput = page.getByLabel('Note title');
|
|
||||||
await expect(titleInput).toBeVisible();
|
|
||||||
|
|
||||||
// Update the title
|
|
||||||
await titleInput.clear();
|
|
||||||
await titleInput.fill(testNotes.title);
|
|
||||||
|
|
||||||
// Trigger blur to save
|
|
||||||
await titleInput.blur();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Note backlinks and graph', () => {
|
|
||||||
test('should show backlinks and graph tabs', async ({ page }) => {
|
|
||||||
// Right panel should have Backlinks and Graph tabs
|
|
||||||
await expect(page.getByRole('tab', { name: /backlinks/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('tab', { name: /graph/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should switch between backlinks and graph views', async ({ page }) => {
|
|
||||||
// Click graph tab
|
|
||||||
await page.getByRole('tab', { name: /graph/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /graph/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Click backlinks tab
|
|
||||||
await page.getByRole('tab', { name: /backlinks/i }).click();
|
|
||||||
await expect(page.getByRole('tab', { name: /backlinks/i })).toHaveAttribute('data-state', 'active');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Daily note', () => {
|
|
||||||
test('should show "Daily note" button', async ({ page }) => {
|
|
||||||
await expect(page.getByRole('button', { name: /daily note/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should create or select daily note when clicking button', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: /daily note/i }).click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
|
|
||||||
// After clicking, a note with today's date should be selected
|
|
||||||
const today = new Date().toLocaleDateString();
|
|
||||||
// The title might contain the date
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+32
-11
@@ -1,5 +1,6 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
import { login } from './helpers/auth';
|
import { login } from './helpers/auth';
|
||||||
|
import { testProjects } from './helpers/fixtures';
|
||||||
|
|
||||||
test.describe('Projects', () => {
|
test.describe('Projects', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
@@ -8,19 +9,39 @@ test.describe('Projects', () => {
|
|||||||
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /projects/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display projects page with grid layout', async ({ page }) => {
|
test('creates a project via the New Project dialog', async ({ page }) => {
|
||||||
await expect(page.getByRole('button', { name: /new project/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should open new project dialog', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: /new project/i }).click();
|
await page.getByRole('button', { name: /new project/i }).click();
|
||||||
// Dialog should appear
|
|
||||||
await page.waitForTimeout(500);
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'New Project' })).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByLabel('Name').fill(testProjects.name);
|
||||||
|
await dialog.getByLabel('Description').fill(testProjects.description);
|
||||||
|
await dialog.getByRole('button', { name: /create project/i }).click();
|
||||||
|
|
||||||
|
// The dialog closes and the project card appears in the grid.
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByText(testProjects.name, { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show project cards in grid', async ({ page }) => {
|
test('opens the project detail page from a project card', async ({ page }) => {
|
||||||
// The grid container should exist
|
// Ensure the fixture project exists.
|
||||||
const grid = page.locator('.grid, [class*="grid"]').first();
|
if (!(await page.getByText(testProjects.name, { exact: true }).isVisible().catch(() => false))) {
|
||||||
await expect(grid).toBeVisible();
|
await page.getByRole('button', { name: /new project/i }).click();
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await dialog.getByLabel('Name').fill(testProjects.name);
|
||||||
|
await dialog.getByRole('button', { name: /create project/i }).click();
|
||||||
|
await expect(page.getByText(testProjects.name, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.getByText(testProjects.name, { exact: true }).first().click();
|
||||||
|
|
||||||
|
await page.waitForURL('**/projects/*', { timeout: 10_000 });
|
||||||
|
await expect(
|
||||||
|
page.getByRole('heading', { name: testProjects.name, level: 3 }),
|
||||||
|
).toBeVisible({ timeout: 10_000 });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { login } from './helpers/auth';
|
|
||||||
|
|
||||||
test.describe('Realtime Updates', () => {
|
|
||||||
test('should connect to SSE endpoint', async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
|
|
||||||
// Navigate to dashboard
|
|
||||||
await page.goto('/dashboard');
|
|
||||||
await expect(page.getByRole('heading', { name: /dashboard/i })).toBeVisible();
|
|
||||||
|
|
||||||
// The SSE connection is established automatically via the realtime hook
|
|
||||||
// Verify the page loaded without errors
|
|
||||||
const consoleMessages: string[] = [];
|
|
||||||
page.on('console', (msg) => {
|
|
||||||
if (msg.type() === 'error') {
|
|
||||||
consoleMessages.push(msg.text());
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
await page.waitForTimeout(2000);
|
|
||||||
|
|
||||||
// Check for SSE-related errors
|
|
||||||
const sseErrors = consoleMessages.filter(
|
|
||||||
(m) => m.includes('realtime') || m.includes('SSE') || m.includes('EventSource')
|
|
||||||
);
|
|
||||||
expect(sseErrors.length).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should have realtime API endpoint', async ({ page }) => {
|
|
||||||
const response = await page.request.get('/api/realtime');
|
|
||||||
// SSE endpoint should return 200 with text/event-stream content type
|
|
||||||
expect(response.status()).toBe(200);
|
|
||||||
const contentType = response.headers()['content-type'] || '';
|
|
||||||
expect(contentType).toContain('text/event-stream');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { login } from './helpers/auth';
|
|
||||||
import { testReports } from './helpers/fixtures';
|
|
||||||
|
|
||||||
test.describe('Reports', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
await page.goto('/reports');
|
|
||||||
// Wait for the reports page to load
|
|
||||||
await expect(page.getByRole('heading', { name: /reports/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Reports page layout', () => {
|
|
||||||
test('should display two-panel layout', async ({ page }) => {
|
|
||||||
// Reports page has: reports list | editor
|
|
||||||
await expect(page.getByText(/step back and see/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /new report/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /from template/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show empty state when no reports exist', async ({ page }) => {
|
|
||||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
|
||||||
const count = await reportItems.count();
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
await expect(page.getByText(/no reports yet/i)).toBeVisible();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Create report', () => {
|
|
||||||
test('should create a new report when clicking "New report"', async ({ page }) => {
|
|
||||||
const createResponsePromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/api/reports') && resp.request().method() === 'POST',
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /new report/i }).click();
|
|
||||||
const response = await createResponsePromise;
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Report from template', () => {
|
|
||||||
test('should show templates view when clicking "From template"', async ({ page }) => {
|
|
||||||
await page.getByRole('button', { name: /from template/i }).click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
|
|
||||||
// Should show template options or a templates view
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Report editor', () => {
|
|
||||||
test('should show editor when a report is selected', async ({ page }) => {
|
|
||||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
|
||||||
const count = await reportItems.count();
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
// Create a report first
|
|
||||||
await page.getByRole('button', { name: /new report/i }).click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Report title input should be visible
|
|
||||||
await expect(page.getByLabel('Report title')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should update report title when edited', async ({ page }) => {
|
|
||||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
|
||||||
const count = await reportItems.count();
|
|
||||||
|
|
||||||
if (count === 0) {
|
|
||||||
await page.getByRole('button', { name: /new report/i }).click();
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
const titleInput = page.getByLabel('Report title');
|
|
||||||
await expect(titleInput).toBeVisible();
|
|
||||||
|
|
||||||
await titleInput.clear();
|
|
||||||
await titleInput.fill(testReports.title);
|
|
||||||
await titleInput.blur();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show report metadata badges', async ({ page }) => {
|
|
||||||
const reportItems = page.locator('button[aria-label^="Open report:"]');
|
|
||||||
const count = await reportItems.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
await reportItems.first().click();
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
|
|
||||||
// Should show report type and domain badges
|
|
||||||
const badges = page.locator('[class*="badge"]');
|
|
||||||
const badgeCount = await badges.count();
|
|
||||||
expect(badgeCount).toBeGreaterThan(0);
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+17
-10
@@ -4,19 +4,26 @@ import { login } from './helpers/auth';
|
|||||||
test.describe('Search', () => {
|
test.describe('Search', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
|
await page.goto('/search');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should display search page with search input', async ({ page }) => {
|
test('renders the search page with the query input', async ({ page }) => {
|
||||||
await page.goto('/search');
|
await expect(page.getByPlaceholder(/search tasks, notes/i)).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: /search/i })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Tasks' })).toBeVisible();
|
||||||
await expect(page.getByPlaceholder(/search/i).first()).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Notes' })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should perform search and show results', async ({ page }) => {
|
test('shows a no-results state for an unmatched query', async ({ page }) => {
|
||||||
await page.goto('/search');
|
await page.getByPlaceholder(/search tasks, notes/i).fill('zzz-no-such-thing-xyz');
|
||||||
const searchInput = page.getByPlaceholder(/search/i).first();
|
await expect(page.getByText(/no results found/i)).toBeVisible({ timeout: 10_000 });
|
||||||
await searchInput.fill('test');
|
});
|
||||||
// Wait for results
|
|
||||||
await page.waitForTimeout(1000);
|
test('filters results by type', async ({ page }) => {
|
||||||
|
await page.getByPlaceholder(/search tasks, notes/i).fill('meeting');
|
||||||
|
await page.getByRole('button', { name: 'Habits' }).click();
|
||||||
|
|
||||||
|
// The filter buttons stay interactive after toggling a type off.
|
||||||
|
await expect(page.getByRole('button', { name: 'Habits' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: 'Tasks' })).toBeVisible();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+59
-157
@@ -6,174 +6,76 @@ test.describe('Settings', () => {
|
|||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/settings');
|
await page.goto('/settings');
|
||||||
// Wait for the settings page to load
|
|
||||||
await expect(page.getByRole('heading', { name: /settings/i })).toBeVisible();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Settings page layout', () => {
|
test('renders every settings tab', async ({ page }) => {
|
||||||
test('should display all settings tabs', async ({ page }) => {
|
const tabs = [
|
||||||
// Vertical tabs: Appearance, Domains, Keyboard Shortcuts, Agents, Webhooks, Import & Export, Error Log
|
'Appearance',
|
||||||
await expect(page.getByRole('tab', { name: /appearance/i })).toBeVisible();
|
'Domains',
|
||||||
await expect(page.getByRole('tab', { name: /domains/i })).toBeVisible();
|
'Tags',
|
||||||
await expect(page.getByRole('tab', { name: /keyboard shortcuts/i })).toBeVisible();
|
'Custom Fields',
|
||||||
await expect(page.getByRole('tab', { name: /agents/i })).toBeVisible();
|
'Keyboard Shortcuts',
|
||||||
await expect(page.getByRole('tab', { name: /webhooks/i })).toBeVisible();
|
'Agents & Permissions',
|
||||||
await expect(page.getByRole('tab', { name: /import.*export/i })).toBeVisible();
|
'Webhooks',
|
||||||
await expect(page.getByRole('tab', { name: /error log/i })).toBeVisible();
|
'Import & Export',
|
||||||
|
'Error Log',
|
||||||
|
];
|
||||||
|
for (const tab of tabs) {
|
||||||
|
await expect(page.getByRole('button', { name: tab })).toBeVisible();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens on the Appearance tab with theme controls', async ({ page }) => {
|
||||||
|
await expect(page.getByRole('heading', { name: 'Theme' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Accent Color' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('heading', { name: 'Font Size' })).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creates a domain from the Domains tab', async ({ page }) => {
|
||||||
|
await page.getByRole('button', { name: 'Domains' }).click();
|
||||||
|
await page.getByRole('button', { name: /new domain/i }).click();
|
||||||
|
|
||||||
|
const dialog = page.getByRole('dialog');
|
||||||
|
await expect(dialog).toBeVisible();
|
||||||
|
await expect(dialog.getByRole('heading', { name: 'New Domain' })).toBeVisible();
|
||||||
|
|
||||||
|
await dialog.getByLabel('Name').fill(testDomains.name);
|
||||||
|
|
||||||
|
const createResponse = page.waitForResponse(
|
||||||
|
(resp) => resp.url().includes('/api/domains') && resp.request().method() === 'POST',
|
||||||
|
);
|
||||||
|
await dialog.getByRole('button', { name: /^create$/i }).click();
|
||||||
|
expect((await createResponse).ok()).toBeTruthy();
|
||||||
|
|
||||||
|
await expect(page.getByText(testDomains.name, { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Appearance settings', () => {
|
test('Webhooks tab opens the New Webhook dialog', async ({ page }) => {
|
||||||
test('should show theme mode selector', async ({ page }) => {
|
await page.getByRole('button', { name: 'Webhooks' }).click();
|
||||||
// Appearance tab should be active by default
|
await expect(page.getByRole('button', { name: /new webhook/i })).toBeVisible();
|
||||||
await expect(page.getByLabel('Theme')).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should change theme mode', async ({ page }) => {
|
await page.getByRole('button', { name: /new webhook/i }).click();
|
||||||
// Find the theme mode select
|
const dialog = page.getByRole('dialog');
|
||||||
const themeSelect = page.getByLabel('Theme');
|
await expect(dialog).toBeVisible();
|
||||||
await expect(themeSelect).toBeVisible();
|
await expect(dialog.getByLabel('Name')).toBeVisible();
|
||||||
|
await expect(dialog.getByLabel('URL')).toBeVisible();
|
||||||
// Click to open dropdown
|
await expect(dialog.getByLabel(/events/i)).toBeVisible();
|
||||||
await themeSelect.click();
|
|
||||||
|
|
||||||
// Should show theme options (light, dark, system)
|
|
||||||
await expect(page.getByRole('option', { name: /light/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: /dark/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('option', { name: /system/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show accent color picker', async ({ page }) => {
|
|
||||||
const colorPicker = page.getByRole('radiogroup', { name: /accent color/i });
|
|
||||||
await expect(colorPicker).toBeVisible();
|
|
||||||
|
|
||||||
// Should have color buttons
|
|
||||||
const colorButtons = colorPicker.getByRole('radio');
|
|
||||||
const count = await colorButtons.count();
|
|
||||||
expect(count).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should change accent color', async ({ page }) => {
|
|
||||||
const colorPicker = page.getByRole('radiogroup', { name: /accent color/i });
|
|
||||||
const colorButtons = colorPicker.getByRole('radio');
|
|
||||||
const count = await colorButtons.count();
|
|
||||||
|
|
||||||
if (count > 1) {
|
|
||||||
// Click a different color
|
|
||||||
await colorButtons.nth(1).click();
|
|
||||||
// Should update the accent color
|
|
||||||
await expect(colorButtons.nth(1)).toHaveAttribute('aria-checked', 'true');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Domains settings', () => {
|
test('Import & Export tab renders import and export sections', async ({ page }) => {
|
||||||
test('should switch to domains tab', async ({ page }) => {
|
await page.getByRole('button', { name: 'Import & Export' }).click();
|
||||||
await page.getByRole('tab', { name: /domains/i }).click();
|
|
||||||
await expect(page.getByText(/manage your workspace domains/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should add a new domain', async ({ page }) => {
|
await expect(page.getByRole('heading', { name: 'Import' })).toBeVisible();
|
||||||
await page.getByRole('tab', { name: /domains/i }).click();
|
await expect(page.getByRole('heading', { name: 'Export' })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', { name: /download export/i })).toBeVisible();
|
||||||
const domainInput = page.getByPlaceholder(/new domain name/i);
|
await expect(page.getByRole('button', { name: /^import$/i })).toBeVisible();
|
||||||
await expect(domainInput).toBeVisible();
|
|
||||||
|
|
||||||
await domainInput.fill(testDomains.name);
|
|
||||||
|
|
||||||
// Intercept the API call
|
|
||||||
const createResponsePromise = page.waitForResponse(
|
|
||||||
(resp) => resp.url().includes('/api/domains') && resp.request().method() === 'POST',
|
|
||||||
);
|
|
||||||
|
|
||||||
await page.getByRole('button', { name: /add/i }).click();
|
|
||||||
|
|
||||||
const response = await createResponsePromise;
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
|
|
||||||
// The new domain should appear in the list
|
|
||||||
await page.waitForTimeout(1_000);
|
|
||||||
await expect(page.getByText(testDomains.name)).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Keyboard shortcuts settings', () => {
|
test('Error Log tab renders its controls', async ({ page }) => {
|
||||||
test('should show shortcuts list', async ({ page }) => {
|
await page.getByRole('button', { name: 'Error Log' }).click();
|
||||||
await page.getByRole('tab', { name: /keyboard shortcuts/i }).click();
|
|
||||||
|
|
||||||
// Should show shortcuts toggle and list
|
await expect(page.getByRole('button', { name: /clear all/i })).toBeVisible();
|
||||||
await expect(page.getByLabel('Enable keyboard shortcuts')).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /reset to defaults/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should toggle keyboard shortcuts', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /keyboard shortcuts/i }).click();
|
|
||||||
|
|
||||||
const toggle = page.getByLabel('Enable keyboard shortcuts');
|
|
||||||
const initialState = await toggle.getAttribute('data-state');
|
|
||||||
|
|
||||||
await toggle.click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// State should have changed
|
|
||||||
const newState = await toggle.getAttribute('data-state');
|
|
||||||
expect(newState).not.toBe(initialState);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Agents settings', () => {
|
|
||||||
test('should show agents list and create button', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /agents/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/agents.*permissions/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /new agent/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should open create agent dialog', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /agents/i }).click();
|
|
||||||
await page.getByRole('button', { name: /new agent/i }).click();
|
|
||||||
|
|
||||||
// Dialog should open
|
|
||||||
await expect(page.getByRole('dialog', { name: /create agent/i })).toBeVisible({ timeout: 5_000 });
|
|
||||||
|
|
||||||
// Should have form fields
|
|
||||||
await expect(page.getByLabel('Name')).toBeVisible();
|
|
||||||
await expect(page.getByLabel('Permission tier')).toBeVisible();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Import & Export settings', () => {
|
|
||||||
test('should show export and import sections', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
|
||||||
|
|
||||||
await expect(page.getByText(/export data/i)).toBeVisible();
|
|
||||||
await expect(page.getByText(/import data/i)).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /export to json/i })).toBeVisible();
|
|
||||||
await expect(page.getByRole('button', { name: /import from json/i })).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should show collection selection for export', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
|
||||||
|
|
||||||
// Should have checkboxes for each collection
|
|
||||||
await expect(page.getByLabel(/select all/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/export tasks/i)).toBeVisible();
|
|
||||||
await expect(page.getByLabel(/export habits/i)).toBeVisible();
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should toggle collection selection', async ({ page }) => {
|
|
||||||
await page.getByRole('tab', { name: /import.*export/i }).click();
|
|
||||||
|
|
||||||
const selectAll = page.getByLabel(/select all/i);
|
|
||||||
await expect(selectAll).toBeVisible();
|
|
||||||
|
|
||||||
// Toggle select all off
|
|
||||||
await selectAll.click();
|
|
||||||
await page.waitForTimeout(300);
|
|
||||||
|
|
||||||
// Tasks checkbox should be unchecked
|
|
||||||
const tasksCheckbox = page.getByLabel(/export tasks/i);
|
|
||||||
// The checkbox state should be unchecked now
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+40
-94
@@ -2,117 +2,63 @@ import { test, expect } from '@playwright/test';
|
|||||||
import { login } from './helpers/auth';
|
import { login } from './helpers/auth';
|
||||||
import { testTasks } from './helpers/fixtures';
|
import { testTasks } from './helpers/fixtures';
|
||||||
|
|
||||||
test.describe('Task Management', () => {
|
test.describe('Tasks', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await login(page);
|
await login(page);
|
||||||
await page.goto('/tasks');
|
await page.goto('/tasks');
|
||||||
// Wait for the tasks page to load
|
|
||||||
await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: /tasks/i })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Tasks Kanban Board', () => {
|
test('shows the kanban board with the four status columns', async ({ page }) => {
|
||||||
test('should display Kanban board with three columns', async ({ page }) => {
|
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
|
||||||
// Should show the three Kanban columns
|
await expect(page.getByRole('heading', { name: 'In Progress' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: /to do/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Done' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: /in progress/i })).toBeVisible();
|
await expect(page.getByRole('heading', { name: 'Cancelled' })).toBeVisible();
|
||||||
await expect(page.getByRole('heading', { name: /done/i })).toBeVisible();
|
});
|
||||||
});
|
|
||||||
|
|
||||||
test('should switch between Board and List views', async ({ page }) => {
|
test('creates a task via the New Task dialog and shows it on the board', async ({ page }) => {
|
||||||
// Default is Board (Kanban) view
|
await page.getByRole('button', { name: /new task/i }).click();
|
||||||
await expect(page.getByText('Board').first()).toHaveAttribute('data-state', 'active');
|
|
||||||
|
|
||||||
// Switch to List view
|
const dialog = page.getByRole('dialog');
|
||||||
await page.getByRole('tab', { name: /list/i }).click();
|
await expect(dialog).toBeVisible();
|
||||||
await expect(page.getByText('List').first()).toHaveAttribute('data-state', 'active');
|
await expect(dialog.getByRole('heading', { name: 'New Task' })).toBeVisible();
|
||||||
|
|
||||||
// Switch back to Board
|
await dialog.getByLabel('Title').fill(testTasks.title);
|
||||||
await page.getByRole('tab', { name: /board/i }).click();
|
await dialog.getByLabel('Description').fill(testTasks.description);
|
||||||
await expect(page.getByText('Board').first()).toHaveAttribute('data-state', 'active');
|
await dialog.getByRole('button', { name: /create task/i }).click();
|
||||||
|
|
||||||
|
// The dialog closes and the task appears in the Todo column.
|
||||||
|
await expect(dialog).toBeHidden();
|
||||||
|
await expect(page.getByText(testTasks.title, { exact: true })).toBeVisible({
|
||||||
|
timeout: 10_000,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Task CRUD', () => {
|
test('opens the task detail page when a task card is clicked', async ({ page }) => {
|
||||||
test('should create a new task via detail panel', async ({ page }) => {
|
// Make sure a task exists before trying to open it.
|
||||||
// Intercept the API call
|
if (!(await page.getByText(testTasks.title, { exact: true }).isVisible().catch(() => false))) {
|
||||||
const createResponsePromise = page.waitForResponse(
|
await page.getByRole('button', { name: /new task/i }).click();
|
||||||
(resp) => resp.url().includes('/api/tasks') && resp.request().method() === 'POST',
|
const dialog = page.getByRole('dialog');
|
||||||
);
|
await dialog.getByLabel('Title').fill(testTasks.title);
|
||||||
|
await dialog.getByRole('button', { name: /create task/i }).click();
|
||||||
|
await expect(page.getByText(testTasks.title, { exact: true })).toBeVisible({ timeout: 10_000 });
|
||||||
|
}
|
||||||
|
|
||||||
// Click on the "To Do" column area to open task creation
|
await page.getByText(testTasks.title, { exact: true }).first().click();
|
||||||
// The app uses a TaskDetailPanel for creation
|
|
||||||
// We'll look for an existing task or the create mechanism
|
|
||||||
// The kanban view fetches tasks - let's verify it loaded
|
|
||||||
await expect(page.getByRole('list', { name: /to do/i })).toBeVisible();
|
|
||||||
|
|
||||||
// Find and click a task if one exists, or trigger creation
|
await page.waitForURL('**/tasks/*', { timeout: 10_000 });
|
||||||
// The task detail panel opens when clicking a task
|
|
||||||
// For creation, the API can be called directly - verify the flow works
|
|
||||||
const taskCards = page.locator('[role="list"] [class*="cursor-grab"]');
|
|
||||||
const count = await taskCards.count();
|
|
||||||
|
|
||||||
// If tasks exist, click one to open detail panel
|
// The detail page renders the task title as a heading.
|
||||||
if (count > 0) {
|
await expect(
|
||||||
await taskCards.first().click();
|
page.getByRole('heading', { name: testTasks.title, level: 3 }),
|
||||||
// Detail panel should open
|
).toBeVisible({ timeout: 10_000 });
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should open task detail panel when clicking a task', async ({ page }) => {
|
|
||||||
// Look for any task card in the kanban board
|
|
||||||
const taskCards = page.locator('[role="list"] [class*="cursor-grab"]');
|
|
||||||
const count = await taskCards.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
await taskCards.first().click();
|
|
||||||
// Detail panel should appear as a dialog/sheet
|
|
||||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5_000 });
|
|
||||||
} else {
|
|
||||||
// No tasks yet – skip gracefully
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test.describe('Task Drag and Drop', () => {
|
test('switches between board and list views', async ({ page }) => {
|
||||||
test('should have draggable task cards', async ({ page }) => {
|
await page.getByRole('tab', { name: /list view/i }).click();
|
||||||
// Verify draggable elements exist
|
await expect(page.getByRole('columnheader', { name: 'Title' })).toBeVisible();
|
||||||
const draggableTasks = page.locator('[role="list"] [class*="cursor-grab"]');
|
|
||||||
const count = await draggableTasks.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
await page.getByRole('tab', { name: /board view/i }).click();
|
||||||
// Verify the first task has cursor-grab (indicating draggable)
|
await expect(page.getByRole('heading', { name: 'Todo' })).toBeVisible();
|
||||||
await expect(draggableTasks.first()).toHaveClass(/cursor-grab/);
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Task Filtering and Search', () => {
|
|
||||||
test('should display domain badges on tasks', async ({ page }) => {
|
|
||||||
// Tasks should show domain badges
|
|
||||||
const domainBadges = page.locator('[role="list"] [class*="badge"]');
|
|
||||||
const count = await domainBadges.count();
|
|
||||||
|
|
||||||
if (count > 0) {
|
|
||||||
// At least one badge should be visible
|
|
||||||
await expect(domainBadges.first()).toBeVisible();
|
|
||||||
} else {
|
|
||||||
test.skip();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test.describe('Task List View', () => {
|
|
||||||
test('should display tasks in list format', async ({ page }) => {
|
|
||||||
// Switch to list view
|
|
||||||
await page.getByRole('tab', { name: /list/i }).click();
|
|
||||||
|
|
||||||
// The list view should be rendered
|
|
||||||
// TasksListView component renders tasks in a table or list format
|
|
||||||
await page.waitForTimeout(500); // Wait for view transition
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"isolatedModules": true,
|
"isolatedModules": true,
|
||||||
|
"types": [],
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"forceConsistentCasingInFileNames": true
|
"forceConsistentCasingInFileNames": true
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
|
||||||
import { login } from './helpers/auth';
|
|
||||||
|
|
||||||
test.describe('Webhooks', () => {
|
|
||||||
test.beforeEach(async ({ page }) => {
|
|
||||||
await login(page);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should display webhooks page with create button', async ({ page }) => {
|
|
||||||
await page.goto('/settings/webhooks');
|
|
||||||
await page.waitForTimeout(1000);
|
|
||||||
|
|
||||||
// The webhooks page should have a create button or heading
|
|
||||||
const heading = page.getByRole('heading', { name: /webhook/i });
|
|
||||||
const createBtn = page.getByRole('button', { name: /create|new webhook/i });
|
|
||||||
|
|
||||||
// At least one should be visible
|
|
||||||
await expect(
|
|
||||||
heading.or(createBtn)
|
|
||||||
).toBeVisible({ timeout: 5000 });
|
|
||||||
});
|
|
||||||
|
|
||||||
test('should list webhook deliveries endpoint', async ({ page }) => {
|
|
||||||
const response = await page.request.get('/api/webhook-deliveries?limit=5');
|
|
||||||
expect(response.ok()).toBeTruthy();
|
|
||||||
const body = await response.json();
|
|
||||||
expect(body).toHaveProperty('items');
|
|
||||||
expect(body).toHaveProperty('totalItems');
|
|
||||||
expect(Array.isArray(body.items)).toBeTruthy();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
+6
-2
@@ -13,10 +13,14 @@
|
|||||||
"dev:web": "cd apps/web && bun run dev",
|
"dev:web": "cd apps/web && bun run dev",
|
||||||
"build": "cd apps/web && bun run build",
|
"build": "cd apps/web && bun run build",
|
||||||
"lint": "echo 'lint: ok'",
|
"lint": "echo 'lint: ok'",
|
||||||
"typecheck": "cd apps/api && tsc --noEmit && cd apps/worker && tsc --noEmit",
|
"typecheck": "cd apps/api && tsc --noEmit && cd ../worker && tsc --noEmit && cd ../web && tsc --noEmit",
|
||||||
"db:push": "drizzle-kit push",
|
"db:push": "drizzle-kit push",
|
||||||
"db:studio": "drizzle-kit studio",
|
"db:studio": "drizzle-kit studio",
|
||||||
"db:generate": "drizzle-kit generate"
|
"db:generate": "drizzle-kit generate",
|
||||||
|
"db:sync": "drizzle-kit push --force",
|
||||||
|
"db:triggers": "bun script/apply-triggers.ts",
|
||||||
|
"db:migrate": "bun run db:sync && bun run db:triggers",
|
||||||
|
"deploy": "bash script/deploy.sh"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.61.1",
|
"@playwright/test": "^1.61.1",
|
||||||
|
|||||||
+24
-28
@@ -1,11 +1,23 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
// CI runs chromium only (the runner installs just that browser + deps, keeping
|
||||||
|
// the pipeline fast). Locally the full 5-browser matrix runs by default.
|
||||||
|
const CI = !!process.env.CI;
|
||||||
|
|
||||||
|
const browserProjects = [
|
||||||
|
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||||
|
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
||||||
|
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
||||||
|
{ name: 'Mobile Chrome', use: { ...devices['Pixel 5'] } },
|
||||||
|
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
fullyParallel: true,
|
fullyParallel: true,
|
||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: CI,
|
||||||
retries: process.env.CI ? 2 : 0,
|
retries: CI ? 2 : 0,
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: CI ? 1 : undefined,
|
||||||
reporter: [['html', { open: 'never' }]],
|
reporter: [['html', { open: 'never' }]],
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
expect: {
|
expect: {
|
||||||
@@ -17,32 +29,16 @@ export default defineConfig({
|
|||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
video: 'on-first-retry',
|
video: 'on-first-retry',
|
||||||
},
|
},
|
||||||
projects: [
|
projects: CI
|
||||||
{
|
? browserProjects.filter((p) => p.name === 'chromium')
|
||||||
name: 'chromium',
|
: browserProjects,
|
||||||
use: { ...devices['Desktop Chrome'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'firefox',
|
|
||||||
use: { ...devices['Desktop Firefox'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'webkit',
|
|
||||||
use: { ...devices['Desktop Safari'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mobile Chrome',
|
|
||||||
use: { ...devices['Pixel 5'] },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Mobile Safari',
|
|
||||||
use: { ...devices['iPhone 12'] },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
webServer: {
|
webServer: {
|
||||||
command: 'npm run dev',
|
// `bun run dev` boots the API (:3001) and the Vite dev server (:3000) via
|
||||||
url: 'http://localhost:3000',
|
// concurrently. Poll the API health endpoint through the Vite proxy so we
|
||||||
reuseExistingServer: !process.env.CI,
|
// don't start running tests until both servers are actually up.
|
||||||
|
command: 'bun run dev',
|
||||||
|
url: 'http://localhost:3000/api/health',
|
||||||
|
reuseExistingServer: !CI,
|
||||||
timeout: 120_000,
|
timeout: 120_000,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { sql } from "../db/client";
|
||||||
|
|
||||||
|
// Apply the search-vector trigger migration idempotently.
|
||||||
|
// 0005_search_vector_trigger.sql uses CREATE OR REPLACE FUNCTION, DROP TRIGGER
|
||||||
|
// IF EXISTS + CREATE TRIGGER, and idempotent backfill UPDATEs, so it is safe
|
||||||
|
// to run on every deploy.
|
||||||
|
const triggerFile = fileURLToPath(
|
||||||
|
new URL("../drizzle/0005_search_vector_trigger.sql", import.meta.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log(`Applying search-vector triggers from ${triggerFile} ...`);
|
||||||
|
await sql.file(triggerFile);
|
||||||
|
console.log("Search-vector triggers applied successfully.");
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to apply search-vector triggers:", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
Executable
+112
@@ -0,0 +1,112 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Project E — idempotent deploy script.
|
||||||
|
#
|
||||||
|
# Intended to run from the Gitea Actions self-hosted runner (projecte-runner),
|
||||||
|
# which lives on the same host as the docker-compose stack, but it is also safe
|
||||||
|
# to run manually from a checkout or from DEPLOY_DIR itself.
|
||||||
|
#
|
||||||
|
# Safety guarantees:
|
||||||
|
# - The host .env is never overwritten (secrets stay on the host).
|
||||||
|
# - The project-e-pg-data volume is never touched/deleted.
|
||||||
|
# - Safe to re-run: schema sync (drizzle-kit push --force) and trigger
|
||||||
|
# application are idempotent, and `docker compose up -d` only recreates
|
||||||
|
# services whose config/image changed.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
DEPLOY_DIR="${DEPLOY_DIR:-/home/projecte/ProjectE}"
|
||||||
|
|
||||||
|
echo "=== Project E deploy ==="
|
||||||
|
echo "Deploy dir: ${DEPLOY_DIR}"
|
||||||
|
echo "Working dir: $(pwd)"
|
||||||
|
|
||||||
|
# ── 1. Sync the CI checkout into DEPLOY_DIR ───────────────────────────────
|
||||||
|
if [ "$(pwd)" != "${DEPLOY_DIR}" ]; then
|
||||||
|
echo "Syncing checkout into ${DEPLOY_DIR} ..."
|
||||||
|
mkdir -p "${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
if command -v rsync &>/dev/null; then
|
||||||
|
rsync -a --delete \
|
||||||
|
--exclude '.git' \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.next' \
|
||||||
|
--exclude 'dist' \
|
||||||
|
--exclude '.turbo' \
|
||||||
|
--exclude '*.tsbuildinfo' \
|
||||||
|
--exclude '.env' \
|
||||||
|
./ "${DEPLOY_DIR}/"
|
||||||
|
else
|
||||||
|
echo "rsync not available; falling back to cp -r of needed top-level entries"
|
||||||
|
cp -r package.json bunfig.toml bun.lock \
|
||||||
|
apps packages db drizzle script \
|
||||||
|
Dockerfile.* \
|
||||||
|
Caddyfile docker-compose.yml \
|
||||||
|
"${DEPLOY_DIR}/"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Already in DEPLOY_DIR; skipping sync"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 2. Operate from the deploy directory ──────────────────────────────────
|
||||||
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
|
# ── 3. Load secrets from the host .env (docker compose also picks these up) ─
|
||||||
|
if [ -f .env ]; then
|
||||||
|
echo "Loading ${DEPLOY_DIR}/.env"
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1091
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
else
|
||||||
|
echo "WARNING: no .env found in ${DEPLOY_DIR}; DATABASE_URL may be unset"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 4. Ensure bun ──────────────────────────────────────────────────────────
|
||||||
|
if ! command -v bun &>/dev/null; then
|
||||||
|
echo "Installing bun 1.3.14 ..."
|
||||||
|
npm i -g bun@1.3.14
|
||||||
|
fi
|
||||||
|
echo "bun: $(bun --version)"
|
||||||
|
|
||||||
|
# ── 5. Install dependencies ────────────────────────────────────────────────
|
||||||
|
if ! bun install --frozen-lockfile; then
|
||||||
|
echo "bun install --frozen-lockfile failed; retrying without --frozen-lockfile"
|
||||||
|
bun install
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 6. Database schema + triggers (both idempotent) ───────────────────────
|
||||||
|
echo "Migrating database schema + triggers (db:migrate) ..."
|
||||||
|
bun run db:migrate
|
||||||
|
|
||||||
|
# ── 7. Build images and start the stack ───────────────────────────────────
|
||||||
|
echo "Building docker images ..."
|
||||||
|
docker compose build
|
||||||
|
echo "Starting stack ..."
|
||||||
|
docker compose up -d
|
||||||
|
|
||||||
|
# ── 8. Wait for API health (up to 60s) ────────────────────────────────────
|
||||||
|
HEALTH_URL="${HEALTH_URL:-http://localhost:3000/api/health}"
|
||||||
|
echo "Waiting for API health at ${HEALTH_URL} (max 60s) ..."
|
||||||
|
healthy=0
|
||||||
|
for i in $(seq 1 60); do
|
||||||
|
code="$(curl -s -o /dev/null -w '%{http_code}' "${HEALTH_URL}" 2>/dev/null || true)"
|
||||||
|
if [ "${code}" = "200" ]; then
|
||||||
|
healthy=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${healthy}" = "1" ]; then
|
||||||
|
echo "API healthy after ${i}s (HTTP 200)"
|
||||||
|
else
|
||||||
|
echo "ERROR: API did not return HTTP 200 within 60s"
|
||||||
|
echo "--- recent api logs ---"
|
||||||
|
docker compose logs --tail=50 api || true
|
||||||
|
echo "------------------------"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── 9. Print SPA status ────────────────────────────────────────────────────
|
||||||
|
echo "SPA HTTP status: $(curl -s -o /dev/null -w '%{http_code}' http://localhost:3000/ || true)"
|
||||||
|
|
||||||
|
echo "Deploy complete: $(date -u +'%Y-%m-%dT%H:%M:%SZ')"
|
||||||
Reference in New Issue
Block a user