diff --git a/prisma/migrations/20260228195459_add_plan_execution_mode_game_id/migration.sql b/prisma/migrations/20260228195459_add_plan_execution_mode_game_id/migration.sql new file mode 100644 index 0000000..4d70cb7 --- /dev/null +++ b/prisma/migrations/20260228195459_add_plan_execution_mode_game_id/migration.sql @@ -0,0 +1,91 @@ +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL PRIMARY KEY, + "metaId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "email" TEXT, + "avatarUrl" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "Session" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "refreshToken" TEXT NOT NULL, + "expiresAt" DATETIME NOT NULL, + "deviceInfo" TEXT, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "LinkedAccount" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "serviceId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "accountId" TEXT NOT NULL, + "avatarUrl" TEXT, + "accessTokenEnc" TEXT NOT NULL, + "refreshTokenEnc" TEXT NOT NULL, + "tokenExpiresAt" DATETIME NOT NULL, + "accessTokenIv" TEXT NOT NULL, + "refreshTokenIv" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "LinkedAccount_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "StreamPlan" ( + "id" TEXT NOT NULL PRIMARY KEY, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'DRAFT', + "executionMode" TEXT NOT NULL DEFAULT 'IN_GAME', + "gameId" TEXT NOT NULL DEFAULT '', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "StreamPlan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "StreamDestination" ( + "id" TEXT NOT NULL PRIMARY KEY, + "planId" TEXT NOT NULL, + "serviceId" TEXT NOT NULL, + "linkedAccountId" TEXT NOT NULL DEFAULT '', + "title" TEXT NOT NULL, + "description" TEXT NOT NULL DEFAULT '', + "privacyStatus" TEXT NOT NULL DEFAULT 'public', + "gameId" TEXT NOT NULL DEFAULT '', + "tags" TEXT NOT NULL DEFAULT '', + "rtmpUrl" TEXT NOT NULL DEFAULT '', + "streamKey" TEXT NOT NULL DEFAULT '', + "broadcastId" TEXT NOT NULL DEFAULT '', + "status" TEXT NOT NULL DEFAULT 'PENDING', + CONSTRAINT "StreamDestination_planId_fkey" FOREIGN KEY ("planId") REFERENCES "StreamPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "User_metaId_key" ON "User"("metaId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Session_refreshToken_key" ON "Session"("refreshToken"); + +-- CreateIndex +CREATE INDEX "Session_userId_idx" ON "Session"("userId"); + +-- CreateIndex +CREATE INDEX "LinkedAccount_userId_idx" ON "LinkedAccount"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "LinkedAccount_userId_serviceId_accountId_key" ON "LinkedAccount"("userId", "serviceId", "accountId"); + +-- CreateIndex +CREATE INDEX "StreamPlan_userId_idx" ON "StreamPlan"("userId"); + +-- CreateIndex +CREATE INDEX "StreamDestination_planId_idx" ON "StreamDestination"("planId"); diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..2a5a444 --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 05c31b1..397c3a8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -54,14 +54,16 @@ model LinkedAccount { } model StreamPlan { - id String @id @default(uuid()) - userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - name String - status String @default("DRAFT") - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - destinations StreamDestination[] + id String @id @default(uuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + name String + status String @default("DRAFT") + executionMode String @default("IN_GAME") + gameId String @default("") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + destinations StreamDestination[] @@index([userId]) } diff --git a/src/routes/health.ts b/src/routes/health.ts index 59da74d..3d0ebb6 100644 --- a/src/routes/health.ts +++ b/src/routes/health.ts @@ -1,8 +1,12 @@ import { FastifyPluginAsync } from 'fastify'; +import { createRequire } from 'module'; + +const require = createRequire(import.meta.url); +const { version } = require('../../package.json'); const healthRoutes: FastifyPluginAsync = async (fastify) => { fastify.get('/health', async () => { - return { status: 'ok', timestamp: new Date().toISOString() }; + return { status: 'ok', timestamp: new Date().toISOString(), version }; }); }; diff --git a/src/routes/streams/plans.ts b/src/routes/streams/plans.ts index b71e368..653605f 100644 --- a/src/routes/streams/plans.ts +++ b/src/routes/streams/plans.ts @@ -3,13 +3,15 @@ import { requireAuth } from '../../middleware/require-auth.js'; import { AppError } from '../../plugins/error-handler.js'; import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js'; import { decrypt, encrypt } from '../../services/crypto.service.js'; -import type { CreateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; +import type { CreateStreamPlanBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; function formatPlan(plan: any): StreamPlanResponse { return { id: plan.id, name: plan.name, status: plan.status, + executionMode: plan.executionMode ?? 'IN_GAME', + gameId: plan.gameId ?? '', createdAt: plan.createdAt.toISOString(), updatedAt: plan.updatedAt.toISOString(), destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({ @@ -113,6 +115,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { required: ['name', 'destinations'], properties: { name: { type: 'string', minLength: 1, maxLength: 200 }, + executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] }, + gameId: { type: 'string', maxLength: 200 }, destinations: { type: 'array', maxItems: 10, @@ -135,7 +139,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { }, }, }, async (request, reply) => { - const { name, destinations } = request.body; + const { name, executionMode, gameId, destinations } = request.body; // Verify user has the required linked accounts const linkedAccounts = await fastify.prisma.linkedAccount.findMany({ @@ -170,6 +174,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { data: { userId: request.userId, name, + executionMode: executionMode ?? 'IN_GAME', + gameId: gameId ?? '', destinations: { create: resolvedDestinations.map((d) => { const account = linkedAccountMap.get(d.linkedAccountId)!; @@ -232,6 +238,98 @@ const planRoutes: FastifyPluginAsync = async (fastify) => { await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); reply.status(200).send({ success: true }); }); + + // PUT /streams/plans/:id — update a DRAFT plan + fastify.put<{ Params: { id: string }; Body: UpdateStreamPlanBody }>('/streams/plans/:id', { + preHandler: [requireAuth], + schema: { + params: { + type: 'object', + required: ['id'], + properties: { id: { type: 'string' } }, + }, + body: { + type: 'object', + properties: { + name: { type: 'string', minLength: 1, maxLength: 200 }, + executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] }, + gameId: { type: 'string', maxLength: 200 }, + destinations: { + type: 'array', + maxItems: 10, + items: { + type: 'object', + required: ['linkedAccountId', 'title'], + properties: { + linkedAccountId: { type: 'string', minLength: 1 }, + title: { type: 'string', minLength: 1, maxLength: 200 }, + description: { type: 'string', maxLength: 5000 }, + privacyStatus: { type: 'string', enum: ['public', 'unlisted', 'private'] }, + gameId: { type: 'string', maxLength: 100 }, + tags: { type: 'string', maxLength: 500 }, + }, + additionalProperties: false, + }, + }, + }, + additionalProperties: false, + }, + }, + }, async (request, reply) => { + const plan = await fastify.prisma.streamPlan.findFirst({ + where: { id: request.params.id, userId: request.userId }, + include: { destinations: true }, + }); + if (!plan) throw new AppError(404, 'Stream plan not found'); + if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited'); + + const { name, executionMode, gameId, destinations } = request.body; + + // If destinations provided, verify accounts and replace them + if (destinations) { + const linkedAccounts = await fastify.prisma.linkedAccount.findMany({ + where: { userId: request.userId }, + }); + const linkedAccountMap = new Map(linkedAccounts.map((a) => [a.id, a])); + + for (const dest of destinations) { + if (!linkedAccountMap.has(dest.linkedAccountId)) { + throw new AppError(400, `Linked account ${dest.linkedAccountId} not found`); + } + } + + // Delete old destinations and create new ones + await fastify.prisma.streamDestination.deleteMany({ where: { planId: plan.id } }); + for (const d of destinations) { + const account = linkedAccountMap.get(d.linkedAccountId)!; + await fastify.prisma.streamDestination.create({ + data: { + planId: plan.id, + serviceId: account.serviceId, + linkedAccountId: d.linkedAccountId, + title: d.title, + description: d.description ?? '', + privacyStatus: d.privacyStatus ?? 'unlisted', + gameId: d.gameId ?? '', + tags: d.tags ?? '', + }, + }); + } + } + + // Update plan fields + const updated = await fastify.prisma.streamPlan.update({ + where: { id: plan.id }, + data: { + ...(name !== undefined && { name }), + ...(executionMode !== undefined && { executionMode }), + ...(gameId !== undefined && { gameId }), + }, + include: { destinations: true }, + }); + + reply.status(200).send(formatPlan(updated)); + }); }; export default planRoutes; diff --git a/src/types/api.ts b/src/types/api.ts index 2153ecd..c4eda2e 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -44,9 +44,18 @@ export interface LinkedAccountResponse { // ── Streams ────────────────────────────────────────────── export interface CreateStreamPlanBody { name: string; + executionMode?: string; + gameId?: string; destinations: CreateDestinationBody[]; } +export interface UpdateStreamPlanBody { + name?: string; + executionMode?: string; + gameId?: string; + destinations?: CreateDestinationBody[]; +} + export interface CreateDestinationBody { linkedAccountId: string; title: string; @@ -60,6 +69,8 @@ export interface StreamPlanResponse { id: string; name: string; status: string; + executionMode: string; + gameId: string; createdAt: string; updatedAt: string; destinations: StreamDestinationResponse[];