MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-37

1.3.1 Schema: project table + identifier generator + work-item-key counter + repo/service layer

Done
Description

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).

Acceptance criteria

  • 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.
  • Identifier + slug are unique per workspace (DB constraints + typed-error translation, not generic Prisma errors).
  • No db.* / $transaction outside the service/repository layers.
  • All quality gates green: pnpm prisma generate && typecheck && lint && format:check && build && test. Existing suite stays green.

Context refs

  • motir-core/CLAUDE.md — the 4-layer contract (auto-loaded)
  • prisma/schema.prisma — current Workspace + WorkspaceMembership models
  • lib/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
  • Story 1.4 § work_item — the consumer of the key counter + project FK
Status
Done
Type
Sub-task