Estimate: 22m · Depends on: 1.2.2
Add the Project model and the data layer that makes projects real, following the 4-layer architecture from motir-core/CLAUDE.md. This is the schema-only + repository/service Subtask; RLS policies and the active-project resolution land in 1.3.2, the UI in 1.3.4.
Schema (verbatim shape): Project { id String @id @default(cuid()), workspaceId String, name String, slug String, identifier String, lastWorkItemNumber Int @default(0), createdAt, updatedAt, archivedAt DateTime? } with workspace Workspace @relation(onDelete: Cascade), @@unique([workspaceId, slug]), @@unique([workspaceId, identifier]), @@index([workspaceId]), @@map("project"). Add WorkspaceMembership.activeProjectId String? with a relation to Project onDelete: SetNull (a member's active project clears, not cascades, if that project is archived/deleted out from under them).
Why a counter column, not a Postgres SEQUENCE: work-item keys (PROD-42) must be per-project, gap-free, and transaction-safe. A SEQUENCE is a per-database object — you'd need one per project (unbounded DDL), and sequence values leak on rollback (gaps). A lastWorkItemNumber column incremented via UPDATE project SET last_work_item_number = last_work_item_number + 1 WHERE id = $1 RETURNING last_work_item_number inside the work-item-create transaction is the durable B2B shape (Linear/Jira/GitHub all use a per-project counter, not DB sequences). Story 1.4 calls this allocator; 1.3.1 ships the column + the repo method + a unit test for it.
Why archivedAt, not hard delete: once Story 1.4 hangs work items off a project, hard-deleting a project would cascade-destroy issue history. Soft-delete via archivedAt is the durable shape a complete product ships; the "delete" UI in 1.3.4 archives. (A true hard-delete-with-cascade is an optional admin operation, addable later if ever needed — soft-delete is the default, not a stopgap.)
What you'll do: Extend prisma/schema.prisma; generate a migration (add_projects). Add lib/repositories/projectRepository.ts (single-op: findById, findBySlug, findByWorkspace, create(tx), update(tx), archive(tx), allocateWorkItemNumber(id, tx) via $queryRaw … RETURNING), extend workspaceMembershipRepository with setActiveProject(userId, workspaceId, projectId, tx). Add lib/services/projectsService.ts (createProject with identifier generation + collision-suffix retry in a transaction, like workspacesService.createWorkspace; renameProject; archiveProject; listProjects; setActiveProject — all asserting workspace membership). Add lib/dto/projects.ts + lib/mappers/projectMappers.ts. Add typed errors to a lib/projects/errors.ts (e.g. IdentifierCollisionError, NotAProjectMemberError — reuse NotAMemberError from lib/workspaces/errors.ts where it fits).
Project model + WorkspaceMembership.activeProjectId added; migration add_projects applies cleanly and the down-migration is reversible.projectRepository exports the single-op methods above; all writes require tx: Prisma.TransactionClient; allocateWorkItemNumber uses UPDATE … RETURNING and is gap-free under concurrent calls (proven by a test in 1.3.5, but the method ships here).projectsService.createProject generates a workspace-unique 3-5-char uppercase identifier from the name, retries with a numeric suffix on collision, and creates the project in one transaction; returns a DTO, never a raw Prisma row.db.* / $transaction outside the service/repository layers.pnpm prisma generate && typecheck && lint && format:check && build && test. Existing suite stays green.motir-core/CLAUDE.md — the 4-layer contract (auto-loaded)prisma/schema.prisma — current Workspace + WorkspaceMembership modelslib/services/workspacesService.ts + lib/repositories/workspace*Repository.ts — the exact pattern to mirror (slug generation, collision retry, required-tx writes, DTO mapping)lib/dto/workspaces.ts + lib/mappers/workspaceMappers.ts — DTO/mapper shape