Add plan execution mode, game ID, and version to health endpoint
- Add executionMode and gameId columns to StreamPlan schema - Add migration for new columns - Support executionMode/gameId in plan create and update endpoints - Add version field to health check response from package.json
This commit is contained in:
@@ -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");
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal file
@@ -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"
|
||||||
@@ -54,14 +54,16 @@ model LinkedAccount {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model StreamPlan {
|
model StreamPlan {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String
|
userId String
|
||||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
name String
|
name String
|
||||||
status String @default("DRAFT")
|
status String @default("DRAFT")
|
||||||
createdAt DateTime @default(now())
|
executionMode String @default("IN_GAME")
|
||||||
updatedAt DateTime @updatedAt
|
gameId String @default("")
|
||||||
destinations StreamDestination[]
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
destinations StreamDestination[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify';
|
import { FastifyPluginAsync } from 'fastify';
|
||||||
|
import { createRequire } from 'module';
|
||||||
|
|
||||||
|
const require = createRequire(import.meta.url);
|
||||||
|
const { version } = require('../../package.json');
|
||||||
|
|
||||||
const healthRoutes: FastifyPluginAsync = async (fastify) => {
|
const healthRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
fastify.get('/health', async () => {
|
fastify.get('/health', async () => {
|
||||||
return { status: 'ok', timestamp: new Date().toISOString() };
|
return { status: 'ok', timestamp: new Date().toISOString(), version };
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,13 +3,15 @@ import { requireAuth } from '../../middleware/require-auth.js';
|
|||||||
import { AppError } from '../../plugins/error-handler.js';
|
import { AppError } from '../../plugins/error-handler.js';
|
||||||
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js';
|
import { getYouTubeBroadcastStatus, refreshYouTubeToken } from '../../services/youtube.service.js';
|
||||||
import { decrypt, encrypt } from '../../services/crypto.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 {
|
function formatPlan(plan: any): StreamPlanResponse {
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: plan.id,
|
||||||
name: plan.name,
|
name: plan.name,
|
||||||
status: plan.status,
|
status: plan.status,
|
||||||
|
executionMode: plan.executionMode ?? 'IN_GAME',
|
||||||
|
gameId: plan.gameId ?? '',
|
||||||
createdAt: plan.createdAt.toISOString(),
|
createdAt: plan.createdAt.toISOString(),
|
||||||
updatedAt: plan.updatedAt.toISOString(),
|
updatedAt: plan.updatedAt.toISOString(),
|
||||||
destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({
|
destinations: (plan.destinations ?? []).map((d: any): StreamDestinationResponse => ({
|
||||||
@@ -113,6 +115,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
required: ['name', 'destinations'],
|
required: ['name', 'destinations'],
|
||||||
properties: {
|
properties: {
|
||||||
name: { type: 'string', minLength: 1, maxLength: 200 },
|
name: { type: 'string', minLength: 1, maxLength: 200 },
|
||||||
|
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
|
||||||
|
gameId: { type: 'string', maxLength: 200 },
|
||||||
destinations: {
|
destinations: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
maxItems: 10,
|
maxItems: 10,
|
||||||
@@ -135,7 +139,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, async (request, reply) => {
|
}, async (request, reply) => {
|
||||||
const { name, destinations } = request.body;
|
const { name, executionMode, gameId, destinations } = request.body;
|
||||||
|
|
||||||
// Verify user has the required linked accounts
|
// Verify user has the required linked accounts
|
||||||
const linkedAccounts = await fastify.prisma.linkedAccount.findMany({
|
const linkedAccounts = await fastify.prisma.linkedAccount.findMany({
|
||||||
@@ -170,6 +174,8 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
data: {
|
data: {
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
name,
|
name,
|
||||||
|
executionMode: executionMode ?? 'IN_GAME',
|
||||||
|
gameId: gameId ?? '',
|
||||||
destinations: {
|
destinations: {
|
||||||
create: resolvedDestinations.map((d) => {
|
create: resolvedDestinations.map((d) => {
|
||||||
const account = linkedAccountMap.get(d.linkedAccountId)!;
|
const account = linkedAccountMap.get(d.linkedAccountId)!;
|
||||||
@@ -232,6 +238,98 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
|
||||||
reply.status(200).send({ success: true });
|
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;
|
export default planRoutes;
|
||||||
|
|||||||
@@ -44,9 +44,18 @@ export interface LinkedAccountResponse {
|
|||||||
// ── Streams ──────────────────────────────────────────────
|
// ── Streams ──────────────────────────────────────────────
|
||||||
export interface CreateStreamPlanBody {
|
export interface CreateStreamPlanBody {
|
||||||
name: string;
|
name: string;
|
||||||
|
executionMode?: string;
|
||||||
|
gameId?: string;
|
||||||
destinations: CreateDestinationBody[];
|
destinations: CreateDestinationBody[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateStreamPlanBody {
|
||||||
|
name?: string;
|
||||||
|
executionMode?: string;
|
||||||
|
gameId?: string;
|
||||||
|
destinations?: CreateDestinationBody[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateDestinationBody {
|
export interface CreateDestinationBody {
|
||||||
linkedAccountId: string;
|
linkedAccountId: string;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -60,6 +69,8 @@ export interface StreamPlanResponse {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
status: string;
|
status: string;
|
||||||
|
executionMode: string;
|
||||||
|
gameId: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
destinations: StreamDestinationResponse[];
|
destinations: StreamDestinationResponse[];
|
||||||
|
|||||||
Reference in New Issue
Block a user