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:
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -66,4 +66,4 @@
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user