跪拜 Guibai
← Back to the summary

ZenTao Reborn: A NestJS + Vue3 Full-Stack Replica With 58 Tables, 229 APIs, and Source-Verified State Machines

Source-Code-Level Replication of ZenTao: A Complete R&D Management System Built with NestJS + Vue3 (58 tables / 229 APIs / 130 assertions, fully open source)

dashboard.png

Foreword

Let's answer the three most common questions first:

Why replicate ZenTao instead of designing your own project management system? Because designing from scratch is likely to be "wishful thinking product design", while ZenTao is a development management methodology validated in production for twenty years: the four-layer framework of project set → product → project → execution, complete state machines for requirements/tasks/Bugs/use cases, and the complete development loop of "requirement split into tasks, tasks linked to versions, versions attached to test orders, failures become Bugs, fixes released". These rules themselves are the most valuable part, worth reading line by line through the source code to understand.

To what extent is it replicated? It's not "interface similarity", it's source-code-level calibration: every state transition rule can be traced to the specific file and line number in ZenTao's PHP source (real examples below). We locally clone the full source of ZenTao open source version 22.7 as the only business fact source, and any business rule dispute is ultimately arbitrated by the source code.

Who is this project for?

Project open source: https://github.com/yhemeo613/zentao-nest (MIT License). If helpful, give it a Star ⭐

Project Overview: A Set of Numbers

Dimension Number
Database MySQL 8.4, 58 tables (managed via TypeORM migration pipeline)
Backend NestJS 11, 28 business modules, Swagger audit 229 endpoints
Permissions 221 permission points (isomorphic with ZenTao's grouppriv table), 7 preset roles
Frontend Vue 3.5 + Element Plus, 34 pages all implemented
Real-time collaboration Socket.IO kanban (lane × column × drag × presence)
Automated verification 130 HTTP assertions + 62 unit tests + 7 Playwright E2E + permission drift gate + full Swagger audit
Deployment docker-compose four containers (MySQL / Redis / server / web), CI dual job

Let's look at some actual screens (1:1 replication of ZenTao style):

Requirement list (supports status/module/keyword filtering, inline assignment):

stories.png

Real-time kanban (lane = requirements/Bugs/tasks, dragging cards triggers server-side task actions):

board.png

Statistics center (ECharts: project overview / execution load / product quality / roadmap):

reports.png

Role permissions (221 permission point tree configuration, isomorphic with ZenTao grouppriv):

roles.png

More screenshots (requirement detail, tasks, Bugs, document center, login page) in the repository README.

Overall Architecture

zentao-nest/
├── apps/
│   ├── server/     # NestJS 11 backend (28 modules, 58 table migration pipeline)
│   │   ├── src/common/     # three guards/interceptors/filters/Redis/Swagger
│   │   ├── src/modules/    # system/program/product/.../board 28 modules
│   │   └── test/           # unit (Vitest) + http (130 assertion scripts)
│   └── web/        # Vue 3.5 frontend (34 pages + Playwright E2E)
├── packages/
│   └── shared/     # ★ dual-side contract package (zero runtime dependencies)
├── docs/           # 10 design documents (interlinked loop)
└── docker-compose.yml

Core Design 1: shared Contract Package, Letting Frontend and Backend Speak the Same Language

The biggest source of disagreement between frontend and backend is "contract inconsistency": backend changes an enum, frontend still uses old value; permission point added, frontend directive doesn't keep up. My solution is packages/shared—a TypeScript package with zero runtime dependencies, imported by both frontend and backend:

Key constraint: this package must not import any npm dependencies, only pure types and pure functions. This ensures it can be used cost-free on either side of the frontend/backend, and also makes "permission drift detection" possible (detailed later).

Core Design 2: The Complete Path of a Request

Request → TraceId middleware → JwtAuthGuard (signature verification + denylist)
     → PermissionsGuard (permission point ← Redis cache ← DB sys_permission)
     → DataScopeGuard (visibility set ← ACL whitelist ∪ team members)
     → Controller → Service (state machine + side effects) → Repository
     → unified response { code, message, data, traceId, timestamp }

The three guards each have their role: JWT only handles "who you are", PermissionsGuard handles "whether you can do this action", DataScopeGuard handles "which data you can see". Functional permissions and data permissions are separated. This is the part of ZenTao's permission model that is most easily overlooked and most worth learning.

Hard Bone ①: Source-Level Calibration of State Machines

This is the most time-consuming and rewarding part of the entire project.

Requirement (Story) Five States and "Evaluation Trimmed by Version"

Requirement state machine: draft → reviewing → active → changing → closed. Looks ordinary, but ZenTao has two counterintuitive rules that only become clear after reading the source:

  1. Evaluation results are trimmed by version. Each time the requirement changes (any of title/spec/verify), version + 1 and reviewers are reset. What about old evaluation records? ZenTao's rule: v1 version (first review) has no "undo review" action, v>1 versions have no "reject review" action. If the first version is rejected, it goes straight back to draft; if a changed version is rejected, the original active version continues to be used. Such rules simply cannot be designed without reading the source.

  2. Multi-reviewer allpass adjudication: each version independently records each reviewer's conclusion, and the moment all have reviewed, the system automatically adjudicates—no need for anyone to click "final confirmation".

  3. No-review switch: sys_config.needReview can globally turn off the review process (draft directly submits as active), while retaining a "mandatory review list"—certain requirement types must go through review.

Task Six States and "Effort-Driven Implicit Migration"

Task six states: wait / doing / done / pause / cancel / closed. The authoritative table of operation → prerequisite states is copied directly from ZenTao's model.php:2533-2596:

// packages/shared/src/enums/task.enum.ts
export const TASK_TRANSITIONS: Record<string, readonly string[]> = {
  start: ['wait'],                          // can only start from "not started"
  restart: ['pause'],                       // only "paused" can restart
  pause: ['doing'],                         // only "in progress" can pause
  finish: ['wait', 'doing', 'pause'],       // non-terminal states can finish
  cancel: ['wait', 'doing', 'pause'],
  close: ['done', 'cancel'],                // only done/canceled can close
  activate: ['done', 'closed', 'cancel'],   // rework: wake terminal states (remaining effort required)
};

Illegal transitions uniformly return error code 3006. The frontend action bar renders according to this table—what buttons are clickable is determined by state, not hard-coded in the frontend.

Even more clever is a ZenTao design: logging effort itself triggers state transitions. Logging effort on a wait task? It automatically becomes doing (Started). Remaining effort filled to 0? Automatically done (Finished). Logging effort on a done task with left≠0? Automatically wakes to doing (Activated):

// Implicit state migration on effort logging (TypeScriptization of ZenTao's tao.php:47-102)
export function deriveStatusOnEffort(
  current: string, left: number, consumed: number,
): { status: string; action: 'Finished' | 'Started' | 'Activated' | 'RecordWorkhour' } | null {
  if (left === 0 && !['done', 'closed', 'cancel'].includes(current)) {
    return { status: 'done', action: 'Finished' };
  }
  if (current === 'wait' && (consumed > 0 || left > 0)) {
    return { status: 'doing', action: 'Started' };
  }
  if (['done', 'pause', 'cancel', 'closed'].includes(current) && left > 0) {
    return { status: 'doing', action: 'Activated' };
  }
  return null;
}

Supporting invariant: consumed = Σeffort is only ever incremented, left = last effort entry. All effort aggregation rules (including parent-child task sums, state derivation priority) are written as pure functions in the shared package. A large part of the 62 unit tests table-drive these pure functions.

stage Engine: 15 Values Automatically Advanced End-to-End

Requirements have a stage field indicating which step of the R&D process it has reached, with 15 possible values. This field is never manually set; it is entirely driven by actions on other entities:

Create plan ──────→ planned
Project import scope ──→ projected
Development task started ──→ developing (design/devel/test buckets advance separately)
Development task finished ──→ developed
Test task started ──→ testing
Test finished ─────→ tested
Release a Release ─→ released
Requirement closed ─────→ closed

The E2E full-loop script (18 steps) asserts each stage transition point by point. The moment that "life of a requirement" test passed was the most satisfying moment of the whole project.

Hard Bone ②: RBAC + Data Permissions

No Permissions in JWT

A common approach is to stuff roles/permissions into the JWT payload, solved in one shot at signing. But this has a fatal flaw: changing permissions only takes effect after the token expires.

My choice is minimal JWT payload—only {sub, account, jti, typ}, no roles/permissions fields. Every authorization queries the DB in real time, with Redis caching for 10 minutes, and actively invalidates the cache when authorization changes. This makes permission changes effective immediately, at the cost of a DB query every 10 minutes.

Why 221 Permission Points and DB as Single Source?

ZenTao's permission model is a (module, action) tuple, stored in the grouppriv table. I organized all ZenTao permission points into 221 constants in the shared package, but at runtime the DB sys_permission table is the single source—seed data is synced from shared constants to the DB at startup, and the guard only recognizes the DB (via Redis cache).

There's a dedicated gate script permission-drift-check.mjs: it compares shared constants ↔ DB table, and any inconsistency directly fails CI. Without this gate, the accident of changing an enum and forgetting to run the seed is only a matter of time.

Three-Layer Data Permission Model

Beyond functional permissions, there's "which data you can see":

Layer Mechanism
Public object acl = open, visible to whole organization
Whitelist object acl = private + sys_acl whitelist (custom visibility list)
Team project/execution team members naturally see objects belonging to them

Implementation uses precomputed visibility set: materialize each user's visible object ID set into Redis; list queries uniformly do WHERE id IN (visible set); single-object unauthorized access uniformly returns 2004. Data permissions and functional permissions are active on every interface simultaneously.

Hard Bone ③: Real-Time Kanban, the Server Is the Only Source of Truth

The kanban is easiest to implement as "frontend drags around, backend just stores a position". ZenTao's kanban semantics are not like that: column is status, drag is action. I put this semantics into a Socket.IO protocol:

sequenceDiagram
    participant A as User A (browser)
    participant G as /ws gateway (JWT handshake)
    participant S as Server (only source of truth)
    participant B as User B (browser)
    A->>G: board:join {executionId}
    G-->>A: ack full kanban (lanes/columns/cells)
    A->>G: card:move {taskId, toColumn, eventId}
    G->>S: map column to task action (e.g. developed→finish)
    S-->>G: task new status + cell.cards placement
    G-->>A: ack {ok, task}
    G-->>B: card:moved / card:status:changed broadcast

Three key decisions:

  1. Dragging to "development complete" column = server executes finish action, goes through the full task state machine (effort validation, implicit migration, parent-child aggregation all included). Column mapping table is in shared package, same source for frontend and backend. If the action fails, the drag rolls back.
  2. eventId idempotent deduplication: frontend resends/reconnect does not cause duplicate actions.
  3. WIP over-limit only warns, does not block: over-limit broadcasts board:wip:exceeded, but the flow is not blocked—this is ZenTao's original trade-off; kanban method should be flexible.

The frontend uses sortablejs for optimistic updates: move the card first, then send the event; if ack fails, roll back; auto-reconnect on disconnect and replay join, fully refresh the kanban.

Testing System: Why 130 "Plain" Script Assertions Instead of supertest

Backend tests use two forms, each with its own role:

62 Vitest unit tests: table-driven state machine tests, enum contracts, permission point completeness, error code derivation—all around the shared pure functions and pure Nest Service logic, running in milliseconds.

130 HTTP assertion scripts (self-written .mjs): actually start the service, connect to real MySQL/Redis, go through the business flow from login:

// apps/server/test/http/p3-assert.mjs (excerpt)
const assert = (id, desc, pass, detail) => {
  results.push({ id, desc, pass, detail: String(detail).slice(0, 140) });
};

async function api(method, path, body, token) {
  const res = await fetch(BASE + path, { /* ... */ });
  return { status: res.status, ...(await res.json().catch(() => ({}))) };
}

// Illegal transition must be rejected: call start on a done task → expect 4xx + 3006
const bad = await api('POST', `/tasks/${doneTaskId}/start`, {}, admin);
assert('T3', 'done cannot start (3006)', bad.code === 3006, bad.message);

Why not supertest: these scripts are positioned not as "tests" but as independently reproducible acceptance evidence—node p3-assert.mjs is one command, the exit code is the number of failures, and it can run locally, in CI, or in any future environment. During acceptance, I need ledger-style results like "26/26 passed", not a test framework's report file.

Six scripts each cover a segment: four-layer framework 26 cases / core entity transition 31 cases / full-loop closure 18 cases / document OA audit notification 26 cases / kanban config statistics attachments 29 cases / Swagger structure audit 4 items. Together with the permission drift gate and 7 Playwright E2E (login chain, unauthorized access, smoke), they form all quality gates.

Docker and CI

cp .env.example .env    # change JWT_SECRET / SEED_ADMIN_PASSWORD
docker compose up -d --build
# visit http://localhost to get the full system

There's a gate that's easy to overlook in the four-container orchestration: migration must run before the application starts. The server container entrypoint first runs TypeORM migration, then starts Nest (production synchronize=false), otherwise the simultaneous start of four containers would result in the race condition of "application up but tables not created".

CI is dual job:

Pitfall Record (All Real Incidents)

1. The shared package's CJS/ESM dual format caused frontend build failure. Initially, shared produced both formats "to be compatible", but Vite/Rollup resolved to the CJS artifact on certain import paths and directly errored. Finally unified to ESM, with the full server/web chain on ESM, and the problem disappeared. Lesson: for internal packages in a monorepo, don't over-optimize for format compatibility; unify the module system.

2. Under TypeScript strict ESM, relative imports must have .js suffix. import { x } from './foo' with moduleResolution: NodeNext doesn't find the file at runtime (though it compiles without error), only exploding when the service starts. Full addition of .js suffix + CI type gate as a safety net.

3. DTO fields "mysteriously disappear". After Nest's global whitelist: true, a certain DTO forgot to hang @IsString() and similar validators, and the frontend-sent fields were silently stripped by whitelist, always landing in DB as undefined. This type of bug had no error, no logs, and was finally caught by HTTP assertions. Lesson: DTO validators must be comprehensive, assertion scripts are the best detector.

4. Date <= string is always false. An expiration comparison compared Date object with string; TypeScript didn't error (structurally comparable), logic always went to the false branch, and a certain 4951 validation completely failed. Lesson: transform boundary inputs at the entrance uniformly; don't trust the type system's leniency.

5. Permission points empty after page refresh. The frontend persists session to localStorage, but doesn't re-fetch /auth/me when restoring the session—the permission point array is empty, and all pages are blocked by v-permission as 403. This bug was caught by page-by-page walkthrough (browser automation to do L4 checks page by page); manual single-page testing could never find it. Fix: route guard re-fetches if permission points are not loaded.

6. 401 refresh storm. When token expires, the page sends 5 concurrent requests, each triggering token refresh, overwriting each other and replaying in a loop. Solution: raw axios single-flight refresh + __retried flag to prevent loops; subsequent requests hang and queue until refresh completes.

7. Docker build three pitfalls. No .dockerignore, the host's node_modules and dist were COPY'd into the image (Linux container holding Windows dependency binaries); corepack drifted to pnpm 12 and --frozen-lockfile behavior changed to silently skip installation; compose missing JWT_SECRET caused inconsistent environment across four containers. All fixed and recorded in the defect archive.

8. The ambiguous parentId semantics in ZenTao. While replicating the execution tree, it turned out ZenTao's parentId represents both "parent execution" and "parent stage"; the close validation of shadow iterations (the default execution auto-created for multiple=0 projects) was misjudged, reporting a nonexistent 4603. Filtering by grade > 1 restored the correct semantics—implicit conventions of old systems are only visible when reading source code.

Quick Start

# Prerequisites: Node ≥ 22, pnpm 10, Docker
git clone https://github.com/yhemeo613/zentao-nest.git
cd zentao-nest

pnpm install
cp .env.example .env                                # MySQL/Redis credentials + JWT_SECRET
cp apps/server/.env.example apps/server/.env
docker compose up -d mysql redis                    # infrastructure

cd apps/server
pnpm exec tsx node_modules/typeorm/cli.js migration:run -d src/database/data-source.ts
cd ../..
pnpm dev                                            # server :3000 + web :5173

Open http://localhost:5173 , log in with seed account admin / Admin@123456. Swagger full 229 endpoints at http://localhost:3000/api/docs .

Final Words

This project changed me in three ways:

  1. Reading source code > reading documentation. ZenTao has plenty of docs, but rules like "evaluation results trimmed by version" and "implicit migration on effort logging" are only clear in source code. The process of reconstructing a mature system's edge cases one by one yields more than reading ten design pattern articles.
  2. Documentation-first is not formalism. 10 design documents, 13 ADRs (append-only, no edits), every business rule traceable to a docs section—this matters even more in the AI collaboration era, because documentation is the context contract for AI.
  3. "Reproducible acceptance evidence" should be a first-class citizen. 130 assertions + permission drift gate + Swagger audit turn "I did it" into "I proved I did it".

Roadmap: 100% replication of the territory page, performance P95 benchmark, Prometheus monitoring, Excel import/export, multi-person tasks... see docs/09 in the repository.

Project address (welcome Star ⭐): https://github.com/yhemeo613/zentao-nest

If you're interested in any part of the implementation (e.g., state machines, data permissions, kanban protocol), leave a comment and I may write follow-ups. Issues / PRs are also welcome.

Comments

Top 2 of 3 from juejin.cn, machine-translated. The original thread is authoritative.

梦里寻码

Tears, I think it's well written, why won't anyone give me likes or stars?

用户254233574754

Nowadays few people can calm down to read code. Your hand-rolling is a bit too exaggerated.

tony_cto

Already starred.