Portal backend: Facebook OAuth, social features, portal comments
- Facebook Login OAuth (meta-web auth service + routes) - Account linking (merge Quest metaId + Facebook facebookId) - User profile updates (bio, isPublic, displayName) - Social endpoints: follow/unfollow, feed (trending/following/recent), likes - Portal comments via WebSocket (subscribe_portal, send_portal_comment) - Prisma migration: Follow, Like models, facebookId/bio/isPublic on User - Provider OAuth source=web redirect support for portal callbacks - Docker compose portal service, CORS multi-origin support
This commit is contained in:
@@ -12,8 +12,26 @@ services:
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/lck.db
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 10s
|
||||
|
||||
portal:
|
||||
build:
|
||||
context: ../lck-control-portal
|
||||
dockerfile: Dockerfile
|
||||
container_name: lck-control-portal
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3200:3000"
|
||||
environment:
|
||||
- NEXT_PUBLIC_API_URL=https://lck.omigame.dev
|
||||
- HOSTNAME=0.0.0.0
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 15s
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Follow" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"followerId" TEXT NOT NULL,
|
||||
"followingId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Follow_followerId_fkey" FOREIGN KEY ("followerId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Follow_followingId_fkey" FOREIGN KEY ("followingId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Like" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"planId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "Like_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Like_planId_fkey" FOREIGN KEY ("planId") REFERENCES "StreamPlan" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"metaId" TEXT,
|
||||
"facebookId" TEXT,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"email" TEXT,
|
||||
"avatarUrl" TEXT,
|
||||
"bio" TEXT NOT NULL DEFAULT '',
|
||||
"isPublic" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("avatarUrl", "createdAt", "displayName", "email", "id", "metaId", "updatedAt") SELECT "avatarUrl", "createdAt", "displayName", "email", "id", "metaId", "updatedAt" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_metaId_key" ON "User"("metaId");
|
||||
CREATE UNIQUE INDEX "User_facebookId_key" ON "User"("facebookId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Follow_followerId_idx" ON "Follow"("followerId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Follow_followingId_idx" ON "Follow"("followingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Follow_followerId_followingId_key" ON "Follow"("followerId", "followingId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Like_planId_idx" ON "Like"("planId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Like_userId_planId_key" ON "Like"("userId", "planId");
|
||||
@@ -9,16 +9,22 @@ datasource db {
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
metaId String @unique
|
||||
metaId String? @unique
|
||||
facebookId String? @unique
|
||||
displayName String
|
||||
email String?
|
||||
avatarUrl String?
|
||||
bio String @default("")
|
||||
isPublic Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
linkedAccounts LinkedAccount[]
|
||||
streamPlans StreamPlan[]
|
||||
sessions Session[]
|
||||
followers Follow[] @relation("following")
|
||||
following Follow[] @relation("follower")
|
||||
likes Like[]
|
||||
}
|
||||
|
||||
model Session {
|
||||
@@ -64,6 +70,7 @@ model StreamPlan {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
destinations StreamDestination[]
|
||||
likes Like[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
@@ -86,3 +93,28 @@ model StreamDestination {
|
||||
|
||||
@@index([planId])
|
||||
}
|
||||
|
||||
model Follow {
|
||||
id String @id @default(uuid())
|
||||
followerId String
|
||||
follower User @relation("follower", fields: [followerId], references: [id], onDelete: Cascade)
|
||||
followingId String
|
||||
following User @relation("following", fields: [followingId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([followerId, followingId])
|
||||
@@index([followerId])
|
||||
@@index([followingId])
|
||||
}
|
||||
|
||||
model Like {
|
||||
id String @id @default(uuid())
|
||||
userId String
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
planId String
|
||||
plan StreamPlan @relation(fields: [planId], references: [id], onDelete: Cascade)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@unique([userId, planId])
|
||||
@@index([planId])
|
||||
}
|
||||
|
||||
17
src/app.ts
17
src/app.ts
@@ -7,12 +7,17 @@ import errorHandlerPlugin from './plugins/error-handler.js';
|
||||
import authPlugin from './plugins/auth.js';
|
||||
import healthRoutes from './routes/health.js';
|
||||
import metaAuthRoutes from './routes/auth/meta.js';
|
||||
import metaWebRoutes from './routes/auth/meta-web.js';
|
||||
import sessionRoutes from './routes/auth/session.js';
|
||||
import linkRoutes from './routes/auth/link.js';
|
||||
import accountRoutes from './routes/providers/accounts.js';
|
||||
import youtubeRoutes from './routes/providers/youtube.js';
|
||||
import twitchRoutes from './routes/providers/twitch.js';
|
||||
import planRoutes from './routes/streams/plans.js';
|
||||
import lifecycleRoutes from './routes/streams/lifecycle.js';
|
||||
import followingRoutes from './routes/social/following.js';
|
||||
import feedRoutes from './routes/social/feed.js';
|
||||
import likesRoutes from './routes/social/likes.js';
|
||||
import { createChatRoutes } from './routes/chat/websocket.js';
|
||||
import { ChatManager } from './services/chat-manager.service.js';
|
||||
import { config } from './config.js';
|
||||
@@ -24,8 +29,11 @@ export async function buildApp() {
|
||||
},
|
||||
});
|
||||
|
||||
// Plugins
|
||||
await app.register(cors, { origin: config.corsOrigin });
|
||||
// Plugins — support comma-separated CORS origins
|
||||
const corsOrigins = config.corsOrigin === '*'
|
||||
? '*'
|
||||
: config.corsOrigin.split(',').map(s => s.trim());
|
||||
await app.register(cors, { origin: corsOrigins });
|
||||
await app.register(rateLimit, {
|
||||
global: true,
|
||||
max: 100,
|
||||
@@ -42,12 +50,17 @@ export async function buildApp() {
|
||||
// Routes
|
||||
await app.register(healthRoutes);
|
||||
await app.register(metaAuthRoutes);
|
||||
await app.register(metaWebRoutes);
|
||||
await app.register(sessionRoutes);
|
||||
await app.register(linkRoutes);
|
||||
await app.register(accountRoutes);
|
||||
await app.register(youtubeRoutes);
|
||||
await app.register(twitchRoutes);
|
||||
await app.register(planRoutes);
|
||||
await app.register(lifecycleRoutes);
|
||||
await app.register(followingRoutes);
|
||||
await app.register(feedRoutes);
|
||||
await app.register(likesRoutes);
|
||||
await app.register(createChatRoutes(chatManager));
|
||||
|
||||
return app;
|
||||
|
||||
@@ -38,6 +38,13 @@ export const config = {
|
||||
redirectUri: required('TWITCH_REDIRECT_URI'),
|
||||
},
|
||||
|
||||
metaWeb: {
|
||||
clientId: optional('META_WEB_CLIENT_ID', ''),
|
||||
clientSecret: optional('META_WEB_CLIENT_SECRET', ''),
|
||||
redirectUri: optional('META_WEB_REDIRECT_URI', ''),
|
||||
},
|
||||
|
||||
portalUrl: optional('PORTAL_URL', 'https://portal.omigame.dev'),
|
||||
appScheme: optional('APP_SCHEME', 'com.omixlab.lckcontrol'),
|
||||
corsOrigin: optional('CORS_ORIGIN', '*'),
|
||||
} as const;
|
||||
|
||||
104
src/routes/auth/link.ts
Normal file
104
src/routes/auth/link.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { verifyUserProof } from '../../services/meta-auth.service.js';
|
||||
import { exchangeFacebookCode, fetchFacebookProfile } from '../../services/meta-web-auth.service.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { LinkQuestBody, LinkFacebookBody } from '../../types/api.js';
|
||||
|
||||
const linkRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /auth/link-quest — portal user links their Quest account
|
||||
fastify.post<{ Body: LinkQuestBody }>('/auth/link-quest', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['metaId'],
|
||||
properties: {
|
||||
metaId: { type: 'string', minLength: 1 },
|
||||
nonce: { type: 'string' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { metaId, nonce } = request.body;
|
||||
|
||||
// Validate nonce if provided
|
||||
if (nonce) {
|
||||
const isValid = await verifyUserProof(metaId, nonce);
|
||||
if (!isValid) {
|
||||
throw new AppError(401, 'Invalid user proof');
|
||||
}
|
||||
}
|
||||
|
||||
// Check if metaId is already linked to another user
|
||||
const existingUser = await fastify.prisma.user.findUnique({ where: { metaId } });
|
||||
if (existingUser && existingUser.id !== request.userId) {
|
||||
// Merge: move all data from existing user to current user
|
||||
await fastify.prisma.$transaction([
|
||||
fastify.prisma.linkedAccount.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.streamPlan.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.session.deleteMany({ where: { userId: existingUser.id } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followerId: existingUser.id }, data: { followerId: request.userId } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followingId: existingUser.id }, data: { followingId: request.userId } }),
|
||||
fastify.prisma.like.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.user.delete({ where: { id: existingUser.id } }),
|
||||
fastify.prisma.user.update({ where: { id: request.userId }, data: { metaId } }),
|
||||
]);
|
||||
} else {
|
||||
// Simply set metaId on current user
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: { metaId },
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// POST /auth/link-facebook — Quest user links their Facebook account
|
||||
fastify.post<{ Body: LinkFacebookBody }>('/auth/link-facebook', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code', 'state'],
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 1 },
|
||||
state: { type: 'string', minLength: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { code } = request.body;
|
||||
|
||||
const { accessToken: fbToken } = await exchangeFacebookCode(code);
|
||||
const profile = await fetchFacebookProfile(fbToken);
|
||||
|
||||
// Check if facebookId is already linked to another user
|
||||
const existingUser = await fastify.prisma.user.findUnique({ where: { facebookId: profile.facebookId } });
|
||||
if (existingUser && existingUser.id !== request.userId) {
|
||||
// Merge: move all data from existing user to current user
|
||||
await fastify.prisma.$transaction([
|
||||
fastify.prisma.linkedAccount.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.streamPlan.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.session.deleteMany({ where: { userId: existingUser.id } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followerId: existingUser.id }, data: { followerId: request.userId } }),
|
||||
fastify.prisma.follow.updateMany({ where: { followingId: existingUser.id }, data: { followingId: request.userId } }),
|
||||
fastify.prisma.like.updateMany({ where: { userId: existingUser.id }, data: { userId: request.userId } }),
|
||||
fastify.prisma.user.delete({ where: { id: existingUser.id } }),
|
||||
fastify.prisma.user.update({ where: { id: request.userId }, data: { facebookId: profile.facebookId } }),
|
||||
]);
|
||||
} else {
|
||||
await fastify.prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data: { facebookId: profile.facebookId },
|
||||
});
|
||||
}
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
};
|
||||
|
||||
export default linkRoutes;
|
||||
128
src/routes/auth/meta-web.ts
Normal file
128
src/routes/auth/meta-web.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { signAccessToken } from '../../plugins/auth.js';
|
||||
import { hashToken } from '../../services/crypto.service.js';
|
||||
import { exchangeFacebookCode, fetchFacebookProfile, getFacebookOAuthUrl } from '../../services/meta-web-auth.service.js';
|
||||
import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthTokensResponse, MetaWebCallbackBody } from '../../types/api.js';
|
||||
|
||||
// In-memory CSRF state store
|
||||
const pendingStates = new Map<string, { expiresAt: number }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, val] of pendingStates) {
|
||||
if (val.expiresAt < now) pendingStates.delete(key);
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
const metaWebRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /auth/meta/web/auth-url — returns Facebook OAuth dialog URL
|
||||
fastify.get('/auth/meta/web/auth-url', {
|
||||
config: {
|
||||
rateLimit: { max: 10, timeWindow: '1 minute' },
|
||||
},
|
||||
}, async () => {
|
||||
const state = randomUUID();
|
||||
pendingStates.set(state, {
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
});
|
||||
|
||||
return {
|
||||
url: getFacebookOAuthUrl(state),
|
||||
state,
|
||||
};
|
||||
});
|
||||
|
||||
// GET /auth/meta/web/callback-redirect — Facebook redirects here → 302 to portal
|
||||
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/auth/meta/web/callback-redirect',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
if (error || !code || !state) {
|
||||
reply.status(302).redirect(
|
||||
`${config.portalUrl}/auth/callback?error=${error || 'missing_params'}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(302).redirect(
|
||||
`${config.portalUrl}/auth/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// POST /auth/meta/web/callback — exchanges Facebook code for JWT
|
||||
fastify.post<{ Body: MetaWebCallbackBody }>('/auth/meta/web/callback', {
|
||||
config: {
|
||||
rateLimit: { max: 10, timeWindow: '1 minute' },
|
||||
},
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['code', 'state'],
|
||||
properties: {
|
||||
code: { type: 'string', minLength: 1 },
|
||||
state: { type: 'string', minLength: 1 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const { code, state } = request.body;
|
||||
|
||||
// Validate CSRF state
|
||||
const pending = pendingStates.get(state);
|
||||
if (!pending || pending.expiresAt < Date.now()) {
|
||||
pendingStates.delete(state);
|
||||
throw new AppError(400, 'Invalid or expired state parameter');
|
||||
}
|
||||
pendingStates.delete(state);
|
||||
|
||||
// Exchange code for Facebook access token
|
||||
const { accessToken: fbToken } = await exchangeFacebookCode(code);
|
||||
const profile = await fetchFacebookProfile(fbToken);
|
||||
|
||||
// Upsert user by facebookId
|
||||
const user = await fastify.prisma.user.upsert({
|
||||
where: { facebookId: profile.facebookId },
|
||||
update: {
|
||||
displayName: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
},
|
||||
create: {
|
||||
facebookId: profile.facebookId,
|
||||
displayName: profile.displayName,
|
||||
email: profile.email,
|
||||
avatarUrl: profile.avatarUrl,
|
||||
},
|
||||
});
|
||||
|
||||
// Create session
|
||||
const refreshToken = randomUUID();
|
||||
const expiresAt = new Date(Date.now() + config.jwt.refreshTtl * 1000);
|
||||
|
||||
await fastify.prisma.session.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
refreshToken: hashToken(refreshToken),
|
||||
expiresAt,
|
||||
deviceInfo: 'web-portal',
|
||||
},
|
||||
});
|
||||
|
||||
const accessToken = await signAccessToken(user.id);
|
||||
|
||||
const response: AuthTokensResponse = {
|
||||
accessToken,
|
||||
refreshToken,
|
||||
expiresIn: config.jwt.accessTtl,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
};
|
||||
|
||||
export default metaWebRoutes;
|
||||
@@ -5,7 +5,7 @@ import { hashToken } from '../../services/crypto.service.js';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { RefreshBody, AuthTokensResponse, UserProfileResponse } from '../../types/api.js';
|
||||
import type { RefreshBody, AuthTokensResponse, UserProfileResponse, UpdateProfileBody } from '../../types/api.js';
|
||||
|
||||
const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /auth/refresh — rotate refresh token, issue new JWT
|
||||
@@ -83,6 +83,49 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
isPublic: user.isPublic,
|
||||
hasQuestLink: !!user.metaId,
|
||||
hasFacebookLink: !!user.facebookId,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
});
|
||||
|
||||
// PATCH /auth/me — update profile
|
||||
fastify.patch<{ Body: UpdateProfileBody }>('/auth/me', {
|
||||
preHandler: [requireAuth],
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
displayName: { type: 'string', minLength: 1, maxLength: 100 },
|
||||
bio: { type: 'string', maxLength: 500 },
|
||||
isPublic: { type: 'boolean' },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
}, async (request, reply) => {
|
||||
const data: any = {};
|
||||
if (request.body.displayName !== undefined) data.displayName = request.body.displayName;
|
||||
if (request.body.bio !== undefined) data.bio = request.body.bio;
|
||||
if (request.body.isPublic !== undefined) data.isPublic = request.body.isPublic;
|
||||
|
||||
const user = await fastify.prisma.user.update({
|
||||
where: { id: request.userId },
|
||||
data,
|
||||
});
|
||||
|
||||
const response: UserProfileResponse = {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
isPublic: user.isPublic,
|
||||
hasQuestLink: !!user.metaId,
|
||||
hasFacebookLink: !!user.facebookId,
|
||||
};
|
||||
|
||||
reply.status(200).send(response);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { verifyAccessToken } from '../../plugins/auth.js';
|
||||
import { ChatManager } from '../../services/chat-manager.service.js';
|
||||
|
||||
interface ChatWsMessage {
|
||||
type: 'subscribe' | 'unsubscribe' | 'send_message';
|
||||
type: 'subscribe' | 'unsubscribe' | 'send_message' | 'subscribe_portal' | 'send_portal_comment' | 'like';
|
||||
planId?: string;
|
||||
destinationId?: string;
|
||||
text?: string;
|
||||
@@ -63,6 +63,24 @@ export function createChatRoutes(chatManager: ChatManager): FastifyPluginAsync {
|
||||
}
|
||||
break;
|
||||
|
||||
case 'subscribe_portal':
|
||||
if (msg.planId) {
|
||||
await chatManager.subscribePortalChat(msg.planId, userId, socket);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'send_portal_comment':
|
||||
if (msg.planId && msg.text) {
|
||||
await chatManager.handlePortalComment(msg.planId, userId, msg.text);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'like':
|
||||
if (msg.planId) {
|
||||
await chatManager.handleLike(msg.planId, userId);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
socket.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${msg.type}` }));
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
||||
|
||||
// In-memory CSRF state store (state → { userId, expiresAt })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number }>();
|
||||
// In-memory CSRF state store (state → { userId, expiresAt, source })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
@@ -23,13 +23,15 @@ setInterval(() => {
|
||||
|
||||
const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /providers/twitch/auth-url — get OAuth URL with CSRF state
|
||||
fastify.get('/providers/twitch/auth-url', {
|
||||
fastify.get<{ Querystring: { source?: string } }>('/providers/twitch/auth-url', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const source = request.query.source;
|
||||
const state = randomUUID();
|
||||
pendingStates.set(state, {
|
||||
userId: request.userId,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||
source,
|
||||
});
|
||||
|
||||
const response: AuthUrlResponse = {
|
||||
@@ -39,21 +41,27 @@ const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link
|
||||
// GET /providers/twitch/callback-redirect — Twitch redirects here → 302 to app deep link or portal
|
||||
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/providers/twitch/callback-redirect',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
|
||||
const pending = state ? pendingStates.get(state) : undefined;
|
||||
const isWeb = pending?.source === 'web';
|
||||
|
||||
if (error || !code || !state) {
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/twitch?error=${error || 'missing_params'}`
|
||||
: `${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`;
|
||||
reply.status(302).redirect(target);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/twitch?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
: `${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
reply.status(302).redirect(target);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import { config } from '../../config.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
||||
|
||||
// In-memory CSRF state store (state → { userId, expiresAt })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number }>();
|
||||
// In-memory CSRF state store (state → { userId, expiresAt, source })
|
||||
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
|
||||
|
||||
// Clean expired states every 5 minutes
|
||||
setInterval(() => {
|
||||
@@ -24,13 +24,15 @@ setInterval(() => {
|
||||
|
||||
const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /providers/youtube/auth-url — get OAuth URL with CSRF state
|
||||
fastify.get('/providers/youtube/auth-url', {
|
||||
fastify.get<{ Querystring: { source?: string } }>('/providers/youtube/auth-url', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const source = request.query.source;
|
||||
const state = randomUUID();
|
||||
pendingStates.set(state, {
|
||||
userId: request.userId,
|
||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 min
|
||||
source,
|
||||
});
|
||||
|
||||
const response: AuthUrlResponse = {
|
||||
@@ -40,21 +42,28 @@ const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /providers/youtube/callback-redirect — Google redirects here → 302 to app deep link
|
||||
// GET /providers/youtube/callback-redirect — Google redirects here → 302 to app deep link or portal
|
||||
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||
'/providers/youtube/callback-redirect',
|
||||
async (request, reply) => {
|
||||
const { code, state, error } = request.query;
|
||||
|
||||
// Check source from pending state to decide redirect target
|
||||
const pending = state ? pendingStates.get(state) : undefined;
|
||||
const isWeb = pending?.source === 'web';
|
||||
|
||||
if (error || !code || !state) {
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/youtube?error=${error || 'missing_params'}`
|
||||
: `${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`;
|
||||
reply.status(302).redirect(target);
|
||||
return;
|
||||
}
|
||||
|
||||
reply.status(302).redirect(
|
||||
`${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
||||
);
|
||||
const target = isWeb
|
||||
? `${config.portalUrl}/accounts/callback/youtube?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`
|
||||
: `${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`;
|
||||
reply.status(302).redirect(target);
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
98
src/routes/social/feed.ts
Normal file
98
src/routes/social/feed.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import type { FeedResponse, FeedItemResponse } from '../../types/api.js';
|
||||
|
||||
const feedRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// GET /social/feed?filter=trending|following|recent&cursor=&limit=20
|
||||
fastify.get<{ Querystring: { filter?: string; cursor?: string; limit?: string } }>('/social/feed', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const filter = request.query.filter || 'trending';
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursorId = request.query.cursor;
|
||||
|
||||
// Base condition: only LIVE plans from public users
|
||||
const baseWhere: any = {
|
||||
status: 'LIVE',
|
||||
user: { isPublic: true },
|
||||
};
|
||||
|
||||
// If following filter, restrict to followed users
|
||||
if (filter === 'following') {
|
||||
const myFollowing = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId },
|
||||
select: { followingId: true },
|
||||
});
|
||||
const followingIds = myFollowing.map(f => f.followingId);
|
||||
baseWhere.userId = { in: followingIds };
|
||||
}
|
||||
|
||||
let orderBy: any;
|
||||
if (filter === 'trending') {
|
||||
orderBy = { likes: { _count: 'desc' as const } };
|
||||
} else {
|
||||
orderBy = { updatedAt: 'desc' as const };
|
||||
}
|
||||
|
||||
const plans = await fastify.prisma.streamPlan.findMany({
|
||||
where: baseWhere,
|
||||
include: {
|
||||
user: { select: { id: true, displayName: true, avatarUrl: true } },
|
||||
destinations: true,
|
||||
_count: { select: { likes: true } },
|
||||
},
|
||||
orderBy,
|
||||
take: limit + 1,
|
||||
...(cursorId ? { cursor: { id: cursorId }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = plans.length > limit;
|
||||
const items = plans.slice(0, limit);
|
||||
|
||||
// Check which plans the current user has liked
|
||||
const planIds = items.map(p => p.id);
|
||||
const myLikes = await fastify.prisma.like.findMany({
|
||||
where: { userId: request.userId, planId: { in: planIds } },
|
||||
select: { planId: true },
|
||||
});
|
||||
const likedSet = new Set(myLikes.map(l => l.planId));
|
||||
|
||||
const feedItems: FeedItemResponse[] = items.map(plan => ({
|
||||
plan: {
|
||||
id: plan.id,
|
||||
name: plan.name,
|
||||
status: plan.status,
|
||||
executionMode: plan.executionMode,
|
||||
gameId: plan.gameId,
|
||||
createdAt: plan.createdAt.toISOString(),
|
||||
updatedAt: plan.updatedAt.toISOString(),
|
||||
destinations: plan.destinations.map(d => ({
|
||||
id: d.id,
|
||||
serviceId: d.serviceId,
|
||||
linkedAccountId: d.linkedAccountId,
|
||||
title: d.title,
|
||||
description: d.description,
|
||||
privacyStatus: d.privacyStatus,
|
||||
gameId: d.gameId,
|
||||
tags: d.tags,
|
||||
rtmpUrl: '',
|
||||
streamKey: '',
|
||||
broadcastId: d.broadcastId,
|
||||
status: d.status,
|
||||
})),
|
||||
user: plan.user,
|
||||
},
|
||||
likeCount: plan._count.likes,
|
||||
commentCount: 0, // portal comments are ephemeral via WebSocket
|
||||
isLiked: likedSet.has(plan.id),
|
||||
}));
|
||||
|
||||
const response: FeedResponse = {
|
||||
items: feedItems,
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export default feedRoutes;
|
||||
180
src/routes/social/following.ts
Normal file
180
src/routes/social/following.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { PublicUserResponse, FollowListResponse } from '../../types/api.js';
|
||||
|
||||
const followingRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /social/follow/:userId — follow a user
|
||||
fastify.post<{ Params: { userId: string } }>('/social/follow/:userId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
if (targetId === request.userId) {
|
||||
throw new AppError(400, 'Cannot follow yourself');
|
||||
}
|
||||
|
||||
const target = await fastify.prisma.user.findUnique({ where: { id: targetId } });
|
||||
if (!target) throw new AppError(404, 'User not found');
|
||||
|
||||
await fastify.prisma.follow.upsert({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /social/follow/:userId — unfollow a user
|
||||
fastify.delete<{ Params: { userId: string } }>('/social/follow/:userId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
await fastify.prisma.follow.deleteMany({
|
||||
where: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// GET /social/followers — list my followers
|
||||
fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/social/followers', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursor = request.query.cursor;
|
||||
|
||||
const follows = await fastify.prisma.follow.findMany({
|
||||
where: { followingId: request.userId },
|
||||
include: {
|
||||
follower: {
|
||||
include: {
|
||||
_count: { select: { followers: true, following: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = follows.length > limit;
|
||||
const items = follows.slice(0, limit);
|
||||
|
||||
// Check which ones the current user follows back
|
||||
const followerIds = items.map(f => f.followerId);
|
||||
const myFollowing = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId, followingId: { in: followerIds } },
|
||||
select: { followingId: true },
|
||||
});
|
||||
const followingSet = new Set(myFollowing.map(f => f.followingId));
|
||||
|
||||
const users: PublicUserResponse[] = items.map(f => ({
|
||||
id: f.follower.id,
|
||||
displayName: f.follower.displayName,
|
||||
avatarUrl: f.follower.avatarUrl,
|
||||
bio: f.follower.bio,
|
||||
followerCount: f.follower._count.followers,
|
||||
followingCount: f.follower._count.following,
|
||||
isFollowing: followingSet.has(f.followerId),
|
||||
}));
|
||||
|
||||
const response: FollowListResponse = {
|
||||
users,
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /social/following — list who I follow
|
||||
fastify.get<{ Querystring: { cursor?: string; limit?: string } }>('/social/following', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const limit = Math.min(parseInt(request.query.limit || '20', 10), 50);
|
||||
const cursor = request.query.cursor;
|
||||
|
||||
const follows = await fastify.prisma.follow.findMany({
|
||||
where: { followerId: request.userId },
|
||||
include: {
|
||||
following: {
|
||||
include: {
|
||||
_count: { select: { followers: true, following: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit + 1,
|
||||
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
|
||||
});
|
||||
|
||||
const hasMore = follows.length > limit;
|
||||
const items = follows.slice(0, limit);
|
||||
|
||||
const users: PublicUserResponse[] = items.map(f => ({
|
||||
id: f.following.id,
|
||||
displayName: f.following.displayName,
|
||||
avatarUrl: f.following.avatarUrl,
|
||||
bio: f.following.bio,
|
||||
followerCount: f.following._count.followers,
|
||||
followingCount: f.following._count.following,
|
||||
isFollowing: true,
|
||||
}));
|
||||
|
||||
const response: FollowListResponse = {
|
||||
users,
|
||||
nextCursor: hasMore ? items[items.length - 1].id : null,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
|
||||
// GET /social/users/:userId — public profile
|
||||
fastify.get<{ Params: { userId: string } }>('/social/users/:userId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const { userId: targetId } = request.params;
|
||||
|
||||
const user = await fastify.prisma.user.findUnique({
|
||||
where: { id: targetId },
|
||||
include: {
|
||||
_count: { select: { followers: true, following: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!user) throw new AppError(404, 'User not found');
|
||||
|
||||
const isFollowing = await fastify.prisma.follow.findUnique({
|
||||
where: {
|
||||
followerId_followingId: {
|
||||
followerId: request.userId,
|
||||
followingId: targetId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const response: PublicUserResponse = {
|
||||
id: user.id,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
bio: user.bio,
|
||||
followerCount: user._count.followers,
|
||||
followingCount: user._count.following,
|
||||
isFollowing: !!isFollowing,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export default followingRoutes;
|
||||
75
src/routes/social/likes.ts
Normal file
75
src/routes/social/likes.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { FastifyPluginAsync } from 'fastify';
|
||||
import { requireAuth } from '../../middleware/require-auth.js';
|
||||
import { AppError } from '../../plugins/error-handler.js';
|
||||
import type { LikeStatusResponse } from '../../types/api.js';
|
||||
|
||||
const likesRoutes: FastifyPluginAsync = async (fastify) => {
|
||||
// POST /social/likes/:planId — like a plan
|
||||
fastify.post<{ Params: { planId: string } }>('/social/likes/:planId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { planId } = request.params;
|
||||
|
||||
const plan = await fastify.prisma.streamPlan.findUnique({ where: { id: planId } });
|
||||
if (!plan) throw new AppError(404, 'Plan not found');
|
||||
|
||||
await fastify.prisma.like.upsert({
|
||||
where: {
|
||||
userId_planId: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
},
|
||||
update: {},
|
||||
create: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// DELETE /social/likes/:planId — unlike a plan
|
||||
fastify.delete<{ Params: { planId: string } }>('/social/likes/:planId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request, reply) => {
|
||||
const { planId } = request.params;
|
||||
|
||||
await fastify.prisma.like.deleteMany({
|
||||
where: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
});
|
||||
|
||||
reply.status(200).send({ success: true });
|
||||
});
|
||||
|
||||
// GET /social/likes/:planId — like count + isLiked
|
||||
fastify.get<{ Params: { planId: string } }>('/social/likes/:planId', {
|
||||
preHandler: [requireAuth],
|
||||
}, async (request) => {
|
||||
const { planId } = request.params;
|
||||
|
||||
const [count, myLike] = await Promise.all([
|
||||
fastify.prisma.like.count({ where: { planId } }),
|
||||
fastify.prisma.like.findUnique({
|
||||
where: {
|
||||
userId_planId: {
|
||||
userId: request.userId,
|
||||
planId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const response: LikeStatusResponse = {
|
||||
count,
|
||||
isLiked: !!myLike,
|
||||
};
|
||||
return response;
|
||||
});
|
||||
};
|
||||
|
||||
export default likesRoutes;
|
||||
@@ -73,8 +73,16 @@ async function getDecryptedToken(
|
||||
};
|
||||
}
|
||||
|
||||
interface PortalSubscriber {
|
||||
userId: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
socket: WebSocket;
|
||||
}
|
||||
|
||||
export class ChatManager {
|
||||
private sessions = new Map<string, ChatSession>(); // key: `${userId}:${planId}`
|
||||
private portalSubscribers = new Map<string, Set<PortalSubscriber>>(); // key: planId
|
||||
private prisma: PrismaClient;
|
||||
private logger: FastifyBaseLogger;
|
||||
|
||||
@@ -184,6 +192,106 @@ export class ChatManager {
|
||||
});
|
||||
}
|
||||
|
||||
async subscribePortalChat(planId: string, userId: string, socket: WebSocket): Promise<void> {
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
if (!this.portalSubscribers.has(planId)) {
|
||||
this.portalSubscribers.set(planId, new Set());
|
||||
}
|
||||
|
||||
// Remove existing subscription for this user+plan
|
||||
const subs = this.portalSubscribers.get(planId)!;
|
||||
for (const sub of subs) {
|
||||
if (sub.userId === userId) {
|
||||
subs.delete(sub);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
subs.add({
|
||||
userId,
|
||||
displayName: user.displayName,
|
||||
avatarUrl: user.avatarUrl,
|
||||
socket,
|
||||
});
|
||||
|
||||
this.logger.info({ planId, userId }, 'Portal chat subscribed');
|
||||
}
|
||||
|
||||
async handlePortalComment(planId: string, userId: string, text: string): Promise<void> {
|
||||
const user = await (this.prisma as any).user.findUnique({ where: { id: userId } });
|
||||
if (!user) return;
|
||||
|
||||
const message = {
|
||||
id: `portal-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`,
|
||||
authorName: user.displayName,
|
||||
authorImageUrl: user.avatarUrl,
|
||||
text,
|
||||
timestamp: Date.now(),
|
||||
isModerator: false,
|
||||
isBroadcaster: false,
|
||||
color: '#00BCD4',
|
||||
};
|
||||
|
||||
// Broadcast to all portal subscribers watching this plan
|
||||
const subs = this.portalSubscribers.get(planId);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
this.sendToSocket(sub.socket, {
|
||||
type: 'chat_message',
|
||||
planId,
|
||||
service: 'PORTAL',
|
||||
destinationId: 'portal',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also send to the plan owner's chat session (so it shows up in their Android app)
|
||||
const plan = await (this.prisma as any).streamPlan.findUnique({ where: { id: planId } });
|
||||
if (plan) {
|
||||
const ownerSession = this.sessions.get(`${plan.userId}:${planId}`);
|
||||
if (ownerSession) {
|
||||
this.sendToSocket(ownerSession.socket, {
|
||||
type: 'chat_message',
|
||||
planId,
|
||||
service: 'PORTAL',
|
||||
destinationId: 'portal',
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleLike(planId: string, userId: string): Promise<void> {
|
||||
// Toggle like in DB
|
||||
const existing = await (this.prisma as any).like.findUnique({
|
||||
where: { userId_planId: { userId, planId } },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
await (this.prisma as any).like.delete({ where: { id: existing.id } });
|
||||
} else {
|
||||
await (this.prisma as any).like.create({ data: { userId, planId } });
|
||||
}
|
||||
|
||||
const count = await (this.prisma as any).like.count({ where: { planId } });
|
||||
|
||||
// Broadcast like update to all portal subscribers
|
||||
const subs = this.portalSubscribers.get(planId);
|
||||
if (subs) {
|
||||
for (const sub of subs) {
|
||||
this.sendToSocket(sub.socket, {
|
||||
type: 'like_update',
|
||||
planId,
|
||||
count,
|
||||
isLiked: sub.userId === userId ? !existing : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async stopChat(planId: string, userId: string): Promise<void> {
|
||||
const sessionKey = `${userId}:${planId}`;
|
||||
const session = this.sessions.get(sessionKey);
|
||||
@@ -222,6 +330,18 @@ export class ChatManager {
|
||||
this.sessions.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up portal subscriptions for this socket
|
||||
for (const [planId, subs] of this.portalSubscribers) {
|
||||
for (const sub of subs) {
|
||||
if (sub.socket === socket) {
|
||||
subs.delete(sub);
|
||||
}
|
||||
}
|
||||
if (subs.size === 0) {
|
||||
this.portalSubscribers.delete(planId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async startYouTubeChat(session: ChatSession, dest: any): Promise<void> {
|
||||
|
||||
66
src/services/meta-web-auth.service.ts
Normal file
66
src/services/meta-web-auth.service.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { config } from '../config.js';
|
||||
|
||||
interface FacebookTokenResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
}
|
||||
|
||||
interface FacebookProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
email?: string;
|
||||
picture?: { data?: { url?: string } };
|
||||
}
|
||||
|
||||
export async function exchangeFacebookCode(code: string): Promise<{ accessToken: string; expiresIn: number }> {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.metaWeb.clientId,
|
||||
client_secret: config.metaWeb.clientSecret,
|
||||
redirect_uri: config.metaWeb.redirectUri,
|
||||
code,
|
||||
});
|
||||
|
||||
const res = await fetch(`https://graph.facebook.com/v19.0/oauth/access_token?${params}`);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Facebook token exchange failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as FacebookTokenResponse;
|
||||
return { accessToken: data.access_token, expiresIn: data.expires_in };
|
||||
}
|
||||
|
||||
export async function fetchFacebookProfile(accessToken: string): Promise<{
|
||||
facebookId: string;
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
}> {
|
||||
const res = await fetch(
|
||||
`https://graph.facebook.com/v19.0/me?fields=id,name,picture.type(large)&access_token=${accessToken}`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`Facebook profile fetch failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as FacebookProfile;
|
||||
return {
|
||||
facebookId: data.id,
|
||||
displayName: data.name,
|
||||
email: data.email ?? null,
|
||||
avatarUrl: data.picture?.data?.url ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function getFacebookOAuthUrl(state: string): string {
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.metaWeb.clientId,
|
||||
redirect_uri: config.metaWeb.redirectUri,
|
||||
state,
|
||||
scope: 'public_profile',
|
||||
response_type: 'code',
|
||||
});
|
||||
return `https://www.facebook.com/v19.0/dialog/oauth?${params}`;
|
||||
}
|
||||
@@ -20,6 +20,31 @@ export interface UserProfileResponse {
|
||||
displayName: string;
|
||||
email: string | null;
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
isPublic: boolean;
|
||||
hasQuestLink: boolean;
|
||||
hasFacebookLink: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateProfileBody {
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
isPublic?: boolean;
|
||||
}
|
||||
|
||||
export interface MetaWebCallbackBody {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export interface LinkQuestBody {
|
||||
metaId: string;
|
||||
nonce?: string;
|
||||
}
|
||||
|
||||
export interface LinkFacebookBody {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// ── Providers ────────────────────────────────────────────
|
||||
@@ -113,3 +138,36 @@ export interface PreparedDestination {
|
||||
streamKey: string;
|
||||
broadcastId: string;
|
||||
}
|
||||
|
||||
// ── Social ──────────────────────────────────────────────
|
||||
export interface PublicUserResponse {
|
||||
id: string;
|
||||
displayName: string;
|
||||
avatarUrl: string | null;
|
||||
bio: string;
|
||||
followerCount: number;
|
||||
followingCount: number;
|
||||
isFollowing: boolean;
|
||||
}
|
||||
|
||||
export interface FeedItemResponse {
|
||||
plan: StreamPlanResponse & { user: { id: string; displayName: string; avatarUrl: string | null } };
|
||||
likeCount: number;
|
||||
commentCount: number;
|
||||
isLiked: boolean;
|
||||
}
|
||||
|
||||
export interface FeedResponse {
|
||||
items: FeedItemResponse[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
export interface LikeStatusResponse {
|
||||
count: number;
|
||||
isLiked: boolean;
|
||||
}
|
||||
|
||||
export interface FollowListResponse {
|
||||
users: PublicUserResponse[];
|
||||
nextCursor: string | null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user