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.
190 lines
6 KiB
Text
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])
|
|
}
|