Skip to content
projectsTickFlow

building tickflow: task management for student orgs

Rewriting the Prisma schema three times, pinning a Swarm service to one node, and still not having built auth.

github sources

Every student org I've been in runs on the same stack: a group chat, a pinned message that went stale in October, and one person who remembers what everyone agreed to. Notion works if someone fills it in. Trello works but it lives off to the side of wherever the group actually talks, so it slowly stops matching reality.

TickFlow is the boring version. Projects, tasks, who's on what, what state it's in. It runs on SCCS infrastructure at tickflow.sccs.swarthmore.edu. Damian did most of the deploy pipeline; the app and the schema are mine.

the schema took the longest

prisma/schema.prisma is about 80 lines and I rewrote it three times.

The first version hung assignees off the task as an Int[] of user IDs. Postgres supports it, Prisma exposes it, and it's fine until you want to answer "what is this person assigned to." Then you're pulling every task and filtering in application code. The fix was a TaskAssignee join table keyed on ([taskId, userId]). Membership works the same way: ProjectMember, composite key, plus a role that's either lead or member. Storing the role is not the same as enforcing it, and nothing enforces it yet.

Nothing clever about that. I just didn't think about the reverse query when I wrote it the first time, and rewriting relations after routes already depend on them is annoying in a way that's hard to appreciate until you've done it.

The one decision I still like is putting blocked and shipped in the status enum instead of making them boolean flags:

backlog | todo | inprogress | blocked | done | shipped

Blocked-and-in-progress isn't a state that means anything, and if it's a flag you have to keep deciding what it means alongside every other status. done is "the person finished it," shipped is "it's actually live," and student orgs need both because those are usually weeks apart. The dashboard counts both as complete. The board keeps them apart.

One smaller thing: projects have a numeric id and a slug, and I started routing by slug, which breaks the moment a project name has a space in it. Routes use the id now. The activity table still builds its links from slugs, so that's a half-finished migration with one broken path in it.

auth is the part I haven't done

There's a password column on User that nothing writes to yet. I keep circling this and not landing.

The honest reason is that I started reading about JWTs because they sounded like the thing you're supposed to use, got a few hours in, and realized that for an app where every user is a Swarthmore student hitting one server, a session row in Postgres does everything I need with less to get wrong. The stateless pitch doesn't buy me much when I'd want revocation anyway. So I stopped, and now it's the last real gap. Probably sessions. Possibly whatever SCCS already uses for SSO, if I can get access to it.

Until then nothing checks who you are. The server actions don't check that you're in the project you're writing to, the assignee picker shows every user instead of the project's members, and the "My Issues" page shows all issues with a note saying so.

what's there

You can make a project, add people, file tasks, assign them, move them across the board. The board loads tasks on the server with project titles and assignee names and groups them into columns on the client. Dragging a card moves it locally first and fires the status action after, which feels right and has no rollback: if the write fails the board and the database disagree until you reload. Drag is native HTML drag events, so no touch and no keyboard.

Search is a 200 ms debounce, then one endpoint that does a case-insensitive title match capped at five projects and five tasks, both queries in parallel. A new keystroke cancels the pending timer but not a request that's already out, so a slow old response can land on top of a newer one. Small dataset, hasn't bitten me, will.

The activity page is the newest 50 tasks with their current status. It's not a history. There is no table of status changes, so "who moved this yesterday" has no answer. The settings page is a real theme picker and real database counts next to a workspace-name field that shows "saved" and saves nothing, notification toggles that are local state, and security controls that don't do anything. I'd rather say that than have someone click them.

deploying it

The app is Next.js 16 with server actions rather than a REST layer, which means most mutations are a function in actions/actions.ts that writes through Prisma and calls revalidatePath. Ninety-one lines for the whole file. I expected to outgrow that by now. There are two read endpoints for search and the sidebar, so it's not zero HTTP API, but close.

Deployment is Docker Swarm behind Traefik, pinned to a specific node:

placement:
  constraints:
    - node.hostname == gull

The pinning is because the Postgres volume is a bind mount on that machine. If the service gets scheduled anywhere else it comes up with an empty database, which I found out the way you'd expect.

The container runs prisma migrate deploy before starting the server. This is the part I'd push back on if someone else did it, since a failed migration takes the app down instead of just failing a deploy step, but I'm the only one deploying and it means I can't forget.

The pipeline itself: a version tag kicks off a GitHub build, a mirror workflow copies the repo to SCCS GitLab, and GitLab builds the image and runs docker stack deploy. There was a brief image-watcher approach in between that got removed. The mirror fires when the build workflow completes, not when it succeeds, despite a comment in the file that says otherwise. I know because I read the comment first.


Current state: you can make a project, add people, file tasks, assign them, move them across the board. No notifications, no auth, no activity history. Next is auth, because without it none of the rest is real.

sources

The repo is swat-sccs/tickflow, as of commit a9ab338. The schema, the actions, the board, the search route, the Swarm file, and the mirror workflow with its comment.