import { z } from 'zod' import { router, memberProcedure, adminProcedure } from '../trpc' import { auth, getSanitizedHeaders } from '@/lib/auth' import { sendInviteEmail, sendAdminCredentialsEmail } from '@/lib/email' import crypto from 'node:crypto' import { prisma } from '@innungsapp/shared' // @ts-ignore — Better Auth exposes its password utilities via `better-auth/crypto` import { hashPassword } from 'better-auth/crypto' /** * Creates a credential (email+password) account for a user that has no such account yet. * Better Auth stores passwords in the `account` table with providerId='credential'. * Uses Better Auth's own `hashPassword` to ensure the hash format matches its verifyPassword. */ async function createCredentialAccount(userId: string, password: string): Promise { const hashed = await hashPassword(password) await prisma.account.create({ data: { id: crypto.randomUUID(), accountId: userId, providerId: 'credential', userId, password: hashed, }, }) } /** * Creates a user + credential account directly in the DB, bypassing Better Auth's admin API. * auth.api.createUser requires the calling session to have role='admin' in Better Auth's own * user table, which our custom role system doesn't set. This avoids the 403 FORBIDDEN error. */ async function createUserDirectly(opts: { email: string; name: string; password: string; mustChangePassword?: boolean }) { const userId = crypto.randomUUID() await prisma.user.create({ data: { id: userId, name: opts.name, email: opts.email, emailVerified: false, mustChangePassword: opts.mustChangePassword ?? false, }, }) // Try better-auth API first (guaranteed correct hash format). // Falls back to direct DB write if API fails (e.g. admin permissions not available). try { const authHeaders = await getSanitizedHeaders() await auth.api.updateUser({ body: { userId, password: opts.password }, headers: authHeaders, }) } catch { await createCredentialAccount(userId, opts.password) } return { id: userId } } const MemberInput = z.object({ name: z.string().min(2), betrieb: z.string().min(2), sparte: z.string().min(2), ort: z.string().min(2), telefon: z.string().optional(), email: z.string().email(), status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).default('aktiv'), istAusbildungsbetrieb: z.boolean().default(false), seit: z.number().int().min(1900).max(2100).optional(), role: z.enum(['member', 'admin']).optional().default('member'), password: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(8).optional()), }) export const membersRouter = router({ /** * List all members in the user's org */ list: memberProcedure .input( z.object({ search: z.string().optional(), status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).optional(), ausbildungsbetrieb: z.boolean().optional(), }) ) .query(async ({ ctx, input }) => { const members = await ctx.prisma.member.findMany({ where: { orgId: ctx.orgId, ...(input.status && { status: input.status }), ...(input.ausbildungsbetrieb !== undefined && { istAusbildungsbetrieb: input.ausbildungsbetrieb, }), ...(input.search && { OR: [ { name: { contains: input.search } }, { betrieb: { contains: input.search } }, { ort: { contains: input.search } }, { sparte: { contains: input.search } }, ], }), }, orderBy: { name: 'asc' }, }) return members }), byId: memberProcedure .input(z.object({ id: z.string() })) .query(async ({ ctx, input }) => { let member = await ctx.prisma.member.findFirst({ where: { id: input.id, orgId: ctx.orgId }, }) let role = 'member' if (member?.userId) { const ur = await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: ctx.orgId, userId: member.userId } } }) if (ur && ur.role === 'admin') role = 'admin' } if (!member) { // Try finding the member by userId (list page uses userId as ID for admins) const memberByUserId = await ctx.prisma.member.findFirst({ where: { userId: input.id, orgId: ctx.orgId }, }) if (memberByUserId) { const ur2 = await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: ctx.orgId, userId: input.id } }, }) if (ur2?.role === 'admin') role = 'admin' return { ...memberByUserId, role } } // Fallback: Check if the ID belongs to a user who is an admin in this org const adminRole = await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: ctx.orgId, userId: input.id } }, include: { user: true } }) if (!adminRole) { // Last resort A: find member by ID regardless of org (org mismatch scenario) const memberAnyOrg = await ctx.prisma.member.findUnique({ where: { id: input.id } }) if (memberAnyOrg) { const callerHasAccess = await ctx.prisma.userRole.findFirst({ where: { userId: ctx.session.user.id, orgId: memberAnyOrg.orgId, role: 'admin' } }) if (callerHasAccess) { const ur = memberAnyOrg.userId ? await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: memberAnyOrg.orgId, userId: memberAnyOrg.userId } } }) : null return { ...memberAnyOrg, role: ur?.role ?? 'member' } } } // Last resort B: input.id is a userId whose UserRole is in a different org than ctx.orgId const roleAnyOrg = await ctx.prisma.userRole.findFirst({ where: { userId: input.id }, include: { user: true }, }) if (roleAnyOrg) { const callerHasAccess = await ctx.prisma.userRole.findFirst({ where: { userId: ctx.session.user.id, orgId: roleAnyOrg.orgId, role: 'admin' } }) if (callerHasAccess) { const memberRecord = await ctx.prisma.member.findFirst({ where: { userId: input.id, orgId: roleAnyOrg.orgId } }) if (memberRecord) return { ...memberRecord, role: roleAnyOrg.role } // Admin without member record — return mock return { id: roleAnyOrg.userId, orgId: roleAnyOrg.orgId, userId: roleAnyOrg.userId, name: roleAnyOrg.user.name, betrieb: 'Administrator', sparte: 'Sonderfunktion', ort: '', telefon: '', email: roleAnyOrg.user.email, status: 'aktiv', istAusbildungsbetrieb: false, seit: new Date().getFullYear(), avatarUrl: null, pushToken: null, createdAt: roleAnyOrg.createdAt, updatedAt: roleAnyOrg.createdAt, role: roleAnyOrg.role, } as any } } throw new Error('Member not found') } if (adminRole.role !== 'admin') throw new Error('Member not found') // Mock a Member object so the frontend form doesn't crash member = { id: adminRole.userId, // use userId here to update orgId: ctx.orgId, userId: adminRole.userId, name: adminRole.user.name, betrieb: 'Administrator', sparte: 'Sonderfunktion', ort: '', telefon: '', email: adminRole.user.email, status: 'aktiv', istAusbildungsbetrieb: false, seit: new Date().getFullYear(), avatarUrl: null, pushToken: null, createdAt: adminRole.createdAt, updatedAt: adminRole.createdAt, } as any role = 'admin' } return { ...member, role } }), create: adminProcedure.input(MemberInput).mutation(async ({ ctx, input }) => { const { role, password, ...rest } = input // 1. Create the member record const member = await ctx.prisma.member.create({ data: { ...rest, orgId: ctx.orgId }, }) // 2. Create a User account if a password was provided OR role is 'admin', // so the role is always persisted (no email sent here). if (password || role === 'admin') { try { const authHeaders = await getSanitizedHeaders() const existing = await ctx.prisma.user.findUnique({ where: { email: input.email } }) let userId: string | undefined = existing?.id const effectivePassword = password || crypto.randomBytes(8).toString('hex') if (!userId) { // Create user + credential account directly via Prisma. // auth.api.createUser requires the caller to have role='admin' in Better Auth's own // user table (not our custom UserRole table), which causes a 403 FORBIDDEN. const newUserRecord = await createUserDirectly({ name: input.name, email: input.email, password: effectivePassword, mustChangePassword: true, }) userId = newUserRecord.id } else if (password) { // User exists and a password was explicitly set. // Check if they already have a credential account; if not, create one directly. const credAccount = await ctx.prisma.account.findFirst({ where: { userId, providerId: 'credential' } }) if (credAccount) { // Credential account exists — update the password via Better Auth await auth.api.updateUser({ body: { userId, password, name: input.name }, headers: authHeaders }) } else { // No credential account yet — create one directly in the DB await createCredentialAccount(userId, password) } } if (userId) { await ctx.prisma.member.update({ where: { id: member.id }, data: { userId } }) await ctx.prisma.userRole.upsert({ where: { orgId_userId: { orgId: ctx.orgId, userId } }, create: { orgId: ctx.orgId, userId, role: role ?? 'member' }, update: { role: role ?? 'member' }, }) } } catch (e) { console.error('Failed to create user account during member creation', e) } } return member }), /** * Create member + send invite email (admin only) */ invite: adminProcedure .input(MemberInput) .mutation(async ({ ctx, input }) => { const { role, password, ...memberData } = input const authHeaders = await getSanitizedHeaders() // 1. Create member record const member = await ctx.prisma.member.create({ data: { ...memberData, orgId: ctx.orgId }, }) const org = await ctx.prisma.organization.findUniqueOrThrow({ where: { id: ctx.orgId }, }) // 2. Create/get User directly via Prisma to avoid auth.api.createUser 403 FORBIDDEN. // (Better Auth's admin API requires the caller's session user to have role='admin' in // its own user table, which our custom UserRole system doesn't set.) let targetUserId: string | undefined try { const effectivePassword = password || (role === 'admin' ? crypto.randomBytes(6).toString('hex') : undefined) if (effectivePassword) { const newUserId = (await createUserDirectly({ name: input.name, email: input.email, password: effectivePassword, mustChangePassword: !password && role === 'admin', })).id targetUserId = newUserId } if (targetUserId) { // link user to member await ctx.prisma.member.update({ where: { id: member.id }, data: { userId: targetUserId } }) // if admin, set role if (role === 'admin') { await ctx.prisma.userRole.upsert({ where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId } }, create: { orgId: ctx.orgId, userId: targetUserId, role: 'admin' }, update: { role: 'admin' } }) // Send admin credentials await sendAdminCredentialsEmail({ to: input.email, adminName: input.name, orgName: org.name, password: password!, loginUrl: process.env.BETTER_AUTH_URL! }) } } } catch (e) { // User may already exist const existingUser = await ctx.prisma.user.findUnique({ where: { email: input.email } }) if (existingUser) { targetUserId = existingUser.id await ctx.prisma.member.update({ where: { id: member.id }, data: { userId: targetUserId } }) if (role === 'admin') { await ctx.prisma.userRole.upsert({ where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId } }, create: { orgId: ctx.orgId, userId: targetUserId, role: 'admin' }, update: { role: 'admin' } }) } } } // 3. Send magic link for members (if not admin or if admin creation failed to send credentials) if (role === 'member') { await sendInviteEmail({ to: input.email, memberName: input.name, orgName: org.name, apiUrl: process.env.BETTER_AUTH_URL!, }) } return member }), /** * Update member (admin only) */ update: adminProcedure .input(z.object({ id: z.string(), data: MemberInput.partial().extend({ role: z.enum(['member', 'admin']).optional() }) })) .mutation(async ({ ctx, input }) => { const { role, password, ...memberData } = input.data let member = await ctx.prisma.member.findFirst({ where: { id: input.id, orgId: ctx.orgId }, }) // If not found by member ID, try by userId (list page links use userId for admins) if (!member) { member = await ctx.prisma.member.findFirst({ where: { userId: input.id, orgId: ctx.orgId }, }) } // For existing members, targetUserId is their associated user ID (can be null). // For fallback admins (no member record), input.id is the User ID. let targetUserId = member ? member.userId : input.id // If they don't have a User record yet, but we want to update their role to Admin, // we need to pre-create their User record so we can attach the UserRole. if (member && !targetUserId && role === 'admin') { const email = memberData.email || member.email const name = memberData.name || member.name // Always generate a password – without one Better Auth creates no credential account // and the user can never log in with email/password. const effectivePassword = password || crypto.randomBytes(8).toString('hex') try { const user = await ctx.prisma.user.findUnique({ where: { email } }) if (user) { targetUserId = user.id // If the existing user has no credential account (e.g. OAuth-only), set a password now const credAccount = await ctx.prisma.account.findFirst({ where: { userId: user.id, providerId: 'credential' } }) if (!credAccount) { // No credential account — create one directly (updateUser can't create from scratch) await createCredentialAccount(user.id, effectivePassword) } } else { const newUserId = (await createUserDirectly({ email, name, password: effectivePassword, mustChangePassword: !password, })).id targetUserId = newUserId } if (targetUserId) { await ctx.prisma.member.update({ where: { id: member.id }, data: { userId: targetUserId } }) } } catch (e) { console.error("Failed to pre-create user for pending invitee admin upgrade", e) } } if (member) { if (Object.keys(memberData).length > 0) { await ctx.prisma.member.update({ where: { id: member.id }, data: memberData as any, }) } } else { // Fallback: Creating skeleton member for former pure-admin that is now turning to member const existingAdmin = await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId as string } } }) if (!existingAdmin || existingAdmin.role !== 'admin') throw new Error('Member not found') // Since we are creating a member profile, ensure required fields const createData = { betrieb: memberData.betrieb || 'Administrator', sparte: memberData.sparte || 'Sonderfunktion', ort: memberData.ort || '', telefon: memberData.telefon || '', email: memberData.email || 'no-reply@innungsapp.de', status: memberData.status || 'aktiv', name: memberData.name || 'Unbekannt', } const existingMemberByUserId = await ctx.prisma.member.findFirst({ where: { userId: targetUserId as string, orgId: ctx.orgId } }) if (existingMemberByUserId) { // Member record already exists — just update it with any new data await ctx.prisma.member.update({ where: { id: existingMemberByUserId.id }, data: createData as any, }) } else { await ctx.prisma.member.create({ data: { ...createData, orgId: ctx.orgId, userId: targetUserId } }) } } if (role && targetUserId) { // Update the role in UserRole await ctx.prisma.userRole.upsert({ where: { orgId_userId: { orgId: ctx.orgId, userId: targetUserId } }, create: { orgId: ctx.orgId, userId: targetUserId, role }, update: { role } }) } // When promoting to admin, ensure a credential (email+password) account exists // so the user can log in to the admin dashboard. if (role === 'admin' && targetUserId && !password) { const credAccount = await ctx.prisma.account.findFirst({ where: { userId: targetUserId, providerId: 'credential' }, }) if (!credAccount) { const generatedPassword = crypto.randomBytes(8).toString('hex') await createCredentialAccount(targetUserId, generatedPassword) try { const targetUser = await ctx.prisma.user.findUnique({ where: { id: targetUserId } }) const org = await ctx.prisma.organization.findUnique({ where: { id: ctx.orgId } }) if (targetUser && org) { await sendAdminCredentialsEmail({ to: targetUser.email, adminName: targetUser.name, orgName: org.name, password: generatedPassword, loginUrl: process.env.BETTER_AUTH_URL!, }) } } catch (e) { console.error('Failed to send admin credentials email after auto-credential creation', e) } } } // Update password if provided if (password && targetUserId) { // Check if the user already has a credential account. // auth.api.updateUser can update an existing credential account password, but CANNOT create one. const existingCredAccount = await ctx.prisma.account.findFirst({ where: { userId: targetUserId, providerId: 'credential' } }) if (existingCredAccount) { const authHeaders = await getSanitizedHeaders() let nameForUpdate = memberData.name if (!nameForUpdate) { const existingUser = await ctx.prisma.user.findUnique({ where: { id: targetUserId }, select: { name: true } }) nameForUpdate = existingUser?.name ?? undefined } await auth.api.updateUser({ body: { userId: targetUserId, password, ...(nameForUpdate && { name: nameForUpdate }), }, headers: authHeaders, }) } else { // No credential account — create one from scratch using Better Auth's own hash format await createCredentialAccount(targetUserId, password) } // Admin has set a password → user must change it on next login await ctx.prisma.user.update({ where: { id: targetUserId }, data: { mustChangePassword: true }, }) } // Keep user.name in sync when member name changes if (memberData.name && targetUserId) { const user = await ctx.prisma.user.findUnique({ where: { id: targetUserId } }) if (user) { await ctx.prisma.user.update({ where: { id: targetUserId }, data: { name: memberData.name } }) } } return member || { success: true } }), /** * Send/resend invite to existing member (admin only) */ resendInvite: adminProcedure .input(z.object({ memberId: z.string() })) .mutation(async ({ ctx, input }) => { const member = await ctx.prisma.member.findFirstOrThrow({ where: { id: input.memberId, orgId: ctx.orgId }, }) const org = await ctx.prisma.organization.findUniqueOrThrow({ where: { id: ctx.orgId }, }) await sendInviteEmail({ to: member.email, memberName: member.name, orgName: org.name, apiUrl: process.env.BETTER_AUTH_URL!, }) return { success: true } }), /** * Get own member profile */ me: memberProcedure.query(async ({ ctx }) => { const member = await ctx.prisma.member.findFirst({ where: { userId: ctx.session.user.id, orgId: ctx.orgId }, include: { org: true }, }) return member }), /** * Update own member profile */ updateMe: memberProcedure .input( z.object({ name: z.string().min(2).optional(), email: z.string().email().optional(), telefon: z.string().optional(), ort: z.string().min(2).optional(), betrieb: z.string().min(2).optional(), sparte: z.string().min(2).optional(), istAusbildungsbetrieb: z.boolean().optional(), }) ) .mutation(async ({ ctx, input }) => { const member = await ctx.prisma.member.findFirst({ where: { userId: ctx.session.user.id, orgId: ctx.orgId }, }) if (!member) throw new Error('Member not found') const updated = await ctx.prisma.member.update({ where: { id: member.id }, data: input, }) // Keep user.name in sync when member name changes if (input.name) { await ctx.prisma.user.update({ where: { id: ctx.session.user.id }, data: { name: input.name }, }) } return updated }), /** * Delete member (admin only) */ delete: adminProcedure .input(z.object({ id: z.string() })) .mutation(async ({ ctx, input }) => { const member = await ctx.prisma.member.findFirst({ where: { id: input.id, orgId: ctx.orgId }, }) if (!member) { // Fallback for user-based "pure" admins const adminRole = await ctx.prisma.userRole.findUnique({ where: { orgId_userId: { orgId: ctx.orgId, userId: input.id } } }) if (!adminRole) throw new Error('Member not found') if (adminRole.userId === ctx.session.user.id) { throw new Error('Sie können Ihren eigenen Account nicht löschen.') } await ctx.prisma.userRole.delete({ where: { id: adminRole.id } }) return { success: true } } if (member.userId === ctx.session.user.id) { throw new Error('Sie können Ihren eigenen Account nicht löschen.') } // 1. Remove UserRole link if exists if (member.userId) { await ctx.prisma.userRole.deleteMany({ where: { orgId: ctx.orgId, userId: member.userId } }) } // 2. Delete member profile await ctx.prisma.member.delete({ where: { id: member.id } }) return { success: true } }), })