import { GraphQLError } from 'graphql' import { prisma } from '../db.js' import { issueAppJwt, type AuthContext } from '../auth.js' const OTP_TTL_MINUTES = Number.parseInt(process.env.LOGIN_OTP_TTL_MINUTES || '10', 10) const SESSION_TTL_DAYS = Number.parseInt(process.env.LOGIN_SESSION_TTL_DAYS || '30', 10) const DEV_LOGIN_OTP_CODE = process.env.LOGIN_OTP_DEV_CODE || '000000' export const userTypeDefs = `#graphql type UserTeam { id: String! name: String! teamType: String logtoOrgId: String createdAt: String } type User { id: String firstName: String lastName: String displayName: String phone: String avatarId: String isManager: Boolean activeTeamId: String activeTeam: UserTeam teams: [UserTeam] } type AuthChallenge { challengeType: String! phone: String! expiresAt: String! codeForDev: String totpConfigured: Boolean! } type AuthPayload { token: String! sessionExpiresAt: String! user: User! } input RequestLoginOtpInput { phone: String! } input VerifyLoginOtpInput { phone: String! code: String! } type TeamMemberInfo { uuid: String! role: String! joinedAt: String } type TeamInvitationInfo { uuid: String! email: String! role: String! status: String! invitedBy: String expiresAt: String createdAt: String } type TeamWithMembers { uuid: String! name: String! members: [TeamMemberInfo] invitations: [TeamInvitationInfo] } input CreateTeamInput { name: String! teamType: String } input UpdateUserInput { firstName: String lastName: String phone: String avatarId: String } type CreateTeamResult { team: UserTeam success: Boolean! } type UpdateUserResult { user: User success: Boolean! } type SwitchTeamResult { success: Boolean! } type Query { me: User getTeam(teamId: String!): TeamWithMembers } type Mutation { requestLoginOtp(input: RequestLoginOtpInput!): AuthChallenge! verifyLoginOtp(input: VerifyLoginOtpInput!): AuthPayload! logout: Boolean! createTeam(input: CreateTeamInput!): CreateTeamResult updateUser(userId: String!, input: UpdateUserInput!): UpdateUserResult switchTeam(teamId: String!): SwitchTeamResult } ` async function getOrCreateProfile(logtoId: string) { let profile = await prisma.userProfile.findUnique({ where: { logtoId: logtoId }, include: { user: true, activeTeam: true } }) if (!profile) { const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } }) profile = await prisma.userProfile.create({ data: { userId: user.id, logtoId: logtoId }, include: { user: true, activeTeam: true }, }) } return profile } function normalizePhone(phone: string): string { const normalized = phone.replace(/[^\d+]/g, '') if (normalized.length < 5) throw new GraphQLError('Invalid phone') return normalized } function displayName(firstName: string, lastName: string, phone: string): string { const name = `${firstName} ${lastName}`.trim() return name.length > 0 ? name : phone } async function getOrCreateProfileByPhone(phone: string) { const logtoId = `phone:${phone}` const existing = await prisma.userProfile.findFirst({ where: { OR: [{ phone }, { logtoId }] }, include: { user: true, activeTeam: true }, }) if (existing !== null) return existing const user = await prisma.user.create({ data: { username: logtoId, firstName: '', lastName: '' } }) return prisma.userProfile.create({ data: { userId: user.id, logtoId, phone }, include: { user: true, activeTeam: true }, }) } async function isManagerUser(userId: number): Promise { const user = await prisma.user.findUnique({ where: { id: userId } }) if (user === null) return false if (user.isStaff || user.isSuperuser) return true const managerMembership = await prisma.teamMember.findFirst({ where: { userId, role: { in: ['OWNER', 'MANAGER', 'ADMIN'] } }, }) return managerMembership !== null } async function mapProfileUser(profile: Awaited>) { const memberships = await prisma.teamMember.findMany({ where: { userId: profile.userId }, include: { team: true } }) const isManager = await isManagerUser(profile.userId) return { id: profile.logtoId, firstName: profile.user.firstName, lastName: profile.user.lastName, displayName: displayName(profile.user.firstName, profile.user.lastName, profile.phone), phone: profile.phone, avatarId: profile.avatarId, isManager, activeTeamId: profile.activeTeam?.uuid ?? null, activeTeam: profile.activeTeam ? { id: profile.activeTeam.uuid, name: profile.activeTeam.name, teamType: profile.activeTeam.teamType, logtoOrgId: profile.activeTeam.logtoOrgId, createdAt: profile.activeTeam.createdAt.toISOString() } : null, teams: memberships.map(m => ({ id: m.team.uuid, name: m.team.name, teamType: m.team.teamType, logtoOrgId: m.team.logtoOrgId, createdAt: m.team.createdAt.toISOString() })), } } async function verifyPhoneLogin(phone: string, code: string) { const normalizedPhone = normalizePhone(phone) const challenge = await prisma.loginChallenge.findFirst({ where: { phone: normalizedPhone, code, usedAt: null, expiresAt: { gt: new Date() }, }, orderBy: { createdAt: 'desc' }, }) if (challenge === null) throw new GraphQLError('Invalid login code') await prisma.loginChallenge.update({ where: { id: challenge.id }, data: { usedAt: new Date() } }) const profile = await getOrCreateProfileByPhone(normalizedPhone) const isManager = await isManagerUser(profile.userId) const token = await issueAppJwt({ userId: profile.logtoId, teamUuid: profile.activeTeam?.uuid ?? null, isManager }) const expiresAt = new Date(Date.now() + SESSION_TTL_DAYS * 24 * 60 * 60 * 1000) return { token, sessionExpiresAt: expiresAt.toISOString(), user: await mapProfileUser(profile), } } export const userResolvers = { Query: { me: async (_: unknown, __: unknown, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError('Not authenticated') const profile = await getOrCreateProfile(ctx.userId) return mapProfileUser(profile) }, getTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError('Not authenticated') const team = await prisma.team.findUnique({ where: { uuid: args.teamId }, include: { members: { include: { user: true } }, invitations: true, }, }) if (!team) return null return { uuid: team.uuid, name: team.name, members: team.members.map(m => ({ uuid: m.uuid, role: m.role, joinedAt: m.joinedAt.toISOString() })), invitations: team.invitations.map(i => ({ uuid: i.uuid, email: i.email, role: i.role, status: i.status, invitedBy: i.invitedBy, expiresAt: i.expiresAt?.toISOString() ?? null, createdAt: i.createdAt.toISOString(), })), } }, }, Mutation: { requestLoginOtp: async (_: unknown, args: { input: { phone: string } }) => { const phone = normalizePhone(args.input.phone) const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000) await prisma.loginChallenge.create({ data: { phone, code: DEV_LOGIN_OTP_CODE, expiresAt } }) return { challengeType: 'otp', phone, expiresAt: expiresAt.toISOString(), codeForDev: process.env.NODE_ENV === 'production' ? null : DEV_LOGIN_OTP_CODE, totpConfigured: true, } }, verifyLoginOtp: async (_: unknown, args: { input: { phone: string; code: string } }) => { return verifyPhoneLogin(args.input.phone, args.input.code) }, logout: async (_: unknown, __: unknown, ctx: AuthContext) => { if (ctx.sessionToken !== undefined) { await prisma.authSession.updateMany({ where: { token: ctx.sessionToken, revokedAt: null }, data: { revokedAt: new Date() } }) } return true }, createTeam: async (_: unknown, args: { input: { name: string; teamType?: string } }, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError('Not authenticated') const profile = await getOrCreateProfile(ctx.userId) const team = await prisma.team.create({ data: { name: args.input.name, teamType: args.input.teamType || 'BUYER', ownerId: profile.userId }, }) await prisma.teamMember.create({ data: { teamId: team.id, userId: profile.userId, role: 'OWNER' } }) await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } }) return { team: { id: team.uuid, name: team.name, teamType: team.teamType, logtoOrgId: team.logtoOrgId, createdAt: team.createdAt.toISOString() }, success: true, } }, updateUser: async (_: unknown, args: { userId: string; input: { firstName?: string; lastName?: string; phone?: string; avatarId?: string } }, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError('Not authenticated') const profile = await getOrCreateProfile(ctx.userId) const userUpdate: Record = {} if (args.input.firstName !== undefined) userUpdate.firstName = args.input.firstName if (args.input.lastName !== undefined) userUpdate.lastName = args.input.lastName if (Object.keys(userUpdate).length > 0) { await prisma.user.update({ where: { id: profile.userId }, data: userUpdate }) } const profileUpdate: Record = {} if (args.input.phone !== undefined) profileUpdate.phone = args.input.phone if (args.input.avatarId !== undefined) profileUpdate.avatarId = args.input.avatarId if (Object.keys(profileUpdate).length > 0) { await prisma.userProfile.update({ where: { id: profile.id }, data: profileUpdate }) } return { user: { id: ctx.userId }, success: true } }, switchTeam: async (_: unknown, args: { teamId: string }, ctx: AuthContext) => { if (!ctx.userId) throw new GraphQLError('Not authenticated') const profile = await getOrCreateProfile(ctx.userId) const team = await prisma.team.findUnique({ where: { uuid: args.teamId } }) if (!team) throw new GraphQLError('Team not found') await prisma.userProfile.update({ where: { id: profile.id }, data: { activeTeamId: team.id } }) return { success: true } }, }, }