{"slug": "building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema", "title": "Building a Project Management Tool from Scratch — Starting with the Prisma Schema", "summary": "A CodeAlpha intern is building a collaborative project management tool from scratch using React, Express.js, PostgreSQL, and Socket.io. The developer designed a Prisma schema with six models—User, Project, ProjectMember, Board, Task, Comment, and Notification—to support features like project creation, member invitations, task assignment, and real-time updates. The schema uses cuid() for IDs and includes explicit relation names to avoid Prisma migration errors.", "body_md": "I'm currently a day into Task 3 of my CodeAlpha Full Stack internship. The task: build a collaborative project management tool — think Trello — using React, Express.js, PostgreSQL, and Socket.io for real-time updates.\n\nBefore touching Express routes or React components, the first thing I had to nail was the database schema. This post is a full breakdown of the schema I designed, the reasoning behind every model, and the specific Prisma patterns that tripped me up while writing it.\n\nIf you're learning Prisma or building anything with relational data, stick around, some of this took me longer to get right than I expected.\n\nBackend: Express.js (Node.js with ES modules)\n\nORM: Prisma\n\nDatabase: PostgreSQL (hosted on Neon)\n\nFrontend: React + Vite (coming later in the series)\n\nReal-time: Socket.io (Task 3 specific)\n\nBefore writing any schema, I mapped out the features:\n\nUsers can create projects and invite other users by email. Projects have boards (columns like To Do, In Progress, Done). Boards contain tasks. Tasks can be assigned to project members, have priorities and due dates, and can be commented on. The app also sends notifications when someone is assigned a task or invited to a project.\n\nThat gave me six models: User, Project, ProjectMember, Board, Task, Comment, and Notification.\n\n```\nprisma\nmodel User {\n  id        String   @id @default(cuid())\n  name      String\n  email     String   @unique\n  password  String\n  createdAt DateTime @default(now())\n\n  memberships   ProjectMember[]\n  assignedTasks Task[]          @relation(\"AssignedTo\")\n  createdTasks  Task[]          @relation(\"CreatedBy\")\n  comments      Comment[]\n  notifications Notification[]\n}\n```\n\n`@default(cuid())`\n\ngenerates a collision-resistant unique ID as a string — something like `clx3k2j0f0000....`\n\nThis is preferable to auto-incrementing integers for publicly exposed IDs because it doesn't leak information about your record count and is safe to put in URLs.\n\nThe two Task relations — `assignedTasks`\n\nand `createdTasks`\n\n— both point to the Task model. Prisma needs you to name them explicitly with `@relation(\"AssignedTo\")`\n\nand `@relation(\"CreatedBy\")`\n\nbecause it can't automatically figure out which relation is which when two fields point at the same model. If you leave the names off, Prisma throws an error during migration. You'll use these same names on the Task model to connect both sides.\n\n```\nprisma\nmodel Project {\n  id          String   @id @default(cuid())\n  name        String\n  description String?\n  createdAt   DateTime @default(now())\n\n  members ProjectMember[]\n  boards  Board[]\n}\n```\n\nSimple. The ? after String makes description optional — Prisma will accept null for that field. Project doesn't have a direct relation to Task, tasks live inside boards, and boards live inside projects. The hierarchy is intentional and covered in the Board section below.\n\n```\nprisma\nmodel ProjectMember {\n  id   String @id @default(cuid())\n  role String @default(\"MEMBER\")\n\n  user      User    @relation(fields: [userId], references: [id])\n  userId    String\n\n  project   Project @relation(fields: [projectId], references: [id])\n  projectId String\n\n  @@unique([userId, projectId])\n}\n```\n\nThis is a junction table — it sits between User and Project to represent a many-to-many relationship. One user can be a member of many projects. One project can have many members. You can't store that on either table alone, so you need this middle model.\n\nWhat makes it more than a basic junction table is the role field. It stores whether the user is an \"OWNER\" or \"MEMBER\". This is what you'll query later when checking permissions — only owners can delete a project or remove other members.\n\nThe `@@unique([userId, projectId])`\n\nat the bottom is a model-level constraint that ensures a user can only be added to a project once. Without it, you could accidentally insert duplicate membership records. If you try to create a duplicate, Prisma throws a unique constraint error which you can catch and return a 409.\n\nOn the relation syntax, every relation in Prisma requires two things: the relation field itself and the foreign key field. For userId, that looks like:\n\n```\nprisma\nuser   User   @relation(fields: [userId], references: [id])\nuserId String\n```\n\nThe fields array contains the foreign key on this model. The references array contains the field it points to on the related model. Both arrays are required, leave one out and Prisma won't generate the migration.\n\n```\nprisma\nmodel Board {\n  id    String @id @default(cuid())\n  name  String\n  order Int\n\n  project   Project @relation(fields: [projectId], references: [id])\n  projectId String\n\n  tasks Task[]\n}\n```\n\nThis is one of the more important design decisions in the schema. Tasks don't belong directly to a project — they belong to a board, which belongs to a project. The reason: moving a task between columns (To Do → In Progress) is just updating the task's boardId to point at a different board. That's the entire drag-and-drop mechanic on the backend. One field update. No complex logic.\n\nThe order field is an integer that controls column rendering order. When you create a project, you auto-create three boards: To Do (order 0), In Progress (order 1), Done (order 2). The frontend sorts them by order: 'asc' and renders them left to right.\n\n```\nprisma\nmodel Task {\n  id          String    @id @default(cuid())\n  title       String\n  description String?\n  dueDate     DateTime?\n  priority    String    @default(\"MEDIUM\")\n  createdAt   DateTime  @default(now())\n\n  board   Board  @relation(fields: [boardId], references: [id])\n  boardId String\n\n  assignedTo   User?   @relation(\"AssignedTo\", fields: [assignedToId], references: [id])\n  assignedToId String?\n\n  createdBy   User   @relation(\"CreatedBy\", fields: [createdById], references: [id])\n  createdById String\n\n  comments Comment[]\n}\n```\n\nThe two user relations here are the most interesting part. A task has a creator (required — every task must have been created by someone) and an assignee (optional — tasks don't always need to be assigned).\n\nFor the optional relation:\n\n```\nprisma\nassignedTo   User?   @relation(\"AssignedTo\", fields: [assignedToId], references: [id])\nassignedToId String?\n```\n\nThe ? on User? and String? both matter. User? tells Prisma the relation itself is nullable. String? tells Prisma the foreign key column in the database can be null. You need both — one without the other causes a validation error.\n\nFor the required relation:\n\n```\nprisma\ncreatedBy   User   @relation(\"CreatedBy\", fields: [createdById], references: [id])\ncreatedById String\n```\n\nNo ?. Every task must have a creator.\n\nThe named relations \"AssignedTo\" and \"CreatedBy\" here must exactly match the names used on the User model. Prisma uses these names to link both sides of the relation together.\n\n```\nprisma\nmodel Comment {\n  id        String   @id @default(cuid())\n  content   String\n  createdAt DateTime @default(now())\n\n  task   Task   @relation(fields: [taskId], references: [id])\n  taskId String\n\n  author   User   @relation(fields: [authorId], references: [id])\n  authorId String\n}\n```\n\nNothing complex here. Comments belong to a task and have an author. The relation field is named author (not user) to make intent clear and avoid ambiguity — there's already a user relation on other models. Consistent, readable naming matters especially when you're querying nested includes.\n\n```\nprisma\nmodel Notification {\n  id        String   @id @default(cuid())\n  message   String\n  read      Boolean  @default(false)\n  createdAt DateTime @default(now())\n\n  user   User   @relation(fields: [userId], references: [id])\n  userId String\n}\n```\n\nIntentionally simple. A notification is just a message for a user, with a read boolean to track whether they've seen it. Notifications get created in two places: when a user is assigned a task, and when a user is invited to a project. The API has endpoints to mark individual notifications as read and to bulk-mark all as read with updateMany.\n\nOnce all six models are written, running the migration is:\n\n```\nbash\nnpx prisma migrate dev --name init\n```\n\nPrisma reads the schema, diffs it against the current database state, generates SQL, and runs it. It also regenerates the Prisma Client so your type-safe queries stay in sync with the schema.\n\nIf you want to inspect the data visually:\n\n```\nbash\nnpx prisma studio\n```\n\nThis opens a browser GUI at localhost:5555 where you can browse and edit records directly — useful when you're testing routes and want to verify data is being written correctly.\n\nA few errors I ran into that are worth documenting:\n\nUsing `@default(now())`\n\non an id field. That's a DateTime default, id fields need `@default(cuid())`\n\nor `@default(uuid())`\n\n. Easy to confuse if you're writing fast.\n\nForgetting the colon in `references: [id]`\n\n. I wrote `references[id]`\n\ntwice before catching it. The full syntax is `@relation(fields: [fieldName], references: [id])`\n\n— both keys require the colon.\n\nUsing single quotes for string defaults. Prisma requires double quotes. `@default('MEMBER')`\n\nthrows. `@default(\"MEMBER\")`\n\nworks.\n\nForgetting relation names when two fields on a model point to the same model. If User has both `assignedTasks`\n\nand `createdTasks`\n\n, and both are `Task[]`\n\n, Prisma can't figure out the mapping without the `@relation(\"name\")`\n\non both sides.\n\nSchema is done and migrated. Next up is the Express backend — auth routes, project routes, task routes, comment routes, and the Socket.io setup for real-time task updates and comments.\n\nI'll be posting each stage as I go. If you're building something similar or just learning Prisma, the relation naming and junction table patterns in this post will come up in pretty much every relational project you build.", "url": "https://wpnews.pro/news/building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema", "canonical_source": "https://dev.to/chinwuba_jeffrey/building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema-161", "published_at": "2026-06-17 21:17:53+00:00", "updated_at": "2026-06-17 21:51:50.496999+00:00", "lang": "en", "topics": ["developer-tools", "large-language-models"], "entities": ["CodeAlpha", "Prisma", "PostgreSQL", "Neon", "React", "Express.js", "Socket.io", "Vite"], "alternates": {"html": "https://wpnews.pro/news/building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema", "markdown": "https://wpnews.pro/news/building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema.md", "text": "https://wpnews.pro/news/building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema.txt", "jsonld": "https://wpnews.pro/news/building-a-project-management-tool-from-scratch-starting-with-the-prisma-schema.jsonld"}}