diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 757def2..1a16a1e 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 ' \ - --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 ` (or `git checkout `) 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":"","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. diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index ac57753..8f364a0 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -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); diff --git a/apps/api/src/routes/analytics.ts b/apps/api/src/routes/analytics.ts index 3a1f93b..fc91656 100644 --- a/apps/api/src/routes/analytics.ts +++ b/apps/api/src/routes/analytics.ts @@ -1,6 +1,6 @@ import { Hono } from "hono"; import { db, tasks, habits, habitCompletions, 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) diff --git a/apps/api/src/routes/states.ts b/apps/api/src/routes/states.ts index d796be7..b01efc4 100644 --- a/apps/api/src/routes/states.ts +++ b/apps/api/src/routes/states.ts @@ -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= — 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`COALESCE(MAX(sort_order), -1)` }) + const [result] = await db + .select({ maxSort: sql`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 = {}; 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) { diff --git a/apps/api/src/routes/tasks.ts b/apps/api/src/routes/tasks.ts index f0121f0..8e1a652 100644 --- a/apps/api/src/routes/tasks.ts +++ b/apps/api/src/routes/tasks.ts @@ -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([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) diff --git a/docker-compose.yml b/docker-compose.yml index 52440f9..3c37a65 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ff46e04..d175ff2 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -66,4 +66,4 @@ "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index d29e7d5..4a7604b 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -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(