Data model
The Prisma schema lives at prisma/schema.prisma and is the single source of truth for the database. Every workspace-scoped table carries a workspaceId foreign key with onDelete: Cascade, so deleting a workspace cleans up the entire content tree it owned in one transaction. Most tables also use a deletedAt? timestamp instead of hard deletes so a re-sync from a third-party source can't accidentally resurrect a row the owner had archived.
This document walks through the models grouped by area. It's not a generated reference — it's a hand-written description of why each model exists and what each field is doing, so you can navigate the schema with intent rather than spelunking field-by-field.
Models by area
| Area | Models | What it stores |
|---|---|---|
| Workspace | Workspace, ViewerJoin, OwnerProfile | Workspace identity, viewer login history, owner profile |
| Content | ContentRecord, ContentMetric, Campaign, Tag, ContentTag | Posts / articles / comments + their time-series metrics |
| Tasks & work | Task, WorkLog | Local tasks + per-day daily summary rows |
| Documents & sheets | Document, Spreadsheet, SharedDocOffer | Local + Notion + Google Docs/Sheets links and uploads |
| Calendar | CalendarEvent | Events created inside RsOpsHub (Google Meet is fetched live) |
| Notifications | Notification | Unified inbox across Linear, Drive, internal events |
| Views | View | Per-resource saved filters / sorts / layouts |
| Linear | LinearTeam, LinearIssue, LinearIssueTemplate | Cached Linear data for instant rendering |
| Audit | ActivityLog | Per-workspace history of significant entity events |
Workspace
The Workspace model is the root of the tree. Each row has identity fields (slug, name, description, logoPath, logoData, logoMime, iconEmoji, color), the viewer access controls (accessCode, codeVersion), and the per-workspace integration credentials. The logoPath column accepts either a /public URL (e.g. /tessl.png) for assets shipped with the repo, or an in-app /api/workspaces/<slug>/logo URL pointing at bytes stored on the row itself (logoData + logoMime). The DB-stored path is what makes deployed instances survive ephemeral filesystems — Vercel and Fly wipe public/uploads on every deploy. The integration columns include notionParentPageId, googleDocsFolderId, googleRefreshToken + googleAccountEmail + googleConnectedAt, hnUsername + hnEnabled + hnLastSyncAt, redditUsername + redditEnabled + redditLastSyncAt, devtoUsername + devtoEnabled + devtoLastSyncAt + devtoApiKey + devtoAccountLabel, linearApiKey + linearAccountLabel + linearAccountEmail + linearUserId + linearDefaultTeamId + linearEnabled + linearLastSyncAt, and posthogApiKey + posthogProjectId + posthogProjectLabel + posthogHost + posthogEnabled. The "owned by me" pattern is consistent: a single workspace row owns every credential needed to talk to every provider on that workspace's behalf.
Soft delete is implemented as deletedAt. The application's listing helpers filter for deletedAt: null everywhere, so a soft-deleted workspace effectively disappears from the UI. Restore by setting the column back to null in the database; there's no in-app UI for this, by design.
ViewerJoin
Every successful viewer login (and every owner-initiated invite) writes a ViewerJoin row capturing the email, the workspace, the codeVersion that was active at issue time, the IP, and the user agent. The codeVersion snapshot is the entire reason rotating the workspace access code instantly invalidates that workspace's viewer sessions — the session payload also carries codeVersion, and the server checks both numbers match on every request. Old rows at lower codeVersions are kept as audit history but no longer authenticate.
OwnerProfile
A single row keyed by lowercased OWNER_EMAIL storing the owner's display name and avatar path. Created lazily on first read. Viewers see this profile on /access and at the top of the sidebar in every workspace so they always know whose hub they've joined.
Content
ContentRecord is the main content table. Every Reddit post / comment / reply, Hacker News submission / comment, dev.to article, and any blog post the owner adds manually lives here. The type enum identifies the kind (REDDIT_POST, REDDIT_COMMENT, REDDIT_REPLY, HACKERNEWS_POST, HACKERNEWS_COMMENT, BLOG_POST, SOCIAL_POST, CUSTOM); platform identifies the source platform (REDDIT, HACKERNEWS, DEV_TO, TWITTER, LINKEDIN, BLOG, YOUTUBE, OTHER); status tracks the publishing pipeline (BACKLOG, IDEA, DRAFT, SCHEDULED, PUBLISHED, ARCHIVED). Engagement metrics (impressions, upvotes, downvotes, comments, shares, engagementRate, ctr, traffic) are all numeric columns the application syncs from APIs where possible and lets the owner edit manually where not. The externalId column is provider:id (e.g. hn:12345, devto:67890) and there is a unique constraint on (workspaceId, externalId) so the same article can never be inserted twice — every sync path uses Prisma's upsert against this constraint.
ContentMetric stores time-series snapshots of a single record's engagement at a given timestamp. This is what the analytics charts read from when they show trends over time.
Campaign is an optional grouping mechanism for ContentRecords — you can tag a series of related posts as one campaign and analyse their combined performance. Tag plus the ContentTag join table provide free-form tags.
Tasks and work
Task is a local task with title, description, status (TODO, IN_PROGRESS, BLOCKED, DONE, ARCHIVED), priority (LOW, MEDIUM, HIGH, URGENT), and an optional dueAt. Soft delete via deletedAt. Completion sets completedAt so the heatmap and analytics can plot it.
WorkLog is one row per workspace per day with a summary text field, an optional hoursLogged, and a metricsJson blob recording the day's task count / Linear closures / content created and published. The unique constraint is (workspaceId, date) so the daily cron upserts cleanly and never duplicates. Manual summaries (typed by the owner) are detected by checking the summary field is non-empty and skipped by the cron — your writeup is never overwritten by AI.
Documents and sheets
Document has a provider enum (LOCAL, NOTION, GOOGLE_DOCS) and an externalId for non-local providers. Local documents store their body inline as markdown; Notion and Google Docs are pulled on demand from their respective APIs. The isShared boolean controls whether viewers can see the row. ownedByMe flags whether the workspace's connected account is the original creator (matters for delete semantics — deleting a not-owned-by-me Google Doc only unlinks it from the workspace; the source file stays untouched in Drive).
Spreadsheet follows the same pattern with a SheetProvider enum (LOCAL, GOOGLE_SHEETS). Local uploads keep the raw bytes in the data Bytes? column up to a hard 10 MB cap; Google Sheets store only metadata + the Drive externalId. The data column makes the upload feature stateless from the host's filesystem perspective — restoring from a Postgres backup restores the files too.
SharedDocOffer represents a Drive document that's been shared with the owner's Google account but not yet claimed into any workspace. The Drive sync surfaces these as "claim me" cards in the notifications inbox; once claimed, a Document row is created and the offer is consumed.
Calendar
CalendarEvent stores generic events with title, description, startsAt, endsAt, eventType (GENERAL, PUBLISHING, DEADLINE, MEETING, REMINDER), and a free-form metadataJson. Note that Google Meet meetings shown on the calendar do NOT live in this table — they're fetched live from the Google Calendar API on each render so they're always fresh. CalendarEvent is for events the owner creates inside RsOpsHub itself.
Notifications
Notification is the unified inbox. Each row carries a source enum (LINEAR, NOTION, GOOGLE_DOCS, SHEETS, INTERNAL, SYSTEM), a free-form kind string for the specific event ("issueAssignedToYou", "viewer_joined", etc.), a title, an optional body, an optional URL the row links to (preferentially an in-app deep link, not the third party), an externalId for dedup, and a readAt timestamp. The Linear sync writes these whenever a new Linear notification arrives; the Drive sync writes them when a new shared document offer appears; internal events (viewer joined, owner action) write them directly.
Views
View is a per-resource saved filter / sort / column layout. The resource enum (CONTENT, TASK, LINEAR) plus kind enum (TABLE, KANBAN, CALENDAR) plus a filtersJson blob lets the owner save "my open urgent tasks" once and re-apply it. This is the model behind the table view tabs at the top of /content and /tasks.
Linear
LinearTeam, LinearIssue, and LinearIssueTemplate cache the Linear data so the UI renders instantly without waiting on Linear's GraphQL. Each row carries the Linear externalId plus enough denormalised fields (state name and color, assignee name and avatar URL, label JSON, due date, parent issue id, subscriber list) that the board page can render hundreds of issues without a single round-trip to Linear. The sync uses upsert against (workspaceId, externalId) to stay idempotent. LinearIssueTemplate mirrors Linear's own templates plus any local-only templates the owner creates inside the app — the origin enum distinguishes them.
ActivityLog
Generic per-workspace audit log of significant events: content.created, content.updated, content.published, task.created, task.completed, task.updated. Each row carries an entityType + entityId so the recent activity feed on the home page can join back to the actual row and render its title alongside the verb. Hard-deleted entities are filtered out at render time so you never see "Updated a task" with no clue which task.
Enums summary
For quick reference, here are the enums and their values:
ContentType:REDDIT_POST,REDDIT_COMMENT,REDDIT_REPLY,HACKERNEWS_POST,HACKERNEWS_COMMENT,BLOG_POST,SOCIAL_POST,CUSTOMPlatform:REDDIT,HACKERNEWS,DEV_TO,TWITTER,LINKEDIN,BLOG,YOUTUBE,OTHERContentStatus:BACKLOG,IDEA,DRAFT,SCHEDULED,PUBLISHED,ARCHIVEDTaskStatus:TODO,IN_PROGRESS,BLOCKED,DONE,ARCHIVEDPriority:LOW,MEDIUM,HIGH,URGENTDocProvider:LOCAL,NOTION,GOOGLE_DOCSSheetProvider:LOCAL,GOOGLE_SHEETSEventType:GENERAL,PUBLISHING,DEADLINE,MEETING,REMINDERNotifSource:LINEAR,NOTION,GOOGLE_DOCS,SHEETS,INTERNAL,SYSTEMViewResource:CONTENT,TASK,LINEARViewKind:TABLE,KANBAN,CALENDAR
Migration discipline
For local development you can run bunx prisma db push to push schema changes directly. For production, use bunx prisma migrate dev to create a versioned migration during development, commit the generated migration files, and run bunx prisma migrate deploy on the production server during deployment. The migration files live in prisma/migrations/ and form an append-only history of every schema change. Never edit a migration that's already been deployed — create a new one to fix any mistake.