@classytic/projects
v0.1.0
Published
Project delivery engine — projects, tasks, timesheets, milestones, client deliverables with approval sign-off, and Stripe-style handover journeys. MongoDB persistence via @classytic/mongokit, arc-compatible events.
Downloads
18
Readme
@classytic/projects
Project delivery engine — not just a task tracker. Projects, tasks, timesheets with an approval → invoice pipeline, milestones with client sign-off and fixed-fee billing, client deliverables with a review loop, and Stripe-onboarding-style handover journeys that end in recorded client acceptance and a support / warranty window.
MongoDB persistence via @classytic/mongokit (claim-based CAS
transitions, multi-tenant scoping, transactions), arc-compatible
events, host-owned outbox. One createProjects() call and the host
gets models, repositories, services, and a wired event bus.
Install
npm install @classytic/projectsPeers: @classytic/mongokit >=3.19, @classytic/repo-core >=0.8,
@classytic/primitives >=0.11, mongoose >=9.4, zod >=4.
Boot
import { createProjects, ensureProjectsReady } from '@classytic/projects';
const engine = await createProjects({
connection: mongoose.connection,
eventTransport: transport, // any arc EventTransport (Memory, Redis, Kafka)
outbox: mongoOutboxStore, // optional durable delivery (host-composed)
bridges: {
invoice: classyticInvoiceAdapter, // optional — enables BillingService
esign: esignAdapter, // optional — verifies acceptance signatures
},
// tenant: { tenantField: 'organizationId', fieldType: 'objectId' } ← default
// modules: { activity: true, templates: true, journeyAutomation: true }
});
await ensureProjectsReady(engine, { skipIndexes: true }); // prod: indexes at deployEvery repository verb takes a ProjectsContext —
{ organizationId, actorId, session?, signal? }. Tenant scoping is
enforced by mongokit's multiTenantPlugin (fail-closed), never by
hand-written filters.
The delivery arc, end to end
const ctx = { organizationId: branchId, actorId: userId };
const R = engine.repositories;
// 1. Provision from a template (project + tasks + milestones + handover journey, atomic)
const tpl = await R.templates.createTemplate({
name: 'Website build',
project: { defaultBillableRate: { amount: 15000, currency: 'USD' } },
milestones: [
{ key: 'launch', name: 'Launch', order: 0,
billing: { trigger: 'on_sign_off', amount: { amount: 500000, currency: 'USD' } } },
],
tasks: [
{ key: 'wireframes', title: 'Wireframes', milestoneKey: 'launch' },
{ key: 'build', title: 'Build', dependsOnKeys: ['wireframes'] },
],
journey: {
kind: 'handover', name: 'Site handover',
stages: [{ key: 'delivery', name: 'Delivery', items: [
{ key: 'launch-ok', name: 'Launch signed off',
requirement: { type: 'milestone_signed_off', refKey: 'launch' } },
{ key: 'creds', name: 'Credentials handed over',
requirement: { type: 'credential_handoff' } },
]}],
supportWindow: { durationMs: 90 * 24 * 3600 * 1000, kind: 'warranty' },
},
}, ctx);
const { project, journey } = await engine.services.provisioning.instantiate(
String(tpl._id), { code: 'ACME-2026', name: 'Acme site', clientRef: 'account_acme' }, ctx);
// 2. Execute: tasks + timesheets (dependency-gated, approval-gated)
await R.projects.activate(String(project._id), ctx);
const log = await R.timeLogs.log({ projectId, taskId, userId, minutes: 240 }, ctx);
await R.timeLogs.submit(String(log._id), ctx);
await R.timeLogs.approve(String(log._id), ctx, 'manager_1'); // task.actualHours accumulates
// 3. Bill: T&M batch OR fixed-fee milestone
await engine.services.billing.invoiceTimeLogs({ logIds: [String(log._id)] }, ctx);
await engine.services.billing.invoiceMilestone(milestoneId, ctx); // once-only fence
// 4. Deliverables: client review loop (optionally gated by an ApprovalChain)
const d = await R.deliverables.createDeliverable({ projectId, name: 'Style guide' }, ctx);
await R.deliverables.submit(String(d._id), { fileRef: 'media://v1' }, ctx);
await R.deliverables.approve(String(d._id), ctx, 'client_1');
// → journey items with requirement { type: 'deliverable_approved', ref } auto-complete
// 5. Handover: journey → acceptance → delivered project + support window (one transaction)
await R.journeys.activate(String(journey._id), ctx);
await R.journeys.requestAcceptance(String(journey._id), ctx); // gated on required items
const result = await engine.services.handover.accept(
String(journey._id), { acceptedBy: 'client_ceo', signatureRef: 'env_123' }, ctx);
// result.project.status === 'delivered', supportWindow runningState machines
Project: draft → active ↔ on_hold; active → delivered → closed; * → cancelled
Task: todo → in_progress ↔ blocked → in_review → done (+ cancelled)
TimeLog: draft → submitted → approved → invoiced (rejected → draft resubmit loop)
Milestone: planned → in_progress → completed → signed_off (+ reopen, cancelled)
Deliverable: draft → submitted → in_review → approved | changes_requested → submitted
Journey: draft → active → in_acceptance → accepted | active (rework) | declined → activeAll transitions are defineStateMachine tables (compile-time) paired
with repo.claim() CAS (runtime) — a race loser throws the same typed
InvalidTransitionError (status: 422) as an illegal move.
Handover journeys (the differentiator)
A journey is a staged checklist with typed requirements:
| Requirement type | Auto-completed when |
|---|---|
| deliverable_approved | the referenced deliverable is approved |
| milestone_completed / milestone_signed_off | the referenced milestone reaches that state |
| task_completed | the referenced task is done |
| manual, document, credential_handoff, training, time_logs_settled, payment_settled | a human calls completeItem() (with optional evidenceRef) |
journeyProgress(journey) gives the Stripe-style progress bar
(per-stage ratios, acceptanceReady, blocking items).
requestAcceptance() is gated on every required item;
HandoverService.accept() verifies the signature (esign bridge),
flips the project to delivered, and opens the support window — one
transaction, events flushed post-commit.
Post-handover support is deliberately light: a supportWindow on the
project plus isWithinSupportWindow() — ticketing, SLA policies, and
warranty claims live in @classytic/support (bridge via events).
Activity timeline (client portal feed)
With modules.activity (default on), every domain event materializes
into an append-only project_activities collection with
visibility: 'internal' | 'client' — milestones, deliverable
submissions, journey progress, and delivery are client-visible out of
the box. activities.comment() adds human posts to the same feed.
Billing bridge
interface InvoiceBridge {
postLineItem?(req: InvoiceLineItemRequest, ctx): Promise<{ invoiceLineRef: string }>;
}BillingService.invoiceTimeLogs batches approved billable logs (same
project / user / currency) into one line; invoiceMilestone posts a
fixed-fee line once (AlreadyInvoicedError on retry). Both send a
deterministic idempotencyKey so the host bridge can dedupe. Honest
race note: the bridge post is external — if the process dies between
post and flip, logs stay approved while the line exists; the
idempotency key is the recovery handle.
Events
~50 events under projects:* (rule 12), full Zod catalog exported as
projectsEventDefinitions from the root and /events subpath — arc
hosts register them for publish-time validation + OpenAPI:
import { projectsEventDefinitions } from '@classytic/projects/events';
for (const def of projectsEventDefinitions) registry.register(def);Unit-of-work discipline (P8.1): events inside
engine.withTransaction() queue under PENDING_EVENTS and flush
after commit; outbox rows commit with the transaction; a host-owned
session with neither queue nor outbox throws UnmanagedSessionError.
Arc integration
export default defineResource({
name: 'project',
prefix: '/projects',
adapter: createAdapter(engine.models.Project, engine.repositories.projects),
actions: {
activate: { handler: (id, _d, req) => engine.repositories.projects.activate(id, req.scope) },
deliver: { handler: (id, d, req) => engine.repositories.projects.deliver(id, req.scope, d) },
// ... hold / close / cancel — all state transitions (rule 30)
},
});CRUD, pagination, and QueryParser filters come free from the adapter; domain verbs map 1:1 to arc actions.
Zod schemas
@classytic/projects/schemas exports create/update schemas per entity
(projectCreateSchema, journeyCreateSchema, …) — the same schemas
the repositories validate with, reusable for arc route bodies.
Pure calculators
billableTotal, budgetBurn, taskProgress, velocity,
journeyProgress, isWithinSupportWindow — safe anywhere, no DB.
Tests
npm test # unit + integration (mongodb-memory-server replica set)Integration suite covers the full template → provision → execute → bill → handover → support-window scenario, tenant isolation (probe test), and the P8.1 ghost-event contract.
License
MIT
