Per-stream visibility: isPublic on StreamPlan, PATCH endpoint, feed + profile updates

This commit is contained in:
2026-03-03 21:37:00 +01:00
parent ed83c651d8
commit bc6c01940a
9 changed files with 280 additions and 45 deletions

46
package-lock.json generated
View File

@@ -10,6 +10,7 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.1", "@fastify/cookie": "^11.0.1",
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.2.1", "@fastify/rate-limit": "^10.2.1",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",
@@ -490,6 +491,12 @@
"fast-uri": "^3.0.0" "fast-uri": "^3.0.0"
} }
}, },
"node_modules/@fastify/busboy": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-3.2.0.tgz",
"integrity": "sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==",
"license": "MIT"
},
"node_modules/@fastify/cookie": { "node_modules/@fastify/cookie": {
"version": "11.0.2", "version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
@@ -530,6 +537,22 @@
"mnemonist": "0.40.0" "mnemonist": "0.40.0"
} }
}, },
"node_modules/@fastify/deepmerge": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/@fastify/deepmerge/-/deepmerge-3.2.1.tgz",
"integrity": "sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/error": { "node_modules/@fastify/error": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
@@ -600,6 +623,29 @@
"dequal": "^2.0.3" "dequal": "^2.0.3"
} }
}, },
"node_modules/@fastify/multipart": {
"version": "9.4.0",
"resolved": "https://registry.npmjs.org/@fastify/multipart/-/multipart-9.4.0.tgz",
"integrity": "sha512-Z404bzZeLSXTBmp/trCBuoVFX28pM7rhv849Q5TsbTFZHuk1lc4QjQITTPK92DKVpXmNtJXeHSSc7GYvqFpxAQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/busboy": "^3.0.0",
"@fastify/deepmerge": "^3.0.0",
"@fastify/error": "^4.0.0",
"fastify-plugin": "^5.0.0",
"secure-json-parse": "^4.0.0"
}
},
"node_modules/@fastify/proxy-addr": { "node_modules/@fastify/proxy-addr": {
"version": "5.1.0", "version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz", "resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",

View File

@@ -18,6 +18,7 @@
"dependencies": { "dependencies": {
"@fastify/cookie": "^11.0.1", "@fastify/cookie": "^11.0.1",
"@fastify/cors": "^10.0.1", "@fastify/cors": "^10.0.1",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.2.1", "@fastify/rate-limit": "^10.2.1",
"@fastify/websocket": "^11.2.0", "@fastify/websocket": "^11.2.0",
"@prisma/client": "^6.4.1", "@prisma/client": "^6.4.1",

View File

@@ -78,6 +78,7 @@ model StreamPlan {
status String @default("DRAFT") status String @default("DRAFT")
executionMode String @default("IN_GAME") executionMode String @default("IN_GAME")
gameId String @default("") gameId String @default("")
isPublic Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
destinations StreamDestination[] destinations StreamDestination[]

View File

@@ -1,5 +1,6 @@
import Fastify from 'fastify'; import Fastify from 'fastify';
import cors from '@fastify/cors'; import cors from '@fastify/cors';
import multipart from '@fastify/multipart';
import rateLimit from '@fastify/rate-limit'; import rateLimit from '@fastify/rate-limit';
import websocket from '@fastify/websocket'; import websocket from '@fastify/websocket';
import prismaPlugin from './plugins/prisma.js'; import prismaPlugin from './plugins/prisma.js';
@@ -15,6 +16,7 @@ import youtubeRoutes from './routes/providers/youtube.js';
import twitchRoutes from './routes/providers/twitch.js'; import twitchRoutes from './routes/providers/twitch.js';
import planRoutes from './routes/streams/plans.js'; import planRoutes from './routes/streams/plans.js';
import lifecycleRoutes from './routes/streams/lifecycle.js'; import lifecycleRoutes from './routes/streams/lifecycle.js';
import previewRoutes from './routes/streams/preview.js';
import followingRoutes from './routes/social/following.js'; import followingRoutes from './routes/social/following.js';
import feedRoutes from './routes/social/feed.js'; import feedRoutes from './routes/social/feed.js';
import likesRoutes from './routes/social/likes.js'; import likesRoutes from './routes/social/likes.js';
@@ -43,6 +45,7 @@ export async function buildApp() {
await app.register(prismaPlugin); await app.register(prismaPlugin);
await app.register(authPlugin); await app.register(authPlugin);
await app.register(websocket); await app.register(websocket);
await app.register(multipart);
// Chat manager (instantiated after prisma is available) // Chat manager (instantiated after prisma is available)
const chatManager = new ChatManager(app.prisma, app.log); const chatManager = new ChatManager(app.prisma, app.log);
@@ -58,6 +61,7 @@ export async function buildApp() {
await app.register(twitchRoutes); await app.register(twitchRoutes);
await app.register(planRoutes); await app.register(planRoutes);
await app.register(lifecycleRoutes); await app.register(lifecycleRoutes);
await app.register(previewRoutes);
await app.register(followingRoutes); await app.register(followingRoutes);
await app.register(feedRoutes); await app.register(feedRoutes);
await app.register(likesRoutes); await app.register(likesRoutes);

View File

@@ -1,5 +1,8 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { existsSync } from 'fs';
import { join } from 'path';
import { optionalAuth } from '../../middleware/require-auth.js'; import { optionalAuth } from '../../middleware/require-auth.js';
import { PREVIEWS_DIR } from '../streams/preview.js';
import type { FeedResponse, FeedItemResponse } from '../../types/api.js'; import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
const feedRoutes: FastifyPluginAsync = async (fastify) => { const feedRoutes: FastifyPluginAsync = async (fastify) => {
@@ -11,10 +14,13 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50); const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
const cursorId = request.query.cursor; const cursorId = request.query.cursor;
// Base condition: LIVE + ENDED plans from public users // Base condition: LIVE + ENDED plans that are public OR owned by the current user
const baseWhere: any = { const baseWhere: any = {
status: { in: ['LIVE', 'ENDED'] }, status: { in: ['LIVE', 'ENDED'] },
user: { isPublic: true }, OR: [
{ isPublic: true },
...(request.userId ? [{ userId: request.userId }] : []),
],
}; };
// If following filter, restrict to followed users (requires auth) // If following filter, restrict to followed users (requires auth)
@@ -80,38 +86,50 @@ const feedRoutes: FastifyPluginAsync = async (fastify) => {
: []; : [];
const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName])); const twitchNameMap = new Map(twitchAccounts.map(a => [a.id, a.displayName]));
const feedItems: FeedItemResponse[] = items.map(plan => ({ const feedItems: FeedItemResponse[] = items.map(plan => {
plan: { const hasPreview = existsSync(join(PREVIEWS_DIR, `${plan.id}.mp4`));
id: plan.id, return {
name: plan.name, plan: {
status: plan.status, id: plan.id,
executionMode: plan.executionMode, name: plan.name,
gameId: plan.gameId, status: plan.status,
createdAt: plan.createdAt.toISOString(), executionMode: plan.executionMode,
updatedAt: plan.updatedAt.toISOString(), gameId: plan.gameId,
destinations: plan.destinations.map(d => ({ isPublic: plan.isPublic,
id: d.id, createdAt: plan.createdAt.toISOString(),
serviceId: d.serviceId, updatedAt: plan.updatedAt.toISOString(),
linkedAccountId: d.linkedAccountId, destinations: plan.destinations.map(d => ({
title: d.title, id: d.id,
description: d.description, serviceId: d.serviceId,
privacyStatus: d.privacyStatus, linkedAccountId: d.linkedAccountId,
gameId: d.gameId, title: d.title,
tags: d.tags, description: d.description,
rtmpUrl: '', privacyStatus: d.privacyStatus,
streamKey: '', gameId: d.gameId,
// Twitch embed needs channel name, not numeric account ID tags: d.tags,
broadcastId: d.serviceId === 'TWITCH' rtmpUrl: '',
? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId) streamKey: '',
: d.broadcastId, // Twitch embed needs channel name, not numeric account ID
status: d.status, broadcastId: d.serviceId === 'TWITCH'
})), ? (twitchNameMap.get(d.linkedAccountId) || d.broadcastId)
user: plan.user, : d.broadcastId,
}, status: d.status,
likeCount: plan._count.likes, })),
commentCount: 0, // portal comments are ephemeral via WebSocket user: plan.user,
isLiked: likedSet.has(plan.id), },
})); previewUrl: hasPreview ? `/streams/plans/${plan.id}/preview` : null,
likeCount: plan._count.likes,
commentCount: 0, // portal comments are ephemeral via WebSocket
isLiked: likedSet.has(plan.id),
};
});
// LIVE streams first, then ENDED
feedItems.sort((a, b) => {
const aLive = a.plan.status === 'LIVE' ? 0 : 1;
const bLive = b.plan.status === 'LIVE' ? 0 : 1;
return aLive - bLive;
});
const response: FeedResponse = { const response: FeedResponse = {
items: feedItems, items: feedItems,

View File

@@ -1,6 +1,7 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../middleware/require-auth.js'; import { requireAuth, optionalAuth } from '../../middleware/require-auth.js';
import { AppError } from '../../plugins/error-handler.js'; import { AppError } from '../../plugins/error-handler.js';
import { formatPlan } from '../streams/plans.js';
import type { PublicUserResponse, FollowListResponse } from '../../types/api.js'; import type { PublicUserResponse, FollowListResponse } from '../../types/api.js';
const followingRoutes: FastifyPluginAsync = async (fastify) => { const followingRoutes: FastifyPluginAsync = async (fastify) => {
@@ -142,7 +143,7 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
// GET /social/users/:userId — public profile // GET /social/users/:userId — public profile
fastify.get<{ Params: { userId: string } }>('/social/users/:userId', { fastify.get<{ Params: { userId: string } }>('/social/users/:userId', {
preHandler: [requireAuth], preHandler: [optionalAuth],
}, async (request) => { }, async (request) => {
const { userId: targetId } = request.params; const { userId: targetId } = request.params;
@@ -155,13 +156,29 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
if (!user) throw new AppError(404, 'User not found'); if (!user) throw new AppError(404, 'User not found');
const isFollowing = await fastify.prisma.follow.findUnique({ let isFollowing = false;
where: { if (request.userId) {
followerId_followingId: { const follow = await fastify.prisma.follow.findUnique({
followerId: request.userId, where: {
followingId: targetId, followerId_followingId: {
followerId: request.userId,
followingId: targetId,
},
}, },
});
isFollowing = !!follow;
}
// Fetch this user's public LIVE + ENDED streams
const publicPlans = await fastify.prisma.streamPlan.findMany({
where: {
userId: targetId,
isPublic: true,
status: { in: ['LIVE', 'ENDED'] },
}, },
include: { destinations: true },
orderBy: { updatedAt: 'desc' },
take: 20,
}); });
const response: PublicUserResponse = { const response: PublicUserResponse = {
@@ -171,7 +188,8 @@ const followingRoutes: FastifyPluginAsync = async (fastify) => {
bio: user.bio, bio: user.bio,
followerCount: user._count.followers, followerCount: user._count.followers,
followingCount: user._count.following, followingCount: user._count.following,
isFollowing: !!isFollowing, isFollowing,
streams: publicPlans.map(formatPlan),
}; };
return response; return response;
}); });

View File

@@ -1,17 +1,21 @@
import { FastifyPluginAsync } from 'fastify'; import { FastifyPluginAsync } from 'fastify';
import { existsSync, unlinkSync } from 'fs';
import { join } from 'path';
import { requireAuth } from '../../middleware/require-auth.js'; 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 { PREVIEWS_DIR } from './preview.js';
import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js'; import type { CreateStreamPlanBody, CreateDestinationBody, UpdateStreamPlanBody, StreamPlanResponse, StreamDestinationResponse } from '../../types/api.js';
function formatPlan(plan: any): StreamPlanResponse { export 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', executionMode: plan.executionMode ?? 'IN_GAME',
gameId: plan.gameId ?? '', gameId: plan.gameId ?? '',
isPublic: plan.isPublic ?? false,
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 => ({
@@ -121,6 +125,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
name: { type: 'string', minLength: 1, maxLength: 200 }, name: { type: 'string', minLength: 1, maxLength: 200 },
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] }, executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
gameId: { type: 'string', maxLength: 200 }, gameId: { type: 'string', maxLength: 200 },
isPublic: { type: 'boolean' },
destinations: { destinations: {
type: 'array', type: 'array',
maxItems: 10, maxItems: 10,
@@ -145,7 +150,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
}, },
}, },
}, async (request, reply) => { }, async (request, reply) => {
const { name, executionMode, gameId, destinations } = request.body; const { name, executionMode, gameId, isPublic, 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({
@@ -185,6 +190,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
name, name,
executionMode: executionMode ?? 'IN_GAME', executionMode: executionMode ?? 'IN_GAME',
gameId: gameId ?? '', gameId: gameId ?? '',
isPublic: isPublic ?? true,
destinations: { destinations: {
create: resolvedDestinations.map((d) => { create: resolvedDestinations.map((d) => {
const isCustom = d.rtmpUrl && d.streamKey; const isCustom = d.rtmpUrl && d.streamKey;
@@ -277,6 +283,13 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan'); request.log.info({ planId: plan.id, userId: request.userId }, 'Deleting plan');
await fastify.prisma.streamPlan.delete({ where: { id: plan.id } }); await fastify.prisma.streamPlan.delete({ where: { id: plan.id } });
// Clean up preview file if exists
const previewPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
if (existsSync(previewPath)) {
try { unlinkSync(previewPath); } catch { /* ignore */ }
}
reply.status(200).send({ success: true }); reply.status(200).send({ success: true });
}); });
@@ -295,6 +308,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
name: { type: 'string', minLength: 1, maxLength: 200 }, name: { type: 'string', minLength: 1, maxLength: 200 },
executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] }, executionMode: { type: 'string', enum: ['IN_GAME', 'APP_STREAMING'] },
gameId: { type: 'string', maxLength: 200 }, gameId: { type: 'string', maxLength: 200 },
isPublic: { type: 'boolean' },
destinations: { destinations: {
type: 'array', type: 'array',
maxItems: 10, maxItems: 10,
@@ -326,7 +340,7 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
if (!plan) throw new AppError(404, 'Stream plan not found'); if (!plan) throw new AppError(404, 'Stream plan not found');
if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited'); if (plan.status !== 'DRAFT') throw new AppError(400, 'Only DRAFT plans can be edited');
const { name, executionMode, gameId, destinations } = request.body; const { name, executionMode, gameId, isPublic, destinations } = request.body;
// If destinations provided, verify accounts and replace them // If destinations provided, verify accounts and replace them
if (destinations) { if (destinations) {
@@ -406,12 +420,44 @@ const planRoutes: FastifyPluginAsync = async (fastify) => {
...(name !== undefined && { name }), ...(name !== undefined && { name }),
...(executionMode !== undefined && { executionMode }), ...(executionMode !== undefined && { executionMode }),
...(gameId !== undefined && { gameId }), ...(gameId !== undefined && { gameId }),
...(isPublic !== undefined && { isPublic }),
}, },
include: { destinations: true }, include: { destinations: true },
}); });
reply.status(200).send(formatPlan(updated)); reply.status(200).send(formatPlan(updated));
}); });
// PATCH /streams/plans/:id/visibility — toggle visibility on any status
fastify.patch<{ Params: { id: string }; Body: { isPublic: boolean } }>('/streams/plans/:id/visibility', {
preHandler: [requireAuth],
schema: {
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string' } },
},
body: {
type: 'object',
required: ['isPublic'],
properties: { isPublic: { type: 'boolean' } },
additionalProperties: false,
},
},
}, async (request, reply) => {
const plan = await fastify.prisma.streamPlan.findFirst({
where: { id: request.params.id, userId: request.userId },
});
if (!plan) throw new AppError(404, 'Stream plan not found');
const updated = await fastify.prisma.streamPlan.update({
where: { id: plan.id },
data: { isPublic: request.body.isPublic },
include: { destinations: true },
});
reply.status(200).send(formatPlan(updated));
});
}; };
export default planRoutes; export default planRoutes;

View File

@@ -0,0 +1,96 @@
import { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../middleware/require-auth.js';
import { AppError } from '../../plugins/error-handler.js';
import { join } from 'path';
import { existsSync, mkdirSync, createReadStream, createWriteStream, statSync, unlinkSync } from 'fs';
import { pipeline } from 'stream/promises';
const PREVIEWS_DIR = join(process.cwd(), 'data', 'previews');
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
// Ensure previews directory exists
if (!existsSync(PREVIEWS_DIR)) {
mkdirSync(PREVIEWS_DIR, { recursive: true });
}
const previewRoutes: FastifyPluginAsync = async (fastify) => {
// POST /streams/plans/:id/preview — upload preview clip (multipart)
fastify.post<{ Params: { id: string } }>('/streams/plans/:id/preview', {
preHandler: [requireAuth],
schema: {
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string' } },
},
},
}, async (request, reply) => {
const plan = await fastify.prisma.streamPlan.findFirst({
where: { id: request.params.id, userId: request.userId },
});
if (!plan) throw new AppError(404, 'Stream plan not found');
const data = await request.file({ limits: { fileSize: MAX_FILE_SIZE } });
if (!data) throw new AppError(400, 'No file uploaded');
const outputPath = join(PREVIEWS_DIR, `${plan.id}.mp4`);
await pipeline(data.file, createWriteStream(outputPath));
// Check if file was truncated (exceeded size limit)
if (data.file.truncated) {
unlinkSync(outputPath);
throw new AppError(413, 'File too large (max 10MB)');
}
request.log.info({ planId: plan.id }, 'Preview clip uploaded');
reply.status(200).send({ success: true });
});
// GET /streams/plans/:id/preview — serve preview clip with range support
fastify.get<{ Params: { id: string } }>('/streams/plans/:id/preview', {
schema: {
params: {
type: 'object',
required: ['id'],
properties: { id: { type: 'string' } },
},
},
}, async (request, reply) => {
const filePath = join(PREVIEWS_DIR, `${request.params.id}.mp4`);
if (!existsSync(filePath)) {
throw new AppError(404, 'Preview not found');
}
const stat = statSync(filePath);
const fileSize = stat.size;
const range = request.headers.range;
reply.header('Accept-Ranges', 'bytes');
reply.header('Cache-Control', 'public, max-age=30');
reply.header('Content-Type', 'video/mp4');
if (range) {
// Parse byte range
const parts = range.replace(/bytes=/, '').split('-');
const start = parseInt(parts[0], 10);
const end = parts[1] ? parseInt(parts[1], 10) : fileSize - 1;
if (start >= fileSize || end >= fileSize || start > end) {
reply.status(416).header('Content-Range', `bytes */${fileSize}`).send();
return;
}
reply.status(206);
reply.header('Content-Range', `bytes ${start}-${end}/${fileSize}`);
reply.header('Content-Length', end - start + 1);
return reply.send(createReadStream(filePath, { start, end }));
}
reply.header('Content-Length', fileSize);
return reply.send(createReadStream(filePath));
});
};
export default previewRoutes;
export { PREVIEWS_DIR };

View File

@@ -71,6 +71,7 @@ export interface CreateStreamPlanBody {
name: string; name: string;
executionMode?: string; executionMode?: string;
gameId?: string; gameId?: string;
isPublic?: boolean;
destinations: CreateDestinationBody[]; destinations: CreateDestinationBody[];
} }
@@ -78,6 +79,7 @@ export interface UpdateStreamPlanBody {
name?: string; name?: string;
executionMode?: string; executionMode?: string;
gameId?: string; gameId?: string;
isPublic?: boolean;
destinations?: CreateDestinationBody[]; destinations?: CreateDestinationBody[];
} }
@@ -98,6 +100,7 @@ export interface StreamPlanResponse {
status: string; status: string;
executionMode: string; executionMode: string;
gameId: string; gameId: string;
isPublic: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
destinations: StreamDestinationResponse[]; destinations: StreamDestinationResponse[];
@@ -140,10 +143,12 @@ export interface PublicUserResponse {
followerCount: number; followerCount: number;
followingCount: number; followingCount: number;
isFollowing: boolean; isFollowing: boolean;
streams?: StreamPlanResponse[];
} }
export interface FeedItemResponse { export interface FeedItemResponse {
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } }; plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
previewUrl: string | null;
likeCount: number; likeCount: number;
commentCount: number; commentCount: number;
isLiked: boolean; isLiked: boolean;