merge: resolve origin/main into feat/plane-lift-merge

Merge origin/main into feat/plane-lift-merge to resolve PR #25 conflicts.

Conflict resolutions:
- apps/api/src/routes/states.ts: took main's complete version (reorder,
  GET /:id, resolveProject helper) since plane-lift's states CRUD was
  already incorporated via main
- apps/api/src/routes/tasks.ts: merged both imports (statesTable +
  taskDependencies), combined filter params (stateId/stateGroup/moduleId/
  cycleId from plane-lift + status removed since tasks.status column no
  longer exists), added stateId to zod schemas and insert/update logic

Additional fixes for clean typecheck:
- Removed deleted files that main re-introduced via merge (automations,
  statuses, timeline, gantt, nlp-parser, canvas, notifications,
  automation-engine, quick-add-bar, drizzle 0007-0009, migrate script)
- Fixed tasks.ts: removed all tasks.status references (column replaced by
  stateId), removed taskStatusEnum, removed /:id/status endpoint
- Fixed analytics.ts: replaced tasks.status checks with completedAt checks
- Fixed index.ts: removed duplicate stateRoutes import and route registration
- Cleaned up drizzle journal duplicate entries

Preserved from main:
- taskDependencies table definition and junction table
- Komodo/CI changes (ci.yml, docker-compose.yml)
- DEPLOY.md host fixes
- taskDependencies route code (dependencies/dependents endpoints)
This commit is contained in:
2026-09-07 22:10:01 +00:00
9 changed files with 390 additions and 362 deletions
-80
View File
@@ -149,83 +149,3 @@ jobs:
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"
+35 -164
View File
@@ -1,6 +1,6 @@
# Project E — Deploy Guide
Production runs as a docker-compose stack on the deploy host (`10.0.0.52`). 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.
Production runs as a docker-compose stack on the deploy host (`10.0.0.52`). The stack has four services: PostgreSQL, the Hono API, the Vite SPA served by Caddy, and the Bun worker. **Komodo now owns builds and deploys** — Gitea Actions runs quality+e2e only.
## Architecture
@@ -18,189 +18,60 @@ Caddy (in the `spa` container) serves the built SPA and reverse-proxies `/api/*`
## 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 |
| Service | Container | Image | Ports | Notes |
|---------|-----------|-------|-------|-------|
| `db` | `project-e-db` | `postgres:16-alpine` | 5432 | Data in `project-e-pg-data` volume |
| `api` | `project-e-api` | `git.buzzbee.dev/BuzzbeeSCD/projecte-api:{tag}` | 3001 → 3000 | Hono on Bun |
| `spa` | `project-e-spa` | `git.buzzbee.dev/BuzzbeeSCD/projecte-spa:{tag}` | 3000 → 80 | Built Vite SPA + Caddy |
| `worker` | `project-e-worker` | `git.buzzbee.dev/BuzzbeeSCD/projecte-worker:{tag}` | none | Bun worker |
All services share the `project-e-network` bridge and restart unless stopped.
## CI/CD pipeline (Gitea Actions)
## CI/CD pipeline
The workflow lives at `.gitea/workflows/ci.yml` and runs on the self-hosted runner `projecte-runner`, which is colocated with the deploy host. The runner must be registered once (see "Setting up the runner" below) before any job can run.
### Gitea Actions (quality + e2e only)
- **`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`.
The workflow lives at `.gitea/workflows/ci.yml` and runs on the self-hosted runner `projecte-runner`. It runs only quality checks and e2e tests:
### What `script/deploy.sh` does
- **`quality`** — runs on every push and PR: typecheck → web build → docker compose build
- **`e2e`** — runs after quality: ephemeral Postgres → db:migrate → Playwright tests
Idempotent, safe to re-run. It:
### Komodo (build + deploy)
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
Komodo manages image builds and stack deploys:
### Setting up the runner (one-time)
- **Build trigger:** push a `v*` tag → Gitea webhook fires → Komodo builds images → pushes to Gitea registry
- **Deploy trigger:** Komodo procedure `release-projecte` builds all three images, then deploys the stack on `10.0.0.52`
- **Webhook URL:** `https://komodo.example.com/listener/github/repo/{id}/build`
- **Rollback:** re-deploy a previous version tag via Komodo UI or API
The workflow runs on the self-hosted runner `projecte-runner`, which must be
registered on the Gitea instance before any job can start (otherwise jobs stay
stuck at "Waiting to run"). Do this once on the deploy host (10.0.0.52), which
is where the `deploy` and `smoke` jobs act on the local docker-compose stack:
## Deploying
1. **Install `act_runner`** — download the `act_runner` binary from the Gitea
Releases page matching your server version, or use the official
`gitea/act_runner` Docker image. (Gitea Actions uses `act_runner`; the exact
binary/URL depends on your Gitea version.)
2. **Create a registration token** — in the Gitea web UI go to
**Repository → Settings → Actions → Runners → Create new runner** and copy
the token.
3. **Register** with a label that matches the workflow's `runs-on`:
### Primary: Komodo
```bash
./act_runner register \
--instance https://git.buzzbee.dev \
--token <REGISTRATION_TOKEN> \
--name projecte-runner \
--labels projecte-runner:host
```
1. Push a version tag: `git tag v1.0.0 && git push origin v1.0.0`
2. Komodo builds images and deploys automatically
3. Verify: `docker ps` on .52, health checks
The label name `projecte-runner` must match `.gitea/workflows/ci.yml`; the
executor (`host` or `docker`) is your choice — `host` is simplest for a
single colocated runner.
4. **Start it** — run `./act_runner daemon` (or install it as a systemd service
so it survives reboots).
### Break-glass: deploy.sh
**Verify:** the Runners page shows it online, then re-trigger the pipeline (a
push to `main`, or "Re-run" on the Actions tab). The `quality` job should leave
"Waiting to run". If jobs stay queued, the runner is offline or its label does
not match `runs-on: projecte-runner` — check the `act_runner` logs.
## Secrets
Secrets live in the host `.env` at `/home/projecte/ProjectE/.env`. This file is gitignored; never commit it. To set it up:
If Komodo is unavailable, `script/deploy.sh` still works:
```bash
cp .env.example .env
ssh projecte
cd /opt/app/ProjectE
export PROJECTE_IMAGE_TAG=v1.0.0
bash script/deploy.sh
```
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
git pull origin main
# Install dependencies
bun install
# Apply schema + triggers (idempotent)
bun run db:migrate
# Build images
docker compose build
# Restart the stack
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
docker compose ps
```
## Database migrations
`bun run db:migrate` chains two idempotent steps:
- `db:sync` — `drizzle-kit push --force`, which syncs the schema in `packages/db/src/schema.ts` to the database
- `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`)
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.
This pulls images from the registry and redeploys. It no longer builds — that's Komodo's job.
## Rollback
Compose images are rebuilt from the checkout, so there are no pinned image tags to restore. To roll back a bad release:
1. In Komodo UI: Deployments → projecte → select previous version tag → Redeploy
2. Or via API: `POST /execute/DeployStack` with the previous image tag
3. Verify health checks pass
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`)
## Secrets
The `project-e-pg-data` volume is untouched by deploys and rollbacks, so the database survives both. If a deploy failed, `deploy.sh` exits non-zero with the recent API logs; do not force it past a failing health check.
## Logs
```bash
# All services
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
```
## Debugging
### API health (direct, :3001)
```bash
curl http://localhost:3001/api/health
```
Returns `{"status":"ok", ...}` with a database ping (`database.connected`, `database.ping_ms`).
### API health (through Caddy)
```bash
curl http://localhost:3000/api/health
```
### SPA health check
```bash
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:3000/
```
### Login test
```bash
curl -s -X POST http://localhost:3000/api/auth/credentials \
-H "Content-Type: application/json" \
-d '{"email":"<INITIAL_ADMIN_EMAIL>","password":"<INITIAL_ADMIN_PASSWORD>"}'
```
Expect HTTP 200 and a `session` cookie.
### Container health
```bash
docker inspect project-e-db --format '{{.State.Health.Status}}'
```
### Restart or rebuild one service
```bash
docker compose restart api
docker compose build spa
docker compose up -d --force-recreate spa
```
## Important notes
- The `project-e-pg-data` Docker volume contains the live database. **Do not delete it.** Back it up (volume snapshot or `pg_dump`) before major schema work.
- Port 3000 is the SPA (Caddy); port 3001 is the API directly (for debugging).
- The MCP endpoint requires a valid API key (separate from JWT auth).
- The root `worker/` directory is legacy. The active worker is `apps/worker`.
Komodo manages runtime secrets (POSTGRES_PASSWORD, AUTH_SECRET, etc.) as Komodo variables. The host `.env` is kept as break-glass fallback only.
+2 -2
View File
@@ -10,6 +10,7 @@ import { domainRoutes } from "./routes/domains";
import { taskRoutes } from "./routes/tasks";
import { habitRoutes } from "./routes/habits";
import { projectRoutes } from "./routes/projects";
import { stateRoutes } from "./routes/states";
import { noteRoutes } from "./routes/notes";
import { searchRoutes } from "./routes/search";
import { calendarRoutes } from "./routes/calendar";
@@ -26,7 +27,6 @@ import { analyticsRoutes } from "./routes/analytics";
import { activityRoutes } from "./routes/activity";
import { importExportRoutes } from "./routes/import-export";
import { notificationRoutes } from "./routes/notifications";
import { stateRoutes } from "./routes/states";
import { moduleRoutes } from "./routes/modules";
import { cycleRoutes } from "./routes/cycles";
import { linkRoutes } from "./routes/links";
@@ -55,6 +55,7 @@ app.route("/api/cycles", cycleRoutes);
app.route("/api/tasks", taskRoutes);
app.route("/api/habits", habitRoutes);
app.route("/api/projects", projectRoutes);
app.route("/api/states", stateRoutes);
app.route("/api/notes", noteRoutes);
app.route("/api/search", searchRoutes);
app.route("/api/calendar", calendarRoutes);
@@ -70,7 +71,6 @@ app.route("/api/error-log", errorLogRoutes);
app.route("/api/analytics", analyticsRoutes);
app.route("/api/activity", activityRoutes);
app.route("/api/notifications", notificationRoutes);
app.route("/api/states", stateRoutes);
app.route("/api/links", linkRoutes);
app.route("/api", importExportRoutes);
app.route("/api", realtimeRoutes);
+2 -2
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, tasks, habits, habitCompletions, projects } from "@project-e/db";
import { and, eq, gte, inArray, isNull, isNotNull, or } from "drizzle-orm";
import { and, eq, gte, inArray, isNotNull, isNull, or } from "drizzle-orm";
import { requireAuth, resolveActiveDomain, requireWorkspaceAccess, AuthError } from "../middleware/auth";
export const analyticsRoutes = new Hono();
@@ -163,7 +163,7 @@ analyticsRoutes.get("/projects", async (c) => {
const projectIds = allProjects.map((p) => p.id);
// Count tasks per project for the domain
// Count tasks per project (any status, including non-done) for the domain
const taskRows = projectIds.length > 0
? await db.select({ projectId: tasks.projectId, completedAt: tasks.completedAt })
.from(tasks)
+187 -77
View File
@@ -1,6 +1,6 @@
import { Hono } from "hono";
import { db, states, projects } from "@project-e/db";
import { and, asc, eq, isNull, sql } from "drizzle-orm";
import { asc, eq, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
import { enqueueWebhooks } from "../middleware/webhook-queue";
@@ -13,43 +13,124 @@ const stateGroupEnum = z.enum(["backlog", "unstarted", "started", "completed", "
const createStateSchema = z.object({
projectId: z.string().uuid("Invalid project id"),
name: z.string().min(1, "Name is required"),
color: z.string().optional().nullable(),
group: stateGroupEnum.optional().default("unstarted"),
sortOrder: z.number().int().optional(),
color: z.string().nullable().optional(),
sortOrder: z.number().int().min(0).optional(),
});
const updateStateSchema = z.object({
name: z.string().min(1).optional(),
color: z.string().optional().nullable(),
color: z.string().nullable().optional(),
group: stateGroupEnum.optional(),
sortOrder: z.number().int().optional(),
sortOrder: z.number().int().min(0).optional(),
});
// GET /api/states — List states filtered by projectId (exclude soft-deleted)
stateRoutes.get("/", async (c) => {
const reorderSchema = z.object({
projectId: z.string().uuid("Invalid project id"),
orderedIds: z.array(z.string().uuid("Invalid state id")),
});
/**
* Resolve a project and verify the user has access to the owning workspace.
* Returns the project row on success, throws AuthError otherwise.
*/
async function resolveProject(c: any, projectId: string, user: { name: string }) {
const [project] = await db
.select()
.from(projects)
.where(eq(projects.id, projectId))
.limit(1);
if (!project) {
throw new AuthError("Project not found", 404, "NOT_FOUND");
}
await requireWorkspaceAccess(c, project.domainId);
return project;
}
// POST /api/states/reorder — bulk reorder states within a project
// This MUST be registered before /:id routes to avoid route conflicts.
stateRoutes.post("/reorder", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = reorderSchema.parse(body);
const project = await resolveProject(c, data.projectId, user);
// Verify all state IDs belong to this project
const existingStates = await db
.select({ id: states.id })
.from(states)
.where(eq(states.projectId, data.projectId));
const validIds = new Set(existingStates.map((s) => s.id));
const invalidIds = data.orderedIds.filter((id) => !validIds.has(id));
if (invalidIds.length > 0) {
return c.json(
{ error: { code: "VALIDATION_ERROR", message: `Invalid state ids: ${invalidIds.join(", ")}` } },
400
);
}
// Assign sortOrder 0..n-1 in one transaction
await db.transaction(async (tx) => {
for (let i = 0; i < data.orderedIds.length; i++) {
await tx
.update(states)
.set({ sortOrder: i, updatedAt: new Date() })
.where(eq(states.id, data.orderedIds[i]));
}
});
await recordActivity({
actor: user.name,
action: "reordered",
entityType: "state",
entityId: data.projectId,
changes: { orderedIds: data.orderedIds },
workspaceId: project.domainId,
});
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.reordered",
entityType: "state",
entityId: data.projectId,
data: { orderedIds: data.orderedIds },
});
return c.json({ success: true });
} 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("[states] POST /reorder error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to reorder states" } }, 500);
}
});
// GET /api/states?projectId=<uuid> — list states for a project
stateRoutes.get("/", async (c) => {
try {
await requireAuth(c);
const url = new URL(c.req.url);
const projectId = url.searchParams.get("projectId") || url.searchParams.get("project_id");
const projectId = url.searchParams.get("projectId");
if (!projectId || !isUuid(projectId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "projectId query parameter is required" } }, 400);
return c.json({ error: { code: "VALIDATION_ERROR", message: "A valid projectId query parameter is required" } }, 400);
}
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, projectId), isNull(projects.deletedAt)))
.limit(1);
const project = await resolveProject(c, projectId, { name: "" });
if (!project) {
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(states)
.where(and(eq(states.projectId, projectId), isNull(states.deletedAt)))
.where(eq(states.projectId, projectId))
.orderBy(asc(states.sortOrder));
return c.json({ items });
@@ -62,50 +143,52 @@ stateRoutes.get("/", async (c) => {
}
});
// POST /api/states — Create a state
// POST /api/states — create a state
stateRoutes.post("/", async (c) => {
try {
const user = await requireAuth(c);
const body = await c.req.json();
const data = createStateSchema.parse(body);
const [project] = await db.select({ id: projects.id, domainId: projects.domainId })
.from(projects)
.where(and(eq(projects.id, data.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 project = await resolveProject(c, data.projectId, user);
// If no sortOrder provided, default to max+1 within the project
let sortOrder = data.sortOrder;
if (sortOrder === undefined) {
const [maxOrder] = await db.select({ max: sql<number>`COALESCE(MAX(sort_order), -1)` })
const [result] = await db
.select({ maxSort: sql<number>`coalesce(max(${states.sortOrder}), -1) + 1` })
.from(states)
.where(eq(states.projectId, data.projectId));
sortOrder = Number(maxOrder?.max || -1) + 1;
sortOrder = result.maxSort;
}
const [state] = await db.insert(states).values({
name: data.name,
color: data.color ?? null,
group: data.group,
sortOrder,
projectId: data.projectId,
}).returning();
const [state] = await db
.insert(states)
.values({
name: data.name,
group: data.group,
color: data.color ?? null,
projectId: data.projectId,
sortOrder,
})
.returning();
await recordActivity({
actor: user.name,
action: "created",
entityType: "state",
entityId: state.id,
changes: { name: state.name, group: state.group, projectId: data.projectId },
changes: { name: state.name, group: state.group, color: state.color },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "state.created", entityType: "state", entityId: state.id, data: { name: state.name, group: state.group } });
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.created",
entityType: "state",
entityId: state.id,
data: { name: state.name, group: state.group },
});
return c.json(state, 201);
} catch (error) {
@@ -120,7 +203,38 @@ stateRoutes.post("/", async (c) => {
}
});
// PATCH /api/states/:id — Update a state
// GET /api/states/:id — get a single state
stateRoutes.get("/:id", async (c) => {
try {
await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [state] = await db
.select()
.from(states)
.where(eq(states.id, id))
.limit(1);
if (!state) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
await resolveProject(c, state.projectId, { name: "" });
return c.json(state);
} catch (error) {
if (error instanceof AuthError) {
return c.json({ error: { code: error.code, message: error.message } }, error.status as any);
}
console.error("[states] GET/:id error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to get state" } }, 500);
}
});
// PATCH /api/states/:id — update a state
stateRoutes.patch("/:id", async (c) => {
try {
const user = await requireAuth(c);
@@ -131,25 +245,17 @@ stateRoutes.patch("/:id", async (c) => {
const body = await c.req.json();
const data = updateStateSchema.parse(body);
const [existing] = await db.select()
const [existing] = await db
.select()
.from(states)
.where(and(eq(states.id, id), isNull(states.deletedAt)))
.where(eq(states.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, existing.projectId))
.limit(1);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
const project = await resolveProject(c, existing.projectId, user);
const updateValues: Record<string, unknown> = {};
if (data.name !== undefined) updateValues.name = data.name;
@@ -158,7 +264,8 @@ stateRoutes.patch("/:id", async (c) => {
if (data.sortOrder !== undefined) updateValues.sortOrder = data.sortOrder;
updateValues.updatedAt = new Date();
const [updated] = await db.update(states)
const [updated] = await db
.update(states)
.set(updateValues)
.where(eq(states.id, id))
.returning();
@@ -168,11 +275,17 @@ stateRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "state",
entityId: id,
changes: { ...data, previousName: existing.name, projectId: existing.projectId },
changes: { ...data, previousName: existing.name },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "state.updated", entityType: "state", entityId: id, data: { ...data, previousName: existing.name } });
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.updated",
entityType: "state",
entityId: id,
data: { ...data, previousName: existing.name },
});
return c.json(updated);
} catch (error) {
@@ -187,7 +300,8 @@ stateRoutes.patch("/:id", async (c) => {
}
});
// DELETE /api/states/:id — Soft-delete a state
// DELETE /api/states/:id — delete a state
// NOTE: The states table has no deleted_at column, so this is a hard delete.
stateRoutes.delete("/:id", async (c) => {
try {
const user = await requireAuth(c);
@@ -196,40 +310,36 @@ stateRoutes.delete("/:id", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const [existing] = await db.select()
const [existing] = await db
.select()
.from(states)
.where(and(eq(states.id, id), isNull(states.deletedAt)))
.where(eq(states.id, id))
.limit(1);
if (!existing) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
const [project] = await db.select({ domainId: projects.domainId })
.from(projects)
.where(eq(projects.id, existing.projectId))
.limit(1);
const project = await resolveProject(c, existing.projectId, user);
if (!project) {
return c.json({ error: { code: "NOT_FOUND", message: "Project not found" } }, 404);
}
await requireWorkspaceAccess(c, project.domainId);
await db.update(states)
.set({ deletedAt: new Date(), updatedAt: new Date() })
.where(eq(states.id, id));
await db.delete(states).where(eq(states.id, id));
await recordActivity({
actor: user.name,
action: "deleted",
entityType: "state",
entityId: id,
changes: { name: existing.name, projectId: existing.projectId },
changes: { name: existing.name, group: existing.group },
workspaceId: project.domainId,
});
await enqueueWebhooks({ workspaceId: project.domainId, event: "state.deleted", entityType: "state", entityId: id, data: { name: existing.name } });
await enqueueWebhooks({
workspaceId: project.domainId,
event: "state.deleted",
entityType: "state",
entityId: id,
data: { name: existing.name },
});
return c.body(null, 204);
} catch (error) {
+142 -27
View File
@@ -1,5 +1,5 @@
import { Hono } from "hono";
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, activityFeed, scheduledJobs, projects, sections, links } from "@project-e/db";
import { db, tasks, states as statesTable, taskTags, tags as tagsTable, taskDependencies, activityFeed, scheduledJobs, projects, sections } from "@project-e/db";
import { and, asc, desc, eq, exists, ilike, inArray, isNull, or, sql } from "drizzle-orm";
import { requireAuth, requireWorkspaceAccess, createErrorResponse, resolveActiveDomain, AuthError, isUuid } from "../middleware/auth";
import { recordActivity } from "../middleware/activity";
@@ -19,8 +19,6 @@ const createTaskSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
stateId: z.string().uuid().optional().nullable(),
moduleId: z.string().uuid().optional().nullable(),
cycleId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
@@ -38,8 +36,6 @@ const updateTaskSchema = z.object({
projectId: z.string().uuid().optional().nullable(),
sectionId: z.string().uuid().optional().nullable(),
stateId: z.string().uuid().optional().nullable(),
moduleId: z.string().uuid().optional().nullable(),
cycleId: z.string().uuid().optional().nullable(),
parentId: z.string().uuid().optional().nullable(),
dueDate: z.string().datetime().optional().nullable(),
estimatedMinutes: z.number().int().positive().optional().nullable(),
@@ -117,10 +113,6 @@ taskRoutes.get("/", async (c) => {
isNull(tasks.deletedAt),
];
if (stateId) {
const stateIds = stateId.split(",");
conditions.push(inArray(tasks.stateId, stateIds));
}
if (priority) {
const priorities = priority.split(",");
conditions.push(inArray(tasks.priority, priorities as any));
@@ -147,6 +139,9 @@ taskRoutes.get("/", async (c) => {
if (sectionId) {
conditions.push(eq(tasks.sectionId, sectionId));
}
if (stateId) {
conditions.push(eq(tasks.stateId, stateId));
}
if (moduleId) {
conditions.push(eq(tasks.moduleId, moduleId));
}
@@ -338,8 +333,6 @@ taskRoutes.post("/", async (c) => {
projectId: data.projectId ?? null,
sectionId: data.sectionId ?? null,
stateId: data.stateId ?? null,
moduleId: data.moduleId ?? null,
cycleId: data.cycleId ?? null,
parentId: data.parentId ?? null,
dueDate: data.dueDate ? new Date(data.dueDate) : null,
estimatedMinutes: data.estimatedMinutes ?? null,
@@ -497,9 +490,23 @@ taskRoutes.get("/:id", async (c) => {
.innerJoin(tagsTable, eq(taskTags.tagId, tagsTable.id))
.where(eq(taskTags.taskId, id));
// Dependencies are now managed via the links table (Phase 2)
const depRows: { id: string; title: string }[] = [];
const dependentRows: { id: string; title: string }[] = [];
// Fetch dependencies (tasks this task depends on)
const depRows = await db.select({
id: tasks.id,
title: tasks.title,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.dependsOnTaskId, tasks.id))
.where(and(eq(taskDependencies.taskId, id), isNull(tasks.deletedAt)));
// Fetch dependents (tasks that depend on this task)
const dependentRows = await db.select({
id: tasks.id,
title: tasks.title,
})
.from(taskDependencies)
.innerJoin(tasks, eq(taskDependencies.taskId, tasks.id))
.where(and(eq(taskDependencies.dependsOnTaskId, id), isNull(tasks.deletedAt)));
return c.json({
...task,
@@ -565,9 +572,6 @@ taskRoutes.patch("/:id", async (c) => {
if (data.priority !== undefined) updateValues.priority = data.priority;
if (data.projectId !== undefined) updateValues.projectId = data.projectId;
if (data.sectionId !== undefined) updateValues.sectionId = data.sectionId;
if (data.stateId !== undefined) updateValues.stateId = data.stateId;
if (data.moduleId !== undefined) updateValues.moduleId = data.moduleId;
if (data.cycleId !== undefined) updateValues.cycleId = data.cycleId;
if (data.parentId !== undefined) updateValues.parentId = data.parentId;
if (data.dueDate !== undefined) updateValues.dueDate = data.dueDate ? new Date(data.dueDate) : null;
if (data.estimatedMinutes !== undefined) updateValues.estimatedMinutes = data.estimatedMinutes;
@@ -585,8 +589,10 @@ taskRoutes.patch("/:id", async (c) => {
if (!state) {
return c.json({ error: { code: "NOT_FOUND", message: "State not found" } }, 404);
}
updateValues.stateId = data.stateId;
updateValues.completedAt = state.group === "completed" ? new Date() : null;
} else {
updateValues.stateId = null;
updateValues.completedAt = null;
}
}
@@ -603,11 +609,11 @@ taskRoutes.patch("/:id", async (c) => {
action: "updated",
entityType: "task",
entityId: id,
changes: { ...data, previousStateId: existing.stateId },
changes: { ...data },
workspaceId: existing.domainId,
});
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data, previousStateId: existing.stateId } });
await enqueueWebhooks({ workspaceId: existing.domainId, event: "task.updated", entityType: "task", entityId: id, data: { ...data } });
if (data.recurrenceRule !== undefined) {
await syncScheduledJob(id, data.recurrenceRule);
@@ -770,19 +776,128 @@ taskRoutes.delete("/:id/tags/:tagId", async (c) => {
}
});
// POST /api/tasks/:id/dependencies — Deprecated: use links table instead (Phase 2)
// POST /api/tasks/:id/dependencies — Make this task depend on another task
taskRoutes.post("/:id/dependencies", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const body = await c.req.json();
const { dependsOnTaskId } = z.object({
dependsOnTaskId: z.string().uuid("Invalid task 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);
// A task cannot depend on itself
if (dependsOnTaskId === id) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "A task cannot depend on itself" } }, 400);
}
const [depTask] = await db.select({ id: tasks.id, domainId: tasks.domainId })
.from(tasks)
.where(and(eq(tasks.id, dependsOnTaskId), isNull(tasks.deletedAt)))
.limit(1);
if (!depTask) {
return c.json({ error: { code: "NOT_FOUND", message: "Dependency task not found" } }, 404);
}
if (depTask.domainId !== task.domainId) {
return c.json({ error: { code: "FORBIDDEN", message: "Dependency task does not belong to this workspace" } }, 403);
}
// Cycle guard: walk the dependency chain (X depends on Y, Y on Z, ...) from
// dependsOnTaskId; reaching id means adding this edge would create a cycle.
let currentId: string | null = dependsOnTaskId;
const visited = new Set<string>([id]);
while (currentId) {
if (visited.has(currentId)) {
return c.json({ error: { code: "VALIDATION_ERROR", message: "Circular dependency detected" } }, 400);
}
visited.add(currentId);
const [next] = await db.select({ dependsOnTaskId: taskDependencies.dependsOnTaskId })
.from(taskDependencies)
.where(eq(taskDependencies.taskId, currentId))
.limit(1);
currentId = next?.dependsOnTaskId ?? null;
}
// Junction table has a composite PK — ignore duplicate edges
await db.insert(taskDependencies).values({ taskId: id, dependsOnTaskId }).onConflictDoNothing();
await recordActivity({
actor: user.name,
action: "dependency_added",
entityType: "task",
entityId: id,
changes: { dependsOnTaskId },
workspaceId: task.domainId,
});
await enqueueWebhooks({ workspaceId: task.domainId, event: "task.updated", entityType: "task", entityId: id, data: { dependsOnTaskId } });
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/dependencies error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to add dependency" } }, 500);
}
});
// DELETE /api/tasks/:id/dependencies/:depId — Deprecated: use links table instead (Phase 2)
// DELETE /api/tasks/:id/dependencies/:depId — Remove a dependency
taskRoutes.delete("/:id/dependencies/:depId", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Dependencies moved to links table (Phase 2)" } }, 404);
});
try {
const user = await requireAuth(c);
const id = c.req.param("id");
if (!isUuid(id)) {
return c.json({ error: { code: "NOT_FOUND", message: "Resource not found" } }, 404);
}
const depId = c.req.param("depId");
// POST /api/tasks/:id/status — Deprecated: use state_id instead (Phase 2)
taskRoutes.post("/:id/status", async (c) => {
return c.json({ error: { code: "NOT_FOUND", message: "Status endpoint replaced by state assignment (Phase 2)" } }, 404);
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 table has no deleted_at — hard delete is correct here
await db.delete(taskDependencies).where(and(eq(taskDependencies.taskId, id), eq(taskDependencies.dependsOnTaskId, depId)));
await recordActivity({
actor: user.name,
action: "dependency_removed",
entityType: "task",
entityId: id,
changes: { removedDependsOnTaskId: depId },
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/dependencies/:depId error:", error);
return c.json({ error: { code: "INTERNAL_ERROR", message: "Failed to remove dependency" } }, 500);
}
});
// GET /api/tasks/:id/history — State change log (from activity feed)
+3 -9
View File
@@ -20,9 +20,7 @@ services:
restart: unless-stopped
api:
build:
context: .
dockerfile: Dockerfile.api
image: git.buzzbee.dev/buzzbeescd/projecte-api:${PROJECTE_IMAGE_TAG:?Set PROJECTE_IMAGE_TAG}
container_name: project-e-api
ports:
- "3001:3000"
@@ -41,9 +39,7 @@ services:
restart: unless-stopped
spa:
build:
context: .
dockerfile: Dockerfile.spa
image: git.buzzbee.dev/buzzbeescd/projecte-spa:${PROJECTE_IMAGE_TAG:?Set PROJECTE_IMAGE_TAG}
container_name: project-e-spa
ports:
- "3000:80"
@@ -54,9 +50,7 @@ services:
restart: unless-stopped
worker:
build:
context: .
dockerfile: Dockerfile.worker
image: git.buzzbee.dev/buzzbeescd/projecte-worker:${PROJECTE_IMAGE_TAG:?Set PROJECTE_IMAGE_TAG}
container_name: project-e-worker
environment:
- NODE_ENV=production
+1 -1
View File
@@ -66,4 +66,4 @@
"breakpoints": true
}
]
}
}
+18
View File
@@ -279,6 +279,24 @@ export const taskTags = pgTable(
]
);
// ── Task Dependencies (junction) ───────────────────────────────────────────────
export const taskDependencies = pgTable(
'task_dependencies',
{
taskId: uuid('task_id')
.notNull()
.references((): any => tasks.id, { onDelete: 'cascade' }),
dependsOnTaskId: uuid('depends_on_task_id')
.notNull()
.references((): any => tasks.id, { onDelete: 'cascade' }),
},
(table) => [
primaryKey({ columns: [table.taskId, table.dependsOnTaskId] }),
index('task_dependencies_depends_on_idx').on(table.dependsOnTaskId),
]
);
// ── Habits ──────────────────────────────────────────────────────────────────────
export const habits = pgTable(