import { desc, eq, sql } from "drizzle-orm"; import { db } from "@/lib/db/client"; import { sessions, sessionDevices, devices } from "@/lib/db/schema"; import { upsertDeviceByBleName } from "./devices"; import { getEventsForSession } from "./session-events"; import type { DeviceCapabilities } from "@/lib/db/schema"; import type { ReplayDeviceSlot } from "@/lib/buttplug/types"; import { PAGE_SIZE, type Page } from "@/lib/pagination"; export interface StartSessionDeviceInput { slotLabel: string; bleName: string; deviceClass?: string | null; capabilities?: DeviceCapabilities; } export interface StartSessionInput { name?: string; devices: StartSessionDeviceInput[]; } /** * Starts a live control session. Replaying an existing session does NOT go * through here - it just plays back the source session's already-recorded * events against newly-mapped devices (see components/sessions/ReplayPlayer) * and bumps the source's playCount/lastPlayedAt (incrementSessionPlayCount) * without creating a session or duplicate events of its own. */ export async function startSession(input: StartSessionInput) { const now = Date.now(); const [session] = await db .insert(sessions) .values({ kind: "live", name: input.name, status: "active", startedAt: now, }) .returning(); const sessionDeviceRows = []; for (const d of input.devices) { const device = await upsertDeviceByBleName({ bleName: d.bleName, deviceClass: d.deviceClass, capabilities: d.capabilities, }); const [sessionDevice] = await db .insert(sessionDevices) .values({ sessionId: session.id, deviceId: device.id, slotLabel: d.slotLabel, connectedAt: now, }) .returning(); sessionDeviceRows.push(sessionDevice); } return { session, sessionDevices: sessionDeviceRows }; } export async function endSession( id: number, input: { status: "completed" | "aborted" }, ) { const [existing] = await db.select().from(sessions).where(eq(sessions.id, id)); if (!existing) return undefined; const endedAt = Date.now(); const durationMs = endedAt - existing.startedAt; await db .update(sessionDevices) .set({ disconnectedAt: endedAt }) .where(eq(sessionDevices.sessionId, id)); const [updated] = await db .update(sessions) .set({ endedAt, durationMs, status: input.status }) .where(eq(sessions.id, id)) .returning(); return updated; } export async function listSessions() { return db.select().from(sessions).orderBy(sessions.startedAt); } export async function listSessionsPage(page: number, pageSize = PAGE_SIZE): Promise> { const [{ count }] = await db.select({ count: sql`count(*)` }).from(sessions); const items = await db .select() .from(sessions) .orderBy(desc(sessions.startedAt)) .limit(pageSize) .offset((page - 1) * pageSize); return { items, page, pageSize, total: count }; } /** Lightweight name-only lookup for page titles - avoids getSessionDetail's device join. */ export async function getSessionName(id: number): Promise { const [row] = await db.select({ name: sessions.name }).from(sessions).where(eq(sessions.id, id)); return row?.name; } export async function getSessionDetail(id: number) { const [session] = await db.select().from(sessions).where(eq(sessions.id, id)); if (!session) return undefined; const sessionDeviceRows = await db .select({ id: sessionDevices.id, slotLabel: sessionDevices.slotLabel, connectedAt: sessionDevices.connectedAt, disconnectedAt: sessionDevices.disconnectedAt, deviceId: devices.id, deviceDisplayName: devices.displayName, deviceBleName: devices.bleName, }) .from(sessionDevices) .innerJoin(devices, eq(sessionDevices.deviceId, devices.id)) .where(eq(sessionDevices.sessionId, id)); const replayedFromName = session.replayedSessionId ? await getSessionName(session.replayedSessionId) : undefined; return { session, devices: sessionDeviceRows, replayedFromName }; } /** Snapshot of a session's devices, shaped for the replay device-remap UI - computed live * from session_devices/devices, not a frozen copy (see ReplayDeviceSlot). */ export async function getSessionForReplay(id: number) { const [session] = await db.select().from(sessions).where(eq(sessions.id, id)); if (!session) return undefined; const deviceSlotRows = await db .select({ sessionDeviceId: sessionDevices.id, slotLabel: sessionDevices.slotLabel, bleName: devices.bleName, deviceClass: devices.deviceClass, capabilities: devices.capabilities, }) .from(sessionDevices) .innerJoin(devices, eq(sessionDevices.deviceId, devices.id)) .where(eq(sessionDevices.sessionId, id)); const deviceSlots: ReplayDeviceSlot[] = deviceSlotRows.map((row) => ({ slotLabel: row.slotLabel, recordedBleName: row.bleName, deviceClass: row.deviceClass, capabilities: row.capabilities, sourceSessionDeviceId: row.sessionDeviceId, })); const events = await getEventsForSession(id); return { session, deviceSlots, events }; } export async function renameSession(id: number, input: { name?: string; description?: string }) { const [updated] = await db.update(sessions).set(input).where(eq(sessions.id, id)).returning(); return updated; } export async function incrementSessionPlayCount(id: number, playedAt: number): Promise { const [existing] = await db.select().from(sessions).where(eq(sessions.id, id)); if (!existing) return; await db .update(sessions) .set({ playCount: existing.playCount + 1, lastPlayedAt: playedAt }) .where(eq(sessions.id, id)); } export async function deleteSession(id: number) { await db.delete(sessions).where(eq(sessions.id, id)); }