// --- 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]) }