stadtwerke/innungsapp/apps/admin/server/routers/members.ts

682 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { z } from 'zod'
import { router, memberProcedure, adminProcedure } from '../trpc'
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<void> {
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,
},
})
await createCredentialAccount(userId, opts.password)
return { id: userId }
}
const nonEmptyString = (min = 2) =>
z.preprocess((val) => (val === '' ? undefined : val), z.string().min(min).optional())
const MemberInput = z.object({
name: z.string().min(2),
betrieb: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
// Member.sparte is required in Prisma; map "not selected" to a safe default.
sparte: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional().default('Sonstiges')),
ort: z.preprocess((v) => (v === '' ? undefined : v), z.string().optional()),
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,
sparte: rest.sparte || 'Sonstiges',
orgId: ctx.orgId,
} as any,
})
// 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 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 hash directly
const newHash = await hashPassword(password)
await ctx.prisma.account.update({ where: { id: credAccount.id }, data: { password: newHash } })
} 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
// 1. Create member record
const member = await ctx.prisma.member.create({
data: {
...memberData,
sparte: memberData.sparte || 'Sonstiges',
orgId: ctx.orgId,
} as any,
})
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: z.object({
name: nonEmptyString(),
betrieb: nonEmptyString(),
sparte: nonEmptyString(),
ort: nonEmptyString(),
telefon: z.string().optional(),
email: z.preprocess((v) => (v === '' ? undefined : v), z.string().email().optional()),
status: z.enum(['aktiv', 'ruhend', 'ausgetreten']).optional(),
istAusbildungsbetrieb: z.boolean().optional(),
seit: z.number().int().min(1900).max(2100).optional(),
role: z.enum(['member', 'admin']).optional(),
password: z.preprocess((val) => (val === '' ? undefined : val), z.string().min(8).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) {
// Update password hash directly — auth.api.updateUser requires Better Auth admin role
// which our custom UserRole system doesn't set, causing silent failures.
const newHash = await hashPassword(password)
await ctx.prisma.account.update({
where: { id: existingCredAccount.id },
data: { password: newHash },
})
} 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 }
}),
})