drop/server/prisma/models/devices.prisma
wdunn001 c21e69aa5f
Some checks failed
Server CI / Lint (push) Failing after 3m38s
Server CI / Typecheck (push) Successful in 5m36s
community: M2 -- device enrollment, wrapper ingest, playtime, played-only listing
Adds the M2 slice of the community layer: Device/DeviceToken/EnrollmentCode/
PlaySession/AchievementUnlock (migration m2_devices_playtime), the
device-token-authenticated ingest surface (POST /api/v1/ingest/enroll,
session-start, session-stop, achievements -- every success acks 2xx +
{"ok":true}, idempotent on sessionId), the /community/devices enrollment UI,
a PowerShell launch wrapper (homelab-compose/drop-stack/wrapper), and unified
playtime rails across Drop + RomM on the profile page.

Also implements the community titles listing change: default view is now
played-only (PlaySession + native Playtime, both platforms), sorted by most
recently played, with a friendly empty state and a "browse all" toggle back
to the full catalog. Adds a live-activity panel (now playing / recently
played) sourced from PlaySession, and a clearly-labeled server-status slot
for M4 to fill in.

PlaySession is the join table for M3/M4/M5: userId, deviceId, titlePlatform
('drop'|'romm'), gameId, communityTitleId, steamAppId, sessionId (unique),
startedAt, endedAt, durationSeconds, endReason, exitCode. No stale-session
sweeper in this pass -- "currently playing" is endedAt IS NULL, filtered to
sessions started within the last 12h for the live panel.
2026-08-03 01:08:39 -04:00

190 lines
6 KiB
Text

// --- M2: device enrollment, wrapper ingest, playtime (drop-community M2) ---
// Ownership: this file belongs to the M2 workstream. M3 owns
// Friendship/ChatRoom/ChatMessage/ChatReadState, M4 owns
// GameServer/ServerStatus/Issue, M5 owns
// Mod/ModVersion/ModFile/AchievementDefinition -- see the milestone brief.
// Do not add fields for those here; do not delete this block to resolve a
// merge conflict, keep all model sets.
//
// Deliberately NOT a Prisma relation to User, Game or CommunityTitle: those
// models are owned elsewhere (user.prisma / content.prisma / community.prisma)
// and Prisma requires every relation to be declared on BOTH sides, so adding
// a `@relation` here would force an edit to those shared files -- exactly the
// concurrent-edit collision this milestone was warned about. Instead
// `userId`, `gameId` and `communityTitleId` below are plain, unenforced-by-FK
// columns, resolved at the application layer, matching how
// browseService.ts/community.prisma already treat Game as "the title spine,
// joined live, never synced in" with no FK from CommunityTitle either.
// `titlePlatform` says which of gameId/communityTitleId is populated.
enum TitlePlatform {
drop
romm
}
enum DevicePlatform {
windows
linux
mac
}
enum DeviceEnrolledVia {
code
admin
}
model Device {
id String @id @default(uuid())
// Soft reference to User.id -- see the header comment for why this isn't
// a Prisma relation.
userId String
name String
platform DevicePlatform?
osVersion String?
wrapperVersion String?
fingerprintHash String?
enrolledVia DeviceEnrolledVia @default(code)
createdAt DateTime @default(now())
lastSeenAt DateTime?
revokedAt DateTime?
revokedReason String?
tokens DeviceToken[]
playSessions PlaySession[]
achievementUnlocks AchievementUnlock[]
@@index([userId])
}
// The plaintext device token (`dcs_<64 hex>`) is shown exactly once, at
// enrollment. Only its sha256 is ever persisted, in `tokenHash`.
// `tokenPrefix` (first 8 hex chars) is stored purely for display on the
// devices page so a user can tell tokens apart without re-showing secrets.
model DeviceToken {
id String @id @default(uuid())
deviceId String
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade)
tokenHash Bytes @unique
tokenPrefix String
createdAt DateTime @default(now())
lastUsedAt DateTime?
revokedAt DateTime?
@@index([deviceId])
}
// Short-lived, single-use code shown in the browser (`/community/devices`)
// and typed into the wrapper's `-Enroll` flow once per machine. 10 minute
// expiry, consumed on first successful redemption.
model EnrollmentCode {
code String @id
// Soft reference to User.id -- see the header comment.
userId String
createdAt DateTime @default(now())
expiresAt DateTime
consumedAt DateTime?
consumedDeviceId String?
@@index([userId])
}
enum PlaySessionEndReason {
clean
crashed
}
// One row per launch. `sessionId` is the client-generated idempotency token
// (see ingest/session-start.post.ts and ingest/session-stop.post.ts): the
// wrapper spools events to disk before attempting delivery and replays its
// whole queue freely after an outage, so both ingest routes must be safe to
// call twice with the same sessionId. `startedAt`/`endedAt` are the client's
// own clock, not server receive time, because delivery can be delayed
// arbitrarily by the offline queue and the duration must reflect when the
// game actually ran.
//
// M3/M4/M5: this is the table to join on for presence, relay occupancy and
// per-user achievement context. Columns: id (uuid pk), userId (plain string,
// soft FK to User.id), deviceId (plain string, FK to Device.id), titlePlatform
// ('drop' | 'romm'), gameId (Game.id when titlePlatform=drop), communityTitleId
// (CommunityTitle.id when titlePlatform=romm), steamAppId (denormalized,
// nullable), sessionId (unique idempotency key), startedAt, endedAt (null
// while still "playing"), durationSeconds (null until endedAt is set),
// endReason ('clean' | 'crashed', null while open), exitCode, wrapperVersion.
// "Currently playing" is `endedAt IS NULL`; there is no scheduled sweeper in
// this pass (see the M2 report for why), so a session whose stop event is
// permanently lost (not just delayed) stays open until an admin closes it.
model PlaySession {
id String @id @default(uuid())
// Soft reference to User.id -- see the header comment.
userId String
deviceId String?
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
titlePlatform TitlePlatform
// Soft references to Game.id / CommunityTitle.id -- see the header comment.
gameId String?
communityTitleId String?
steamAppId Int?
sessionId String @unique
startedAt DateTime
endedAt DateTime?
durationSeconds Int?
endReason PlaySessionEndReason?
exitCode Int?
wrapperVersion String?
createdAt DateTime @default(now())
@@index([userId, startedAt(sort: Desc)])
@@index([titlePlatform, gameId])
@@index([titlePlatform, communityTitleId])
@@index([deviceId])
}
enum AchievementUnlockSource {
gse_upload
manual
admin
}
// Self-reported (see DESIGN.md's trust model -- accepted deliberately).
// Keyed on (steamAppId, apiName) rather than a definitionId relation because
// AchievementDefinition belongs to M5 and may not exist yet when this lands;
// `steamAppId` is denormalized here specifically so M5 can join on
// (steamAppId, apiName) once its catalog exists, with zero migration on
// either side.
model AchievementUnlock {
id String @id @default(uuid())
// Soft reference to User.id -- see the header comment.
userId String
deviceId String?
device Device? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
steamAppId Int
apiName String
unlockedAt DateTime
firstReportedAt DateTime @default(now())
source AchievementUnlockSource @default(gse_upload)
@@unique([userId, steamAppId, apiName])
@@index([steamAppId, apiName])
}