diff --git a/apps/server/package.json b/apps/server/package.json index 9aba4a9..3193a38 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -38,6 +38,7 @@ "pino": "^10.1.0", "pino-pretty": "^13.1.3", "razorpay": "^2.9.6", + "resend": "^6.9.3", "ws": "^8.19.0", "zod": "^3.25.76" }, diff --git a/apps/server/prisma/schema.prisma b/apps/server/prisma/schema.prisma index 0576abd..52b6525 100644 --- a/apps/server/prisma/schema.prisma +++ b/apps/server/prisma/schema.prisma @@ -16,8 +16,10 @@ model User { avatarUrl String? email String? tier Tier @default(FREE) - privacyMode Boolean @default(false) - createdAt DateTime @default(now()) + privacyMode Boolean @default(false) + customStatus String? @db.VarChar(50) + ghostMode Boolean @default(false) + createdAt DateTime @default(now()) updatedAt DateTime @updatedAt // Phase 5: Razorpay Billing diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 0403aff..2da463d 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -51,6 +51,15 @@ const envSchema = z SLACK_CLIENT_SECRET: z.string().trim().min(1).optional(), SLACK_SIGNING_SECRET: z.string().trim().min(1).optional(), + /* Auth0 SSO/SAML (Optional - TEAM tier) */ + AUTH0_DOMAIN: z.string().trim().min(1).optional(), + AUTH0_CLIENT_ID: z.string().trim().min(1).optional(), + AUTH0_CLIENT_SECRET: z.string().trim().min(1).optional(), + AUTH0_CALLBACK_URL: z.string().url().optional(), + + /* Resend Email (Optional) */ + RESEND_API_KEY: z.string().trim().min(1).optional(), + /* Razorpay Billing (Required for billing features) */ RAZORPAY_KEY_ID: z.string().trim().min(1, 'RAZORPAY_KEY_ID is required'), RAZORPAY_KEY_SECRET: z.string().trim().min(1, 'RAZORPAY_KEY_SECRET is required'), @@ -91,6 +100,23 @@ const envSchema = z path: ['SLACK_CLIENT_ID'], }); } + + const auth0Vars = [ + val.AUTH0_DOMAIN, + val.AUTH0_CLIENT_ID, + val.AUTH0_CLIENT_SECRET, + val.AUTH0_CALLBACK_URL, + ]; + const auth0AnySet = auth0Vars.some((v) => v != null); + const auth0AllSet = auth0Vars.every((v) => v != null); + if (auth0AnySet && !auth0AllSet) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'If enabling Auth0 SSO, set AUTH0_DOMAIN, AUTH0_CLIENT_ID, AUTH0_CLIENT_SECRET, and AUTH0_CALLBACK_URL', + path: ['AUTH0_DOMAIN'], + }); + } }); /** diff --git a/apps/server/src/routes/auth.ts b/apps/server/src/routes/auth.ts index cc27fe1..0a19ae3 100644 --- a/apps/server/src/routes/auth.ts +++ b/apps/server/src/routes/auth.ts @@ -1,16 +1,23 @@ /** * Authentication Routes * - * GitHub OAuth flow for user authentication. + * GitHub OAuth flow and Auth0 SSO/SAML for user authentication. */ import { z } from 'zod'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -import { isProduction } from '@/config'; +import { env, isProduction } from '@/config'; import { ValidationError } from '@/lib/errors'; import { logger } from '@/lib/logger'; +import { + isAuth0Configured, + getAuth0AuthorizeUrl, + exchangeAuth0Code, + getAuth0UserInfo, +} from '@/services/auth0'; +import { getDb } from '@/services/db'; import { getGitHubAuthUrl, authenticateWithGitHub } from '@/services/github'; /** @@ -212,4 +219,134 @@ export function authRoutes(app: FastifyInstance): void { }); } ); + + // ─── Auth0 SSO Routes ─────────────────────────────────────────────── + + // GET /sso - Redirect to Auth0 for SSO login + app.get('/sso', async (request: FastifyRequest, reply: FastifyReply) => { + if (!isAuth0Configured()) { + return reply.status(501).send({ + error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured on this server' }, + }); + } + + const querySchema = z.object({ + connection: z.string().optional(), + }); + const query = querySchema.safeParse(request.query); + const connection = query.success ? query.data.connection : undefined; + + const state = crypto.randomUUID(); + + reply.setCookie('sso_state', state, { + httpOnly: true, + secure: isProduction, + sameSite: 'lax', + maxAge: 600, + path: '/', + }); + + const authUrl = getAuth0AuthorizeUrl(state, connection); + logger.debug({ connection }, 'Redirecting to Auth0 SSO'); + + return reply.redirect(authUrl); + }); + + // GET /sso/callback - Auth0 SSO callback + app.get( + '/sso/callback', + { + config: { + rateLimit: { max: 10, timeWindow: '1 minute' }, + }, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!isAuth0Configured()) { + return reply.status(501).send({ + error: { code: 'SSO_NOT_CONFIGURED', message: 'SSO is not configured' }, + }); + } + + const callbackSchema = z.object({ + code: z.string().min(1), + state: z.string().min(1), + error: z.string().optional(), + error_description: z.string().optional(), + }); + + const result = callbackSchema.safeParse(request.query); + if (!result.success) { + throw new ValidationError('Invalid SSO callback parameters'); + } + + const { code, state, error, error_description } = result.data; + + /* Validate CSRF state */ + const storedState = request.cookies.sso_state; + if (!state || !storedState || state !== storedState) { + logger.warn('SSO state mismatch'); + return reply.status(400).send({ + error: { code: 'INVALID_STATE', message: 'Invalid SSO state parameter' }, + }); + } + reply.clearCookie('sso_state'); + + if (error) { + logger.warn({ error, error_description }, 'Auth0 SSO error'); + return reply.status(400).send({ + error: { code: 'SSO_ERROR', message: error_description ?? error }, + }); + } + + /* Exchange code for tokens and fetch user info */ + const tokens = await exchangeAuth0Code(code); + const userInfo = await getAuth0UserInfo(tokens.access_token); + + const db = getDb(); + + /* Find existing user by email or create a new one */ + let user = await db.user.findFirst({ + where: { email: userInfo.email }, + }); + + if (!user) { + user = await db.user.create({ + data: { + githubId: `auth0_${userInfo.sub}`, + username: userInfo.nickname ?? userInfo.email.split('@')[0] ?? 'user', + displayName: userInfo.name || null, + avatarUrl: userInfo.picture || null, + email: userInfo.email, + tier: 'TEAM', + }, + }); + + logger.info({ userId: user.id, email: userInfo.email }, 'New SSO user created'); + } + + /* Generate JWT */ + const token = app.jwt.sign( + { + userId: user.id, + username: user.username, + tier: user.tier, + }, + { expiresIn: '7d' } + ); + + logger.info({ userId: user.id }, 'SSO authentication successful'); + + /* Redirect to web app with token */ + const webUrl = new URL('/dashboard', env.WEB_APP_URL); + webUrl.searchParams.set('token', token); + return reply.redirect(webUrl.toString()); + } + ); + + // GET /sso/status - Check if SSO is available + app.get('/sso/status', async (_request: FastifyRequest, reply: FastifyReply) => { + return reply.send({ + enabled: isAuth0Configured(), + }); + }); } diff --git a/apps/server/src/routes/leaderboards.ts b/apps/server/src/routes/leaderboards.ts index 10dccef..2f2fe06 100644 --- a/apps/server/src/routes/leaderboards.ts +++ b/apps/server/src/routes/leaderboards.ts @@ -40,336 +40,466 @@ export function leaderboardRoutes(app: FastifyInstance): void { /** * GET /leaderboards/weekly/time - Top users by coding time this week */ - app.get('/weekly/time', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const { page, limit } = PaginationSchema.parse(request.query); - const redis = getRedis(); + app.get( + '/weekly/time', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const { page, limit } = PaginationSchema.parse(request.query); + const redis = getRedis(); + + const start = (page - 1) * limit; + const end = start + limit - 1; + + // Get leaderboard entries with scores (WITHSCORES returns alternating member, score) + const entries = await redis.zrevrange( + REDIS_KEYS.weeklyLeaderboard('time'), + start, + end, + 'WITHSCORES' + ); + + // Get total count for pagination + const total = await redis.zcard(REDIS_KEYS.weeklyLeaderboard('time')); + + // Get current user's rank + const myRank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId); + + // Extract user IDs from entries (even indices: 0, 2, 4...) + const userIds: string[] = []; + for (let i = 0; i < entries.length; i += 2) { + const entryUserId = entries[i]; + if (entryUserId) { + userIds.push(entryUserId); + } + } - const start = (page - 1) * limit; - const end = start + limit - 1; + if (userIds.length === 0) { + return reply.send({ + data: { + leaderboard: [], + myRank: myRank !== null ? myRank + 1 : null, + pagination: { page, limit, total, hasMore: false }, + }, + }); + } - // Get leaderboard entries with scores (WITHSCORES returns alternating member, score) - const entries = await redis.zrevrange(REDIS_KEYS.weeklyLeaderboard('time'), start, end, 'WITHSCORES'); + // Fetch user details + const users = await db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); - // Get total count for pagination - const total = await redis.zcard(REDIS_KEYS.weeklyLeaderboard('time')); + const userMap = new Map(users.map((u) => [u.id, u])); - // Get current user's rank - const myRank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('time'), userId); + // Get user's friends for "isFriend" flag + const friends = await db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + }); + const friendIds = new Set(friends.map((f) => f.followingId)); + + // Build leaderboard entries + const leaderboard: LeaderboardEntry[] = userIds.map((id, index) => { + const user = userMap.get(id); + const scoreStr = entries[index * 2 + 1]; + const score = scoreStr ? parseInt(scoreStr, 10) : 0; + + return { + rank: start + index + 1, + userId: id, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score, + isFriend: friendIds.has(id), + }; + }); - // Extract user IDs from entries (even indices: 0, 2, 4...) - const userIds: string[] = []; - for (let i = 0; i < entries.length; i += 2) { - const entryUserId = entries[i]; - if (entryUserId) { - userIds.push(entryUserId); - } - } + // Set cache headers (1 minute cache for non-real-time efficiency) + reply.header('Cache-Control', 'public, max-age=60'); - if (userIds.length === 0) { return reply.send({ data: { - leaderboard: [], + leaderboard, myRank: myRank !== null ? myRank + 1 : null, - pagination: { page, limit, total, hasMore: false }, + pagination: { + page, + limit, + total, + hasMore: start + limit < total, + }, }, }); } - - // Fetch user details - const users = await db.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true, displayName: true, avatarUrl: true }, - }); - - const userMap = new Map(users.map((u) => [u.id, u])); - - // Get user's friends for "isFriend" flag - const friends = await db.follow.findMany({ - where: { followerId: userId }, - select: { followingId: true }, - }); - const friendIds = new Set(friends.map((f) => f.followingId)); - - // Build leaderboard entries - const leaderboard: LeaderboardEntry[] = userIds.map((id, index) => { - const user = userMap.get(id); - const scoreStr = entries[index * 2 + 1]; - const score = scoreStr ? parseInt(scoreStr, 10) : 0; - - return { - rank: start + index + 1, - userId: id, - username: user?.username ?? 'Unknown', - displayName: user?.displayName ?? null, - avatarUrl: user?.avatarUrl ?? null, - score, - isFriend: friendIds.has(id), - }; - }); - - // Set cache headers (1 minute cache for non-real-time efficiency) - reply.header('Cache-Control', 'public, max-age=60'); - - return reply.send({ - data: { - leaderboard, - myRank: myRank !== null ? myRank + 1 : null, - pagination: { - page, - limit, - total, - hasMore: start + limit < total, - }, - }, - }); - }); + ); /** * GET /leaderboards/weekly/commits - Top users by commit count this week */ - app.get('/weekly/commits', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const { page, limit } = PaginationSchema.parse(request.query); - const redis = getRedis(); - - const start = (page - 1) * limit; - const end = start + limit - 1; - - // Get leaderboard entries with scores - const entries = await redis.zrevrange(REDIS_KEYS.weeklyLeaderboard('commits'), start, end, 'WITHSCORES'); - const total = await redis.zcard(REDIS_KEYS.weeklyLeaderboard('commits')); - const myRank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('commits'), userId); - - // Extract user IDs - const userIds: string[] = []; - for (let i = 0; i < entries.length; i += 2) { - const entryUserId = entries[i]; - if (entryUserId) { - userIds.push(entryUserId); + app.get( + '/weekly/commits', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const { page, limit } = PaginationSchema.parse(request.query); + const redis = getRedis(); + + const start = (page - 1) * limit; + const end = start + limit - 1; + + // Get leaderboard entries with scores + const entries = await redis.zrevrange( + REDIS_KEYS.weeklyLeaderboard('commits'), + start, + end, + 'WITHSCORES' + ); + const total = await redis.zcard(REDIS_KEYS.weeklyLeaderboard('commits')); + const myRank = await redis.zrevrank(REDIS_KEYS.weeklyLeaderboard('commits'), userId); + + // Extract user IDs + const userIds: string[] = []; + for (let i = 0; i < entries.length; i += 2) { + const entryUserId = entries[i]; + if (entryUserId) { + userIds.push(entryUserId); + } } - } - if (userIds.length === 0) { + if (userIds.length === 0) { + return reply.send({ + data: { + leaderboard: [], + myRank: myRank !== null ? myRank + 1 : null, + pagination: { page, limit, total, hasMore: false }, + }, + }); + } + + // Fetch user details and friends + const [users, friends] = await Promise.all([ + db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }), + db.follow.findMany({ + where: { followerId: userId }, + select: { followingId: true }, + }), + ]); + + const userMap = new Map(users.map((u) => [u.id, u])); + const friendIds = new Set(friends.map((f) => f.followingId)); + + // Build leaderboard entries + const leaderboard: LeaderboardEntry[] = userIds.map((id, index) => { + const user = userMap.get(id); + const scoreStr = entries[index * 2 + 1]; + const score = scoreStr ? parseInt(scoreStr, 10) : 0; + + return { + rank: start + index + 1, + userId: id, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score, + isFriend: friendIds.has(id), + }; + }); + + reply.header('Cache-Control', 'public, max-age=60'); + return reply.send({ data: { - leaderboard: [], + leaderboard, myRank: myRank !== null ? myRank + 1 : null, - pagination: { page, limit, total, hasMore: false }, + pagination: { page, limit, total, hasMore: start + limit < total }, }, }); } + ); - // Fetch user details and friends - const [users, friends] = await Promise.all([ - db.user.findMany({ - where: { id: { in: userIds } }, - select: { id: true, username: true, displayName: true, avatarUrl: true }, - }), - db.follow.findMany({ + /** + * GET /leaderboards/friends - Friends-only leaderboard by coding time + */ + app.get( + '/friends', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const redis = getRedis(); + + // Get user's friends + const friends = await db.follow.findMany({ where: { followerId: userId }, select: { followingId: true }, - }), - ]); - - const userMap = new Map(users.map((u) => [u.id, u])); - const friendIds = new Set(friends.map((f) => f.followingId)); - - // Build leaderboard entries - const leaderboard: LeaderboardEntry[] = userIds.map((id, index) => { - const user = userMap.get(id); - const scoreStr = entries[index * 2 + 1]; - const score = scoreStr ? parseInt(scoreStr, 10) : 0; - - return { - rank: start + index + 1, - userId: id, - username: user?.username ?? 'Unknown', - displayName: user?.displayName ?? null, - avatarUrl: user?.avatarUrl ?? null, - score, - isFriend: friendIds.has(id), - }; - }); + }); + const friendIds = friends.map((f) => f.followingId); - reply.header('Cache-Control', 'public, max-age=60'); + // Include self in the leaderboard + const allIds = [...friendIds, userId]; - return reply.send({ - data: { - leaderboard, - myRank: myRank !== null ? myRank + 1 : null, - pagination: { page, limit, total, hasMore: start + limit < total }, - }, - }); - }); + if (allIds.length === 1) { + // Only self, get own score + const myScore = await redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId); + const myUser = await db.user.findUnique({ + where: { id: userId }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); - /** - * GET /leaderboards/friends - Friends-only leaderboard by coding time - */ - app.get('/friends', { onRequest: [app.authenticate] }, async (request: FastifyRequest, reply: FastifyReply) => { - const { userId } = request.user as { userId: string }; - const redis = getRedis(); - - // Get user's friends - const friends = await db.follow.findMany({ - where: { followerId: userId }, - select: { followingId: true }, - }); - const friendIds = friends.map((f) => f.followingId); - - // Include self in the leaderboard - const allIds = [...friendIds, userId]; - - if (allIds.length === 1) { - // Only self, get own score - const myScore = await redis.zscore(REDIS_KEYS.weeklyLeaderboard('time'), userId); - const myUser = await db.user.findUnique({ - where: { id: userId }, - select: { id: true, username: true, displayName: true, avatarUrl: true }, - }); + if (!myUser || !myScore) { + return reply.send({ data: { leaderboard: [], myRank: null } }); + } + + return reply.send({ + data: { + leaderboard: [ + { + rank: 1, + userId, + username: myUser.username, + displayName: myUser.displayName, + avatarUrl: myUser.avatarUrl, + score: parseInt(myScore, 10), + isFriend: false, + }, + ], + myRank: 1, + }, + }); + } - if (!myUser || !myScore) { + // Get scores for all friends using pipeline + const pipeline = redis.pipeline(); + for (const id of allIds) { + pipeline.zscore(REDIS_KEYS.weeklyLeaderboard('time'), id); + } + const results = await pipeline.exec(); + + // Build score map + const scoreMap: { userId: string; score: number }[] = []; + for (let i = 0; i < allIds.length; i++) { + const id = allIds[i]; + const result = results?.[i]; + const score = result?.[1] ? parseInt(result[1] as string, 10) : 0; + if (id && score > 0) { + scoreMap.push({ userId: id, score }); + } + } + + // Sort by score descending + scoreMap.sort((a, b) => b.score - a.score); + + if (scoreMap.length === 0) { return reply.send({ data: { leaderboard: [], myRank: null } }); } + // Get user details + const users = await db.user.findMany({ + where: { id: { in: scoreMap.map((s) => s.userId) } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + const userMap = new Map(users.map((u) => [u.id, u])); + + // Build leaderboard + const leaderboard: LeaderboardEntry[] = scoreMap.map((s, index) => { + const user = userMap.get(s.userId); + return { + rank: index + 1, + userId: s.userId, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score: s.score, + isFriend: s.userId !== userId, + }; + }); + + const myRank = leaderboard.findIndex((e) => e.userId === userId) + 1; + return reply.send({ data: { - leaderboard: [ - { - rank: 1, - userId, - username: myUser.username, - displayName: myUser.displayName, - avatarUrl: myUser.avatarUrl, - score: parseInt(myScore, 10), - isFriend: false, - }, - ], - myRank: 1, + leaderboard, + myRank: myRank > 0 ? myRank : null, }, }); } + ); - // Get scores for all friends using pipeline - const pipeline = redis.pipeline(); - for (const id of allIds) { - pipeline.zscore(REDIS_KEYS.weeklyLeaderboard('time'), id); - } - const results = await pipeline.exec(); - - // Build score map - const scoreMap: { userId: string; score: number }[] = []; - for (let i = 0; i < allIds.length; i++) { - const id = allIds[i]; - const result = results?.[i]; - const score = result?.[1] ? parseInt(result[1] as string, 10) : 0; - if (id && score > 0) { - scoreMap.push({ userId: id, score }); + /** + * GET /leaderboards/team/:teamId - Team-scoped leaderboard + */ + app.get( + '/team/:teamId', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const paramsSchema = z.object({ teamId: z.string().min(1) }); + const paramsResult = paramsSchema.safeParse(request.params); + + if (!paramsResult.success) { + return reply + .status(400) + .send({ error: { code: 'INVALID_PARAMS', message: 'Invalid team ID' } }); } - } - // Sort by score descending - scoreMap.sort((a, b) => b.score - a.score); + const { teamId } = paramsResult.data; + const redis = getRedis(); - if (scoreMap.length === 0) { - return reply.send({ data: { leaderboard: [], myRank: null } }); - } + // Verify user is a team member + const team = await db.team.findUnique({ + where: { id: teamId }, + select: { ownerId: true }, + }); - // Get user details - const users = await db.user.findMany({ - where: { id: { in: scoreMap.map((s) => s.userId) } }, - select: { id: true, username: true, displayName: true, avatarUrl: true }, - }); - - const userMap = new Map(users.map((u) => [u.id, u])); - - // Build leaderboard - const leaderboard: LeaderboardEntry[] = scoreMap.map((s, index) => { - const user = userMap.get(s.userId); - return { - rank: index + 1, - userId: s.userId, - username: user?.username ?? 'Unknown', - displayName: user?.displayName ?? null, - avatarUrl: user?.avatarUrl ?? null, - score: s.score, - isFriend: s.userId !== userId, - }; - }); + if (!team) { + return reply.status(404).send({ error: { code: 'NOT_FOUND', message: 'Team not found' } }); + } - const myRank = leaderboard.findIndex((e) => e.userId === userId) + 1; + const isOwner = team.ownerId === userId; + if (!isOwner) { + const membership = await db.teamMember.findUnique({ + where: { userId_teamId: { userId, teamId } }, + }); + if (!membership) { + return reply + .status(403) + .send({ error: { code: 'FORBIDDEN', message: 'Not a team member' } }); + } + } + + // Get all team members + const members = await db.teamMember.findMany({ + where: { teamId }, + select: { userId: true }, + }); - return reply.send({ - data: { - leaderboard, - myRank: myRank > 0 ? myRank : null, - }, - }); - }); + const memberIds = [...members.map((m) => m.userId), team.ownerId]; + const uniqueIds = [...new Set(memberIds)]; + + // Get scores for all members from Redis + const pipeline = redis.pipeline(); + for (const id of uniqueIds) { + pipeline.zscore(REDIS_KEYS.weeklyLeaderboard('time'), id); + } + const results = await pipeline.exec(); + + // Build score map + const scoreEntries: { userId: string; score: number }[] = []; + for (let i = 0; i < uniqueIds.length; i++) { + const id = uniqueIds[i]; + const result = results?.[i]; + const score = result?.[1] ? parseInt(result[1] as string, 10) : 0; + if (id) { + scoreEntries.push({ userId: id, score }); + } + } + + // Sort by score descending + scoreEntries.sort((a, b) => b.score - a.score); + + // Get user details + const users = await db.user.findMany({ + where: { id: { in: uniqueIds } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }); + + const userMap = new Map(users.map((u) => [u.id, u])); + + const leaderboard = scoreEntries.map((s, index) => { + const user = userMap.get(s.userId); + return { + rank: index + 1, + userId: s.userId, + username: user?.username ?? 'Unknown', + displayName: user?.displayName ?? null, + avatarUrl: user?.avatarUrl ?? null, + score: s.score, + isFriend: false, + }; + }); + + const myRank = leaderboard.findIndex((e) => e.userId === userId) + 1; + + return reply.send({ + data: { + leaderboard, + myRank: myRank > 0 ? myRank : null, + }, + }); + } + ); /** * GET /leaderboards/network-activity - Current network activity (heatmap data) */ - app.get('/network-activity', { onRequest: [app.authenticate] }, async (_request: FastifyRequest, reply: FastifyReply) => { - const redis = getRedis(); - const currentMinute = Math.floor(Date.now() / 60000); - - // Get last 5 minutes of activity using pipeline - const pipeline = redis.pipeline(); - for (let i = 0; i < 5; i++) { - pipeline.hgetall(REDIS_KEYS.networkIntensity(currentMinute - i)); - } - const results = await pipeline.exec(); - - // Aggregate active users and languages - let totalActiveUsers = 0; - const languageCounts = new Map(); - - if (results) { - for (const result of results) { - const data = result[1] as Record | null; - if (data) { - if (data.count) { - totalActiveUsers += parseInt(data.count, 10); - } - // Aggregate language counts - for (const [key, value] of Object.entries(data)) { - if (key.startsWith('lang:')) { - const lang = key.slice(5); - languageCounts.set(lang, (languageCounts.get(lang) ?? 0) + parseInt(value, 10)); + app.get( + '/network-activity', + { onRequest: [app.authenticate] }, + async (_request: FastifyRequest, reply: FastifyReply) => { + const redis = getRedis(); + const currentMinute = Math.floor(Date.now() / 60000); + + // Get last 5 minutes of activity using pipeline + const pipeline = redis.pipeline(); + for (let i = 0; i < 5; i++) { + pipeline.hgetall(REDIS_KEYS.networkIntensity(currentMinute - i)); + } + const results = await pipeline.exec(); + + // Aggregate active users and languages + let totalActiveUsers = 0; + const languageCounts = new Map(); + + if (results) { + for (const result of results) { + const data = result[1] as Record | null; + if (data) { + if (data.count) { + totalActiveUsers += parseInt(data.count, 10); + } + // Aggregate language counts + for (const [key, value] of Object.entries(data)) { + if (key.startsWith('lang:')) { + const lang = key.slice(5); + languageCounts.set(lang, (languageCounts.get(lang) ?? 0) + parseInt(value, 10)); + } } } } } - } - // Calculate intensity (0-100 scale) - const averageIntensity = Math.min(100, totalActiveUsers * 10); - const isHot = totalActiveUsers >= NETWORK_HOT_THRESHOLD; - - // Get top languages - const topLanguages = Array.from(languageCounts.entries()) - .sort((a, b) => b[1] - a[1]) - .slice(0, 5) - .map(([language, count]) => ({ language, count })); - - const networkActivity: NetworkActivity = { - totalActiveUsers, - averageIntensity, - isHot, - message: isHot - ? 'Your network is 🔥 active right now!' - : `${String(totalActiveUsers)} developer${totalActiveUsers !== 1 ? 's' : ''} coding`, - }; - - // Short cache for real-time feel - reply.header('Cache-Control', 'public, max-age=10'); - - return reply.send({ - data: { - ...networkActivity, - topLanguages, - }, - }); - }); + // Calculate intensity (0-100 scale) + const averageIntensity = Math.min(100, totalActiveUsers * 10); + const isHot = totalActiveUsers >= NETWORK_HOT_THRESHOLD; + + // Get top languages + const topLanguages = Array.from(languageCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 5) + .map(([language, count]) => ({ language, count })); + + const networkActivity: NetworkActivity = { + totalActiveUsers, + averageIntensity, + isHot, + message: isHot + ? 'Your network is 🔥 active right now!' + : `${String(totalActiveUsers)} developer${totalActiveUsers !== 1 ? 's' : ''} coding`, + }; + + // Short cache for real-time feel + reply.header('Cache-Control', 'public, max-age=10'); + + return reply.send({ + data: { + ...networkActivity, + topLanguages, + }, + }); + } + ); } diff --git a/apps/server/src/routes/stats.ts b/apps/server/src/routes/stats.ts index 6e9af83..7a92b82 100644 --- a/apps/server/src/routes/stats.ts +++ b/apps/server/src/routes/stats.ts @@ -395,6 +395,82 @@ export function statsRoutes(app: FastifyInstance): void { } ); + /** + * GET /stats/history - Get historical stats with time range + * Query params: range=7d|14d|30d (default 7d) + */ + const HistoryQuerySchema = z.object({ + range: z.enum(['7d', '14d', '30d']).default('7d'), + }); + + app.get( + '/history', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const queryResult = HistoryQuerySchema.safeParse(request.query); + const range = queryResult.success ? queryResult.data.range : '7d'; + + const days = range === '30d' ? 30 : range === '14d' ? 14 : 7; + const startDate = new Date(); + startDate.setUTCDate(startDate.getUTCDate() - days); + startDate.setUTCHours(0, 0, 0, 0); + + // Get weekly stats records that fall within the range + const stats = await db.weeklyStats.findMany({ + where: { + userId, + weekStart: { gte: startDate }, + }, + orderBy: { weekStart: 'asc' }, + }); + + // Get daily session data from Redis for more granular view + const redis = getRedis(); + const pipeline = redis.pipeline(); + const dates: string[] = []; + + for (let i = 0; i < days; i++) { + const date = new Date(); + date.setUTCDate(date.getUTCDate() - i); + const dateStr = date.toISOString().split('T')[0] ?? ''; + dates.push(dateStr); + pipeline.get(REDIS_KEYS.dailySession(userId, dateStr)); + } + + const results = await pipeline.exec(); + + const dailySessions = dates + .map((date, i) => ({ + date, + seconds: results?.[i]?.[1] ? parseInt(results[i][1] as string, 10) : 0, + })) + .reverse(); + + const weeklyBreakdown = stats.map((s) => ({ + weekStart: s.weekStart.toISOString(), + totalSeconds: s.totalSeconds, + totalSessions: s.totalSessions, + totalCommits: s.totalCommits, + topLanguage: s.topLanguage, + topProject: s.topProject, + })); + + return reply.send({ + data: { + range, + dailySessions, + weeklyBreakdown, + summary: { + totalSeconds: dailySessions.reduce((sum, d) => sum + d.seconds, 0), + activeDays: dailySessions.filter((d) => d.seconds > 0).length, + totalDays: days, + }, + }, + }); + } + ); + /** * GET /stats/achievements - Get all user achievements (paginated) */ diff --git a/apps/server/src/routes/teams.ts b/apps/server/src/routes/teams.ts index 916881a..5ab9d65 100644 --- a/apps/server/src/routes/teams.ts +++ b/apps/server/src/routes/teams.ts @@ -22,6 +22,7 @@ import { } from '@/lib/errors'; import { logger } from '@/lib/logger'; import { getDb } from '@/services/db'; +import { sendTeamInviteEmail } from '@/services/email'; /* ============================================================================ * Validation Schemas @@ -443,6 +444,212 @@ export function teamRoutes(app: FastifyInstance): void { } ); + // GET /:id/analytics - Team analytics + app.get( + '/:id/analytics', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + const paramsResult = TeamIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid team ID'); + } + + const { id: teamId } = paramsResult.data; + await checkTeamMember(db, teamId, userId); + + // Get all team member IDs + const team = await db.team.findUnique({ + where: { id: teamId }, + select: { ownerId: true }, + }); + + if (!team) throw new NotFoundError('Team', teamId); + + const members = await db.teamMember.findMany({ + where: { teamId }, + select: { userId: true }, + }); + + const memberIds = [...new Set([...members.map((m) => m.userId), team.ownerId])]; + + // Get last 4 weeks of stats for all members + const fourWeeksAgo = new Date(); + fourWeeksAgo.setUTCDate(fourWeeksAgo.getUTCDate() - 28); + fourWeeksAgo.setUTCHours(0, 0, 0, 0); + + const stats = await db.weeklyStats.findMany({ + where: { + userId: { in: memberIds }, + weekStart: { gte: fourWeeksAgo }, + }, + include: { + user: { + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }, + }, + orderBy: { weekStart: 'asc' }, + }); + + // Aggregate by week + const weeklyAggregates = new Map< + string, + { + totalSeconds: number; + totalCommits: number; + activeMembers: number; + languages: Map; + } + >(); + + for (const s of stats) { + const week = s.weekStart.toISOString(); + const existing = weeklyAggregates.get(week) ?? { + totalSeconds: 0, + totalCommits: 0, + activeMembers: 0, + languages: new Map(), + }; + existing.totalSeconds += s.totalSeconds; + existing.totalCommits += s.totalCommits; + existing.activeMembers += 1; + if (s.topLanguage) { + existing.languages.set(s.topLanguage, (existing.languages.get(s.topLanguage) ?? 0) + 1); + } + weeklyAggregates.set(week, existing); + } + + // Per-member summary + const memberStats = new Map< + string, + { + totalSeconds: number; + totalCommits: number; + username: string; + displayName: string | null; + avatarUrl: string | null; + } + >(); + + for (const s of stats) { + const existing = memberStats.get(s.userId) ?? { + totalSeconds: 0, + totalCommits: 0, + username: s.user.username, + displayName: s.user.displayName, + avatarUrl: s.user.avatarUrl, + }; + existing.totalSeconds += s.totalSeconds; + existing.totalCommits += s.totalCommits; + memberStats.set(s.userId, existing); + } + + // Language breakdown + const languageCounts = new Map(); + for (const s of stats) { + if (s.topLanguage) { + languageCounts.set( + s.topLanguage, + (languageCounts.get(s.topLanguage) ?? 0) + s.totalSeconds + ); + } + } + + const topLanguages = Array.from(languageCounts.entries()) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8) + .map(([language, seconds]) => ({ language, seconds })); + + return reply.send({ + data: { + weeklyTrend: Array.from(weeklyAggregates.entries()).map(([week, data]) => ({ + weekStart: week, + totalSeconds: data.totalSeconds, + totalCommits: data.totalCommits, + activeMembers: data.activeMembers, + })), + memberActivity: Array.from(memberStats.entries()) + .map(([memberId, data]) => ({ + userId: memberId, + ...data, + })) + .sort((a, b) => b.totalSeconds - a.totalSeconds), + topLanguages, + summary: { + totalMembers: memberIds.length, + totalSeconds: Array.from(memberStats.values()).reduce( + (sum, m) => sum + m.totalSeconds, + 0 + ), + totalCommits: Array.from(memberStats.values()).reduce( + (sum, m) => sum + m.totalCommits, + 0 + ), + }, + }, + }); + } + ); + + // GET /:id/conflicts - Get active conflicts for a team + app.get( + '/:id/conflicts', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + const paramsResult = TeamIdParamsSchema.safeParse(request.params); + if (!paramsResult.success) { + throw new ValidationError('Invalid team ID'); + } + const { id: teamId } = paramsResult.data; + await checkTeamMember(db, teamId, userId); + + // Scan Redis for all editing keys for this team + const redis = (await import('@/services/redis')).getRedis(); + const pattern = `editing:${teamId}:*`; + const conflicts: { fileHash: string; editors: string[] }[] = []; + let cursor = '0'; + + do { + const [nextCursor, keys] = await redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100); + cursor = nextCursor; + + for (const key of keys) { + const editors = await redis.smembers(key); + if (editors.length > 1) { + const fileHash = key.split(':').pop() ?? ''; + conflicts.push({ fileHash, editors }); + } + } + } while (cursor !== '0'); + + // Resolve editor user details + const allEditorIds = [...new Set(conflicts.flatMap((c) => c.editors))]; + const users = + allEditorIds.length > 0 + ? await db.user.findMany({ + where: { id: { in: allEditorIds } }, + select: { id: true, username: true, displayName: true, avatarUrl: true }, + }) + : []; + + const userMap = new Map(users.map((u) => [u.id, u])); + + return reply.send({ + data: conflicts.map((c) => ({ + fileHash: c.fileHash, + editors: c.editors.map((id) => ({ + id, + username: userMap.get(id)?.username ?? 'Unknown', + displayName: userMap.get(id)?.displayName ?? null, + avatarUrl: userMap.get(id)?.avatarUrl ?? null, + })), + })), + }); + } + ); + // POST /:id/invite - Invite member app.post( '/:id/invite', @@ -530,7 +737,18 @@ export function teamRoutes(app: FastifyInstance): void { logger.info({ teamId, email, inviterId: userId }, 'Team invitation created'); - // TODO: Send invitation email with token + // Send invitation email (non-blocking) + const joinUrl = `${env.WEB_APP_URL}/dashboard/teams/${teamId}/join?token=${token}`; + sendTeamInviteEmail({ + to: email, + teamName: invitation.team.name, + inviterName: invitation.inviter.displayName ?? invitation.inviter.username, + role: invitation.role, + joinUrl, + expiresAt: invitation.expiresAt.toISOString(), + }).catch((err: unknown) => { + logger.error({ error: err, email, teamId }, 'Failed to send invitation email'); + }); return reply.status(201).send({ data: { diff --git a/apps/server/src/routes/users.ts b/apps/server/src/routes/users.ts index ac1cbf6..95c0192 100644 --- a/apps/server/src/routes/users.ts +++ b/apps/server/src/routes/users.ts @@ -11,6 +11,7 @@ import type { UserDTO } from '@devradar/shared'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; import { NotFoundError, ValidationError } from '@/lib/errors'; +import { logger } from '@/lib/logger'; import { getDb } from '@/services/db'; import { getPresence } from '@/services/redis'; @@ -35,6 +36,8 @@ function toUserDTO(user: { avatarUrl: string | null; tier: string; privacyMode: boolean; + customStatus: string | null; + ghostMode: boolean; createdAt: Date; }): UserDTO { /* Runtime validation of tier value */ @@ -50,6 +53,8 @@ function toUserDTO(user: { avatarUrl: user.avatarUrl, tier, privacyMode: user.privacyMode, + customStatus: user.customStatus, + ghostMode: user.ghostMode, createdAt: user.createdAt.toISOString(), }; } @@ -143,6 +148,8 @@ export function userRoutes(app: FastifyInstance): void { avatarUrl: true, tier: true, privacyMode: true, + customStatus: true, + ghostMode: true, createdAt: true, _count: { select: { @@ -201,13 +208,24 @@ export function userRoutes(app: FastifyInstance): void { const updateData = result.data; /* Build update object explicitly to handle exactOptionalPropertyTypes */ - const prismaUpdateData: { displayName?: string | null; privacyMode?: boolean } = {}; + const prismaUpdateData: { + displayName?: string | null; + privacyMode?: boolean; + ghostMode?: boolean; + customStatus?: string | null; + } = {}; if (updateData.displayName !== undefined) { prismaUpdateData.displayName = updateData.displayName; } if (updateData.privacyMode !== undefined) { prismaUpdateData.privacyMode = updateData.privacyMode; } + if (updateData.ghostMode !== undefined) { + prismaUpdateData.ghostMode = updateData.ghostMode; + } + if (updateData.customStatus !== undefined) { + prismaUpdateData.customStatus = updateData.customStatus; + } const user = await db.user.update({ where: { id: userId }, @@ -219,4 +237,23 @@ export function userRoutes(app: FastifyInstance): void { }); } ); + + // DELETE /me - Delete current user account + app.delete( + '/me', + { onRequest: [app.authenticate] }, + async (request: FastifyRequest, reply: FastifyReply) => { + const { userId } = request.user as { userId: string }; + + await db.$transaction(async (tx) => { + // Delete teams owned by this user (cascade handles members/invitations) + await tx.team.deleteMany({ where: { ownerId: userId } }); + // Delete the user (cascade handles follows, friend requests, achievements, stats, team memberships) + await tx.user.delete({ where: { id: userId } }); + }); + + logger.info({ userId }, 'User account deleted'); + return reply.status(204).send(); + } + ); } diff --git a/apps/server/src/services/auth0.ts b/apps/server/src/services/auth0.ts new file mode 100644 index 0000000..3d27ee6 --- /dev/null +++ b/apps/server/src/services/auth0.ts @@ -0,0 +1,122 @@ +/** + * Auth0 SSO/SAML Service + * + * Handles Auth0 OAuth 2.0 / SAML flow for enterprise SSO authentication. + * This is a TEAM tier feature for organizations that require centralized login. + */ + +import { env } from '@/config'; +import { logger } from '@/lib/logger'; + +interface Auth0TokenResponse { + access_token: string; + id_token: string; + token_type: string; + expires_in: number; +} + +interface Auth0UserInfo { + sub: string; + email: string; + email_verified: boolean; + name: string; + nickname: string; + picture: string; +} + +const AUTH0_API_TIMEOUT_MS = 10000; + +/** + * Check whether Auth0 SSO is fully configured. + * + * @returns true if all required Auth0 environment variables are set + */ +export function isAuth0Configured(): boolean { + return !!( + env.AUTH0_DOMAIN && + env.AUTH0_CLIENT_ID && + env.AUTH0_CLIENT_SECRET && + env.AUTH0_CALLBACK_URL + ); +} + +/** + * Build the Auth0 authorization URL for initiating the SSO flow. + * + * @param state - CSRF state token + * @param connection - Optional Auth0 connection name (e.g. 'google-oauth2', 'samlp') + * @returns Full authorization URL to redirect the user to + */ +export function getAuth0AuthorizeUrl(state: string, connection?: string): string { + const clientId = env.AUTH0_CLIENT_ID ?? ''; + const redirectUri = env.AUTH0_CALLBACK_URL ?? ''; + const domain = env.AUTH0_DOMAIN ?? ''; + + const params = new URLSearchParams({ + response_type: 'code', + client_id: clientId, + redirect_uri: redirectUri, + scope: 'openid profile email', + state, + }); + + if (connection) { + params.set('connection', connection); + } + + return `https://${domain}/authorize?${params.toString()}`; +} + +/** + * Exchange an authorization code for Auth0 tokens. + * + * @param code - Authorization code from the Auth0 callback + * @returns Token response containing access_token and id_token + * @throws Error if the token exchange fails + */ +export async function exchangeAuth0Code(code: string): Promise { + const domain = env.AUTH0_DOMAIN ?? ''; + const response = await fetch(`https://${domain}/oauth/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + grant_type: 'authorization_code', + client_id: env.AUTH0_CLIENT_ID, + client_secret: env.AUTH0_CLIENT_SECRET, + code, + redirect_uri: env.AUTH0_CALLBACK_URL, + }), + signal: AbortSignal.timeout(AUTH0_API_TIMEOUT_MS), + }); + + if (!response.ok) { + const error = await response.text(); + logger.error({ error, status: response.status }, 'Auth0 token exchange failed'); + throw new Error('Failed to exchange Auth0 authorization code'); + } + + return response.json() as Promise; +} + +/** + * Fetch user profile information from Auth0 using an access token. + * + * @param accessToken - Auth0 access token + * @returns User profile information + * @throws Error if the request fails + */ +export async function getAuth0UserInfo(accessToken: string): Promise { + const domain = env.AUTH0_DOMAIN ?? ''; + const response = await fetch(`https://${domain}/userinfo`, { + headers: { Authorization: `Bearer ${accessToken}` }, + signal: AbortSignal.timeout(AUTH0_API_TIMEOUT_MS), + }); + + if (!response.ok) { + const error = await response.text(); + logger.error({ error, status: response.status }, 'Auth0 userinfo request failed'); + throw new Error('Failed to get Auth0 user info'); + } + + return response.json() as Promise; +} diff --git a/apps/server/src/services/email.ts b/apps/server/src/services/email.ts new file mode 100644 index 0000000..6f6fe15 --- /dev/null +++ b/apps/server/src/services/email.ts @@ -0,0 +1,64 @@ +import { Resend } from 'resend'; + +import { env } from '@/config'; +import { logger } from '@/lib/logger'; + +let resend: Resend | null = null; + +function getResend(): Resend | null { + if (!env.RESEND_API_KEY) return null; + resend ??= new Resend(env.RESEND_API_KEY); + return resend; +} + +interface TeamInviteEmailParams { + to: string; + teamName: string; + inviterName: string; + role: string; + joinUrl: string; + expiresAt: string; +} + +export async function sendTeamInviteEmail(params: TeamInviteEmailParams): Promise { + const client = getResend(); + if (!client) { + logger.warn('RESEND_API_KEY not configured, skipping invitation email'); + return false; + } + + try { + await client.emails.send({ + from: 'DevRadar ', + to: params.to, + subject: `You're invited to join ${params.teamName} on DevRadar`, + html: ` +
+
+

Team Invitation

+

+ ${params.inviterName} has invited you to join + ${params.teamName} as a ${params.role.toLowerCase()}. +

+ + Accept Invitation + +

+ This invitation expires on ${new Date(params.expiresAt).toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}. + If you didn't expect this invitation, you can safely ignore it. +

+
+

+ DevRadar — See what your friends are coding +

+
+ `, + }); + + logger.info({ to: params.to, teamName: params.teamName }, 'Team invitation email sent'); + return true; + } catch (error) { + logger.error({ error, to: params.to }, 'Failed to send team invitation email'); + return false; + } +} diff --git a/apps/web/src/app/dashboard/dashboard-shell.tsx b/apps/web/src/app/dashboard/dashboard-shell.tsx index 0af30d7..6c6d402 100644 --- a/apps/web/src/app/dashboard/dashboard-shell.tsx +++ b/apps/web/src/app/dashboard/dashboard-shell.tsx @@ -5,7 +5,7 @@ import { toast } from 'sonner'; import { useAuth } from '@/lib/auth'; import { useWebSocket } from '@/lib/hooks'; -import { Sidebar } from '@/components/dashboard'; +import { Sidebar, ConflictToastListener } from '@/components/dashboard'; export function DashboardShell({ children }: { children: React.ReactNode }) { const { isAuthenticated } = useAuth(); @@ -44,6 +44,7 @@ export function DashboardShell({ children }: { children: React.ReactNode }) {
{children}
+
); } diff --git a/apps/web/src/app/dashboard/settings/page.tsx b/apps/web/src/app/dashboard/settings/page.tsx index b6a9d3d..ecdac51 100644 --- a/apps/web/src/app/dashboard/settings/page.tsx +++ b/apps/web/src/app/dashboard/settings/page.tsx @@ -8,6 +8,8 @@ import { toast } from 'sonner'; import { useAuth } from '@/lib/auth'; import { usersApi } from '@/lib/api'; +import { useThemePreset } from '@/lib/theme-context'; +import { THEME_PRESETS } from '@/lib/themes'; import { Button } from '@/components/ui/button'; import { Switch } from '@/components/ui/switch'; import { cn } from '@/lib/utils'; @@ -29,8 +31,9 @@ function SectionHeader({ title }: { title: string }) { } export default function SettingsPage() { - const { user, refreshUser, isLoading: authLoading } = useAuth(); + const { user, refreshUser, signOut, isLoading: authLoading } = useAuth(); const { theme, setTheme } = useTheme(); + const { preset, setPreset, setCustomColor, customColors } = useThemePreset(); const [displayName, setDisplayName] = useState(''); const [privacyMode, setPrivacyMode] = useState(false); @@ -38,13 +41,22 @@ export default function SettingsPage() { const [friendReqNotifs, setFriendReqNotifs] = useState(true); const [savingProfile, setSavingProfile] = useState(false); const [savingPrivacy, setSavingPrivacy] = useState(false); + const [ghostMode, setGhostMode] = useState(false); + const [savingGhost, setSavingGhost] = useState(false); + const [customStatus, setCustomStatus] = useState(''); + const [savingStatus, setSavingStatus] = useState(false); + const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteConfirmText, setDeleteConfirmText] = useState(''); + const [isDeleting, setIsDeleting] = useState(false); useEffect(() => { if (user) { setDisplayName(user.displayName || ''); setPrivacyMode(user.privacyMode); + setGhostMode(user.ghostMode); + setCustomStatus(user.customStatus || ''); } - }, [user?.id]); + }, [user]); useEffect(() => { const storedPoke = localStorage.getItem('pref-poke-notifications'); @@ -91,6 +103,46 @@ export default function SettingsPage() { localStorage.setItem('pref-friend-request-notifications', String(checked)); }; + const handleGhostToggle = async (checked: boolean) => { + setGhostMode(checked); + setSavingGhost(true); + try { + await usersApi.updateMe({ ghostMode: checked }); + await refreshUser(); + toast.success(checked ? 'Ghost mode enabled' : 'Ghost mode disabled'); + } catch { + setGhostMode(!checked); + toast.error('Failed to update ghost mode'); + } finally { + setSavingGhost(false); + } + }; + + const handleSaveStatus = async () => { + setSavingStatus(true); + try { + await usersApi.updateMe({ customStatus: customStatus || null }); + await refreshUser(); + toast.success('Status updated'); + } catch { + toast.error('Failed to update status'); + } finally { + setSavingStatus(false); + } + }; + + const handleDeleteAccount = async () => { + if (deleteConfirmText !== user?.username) return; + setIsDeleting(true); + try { + await usersApi.deleteAccount(); + signOut(); + } catch { + toast.error('Failed to delete account'); + setIsDeleting(false); + } + }; + if (authLoading) { return (
@@ -161,6 +213,45 @@ export default function SettingsPage() {
+
+ +
+
+ +
+ setCustomStatus(e.target.value)} + placeholder="What are you working on?" + maxLength={50} + className="flex-1 bg-transparent border border-border px-3 py-1.5 text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:border-foreground/30" + disabled={user.tier === 'FREE'} + /> + +
+
+ {user.tier === 'FREE' ? ( + Requires PRO + ) : ( + + {customStatus.length}/50 + + )} +
+
+
+
+
@@ -176,6 +267,22 @@ export default function SettingsPage() { disabled={savingPrivacy} />
+
+
+ Ghost mode + + Go completely invisible — not shown in any friend lists or leaderboards + + {user.tier === 'FREE' && ( + Requires PRO + )} +
+ +
@@ -201,6 +308,57 @@ export default function SettingsPage() { ); })}
+ +
+ +
+ {THEME_PRESETS.map((t) => ( + + ))} +
+
+ +
+ +
+ { + if (user.tier === 'FREE') return; + setCustomColor('accent', e.target.value); + }} + disabled={user.tier === 'FREE'} + className="w-8 h-8 border border-border cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed" + /> + + {preset === 'custom' ? 'Custom' : 'Pick a color to customize'} + +
+
@@ -230,13 +388,67 @@ export default function SettingsPage() {
- -

Account deletion coming soon

+

+ Permanently delete your account and all associated data +

+ + {showDeleteModal && ( +
+
+

Delete Account

+

+ This action is permanent and cannot be undone. All your data including stats, + achievements, friends, and team memberships will be permanently deleted. +

+
+ + setDeleteConfirmText(e.target.value)} + placeholder={user.username} + className="w-full bg-transparent border border-border px-3 py-1.5 text-sm font-mono placeholder:text-muted-foreground focus:outline-none focus:border-destructive/50" + autoFocus + /> +
+
+ + +
+
+
+ )} ); } diff --git a/apps/web/src/app/dashboard/stats/page.tsx b/apps/web/src/app/dashboard/stats/page.tsx index 7ee100b..ac9686b 100644 --- a/apps/web/src/app/dashboard/stats/page.tsx +++ b/apps/web/src/app/dashboard/stats/page.tsx @@ -5,7 +5,8 @@ import { Flame, Snowflake, Clock, Code2, Hash, Layers } from 'lucide-react'; import { useAuth } from '@/lib/auth'; import { statsApi } from '@/lib/api'; -import type { UserStats } from '@/lib/api'; +import type { UserStats, StatsHistory } from '@/lib/api'; +import { cn } from '@/lib/utils'; import { ContributionHeatmap } from '@/components/dashboard/contribution-heatmap'; import { AchievementCard } from '@/components/dashboard/achievement-card'; @@ -16,9 +17,11 @@ function formatTime(seconds: number): string { } export default function StatsPage() { - const { isAuthenticated, isLoading: authLoading } = useAuth(); + const { user, isAuthenticated, isLoading: authLoading } = useAuth(); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); + const [range, setRange] = useState<'7d' | '14d' | '30d'>('7d'); + const [history, setHistory] = useState(null); const fetchStats = useCallback(async () => { setLoading(true); @@ -32,13 +35,23 @@ export default function StatsPage() { } }, []); + const fetchHistory = useCallback(async (r: '7d' | '14d' | '30d') => { + try { + const res = await statsApi.getHistory(r); + setHistory(res.data); + } catch { + /* handled by empty state */ + } + }, []); + useEffect(() => { if (isAuthenticated) { fetchStats(); + fetchHistory(range); } else if (!authLoading) { setLoading(false); } - }, [isAuthenticated, authLoading, fetchStats]); + }, [isAuthenticated, authLoading, fetchStats, fetchHistory, range]); if (authLoading || loading) { return ( @@ -86,6 +99,68 @@ export default function StatsPage() {

Stats

+
+ {(['7d', '14d', '30d'] as const).map((r) => { + const needsPro = r !== '7d' && user?.tier === 'FREE'; + return ( + + ); + })} +
+ + {history && ( +
+
+ + Daily activity + + + {history.summary.activeDays}/{history.summary.totalDays} active days + +
+
+ {history.dailySessions.map((day) => { + const maxSeconds = Math.max(...history.dailySessions.map((d) => d.seconds), 1); + const height = day.seconds > 0 ? Math.max(4, (day.seconds / maxSeconds) * 100) : 0; + return ( +
+
+
+ ); + })} +
+
+ + {history.dailySessions[0]?.date ?? ''} + + + {history.dailySessions[history.dailySessions.length - 1]?.date ?? ''} + +
+
+ )} +
diff --git a/apps/web/src/app/dashboard/teams/[id]/page.tsx b/apps/web/src/app/dashboard/teams/[id]/page.tsx index b204939..7e1a3f9 100644 --- a/apps/web/src/app/dashboard/teams/[id]/page.tsx +++ b/apps/web/src/app/dashboard/teams/[id]/page.tsx @@ -4,18 +4,42 @@ import { useState, useEffect, useCallback } from 'react'; import { useParams } from 'next/navigation'; import Link from 'next/link'; import Image from 'next/image'; -import { ArrowLeft, UserPlus, Trash2, Radio } from 'lucide-react'; +import { + ArrowLeft, + UserPlus, + Trash2, + Radio, + Trophy, + BarChart3, + Users, + Clock, + GitCommit, +} from 'lucide-react'; import { toast } from 'sonner'; import { useAuth } from '@/lib/auth'; -import { teamsApi } from '@/lib/api'; -import type { TeamDetail, TeamMember, TeamInvitation, RoleType } from '@/lib/api'; +import { teamsApi, leaderboardApi, slackApi } from '@/lib/api'; +import type { + TeamDetail, + TeamMember, + TeamInvitation, + TeamAnalytics, + LeaderboardEntry, + RoleType, + ConflictAlert, +} from '@/lib/api'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Button } from '@/components/ui/button'; import { InviteMemberModal } from '@/components/dashboard/invite-member-modal'; import { roleBadgeColors } from '@/components/dashboard/constants'; import { cn } from '@/lib/utils'; +function formatTime(seconds: number): string { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; +} + function MemberRow({ member, isOwner, @@ -129,6 +153,526 @@ function InvitationRow({ ); } +function TeamLeaderboard({ teamId, currentUserId }: { teamId: string; currentUserId: string }) { + const [entries, setEntries] = useState([]); + const [myRank, setMyRank] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + leaderboardApi + .team(teamId) + .then((res) => { + if (!cancelled) { + setEntries(res.data.leaderboard); + setMyRank(res.data.myRank); + } + }) + .catch(() => { + if (!cancelled) setEntries([]); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [teamId]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (entries.length === 0) { + return ( +
+ +

+ No leaderboard data yet. Start coding to appear here. +

+
+ ); + } + + return ( +
+ {myRank != null && ( +
+
+ + Your team rank + +
#{myRank}
+
+ by weekly time +
+ )} + +
+
+ # + Member + Time +
+ + {entries.map((entry) => { + const isMe = entry.userId === currentUserId; + return ( +
+ {entry.rank} +
+ {entry.avatarUrl ? ( + {entry.displayName + ) : ( +
+ {(entry.displayName || entry.username).charAt(0).toUpperCase()} +
+ )} + + {entry.displayName || entry.username} + {isMe && ( + you + )} + +
+ + {formatTime(entry.score)} + +
+ ); + })} +
+
+ ); +} + +function TeamAnalyticsTab({ teamId }: { teamId: string }) { + const [analytics, setAnalytics] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + teamsApi + .analytics(teamId) + .then((res) => { + if (!cancelled) setAnalytics(res.data); + }) + .catch(() => { + if (!cancelled) setAnalytics(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [teamId]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!analytics) { + return ( +
+ +

No analytics data available yet.

+
+ ); + } + + const maxWeeklySeconds = Math.max(...analytics.weeklyTrend.map((w) => w.totalSeconds), 1); + const maxMemberSeconds = Math.max(...analytics.memberActivity.map((m) => m.totalSeconds), 1); + const maxLangSeconds = Math.max(...analytics.topLanguages.map((l) => l.seconds), 1); + + return ( +
+ {/* Summary */} +
+
+
+ + Members + + +
+
+ {analytics.summary.totalMembers} +
+
+
+
+ + Total Time + + +
+
+ {formatTime(analytics.summary.totalSeconds)} +
+
+
+
+ + Commits + + +
+
+ {analytics.summary.totalCommits.toLocaleString()} +
+
+
+ + {/* Weekly Trend */} + {analytics.weeklyTrend.length > 0 && ( +
+
+ + Weekly Trend + + + last {analytics.weeklyTrend.length} weeks + +
+
+ {analytics.weeklyTrend.map((week) => { + const height = + week.totalSeconds > 0 + ? Math.max(8, (week.totalSeconds / maxWeeklySeconds) * 100) + : 0; + const weekDate = new Date(week.weekStart); + const label = `${weekDate.getMonth() + 1}/${weekDate.getDate()}`; + return ( +
+
+
+
+ {label} +
+ ); + })} +
+
+ )} + + {/* Member Activity */} + {analytics.memberActivity.length > 0 && ( +
+
+ + Member Activity (last 4 weeks) + +
+ {analytics.memberActivity.map((member) => { + const barWidth = Math.max(2, (member.totalSeconds / maxMemberSeconds) * 100); + return ( +
+
+
+ {member.avatarUrl ? ( + {member.displayName + ) : ( +
+ {(member.displayName || member.username).charAt(0).toUpperCase()} +
+ )} + + {member.displayName || member.username} + +
+
+ + {member.totalCommits} commits + + + {formatTime(member.totalSeconds)} + +
+
+
+
+
+
+ ); + })} +
+ )} + + {/* Language Breakdown */} + {analytics.topLanguages.length > 0 && ( +
+
+ + Top Languages + +
+
+ {analytics.topLanguages.map((lang) => { + const barWidth = Math.max(2, (lang.seconds / maxLangSeconds) * 100); + return ( +
+
+ {lang.language} + + {formatTime(lang.seconds)} + +
+
+
+
+
+ ); + })} +
+
+ )} +
+ ); +} + +function ConflictRadarPanel({ teamId }: { teamId: string }) { + const [conflicts, setConflicts] = useState([]); + const [loading, setLoading] = useState(true); + + const fetchConflicts = useCallback(async () => { + try { + const res = await teamsApi.conflicts(teamId); + setConflicts(res.data); + } catch { + setConflicts([]); + } finally { + setLoading(false); + } + }, [teamId]); + + useEffect(() => { + fetchConflicts(); + const interval = setInterval(fetchConflicts, 30000); + return () => clearInterval(interval); + }, [fetchConflicts]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (conflicts.length === 0) { + return ( +
+ + + No Active Conflicts + +

+ Monitoring for conflicts... When two team members edit the same file simultaneously, + alerts will appear here. +

+
+ ); + } + + return ( +
+
+ + {conflicts.length} active conflict{conflicts.length > 1 ? 's' : ''} + + + auto-refreshes every 30s + +
+ {conflicts.map((conflict) => ( +
+
+ + {conflict.fileHash.slice(0, 12)}... +
+
+ Editors: + {conflict.editors.map((editor) => ( +
+ {editor.avatarUrl ? ( + {editor.displayName + ) : ( +
+ {(editor.displayName || editor.username).charAt(0).toUpperCase()} +
+ )} + + {editor.displayName || editor.username} + +
+ ))} +
+
+ ))} +
+ ); +} + +function SlackIntegrationPanel({ teamId }: { teamId: string }) { + const [status, setStatus] = useState<{ + connected: boolean; + slackTeamName?: string; + channelId?: string; + connectedAt?: string; + } | null>(null); + const [loading, setLoading] = useState(true); + const [disconnecting, setDisconnecting] = useState(false); + + const fetchStatus = useCallback(async () => { + try { + const res = await slackApi.getStatus(teamId); + setStatus(res); + } catch { + setStatus({ connected: false }); + } finally { + setLoading(false); + } + }, [teamId]); + + useEffect(() => { + fetchStatus(); + }, [fetchStatus]); + + const handleConnect = () => { + const token = localStorage.getItem('auth_token'); + const apiUrl = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; + // Redirect to server's Slack install endpoint (it handles OAuth redirect) + window.location.href = `${apiUrl}/slack/install?teamId=${encodeURIComponent(teamId)}&token=${token ? encodeURIComponent(token) : ''}`; + }; + + const handleDisconnect = async () => { + setDisconnecting(true); + try { + await slackApi.disconnect(teamId); + setStatus({ connected: false }); + toast.success('Slack disconnected'); + } catch { + toast.error('Failed to disconnect Slack'); + } finally { + setDisconnecting(false); + } + }; + + if (loading) { + return ( +
+
+
+ ); + } + + if (status?.connected) { + return ( +
+ + Slack Integration + +
+
+
+ S +
+
+ {status.slackTeamName} + + Connected{' '} + {status.connectedAt ? new Date(status.connectedAt).toLocaleDateString() : ''} + +
+ Connected +
+ + {status.channelId && ( +
+ + Default Channel + + #{status.channelId} +
+ )} + +

+ Your team can use{' '} + /devradar status in Slack to + see who's coding. +

+ +
+ +
+
+
+ ); + } + + return ( +
+ + Slack Integration + +

+ Connect your Slack workspace to get team status updates and use /devradar commands. +

+ +
+ ); +} + export default function TeamDetailPage() { const params = useParams(); const teamId = params.id as string; @@ -271,6 +815,12 @@ export default function TeamDetailPage() { Members ({allMembers.length}) + + Leaderboard + + + Analytics + {isAdmin && ( Invitations @@ -313,6 +863,14 @@ export default function TeamDetailPage() {
+ + + + + + + + {isAdmin && (
@@ -336,31 +894,12 @@ export default function TeamDetailPage() { {isOwner && ( -
- - Slack Integration - -

- Connect your Slack workspace to get team status updates and use /devradar commands. -

- -
+
)} -
- - - Conflict Radar - -

- Monitoring for conflicts... When two team members edit the same file simultaneously, - alerts will appear here. -

-
+
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 3709cf5..2a477a8 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -6,6 +6,7 @@ import { Toaster } from '@/components/ui/sonner'; import { LayoutShell } from '@/components/layout'; import { SITE_CONFIG } from '@/lib/constants'; import { ThemeProvider } from '@/components/theme-provider'; +import { ThemePresetProvider } from '@/lib/theme-context'; import { AuthProvider } from '@/lib/auth'; const syne = Syne({ @@ -95,17 +96,19 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - - -
+ + + +
- {children} - + {children} + +
diff --git a/apps/web/src/components/dashboard/conflict-toast.tsx b/apps/web/src/components/dashboard/conflict-toast.tsx new file mode 100644 index 0000000..17bab16 --- /dev/null +++ b/apps/web/src/components/dashboard/conflict-toast.tsx @@ -0,0 +1,45 @@ +'use client'; + +import { useRef } from 'react'; +import { toast } from 'sonner'; +import { useWebSocket } from '@/lib/hooks/use-websocket'; +import { useAuth } from '@/lib/auth'; + +interface ConflictAlertPayload { + fileHash: string; + editors: string[]; + teamId: string; +} + +export function ConflictToastListener() { + const { user } = useAuth(); + const shownRef = useRef(new Set()); + + useWebSocket({ + enabled: !!user, + handlers: { + CONFLICT_ALERT: (payload) => { + const alert = payload as ConflictAlertPayload; + const key = `${alert.teamId}:${alert.fileHash}`; + + // Don't show duplicate toasts for the same conflict + if (shownRef.current.has(key)) return; + shownRef.current.add(key); + + // Clear after 5 minutes + setTimeout(() => shownRef.current.delete(key), 300000); + + const otherEditors = alert.editors.filter((id) => id !== user?.id).length; + toast.warning( + `Conflict detected: ${otherEditors} other editor${otherEditors > 1 ? 's' : ''} on the same file`, + { + description: `File hash: ${alert.fileHash.slice(0, 8)}...`, + duration: 10000, + } + ); + }, + }, + }); + + return null; +} diff --git a/apps/web/src/components/dashboard/friend-card.tsx b/apps/web/src/components/dashboard/friend-card.tsx index af20cca..3c61e82 100644 --- a/apps/web/src/components/dashboard/friend-card.tsx +++ b/apps/web/src/components/dashboard/friend-card.tsx @@ -68,6 +68,11 @@ export function FriendItem({ friend, onUnfollow, loading }: FriendItemProps) { @{friend.username} {hasStatus && friend.activity?.language && <> · {friend.activity.language}} + {hasStatus && friend.customStatus && ( +

+ {friend.customStatus} +

+ )}
{onUnfollow && ( + )} +
); } export function Header() { const { scrollY } = useScroll(); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); - const { isAuthenticated, signIn } = useAuth(); + const { isAuthenticated, signIn, signInWithSSO, ssoEnabled } = useAuth(); const headerOpacity = useTransform(scrollY, [0, 50], [0, 1]); @@ -345,17 +352,32 @@ export function Header() { Dashboard ) : ( - + <> + + {ssoEnabled && ( + + )} + )}
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts index d89c0d2..813f159 100644 --- a/apps/web/src/lib/api/index.ts +++ b/apps/web/src/lib/api/index.ts @@ -2,6 +2,7 @@ import { api } from '@/lib/auth/api'; import type { UserStats, WeeklyStats, + StatsHistory, Friend, Follower, FriendRequest, @@ -11,6 +12,8 @@ import type { TeamSummary, TeamDetail, TeamInvitation, + TeamAnalytics, + ConflictAlert, PaginatedResponse, } from './types'; @@ -20,6 +23,8 @@ export * from './types'; export const statsApi = { getMyStats: () => api<{ data: UserStats }>('/api/v1/stats/me'), getWeeklyStats: () => api<{ data: WeeklyStats }>('/api/v1/stats/weekly'), + getHistory: (range: '7d' | '14d' | '30d' = '7d') => + api<{ data: StatsHistory }>(`/api/v1/stats/history?range=${range}`), }; // ---- Friends ---- @@ -82,6 +87,10 @@ export const leaderboardApi = { ), friends: () => api<{ data: LeaderboardResponse }>('/api/v1/leaderboards/friends'), networkActivity: () => api<{ data: NetworkActivity }>('/api/v1/leaderboards/network-activity'), + team: (teamId: string) => + api<{ data: { leaderboard: LeaderboardResponse['leaderboard']; myRank: number | null } }>( + `/api/v1/leaderboards/team/${encodeURIComponent(teamId)}` + ), }; // ---- Teams ---- @@ -136,6 +145,24 @@ export const teamsApi = { `/api/v1/teams/${encodeURIComponent(teamId)}/invitations/${encodeURIComponent(invitationId)}`, { method: 'DELETE' } ), + analytics: (id: string) => + api<{ data: TeamAnalytics }>(`/api/v1/teams/${encodeURIComponent(id)}/analytics`), + conflicts: (id: string) => + api<{ data: ConflictAlert[] }>(`/api/v1/teams/${encodeURIComponent(id)}/conflicts`), +}; + +// ---- Slack ---- +export const slackApi = { + getStatus: (teamId: string) => + api<{ + connected: boolean; + slackWorkspaceId?: string; + slackTeamName?: string; + channelId?: string; + connectedAt?: string; + }>(`/slack/status/${encodeURIComponent(teamId)}`), + disconnect: (teamId: string) => + api(`/slack/disconnect/${encodeURIComponent(teamId)}`, { method: 'DELETE' }), }; // ---- Users ---- @@ -151,9 +178,22 @@ export const usersApi = { activity?: unknown; }; }>(`/api/v1/users/${encodeURIComponent(id)}`), - updateMe: (data: { displayName?: string | null; privacyMode?: boolean }) => - api<{ data: PublicUser & { tier: string; privacyMode: boolean } }>('/api/v1/users/me', { + updateMe: (data: { + displayName?: string | null; + privacyMode?: boolean; + ghostMode?: boolean; + customStatus?: string | null; + }) => + api<{ + data: PublicUser & { + tier: string; + privacyMode: boolean; + ghostMode: boolean; + customStatus: string | null; + }; + }>('/api/v1/users/me', { method: 'PATCH', body: JSON.stringify(data), }), + deleteAccount: () => api('/api/v1/users/me', { method: 'DELETE' }), }; diff --git a/apps/web/src/lib/api/types.ts b/apps/web/src/lib/api/types.ts index d8e1d25..f65e213 100644 --- a/apps/web/src/lib/api/types.ts +++ b/apps/web/src/lib/api/types.ts @@ -40,6 +40,8 @@ export interface ActivityPayload { export interface Friend extends PublicUser { tier: TierType; privacyMode: boolean; + ghostMode?: boolean; + customStatus?: string | null; status: UserStatusType | 'incognito'; activity?: ActivityPayload; followedAt: string; @@ -93,6 +95,30 @@ export interface UserStats { recentAchievements: Achievement[]; } +// ---- History ---- +export interface DailySession { + date: string; + seconds: number; +} + +export interface StatsHistory { + range: string; + dailySessions: DailySession[]; + weeklyBreakdown: { + weekStart: string; + totalSeconds: number; + totalSessions: number; + totalCommits: number; + topLanguage: string | null; + topProject: string | null; + }[]; + summary: { + totalSeconds: number; + activeDays: number; + totalDays: number; + }; +} + // ---- Leaderboard ---- export interface LeaderboardEntry { rank: number; @@ -154,6 +180,41 @@ export interface TeamInvitation { createdAt: string; } +// ---- Team Analytics ---- +export interface TeamAnalytics { + weeklyTrend: { + weekStart: string; + totalSeconds: number; + totalCommits: number; + activeMembers: number; + }[]; + memberActivity: { + userId: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + totalSeconds: number; + totalCommits: number; + }[]; + topLanguages: { language: string; seconds: number }[]; + summary: { + totalMembers: number; + totalSeconds: number; + totalCommits: number; + }; +} + +// ---- Conflict Radar ---- +export interface ConflictAlert { + fileHash: string; + editors: { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + }[]; +} + // ---- WebSocket ---- export type MessageType = | 'AUTH' diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx index ad84d22..8fb1bc0 100644 --- a/apps/web/src/lib/auth/auth-context.tsx +++ b/apps/web/src/lib/auth/auth-context.tsx @@ -10,6 +10,8 @@ export interface User { tier: 'FREE' | 'PRO' | 'TEAM'; githubId: string; privacyMode: boolean; + ghostMode: boolean; + customStatus: string | null; } interface AuthContextType { @@ -18,6 +20,8 @@ interface AuthContextType { isAuthenticated: boolean; signIn: () => void; signOut: () => void; + signInWithSSO: () => void; + ssoEnabled: boolean; refreshUser: () => Promise; } @@ -28,6 +32,7 @@ const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); const [isLoading, setIsLoading] = useState(true); + const [ssoEnabled, setSsoEnabled] = useState(false); const refreshUser = useCallback(async () => { try { @@ -58,9 +63,23 @@ export function AuthProvider({ children }: { children: ReactNode }) { }, []); useEffect(() => { + // Handle token from SSO redirect (e.g. /dashboard?token=xxx) + const params = new URLSearchParams(window.location.search); + const token = params.get('token'); + if (token) { + localStorage.setItem('auth_token', token); + window.history.replaceState({}, '', window.location.pathname); + } refreshUser(); }, [refreshUser]); + useEffect(() => { + fetch(`${API_URL}/auth/sso/status`) + .then((res) => res.json()) + .then((data: { enabled: boolean }) => setSsoEnabled(data.enabled)) + .catch(() => setSsoEnabled(false)); + }, []); + const signIn = () => { const token = localStorage.getItem('auth_token'); if (token) { @@ -70,6 +89,10 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }; + const signInWithSSO = () => { + window.location.href = `${API_URL}/auth/sso`; + }; + const signOut = async () => { try { const token = localStorage.getItem('auth_token'); @@ -96,6 +119,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { isAuthenticated: !!user, signIn, signOut, + signInWithSSO, + ssoEnabled, refreshUser, }} > diff --git a/apps/web/src/lib/theme-context.tsx b/apps/web/src/lib/theme-context.tsx new file mode 100644 index 0000000..793d139 --- /dev/null +++ b/apps/web/src/lib/theme-context.tsx @@ -0,0 +1,86 @@ +'use client'; + +import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'; +import { THEME_PRESETS, type ThemePreset } from './themes'; + +interface ThemeContextType { + preset: string; + customColors: ThemePreset['colors'] | null; + setPreset: (id: string) => void; + setCustomColor: (key: keyof ThemePreset['colors'], value: string) => void; + resetCustomColors: () => void; +} + +const ThemeContext = createContext(undefined); + +function applyThemeColors(colors: ThemePreset['colors']) { + const root = document.documentElement; + root.style.setProperty('--accent', colors.accent); + root.style.setProperty('--accent-foreground', colors.accentForeground); +} + +export function ThemePresetProvider({ children }: { children: ReactNode }) { + const [preset, setPresetState] = useState(() => { + if (typeof window === 'undefined') return 'default'; + return localStorage.getItem('devradar-theme-preset') ?? 'default'; + }); + const [customColors, setCustomColors] = useState(() => { + if (typeof window === 'undefined') return null; + const saved = localStorage.getItem('devradar-theme-custom'); + if (!saved) return null; + try { + return JSON.parse(saved); + } catch { + return null; + } + }); + + useEffect(() => { + if (customColors) { + applyThemeColors(customColors); + } else { + const presetData = THEME_PRESETS.find((p) => p.id === preset); + if (presetData) applyThemeColors(presetData.colors); + } + }, []); + + const setPreset = useCallback((id: string) => { + setPresetState(id); + setCustomColors(null); + localStorage.setItem('devradar-theme-preset', id); + localStorage.removeItem('devradar-theme-custom'); + const presetData = THEME_PRESETS.find((p) => p.id === id); + if (presetData) applyThemeColors(presetData.colors); + }, []); + + const setCustomColor = useCallback((key: keyof ThemePreset['colors'], value: string) => { + setCustomColors((prev) => { + const current = prev ?? THEME_PRESETS[0]!.colors; + const updated = { ...current, [key]: value }; + localStorage.setItem('devradar-theme-custom', JSON.stringify(updated)); + applyThemeColors(updated); + return updated; + }); + setPresetState('custom'); + localStorage.setItem('devradar-theme-preset', 'custom'); + }, []); + + const resetCustomColors = useCallback(() => { + setCustomColors(null); + setPreset('default'); + }, [setPreset]); + + return ( + + {children} + + ); +} + +export function useThemePreset() { + const context = useContext(ThemeContext); + if (!context) throw new Error('useThemePreset must be used within ThemePresetProvider'); + return context; +} diff --git a/apps/web/src/lib/themes.ts b/apps/web/src/lib/themes.ts new file mode 100644 index 0000000..4ea44b3 --- /dev/null +++ b/apps/web/src/lib/themes.ts @@ -0,0 +1,77 @@ +export interface ThemePreset { + id: string; + name: string; + colors: { + accent: string; + accentForeground: string; + primary: string; + secondary: string; + }; +} + +export const THEME_PRESETS: ThemePreset[] = [ + { + id: 'default', + name: 'Default', + colors: { + accent: '#f5f5f5', + accentForeground: '#262626', + primary: '#171717', + secondary: '#f5f5f5', + }, + }, + { + id: 'monokai', + name: 'Monokai', + colors: { + accent: '#a6e22e', + accentForeground: '#000000', + primary: '#a6e22e', + secondary: '#7e57c2', + }, + }, + { + id: 'dracula', + name: 'Dracula', + colors: { + accent: '#bd93f9', + accentForeground: '#282a36', + primary: '#bd93f9', + secondary: '#50fa7b', + }, + }, + { + id: 'nord', + name: 'Nord', + colors: { + accent: '#81a1c1', + accentForeground: '#2e3440', + primary: '#81a1c1', + secondary: '#8fbcbb', + }, + }, + { + id: 'solarized', + name: 'Solarized', + colors: { + accent: '#2aa198', + accentForeground: '#073642', + primary: '#2aa198', + secondary: '#cb4b16', + }, + }, + { + id: 'github', + name: 'GitHub', + colors: { + accent: '#1f6feb', + accentForeground: '#ffffff', + primary: '#1f6feb', + secondary: '#2ea043', + }, + }, +]; + +export function getPreset(id: string): ThemePreset | undefined { + return THEME_PRESETS.find((p) => p.id === id); +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 9e98c51..0c74bc6 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -83,6 +83,8 @@ export interface UserDTO { avatarUrl: string | null; tier: TierType; privacyMode: boolean; + customStatus: string | null; + ghostMode: boolean; /** Account creation timestamp as ISO 8601 / RFC3339 string (e.g., "2024-01-01T12:00:00Z") */ createdAt: string; } diff --git a/packages/shared/src/validators.ts b/packages/shared/src/validators.ts index 03c15ae..a4759e2 100644 --- a/packages/shared/src/validators.ts +++ b/packages/shared/src/validators.ts @@ -68,6 +68,8 @@ export const LoginRequestSchema = z.object({ export const UserUpdateSchema = z.object({ displayName: z.string().min(1).max(100).optional(), privacyMode: z.boolean().optional(), + ghostMode: z.boolean().optional(), + customStatus: z.string().max(50).optional().nullable(), }); export const PaginationQuerySchema = z.object({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index acd8938..dd9a991 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -134,6 +134,9 @@ importers: razorpay: specifier: ^2.9.6 version: 2.9.6 + resend: + specifier: ^6.9.3 + version: 6.9.3 ws: specifier: ^8.19.0 version: 8.19.0 @@ -1726,6 +1729,9 @@ packages: resolution: {integrity: sha512-ERcExbWrnkDN8ovoWWe6Wgt/usanj1dWUd18dJLpctUI4mlPS0nKt81Joh8VI+OPbNnY1lIilVt9gdMBD9U2ig==} engines: {node: '>= 18', npm: '>= 8.6.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2867,6 +2873,9 @@ packages: fast-safe-stringify@2.1.1: resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==} + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} @@ -4029,6 +4038,9 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} + postal-mime@2.7.3: + resolution: {integrity: sha512-MjhXadAJaWgYzevi46+3kLak8y6gbg0ku14O1gO/LNOuay8dO+1PtcSGvAdgDR0DoIsSaiIA8y/Ddw6MnrO0Tw==} + postcss@8.4.31: resolution: {integrity: sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==} engines: {node: ^10 || ^12 || >=14} @@ -4240,6 +4252,15 @@ packages: resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} engines: {node: '>=0.10.0'} + resend@6.9.3: + resolution: {integrity: sha512-GRXjH9XZBJA+daH7bBVDuTShr22iWCxXA8P7t495G4dM/RC+d+3gHBK/6bz9K6Vpcq11zRQKmD+B+jECwQlyGQ==} + engines: {node: '>=20'} + peerDependencies: + '@react-email/render': '*' + peerDependenciesMeta: + '@react-email/render': + optional: true + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -4463,6 +4484,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4578,6 +4602,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.84.1: + resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==} + table@6.9.0: resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} engines: {node: '>=10.0.0'} @@ -4821,6 +4848,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} hasBin: true @@ -6280,6 +6311,8 @@ snapshots: transitivePeerDependencies: - debug + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -7706,6 +7739,8 @@ snapshots: fast-safe-stringify@2.1.1: {} + fast-sha256@1.3.0: {} + fast-uri@3.1.0: {} fastfall@1.5.1: @@ -8901,6 +8936,8 @@ snapshots: possible-typed-array-names@1.1.0: {} + postal-mime@2.7.3: {} + postcss@8.4.31: dependencies: nanoid: 3.3.11 @@ -9133,6 +9170,11 @@ snapshots: require-from-string@2.0.2: {} + resend@6.9.3: + dependencies: + postal-mime: 2.7.3 + svix: 1.84.1 + resolve-from@4.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -9386,6 +9428,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@3.9.0: {} @@ -9523,6 +9570,11 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.84.1: + dependencies: + standardwebhooks: 1.0.0 + uuid: 10.0.0 + table@6.9.0: dependencies: ajv: 8.17.1 @@ -9792,6 +9844,8 @@ snapshots: util-deprecate@1.0.2: {} + uuid@10.0.0: {} + uuid@8.3.2: {} valibot@1.2.0(typescript@5.9.3):