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:
|
environment:
|
||||||
- DATABASE_URL=file:/app/data/lck.db
|
- DATABASE_URL=file:/app/data/lck.db
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
|
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:3000/health"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 10s
|
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 {
|
model User {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
metaId String @unique
|
metaId String? @unique
|
||||||
|
facebookId String? @unique
|
||||||
displayName String
|
displayName String
|
||||||
email String?
|
email String?
|
||||||
avatarUrl String?
|
avatarUrl String?
|
||||||
|
bio String @default("")
|
||||||
|
isPublic Boolean @default(false)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
linkedAccounts LinkedAccount[]
|
linkedAccounts LinkedAccount[]
|
||||||
streamPlans StreamPlan[]
|
streamPlans StreamPlan[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
|
followers Follow[] @relation("following")
|
||||||
|
following Follow[] @relation("follower")
|
||||||
|
likes Like[]
|
||||||
}
|
}
|
||||||
|
|
||||||
model Session {
|
model Session {
|
||||||
@@ -64,6 +70,7 @@ model StreamPlan {
|
|||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
destinations StreamDestination[]
|
destinations StreamDestination[]
|
||||||
|
likes Like[]
|
||||||
|
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
}
|
}
|
||||||
@@ -86,3 +93,28 @@ model StreamDestination {
|
|||||||
|
|
||||||
@@index([planId])
|
@@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 authPlugin from './plugins/auth.js';
|
||||||
import healthRoutes from './routes/health.js';
|
import healthRoutes from './routes/health.js';
|
||||||
import metaAuthRoutes from './routes/auth/meta.js';
|
import metaAuthRoutes from './routes/auth/meta.js';
|
||||||
|
import metaWebRoutes from './routes/auth/meta-web.js';
|
||||||
import sessionRoutes from './routes/auth/session.js';
|
import sessionRoutes from './routes/auth/session.js';
|
||||||
|
import linkRoutes from './routes/auth/link.js';
|
||||||
import accountRoutes from './routes/providers/accounts.js';
|
import accountRoutes from './routes/providers/accounts.js';
|
||||||
import youtubeRoutes from './routes/providers/youtube.js';
|
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 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 { createChatRoutes } from './routes/chat/websocket.js';
|
||||||
import { ChatManager } from './services/chat-manager.service.js';
|
import { ChatManager } from './services/chat-manager.service.js';
|
||||||
import { config } from './config.js';
|
import { config } from './config.js';
|
||||||
@@ -24,8 +29,11 @@ export async function buildApp() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Plugins
|
// Plugins — support comma-separated CORS origins
|
||||||
await app.register(cors, { origin: config.corsOrigin });
|
const corsOrigins = config.corsOrigin === '*'
|
||||||
|
? '*'
|
||||||
|
: config.corsOrigin.split(',').map(s => s.trim());
|
||||||
|
await app.register(cors, { origin: corsOrigins });
|
||||||
await app.register(rateLimit, {
|
await app.register(rateLimit, {
|
||||||
global: true,
|
global: true,
|
||||||
max: 100,
|
max: 100,
|
||||||
@@ -42,12 +50,17 @@ export async function buildApp() {
|
|||||||
// Routes
|
// Routes
|
||||||
await app.register(healthRoutes);
|
await app.register(healthRoutes);
|
||||||
await app.register(metaAuthRoutes);
|
await app.register(metaAuthRoutes);
|
||||||
|
await app.register(metaWebRoutes);
|
||||||
await app.register(sessionRoutes);
|
await app.register(sessionRoutes);
|
||||||
|
await app.register(linkRoutes);
|
||||||
await app.register(accountRoutes);
|
await app.register(accountRoutes);
|
||||||
await app.register(youtubeRoutes);
|
await app.register(youtubeRoutes);
|
||||||
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(followingRoutes);
|
||||||
|
await app.register(feedRoutes);
|
||||||
|
await app.register(likesRoutes);
|
||||||
await app.register(createChatRoutes(chatManager));
|
await app.register(createChatRoutes(chatManager));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
|
|||||||
@@ -38,6 +38,13 @@ export const config = {
|
|||||||
redirectUri: required('TWITCH_REDIRECT_URI'),
|
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'),
|
appScheme: optional('APP_SCHEME', 'com.omixlab.lckcontrol'),
|
||||||
corsOrigin: optional('CORS_ORIGIN', '*'),
|
corsOrigin: optional('CORS_ORIGIN', '*'),
|
||||||
} as const;
|
} 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 { requireAuth } from '../../middleware/require-auth.js';
|
||||||
import { config } from '../../config.js';
|
import { config } from '../../config.js';
|
||||||
import { AppError } from '../../plugins/error-handler.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) => {
|
const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
// POST /auth/refresh — rotate refresh token, issue new JWT
|
// POST /auth/refresh — rotate refresh token, issue new JWT
|
||||||
@@ -83,6 +83,49 @@ const sessionRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
displayName: user.displayName,
|
displayName: user.displayName,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
avatarUrl: user.avatarUrl,
|
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);
|
reply.status(200).send(response);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { verifyAccessToken } from '../../plugins/auth.js';
|
|||||||
import { ChatManager } from '../../services/chat-manager.service.js';
|
import { ChatManager } from '../../services/chat-manager.service.js';
|
||||||
|
|
||||||
interface ChatWsMessage {
|
interface ChatWsMessage {
|
||||||
type: 'subscribe' | 'unsubscribe' | 'send_message';
|
type: 'subscribe' | 'unsubscribe' | 'send_message' | 'subscribe_portal' | 'send_portal_comment' | 'like';
|
||||||
planId?: string;
|
planId?: string;
|
||||||
destinationId?: string;
|
destinationId?: string;
|
||||||
text?: string;
|
text?: string;
|
||||||
@@ -63,6 +63,24 @@ export function createChatRoutes(chatManager: ChatManager): FastifyPluginAsync {
|
|||||||
}
|
}
|
||||||
break;
|
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:
|
default:
|
||||||
socket.send(JSON.stringify({ type: 'error', error: `Unknown message type: ${msg.type}` }));
|
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 { AppError } from '../../plugins/error-handler.js';
|
||||||
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
||||||
|
|
||||||
// In-memory CSRF state store (state → { userId, expiresAt })
|
// In-memory CSRF state store (state → { userId, expiresAt, source })
|
||||||
const pendingStates = new Map<string, { userId: string; expiresAt: number }>();
|
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
|
||||||
|
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
@@ -23,13 +23,15 @@ setInterval(() => {
|
|||||||
|
|
||||||
const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
// GET /providers/twitch/auth-url — get OAuth URL with CSRF state
|
// 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],
|
preHandler: [requireAuth],
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
|
const source = request.query.source;
|
||||||
const state = randomUUID();
|
const state = randomUUID();
|
||||||
pendingStates.set(state, {
|
pendingStates.set(state, {
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000,
|
expiresAt: Date.now() + 10 * 60 * 1000,
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: AuthUrlResponse = {
|
const response: AuthUrlResponse = {
|
||||||
@@ -39,21 +41,27 @@ const twitchRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return response;
|
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 } }>(
|
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||||
'/providers/twitch/callback-redirect',
|
'/providers/twitch/callback-redirect',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { code, state, error } = request.query;
|
const { code, state, error } = request.query;
|
||||||
|
|
||||||
|
const pending = state ? pendingStates.get(state) : undefined;
|
||||||
|
const isWeb = pending?.source === 'web';
|
||||||
|
|
||||||
if (error || !code || !state) {
|
if (error || !code || !state) {
|
||||||
reply.status(302).redirect(
|
const target = isWeb
|
||||||
`${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`,
|
? `${config.portalUrl}/accounts/callback/twitch?error=${error || 'missing_params'}`
|
||||||
);
|
: `${config.appScheme}://twitch/callback?error=${error || 'missing_params'}`;
|
||||||
|
reply.status(302).redirect(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.status(302).redirect(
|
const target = isWeb
|
||||||
`${config.appScheme}://twitch/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
? `${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 { AppError } from '../../plugins/error-handler.js';
|
||||||
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
import type { AuthUrlResponse, ProviderCallbackBody, LinkedAccountResponse } from '../../types/api.js';
|
||||||
|
|
||||||
// In-memory CSRF state store (state → { userId, expiresAt })
|
// In-memory CSRF state store (state → { userId, expiresAt, source })
|
||||||
const pendingStates = new Map<string, { userId: string; expiresAt: number }>();
|
const pendingStates = new Map<string, { userId: string; expiresAt: number; source?: string }>();
|
||||||
|
|
||||||
// Clean expired states every 5 minutes
|
// Clean expired states every 5 minutes
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
@@ -24,13 +24,15 @@ setInterval(() => {
|
|||||||
|
|
||||||
const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
||||||
// GET /providers/youtube/auth-url — get OAuth URL with CSRF state
|
// 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],
|
preHandler: [requireAuth],
|
||||||
}, async (request) => {
|
}, async (request) => {
|
||||||
|
const source = request.query.source;
|
||||||
const state = randomUUID();
|
const state = randomUUID();
|
||||||
pendingStates.set(state, {
|
pendingStates.set(state, {
|
||||||
userId: request.userId,
|
userId: request.userId,
|
||||||
expiresAt: Date.now() + 10 * 60 * 1000, // 10 min
|
expiresAt: Date.now() + 10 * 60 * 1000, // 10 min
|
||||||
|
source,
|
||||||
});
|
});
|
||||||
|
|
||||||
const response: AuthUrlResponse = {
|
const response: AuthUrlResponse = {
|
||||||
@@ -40,21 +42,28 @@ const youtubeRoutes: FastifyPluginAsync = async (fastify) => {
|
|||||||
return response;
|
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 } }>(
|
fastify.get<{ Querystring: { code?: string; state?: string; error?: string } }>(
|
||||||
'/providers/youtube/callback-redirect',
|
'/providers/youtube/callback-redirect',
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { code, state, error } = request.query;
|
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) {
|
if (error || !code || !state) {
|
||||||
reply.status(302).redirect(
|
const target = isWeb
|
||||||
`${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`,
|
? `${config.portalUrl}/accounts/callback/youtube?error=${error || 'missing_params'}`
|
||||||
);
|
: `${config.appScheme}://youtube/callback?error=${error || 'missing_params'}`;
|
||||||
|
reply.status(302).redirect(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reply.status(302).redirect(
|
const target = isWeb
|
||||||
`${config.appScheme}://youtube/callback?code=${encodeURIComponent(code)}&state=${encodeURIComponent(state)}`,
|
? `${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 {
|
export class ChatManager {
|
||||||
private sessions = new Map<string, ChatSession>(); // key: `${userId}:${planId}`
|
private sessions = new Map<string, ChatSession>(); // key: `${userId}:${planId}`
|
||||||
|
private portalSubscribers = new Map<string, Set<PortalSubscriber>>(); // key: planId
|
||||||
private prisma: PrismaClient;
|
private prisma: PrismaClient;
|
||||||
private logger: FastifyBaseLogger;
|
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> {
|
async stopChat(planId: string, userId: string): Promise<void> {
|
||||||
const sessionKey = `${userId}:${planId}`;
|
const sessionKey = `${userId}:${planId}`;
|
||||||
const session = this.sessions.get(sessionKey);
|
const session = this.sessions.get(sessionKey);
|
||||||
@@ -222,6 +330,18 @@ export class ChatManager {
|
|||||||
this.sessions.delete(key);
|
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> {
|
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;
|
displayName: string;
|
||||||
email: string | null;
|
email: string | null;
|
||||||
avatarUrl: 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 ────────────────────────────────────────────
|
// ── Providers ────────────────────────────────────────────
|
||||||
@@ -113,3 +138,36 @@ export interface PreparedDestination {
|
|||||||
streamKey: string;
|
streamKey: string;
|
||||||
broadcastId: 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