drop/server/prisma/models/community.prisma
wdunn001 e80033e68e
Some checks failed
Server CI / Lint (push) Failing after 2m23s
Server CI / Typecheck (push) Failing after 2m56s
community: M3 -- friends, presence, chat
Friendship (request/accept/decline/remove, self-request and crossing-request
handling, DB-level pair uniqueness via a hand-written expression index),
ChatRoom/ChatMessage/ChatReadState backed by Nitro's built-in websocket
(server/api/v1/community/ws.get.ts) behind a ChatTransport seam
(server/internal/community/chatTransport.ts) with a REST send fallback.

Mid-milestone product redirect: chat's primary job is coordinating the
persistent-world servers (EQEmu/WoW/DAoC), not per-title discussion --
ChatRoomKind gets a first-class `server` case and chatService.ts ships a
static KNOWN_SERVERS registry keyed the same way M4's GameServer will be,
once that lands (gameServerId reserved for that wiring-up pass).

Presence has no new table -- "online" is a live websocket registry
(presenceRuntime.ts), "playing X" reads through an isolated query module
(presenceService.ts) that degrades to an empty, clearly-labeled result if
PlaySession's shape doesn't match what it expects.

Pages: /community/friends, /community/chat, plus a friends/servers strip on
/community itself. Migration 20260803120000_m3_friends_chat.
2026-08-03 01:39:04 -04:00

503 lines
18 KiB
Text

// Community (drop-community M1, folded into the fork -- see
// server/internal/community/README.md for the full story). Only a single
// new table is needed here: Drop's own `Game` model already IS the title
// spine for Drop content (id, mName, mCoverObjectId, ...), so there is no
// separate "DropTitle" -- community pages join straight onto `Game` and
// `Playtime`, which already exist. The one foreign catalog that needs its
// own row is RomM (games.quasarke.net), a different app with its own
// database, so it gets a small anti-corruption table synced on a schedule.
//
// Deliberately NOT modeled yet (still design-only, see drop-community's
// DESIGN.md, kept as the reference doc): issues, game servers (M4). Those
// are additive on top of this file, never a rewrite of it. Mods and
// achievement definitions (M5) and friends/presence/chat (M3) are modeled
// below.
//
// Ownership boundary, so a future milestone knows where its edges are:
// Friendship/ChatRoom/ChatMessage/ChatReadState (M3), Mod*/
// AchievementDefinition (M5) live in this file; Device/DeviceToken/
// PlaySession/AchievementUnlock (M2) live in devices.prisma;
// GameServer/ServerStatus/Issue (M4) do not exist yet. Never touch a model
// you don't own.
enum CommunityTitlePlatform {
romm
external
}
model CommunityTitle {
id String @id @default(uuid())
platform CommunityTitlePlatform
externalId String // RomM's numeric rom id, as a string
slug String @unique
name String
sortName String
coverUrl String?
deepLink String? // link out to games.quasarke.net/rom/:id
metadata Json? // source payload kept for debugging (romm platform id/slug/name)
isVisible Boolean @default(true) // sync never deletes; a title missing from a resync is hidden, not dropped
firstSeenAt DateTime @default(now())
lastSyncedAt DateTime @default(now())
@@unique([platform, externalId], name: "platformExternalKey")
@@index([sortName])
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
}
// --- M5 --- Mods catalog + achievement definitions/display.
// See drop-community DESIGN.md section 7.6 (mods) and 7.8 (achievements) --
// the field names below map onto those tables as closely as the "folded
// into Drop" reality allows. Three deliberate deviations from the design
// doc, all explained where they'd otherwise look like a mistake:
//
// 1. `title_id` (the generic platform/romm/external spine) becomes a plain
// `gameId` string pointing at Drop's own `Game`, not a polymorphic
// reference and NOT a Prisma `@relation`. Every mod and every
// achievement schema we can actually source targets a Steam-era Drop
// title (see homelab-compose/docs/game-mods-archive.md: only 3 of ~54
// titles have a real Thunderstore ecosystem, and RomM's ROM-hacking mod
// scene was deliberately not pursued) -- so a polymorphic
// CommunityTitle-or-Game reference for a case that doesn't exist yet
// would be premature abstraction the M1 author already declined once
// (see the CommunityTitle comment above). And per devices.prisma's
// precedent (read that header first): a Prisma relation must be declared
// on BOTH sides, which would force an edit to content.prisma for every
// milestone in parallel -- exactly the concurrent-edit collision this
// fork's milestones were warned about. `gameId` is resolved at the
// application layer instead, same as Device/PlaySession do for Game.
// 2. Same reasoning for `uploaderId`/`identityId` -> User.id: soft
// references, no relation declared in user.prisma.
// 3. `achievement_unlock` and the `achievement_progress` rollup are NOT
// modeled here. M2 owns AchievementUnlock (see devices.prisma) and keys
// it on (steamAppId, apiName) specifically so this milestone can join on
// those two columns with zero migration on either side -- no
// definitionId FK needed or wanted. Completion % is computed on read by
// joining AchievementDefinition against AchievementUnlock, rather than
// maintaining a second rollup table this milestone doesn't own.
enum ModKind {
mod
expansion
patch
tool
}
enum ModArchiveFormat {
zip
sevenzip
rar
}
enum ModInstallRoot {
game_root
documents
appdata
}
enum ModDependencyKind {
requires
conflicts
recommends
}
enum ModFileAction {
created
overwrote
skipped
}
enum ModInstallationStatus {
installed
uninstalled
failed
orphaned
}
enum AchievementSource {
goldberg_schema
manual
romm
}
model Mod {
id String @id @default(uuid())
// Soft reference to Game.id -- see the file header for why this isn't a
// Prisma relation.
gameId String
kind ModKind @default(mod)
slug String @unique
name String
summary String?
description String? // markdown
author String? // the mod's actual author, per SOURCES.txt / the manifest -- may differ from uploader
// Soft reference to User.id -- see the file header.
uploaderId String?
// Free text, not an enum: archive-mod.py already emits "thunderstore" and
// "modio" (Nexus stubbed, see the archive doc), and "manual" covers
// anything entered by hand through the admin form. A new source
// shouldn't force a migration.
sourcePlatform String?
sourceRef String? // e.g. Thunderstore full_name "Owner-Name", mod.io "game/mod" name_id pair
// { source_url, retrieved_at, retrieved_by, original_filename?,
// license_note?, archive_note? } per DESIGN.md 7.6 -- required by the
// collection pipeline, not optional metadata. A mod whose origin we
// cannot state is a mod we cannot re-acquire or audit.
provenance Json
isPublished Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Installations are reachable via versions[].installations -- ModInstallation
// relates to ModVersion (a specific version is what's installed), not to Mod.
versions ModVersion[]
dependents ModDependency[] @relation("dependencyTarget")
@@index([gameId])
@@index([name(ops: raw("gist_trgm_ops(siglen=32)"))], type: Gist)
}
model ModVersion {
id String @id @default(uuid())
modId String
mod Mod @relation(fields: [modId], references: [id], onDelete: Cascade)
version String
versionSort String // normalized for ordering
// Intended cdn.quasarke.net/game-mods/... path per
// homelab-compose/docs/game-mods-archive.md's storage layout. May 404
// today -- that vhost has no file_server wired to /mnt/local24 yet, and
// fixing that is explicitly out of this milestone's scope. See
// server/internal/community/README.md for the current serving story.
archiveUrl String
// Path relative to the /mnt/local24/game-mods mount (read-only in this
// container), used by the local streaming-download route that serves
// downloads today without touching Caddy. Null for versions registered
// by hand through the admin form rather than imported from the pipeline.
archiveRelPath String?
archiveSha256 String
archiveSize BigInt
archiveFormat ModArchiveFormat @default(zip)
changelog String?
installRoot ModInstallRoot @default(game_root)
installSubpath String?
minBaseVersion String?
maxBaseVersion String?
releasedAt DateTime?
createdAt DateTime @default(now())
dependencies ModDependency[] @relation("versionDependencies")
installations ModInstallation[]
@@unique([modId, version])
@@index([modId])
}
model ModDependency {
id String @id @default(uuid())
modVersionId String
modVersion ModVersion @relation("versionDependencies", fields: [modVersionId], references: [id], onDelete: Cascade)
// Free-text ref (e.g. Thunderstore full_name "Owner-Name") -- an archived
// mod can declare a dependency on another package we have not archived
// ourselves, so this cannot be a hard FK. dependsOnModId is filled in
// opportunistically once/if the ref resolves to a Mod we actually have,
// which is what lets the install-plan resolver walk real edges.
dependsOnRef String
dependsOnModId String?
dependsOnMod Mod? @relation("dependencyTarget", fields: [dependsOnModId], references: [id], onDelete: SetNull)
versionRange String?
kind ModDependencyKind @default(requires)
@@index([modVersionId])
@@index([dependsOnModId])
}
// The install manifest -- the actually valuable idea taken from
// LANCommander (DESIGN.md 7.6). Recording every file at install time, with
// what the install did to it, is what makes uninstall exact: `created`
// files get deleted, `overwrote` files get RESTORED from their backup
// object (deleting them would strip a base-game file), `skipped` files are
// left alone. No client wrapper exists yet to drive this automatically
// (that's a device/session concern, M2 territory) -- these tables and their
// API are built and tested standalone; a wrapper can start calling them
// with zero schema changes once it exists.
model ModInstallation {
id String @id @default(uuid())
// Soft reference to User.id -- see the file header.
identityId String
// Soft reference to M2's Device.id (see devices.prisma). Nullable: M2's
// table may not exist at migration time either way, and this must not
// depend on a table owned by a different milestone.
deviceId String?
modVersionId String
modVersion ModVersion @relation(fields: [modVersionId], references: [id], onDelete: Cascade)
// Soft reference to Game.id -- see the file header.
gameId String
installPath String
status ModInstallationStatus @default(installed)
installedAt DateTime @default(now())
uninstalledAt DateTime?
files ModInstalledFile[]
@@index([identityId])
@@index([gameId])
@@index([modVersionId])
}
model ModInstalledFile {
id String @id @default(uuid())
installationId String
installation ModInstallation @relation(fields: [installationId], references: [id], onDelete: Cascade)
relativePath String
sha256 String
sizeBytes BigInt
actionTaken ModFileAction
// Drop's own object store id -- the original bytes, when actionTaken=overwrote.
backupObjectId String?
@@index([installationId])
}
// Achievement DEFINITIONS + DISPLAY only -- see the file header for why
// unlocks/progress are not modeled here.
model AchievementDefinition {
id String @id @default(uuid())
// Soft reference to Game.id -- see the file header.
gameId String
steamAppId Int // denormalized for the import join -- Game.metadataId when metadataSource=Steam; also the join key against M2's AchievementUnlock
apiName String // Goldberg's key, e.g. ACH_WIN_ONE_GAME
displayName String
description String?
iconObjectId String? // unlocked icon, copied into Drop's own object store at import time
iconGrayObjectId String? // locked icon
isHidden Boolean @default(false)
sortOrder Int @default(0)
source AchievementSource @default(goldberg_schema)
schemaHash String? // lets a re-import skip an unchanged schema
importedAt DateTime @default(now())
@@unique([gameId, apiName])
@@index([steamAppId])
}
// --- M3: friends, presence, chat -----------------------------------------
//
// No new identity table (User already is identity, per the comment above)
// and deliberately no `presence` table either: presence is derived, not
// stored. "Online" is whoever currently holds a chat/community websocket
// connection (see server/internal/community/chatTransport.ts, same
// in-memory-map shape as server/internal/notifications). "Playing X" reads
// M2's PlaySession table (devices.prisma) through an isolated query module
// (server/internal/community/presenceService.ts) -- PlaySession landed
// partway through this milestone with exactly the join contract its own
// header comment promises M3, so that module reads it directly rather than
// through a defensive fallback.
//
// Product redirect mid-milestone: chat's primary job is coordinating the
// persistent-world servers (EQEmu, WoW/TrinityCore, DAoC), not generic
// per-title discussion -- "who's on, who needs a healer, logging on in 10."
// So ChatRoomKind gets a `server` case that's first-class alongside `title`,
// and presenceService.ts treats "who's on <server> right now" as the
// priority query, not a generic online/offline dot. See ChatRoom's
// serverKey/gameServerId fields below for how server rooms are identified
// ahead of M4's GameServer registry landing.
//
// This fork never adopted DESIGN.md's unified `title` table (see
// browseService.ts's header) -- Drop's own Game IS the title spine for
// Drop content, and CommunityTitle (above) is the one for RomM. A chat room
// therefore carries a plain, Prisma-relation-less `gameId` / `communityTitleId`
// pair rather than one FK to a shared table. They're deliberately NOT
// declared as Prisma `@relation`s (which would require adding a back-reference
// field to `Game` in content.prisma and to `CommunityTitle` above -- models
// this milestone doesn't own the surrounding context of, and a concurrent
// milestone touching either one becomes an unnecessary merge conflict).
// Referential integrity is still real: both columns get a hand-written FK
// in this migration's migration.sql, enforced by Postgres, just not
// type-checked by Prisma. Existence is additionally checked in
// chatService.ts before a room is created.
enum FriendshipStatus {
pending
accepted
declined
}
model Friendship {
id String @id @default(uuid(7))
requesterId String
requester User @relation("FriendshipRequester", fields: [requesterId], references: [id], onDelete: Cascade)
addresseeId String
addressee User @relation("FriendshipAddressee", fields: [addresseeId], references: [id], onDelete: Cascade)
status FriendshipStatus @default(pending)
createdAt DateTime @default(now())
respondedAt DateTime?
// Blocks an exact same-direction duplicate. The "crossing requests" case
// (A -> B pending, then B -> A) needs a direction-agnostic uniqueness --
// least(requesterId, addresseeId)/greatest(...) -- which the Prisma schema
// language can't express as an expression index; it's hand-added in
// migration.sql and handled explicitly in friendsService.ts (a crossing
// request resolves onto the existing row as an accept, not a new row).
@@unique([requesterId, addresseeId], name: "requesterAddresseeKey")
@@index([addresseeId, status])
@@index([requesterId, status])
}
enum ChatRoomKind {
server // persistent-world server coordination (EQEmu, WoW, DAoC) -- see below, this is the primary use case
title
direct
global
}
enum ChatRoomVisibility {
public
private
}
model ChatRoom {
id String @id @default(uuid(7))
kind ChatRoomKind
// Set only for kind=server: a stable slug ("eqemu" | "wow" | "daoc" ...),
// NOT a foreign key, because chat landed before M4's GameServer registry
// did. M4 owns GameServer/ServerStatus/Issue and is concurrent with this
// milestone; per the mid-milestone product redirect ("make per-server
// rooms first-class... reference M4's model rather than duplicating it;
// if it's not there yet, key rooms by a stable identifier and wire it up
// after"), chatService.ts ships a small static registry of the three
// persistent-world servers keyed the same way (everquest.quasarke.net,
// wow.quasarke.net, daoc.quasarke.net) rather than waiting on M4.
// `gameServerId` is reserved for that wiring-up pass: once GameServer
// exists, backfill it from `serverKey` (matched by hand, since this
// milestone doesn't know M4's key scheme) and prefer it over serverKey
// everywhere serverKey is read today. Both nullable, not a Prisma
// relation, same reasoning as gameId/communityTitleId below.
serverKey String? @unique
gameServerId String? @unique // TODO(M4 integration): populate once GameServer exists; unused until then
// Set only for kind=title (exactly one of the two); see file header for
// why these aren't Prisma relations.
gameId String? @unique
communityTitleId String? @unique
// Set only for kind=direct: least(userIdA)||':'||greatest(userIdA,userIdB),
// computed in chatService.ts, so two users clicking "message" at the same
// instant land in the one room a unique constraint guarantees.
dmKey String? @unique
name String?
topic String?
visibility ChatRoomVisibility @default(public)
createdById String?
createdBy User? @relation("ChatRoomCreatedBy", fields: [createdById], references: [id], onDelete: SetNull)
isArchived Boolean @default(false)
createdAt DateTime @default(now())
messages ChatMessage[]
readStates ChatReadState[]
@@index([kind, isArchived])
}
enum ChatMessageKind {
text
system
}
model ChatMessage {
id String @id @default(uuid(7)) // UUIDv7: time-sortable, makes history paging a plain indexed range scan
roomId String
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
senderId String? // null for system messages
sender User? @relation("ChatMessageSender", fields: [senderId], references: [id], onDelete: SetNull)
kind ChatMessageKind @default(text)
body String
clientNonce String? // dedup key for optimistic send + reconnect replay
replyToId String?
replyTo ChatMessage? @relation("ChatMessageReplyTo", fields: [replyToId], references: [id], onDelete: SetNull)
replies ChatMessage[] @relation("ChatMessageReplyTo")
editedAt DateTime?
deletedAt DateTime? // soft delete; body blanked, row kept
createdAt DateTime @default(now())
// Both senderId and clientNonce are nullable; Postgres never treats a NULL
// as equal to another NULL in a unique constraint, so this behaves exactly
// like DESIGN.md's `where client_nonce is not null` partial index without
// needing one.
@@unique([roomId, senderId, clientNonce], name: "dedupeKey")
@@index([roomId, id])
}
// Doubles as room membership: a row's existence means "this user has this
// room open", created on first read/visit. Public title/global rooms don't
// gate posting on membership (see chatService.ts authorizeRoom); DM rooms
// are membership-gated implicitly by dmKey containing exactly these two
// user ids. This is the "unread state" DESIGN.md put on chat_room_member --
// folded in here directly since this milestone doesn't own a separate
// membership model.
model ChatReadState {
roomId String
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
userId String
user User @relation("ChatReadState", fields: [userId], references: [id], onDelete: Cascade)
lastReadMessageId String?
joinedAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@id([roomId, userId])
@@index([userId])
}