This commit is contained in:
Timo Knuth
2026-02-27 15:19:24 +01:00
parent b7f8221095
commit 253c3c1c6d
134 changed files with 11188 additions and 1871 deletions

View File

@@ -4,6 +4,7 @@ import { newsRouter } from './news'
import { termineRouter } from './termine'
import { stellenRouter } from './stellen'
import { organizationsRouter } from './organizations'
import { messagesRouter } from './messages'
export const appRouter = router({
members: membersRouter,
@@ -11,6 +12,7 @@ export const appRouter = router({
termine: termineRouter,
stellen: stellenRouter,
organizations: organizationsRouter,
messages: messagesRouter,
})
export type AppRouter = typeof appRouter

View File

@@ -1,7 +1,59 @@
import { z } from 'zod'
import { router, memberProcedure, adminProcedure } from '../trpc'
import { auth } from '@/lib/auth'
import { sendInviteEmail } from '@/lib/email'
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<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,
},
})
// 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),
@@ -13,6 +65,8 @@ const MemberInput = z.object({
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({
@@ -49,67 +103,270 @@ export const membersRouter = router({
return members
}),
/**
* Get a single member by ID
*/
byId: memberProcedure
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
const member = await ctx.prisma.member.findFirst({
let member = await ctx.prisma.member.findFirst({
where: { id: input.id, orgId: ctx.orgId },
})
if (!member) throw new Error('Member not found')
return member
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 a new member (admin only)
*/
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: {
...input,
orgId: ctx.orgId,
},
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: { ...input, orgId: ctx.orgId },
data: { ...memberData, orgId: ctx.orgId },
})
// 2. Create/get User via better-auth admin
try {
await auth.api.createUser({
body: {
name: input.name,
email: input.email,
role: 'user',
password: undefined,
},
})
} catch {
// User may already exist — that's ok
}
// 3. Send magic link
const org = await ctx.prisma.organization.findUniqueOrThrow({
where: { id: ctx.orgId },
})
await sendInviteEmail({
to: input.email,
memberName: input.name,
orgName: org.name,
apiUrl: process.env.BETTER_AUTH_URL!,
})
// 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
}),
@@ -118,20 +375,188 @@ export const membersRouter = router({
* Update member (admin only)
*/
update: adminProcedure
.input(z.object({ id: z.string(), data: MemberInput.partial() }))
.input(z.object({ id: z.string(), data: MemberInput.partial().extend({ role: z.enum(['member', 'admin']).optional() }) }))
.mutation(async ({ ctx, input }) => {
const member = await ctx.prisma.member.updateMany({
const { role, password, ...memberData } = input.data
let member = await ctx.prisma.member.findFirst({
where: { id: input.id, orgId: ctx.orgId },
data: input.data,
})
// Keep user.name in sync when member name changes
if (input.data.name) {
const m = await ctx.prisma.member.findFirst({ where: { id: input.id }, select: { userId: true } })
if (m?.userId) {
await ctx.prisma.user.update({ where: { id: m.userId }, data: { name: input.data.name } })
// 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)
}
}
return member
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 }
}),
/**
@@ -165,4 +590,84 @@ export const membersRouter = router({
})
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 }
}),
})

View File

@@ -0,0 +1,184 @@
import { z } from 'zod'
import { router, memberProcedure } from '../trpc'
import { TRPCError } from '@trpc/server'
export const messagesRouter = router({
// List all conversations for the current member
getConversations: memberProcedure.query(async ({ ctx }) => {
const member = await ctx.prisma.member.findFirst({
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
})
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
const convMembers = await ctx.prisma.conversationMember.findMany({
where: { memberId: member.id },
include: {
conversation: {
include: {
members: {
include: { member: { select: { id: true, name: true, betrieb: true, avatarUrl: true } } },
},
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { body: true, createdAt: true, senderId: true },
},
},
},
},
orderBy: { conversation: { updatedAt: 'desc' } },
})
return convMembers.map((cm) => {
const other = cm.conversation.members.find((m) => m.memberId !== member.id)?.member
const lastMsg = cm.conversation.messages[0] ?? null
const unread =
lastMsg &&
(!cm.lastReadAt || lastMsg.createdAt > cm.lastReadAt) &&
lastMsg.senderId !== member.id
return {
conversationId: cm.conversationId,
other,
lastMessage: lastMsg,
hasUnread: !!unread,
updatedAt: cm.conversation.updatedAt,
}
})
}),
// Get or create a 1-on-1 conversation between current member and another
getOrCreate: memberProcedure
.input(z.object({ otherMemberId: z.string() }))
.mutation(async ({ ctx, input }) => {
const member = await ctx.prisma.member.findFirst({
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
})
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
const other = await ctx.prisma.member.findFirst({
where: { id: input.otherMemberId, orgId: ctx.orgId },
})
if (!other) throw new TRPCError({ code: 'NOT_FOUND', message: 'Mitglied nicht gefunden' })
// Find existing conversation between the two members in this org
const existing = await ctx.prisma.conversation.findFirst({
where: {
orgId: ctx.orgId,
members: { every: { memberId: { in: [member.id, other.id] } } },
AND: [
{ members: { some: { memberId: member.id } } },
{ members: { some: { memberId: other.id } } },
],
},
})
if (existing) return { conversationId: existing.id }
const conv = await ctx.prisma.conversation.create({
data: {
orgId: ctx.orgId,
members: {
create: [{ memberId: member.id }, { memberId: other.id }],
},
},
})
return { conversationId: conv.id }
}),
// Get messages for a conversation
getMessages: memberProcedure
.input(z.object({ conversationId: z.string(), cursor: z.string().optional() }))
.query(async ({ ctx, input }) => {
const member = await ctx.prisma.member.findFirst({
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
})
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
// Verify membership in conversation
const cm = await ctx.prisma.conversationMember.findUnique({
where: { conversationId_memberId: { conversationId: input.conversationId, memberId: member.id } },
})
if (!cm) throw new TRPCError({ code: 'FORBIDDEN' })
const messages = await ctx.prisma.message.findMany({
where: { conversationId: input.conversationId },
include: { sender: { select: { id: true, name: true, avatarUrl: true } } },
orderBy: { createdAt: 'desc' },
take: 40,
...(input.cursor ? { skip: 1, cursor: { id: input.cursor } } : {}),
})
// Mark as read
await ctx.prisma.conversationMember.update({
where: { conversationId_memberId: { conversationId: input.conversationId, memberId: member.id } },
data: { lastReadAt: new Date() },
})
return {
messages: messages.reverse(),
nextCursor: messages.length === 40 ? messages[0]?.id : undefined,
}
}),
// Send a message
sendMessage: memberProcedure
.input(z.object({ conversationId: z.string(), body: z.string().min(1).max(2000) }))
.mutation(async ({ ctx, input }) => {
const member = await ctx.prisma.member.findFirst({
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
})
if (!member) throw new TRPCError({ code: 'NOT_FOUND' })
const cm = await ctx.prisma.conversationMember.findUnique({
where: { conversationId_memberId: { conversationId: input.conversationId, memberId: member.id } },
})
if (!cm) throw new TRPCError({ code: 'FORBIDDEN' })
const message = await ctx.prisma.message.create({
data: {
conversationId: input.conversationId,
senderId: member.id,
body: input.body.trim(),
},
include: { sender: { select: { id: true, name: true, avatarUrl: true } } },
})
// Update conversation updatedAt so it sorts to top
await ctx.prisma.conversation.update({
where: { id: input.conversationId },
data: { updatedAt: new Date() },
})
return message
}),
// Count total unread conversations
unreadCount: memberProcedure.query(async ({ ctx }) => {
const member = await ctx.prisma.member.findFirst({
where: { userId: ctx.session.user.id, orgId: ctx.orgId },
})
if (!member) return { count: 0 }
const convMembers = await ctx.prisma.conversationMember.findMany({
where: { memberId: member.id },
include: {
conversation: {
include: {
messages: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true, senderId: true },
},
},
},
},
})
const count = convMembers.filter((cm) => {
const last = cm.conversation.messages[0]
return last && last.senderId !== member.id && (!cm.lastReadAt || last.createdAt > cm.lastReadAt)
}).length
return { count }
}),
})

View File

@@ -7,6 +7,16 @@ const NewsInput = z.object({
body: z.string().min(10),
kategorie: z.enum(['Wichtig', 'Pruefung', 'Foerderung', 'Veranstaltung', 'Allgemein']),
publishedAt: z.string().datetime().optional().nullable(),
attachments: z
.array(
z.object({
name: z.string(),
storagePath: z.string(),
sizeBytes: z.number().int().optional().nullable(),
mimeType: z.string().optional().nullable(),
})
)
.optional(),
})
export const newsRouter = router({
@@ -104,6 +114,16 @@ export const newsRouter = router({
body: input.body,
kategorie: input.kategorie,
publishedAt: input.publishedAt ? new Date(input.publishedAt) : null,
...(input.attachments && input.attachments.length > 0 && {
attachments: {
create: input.attachments.map((a) => ({
name: a.name,
storagePath: a.storagePath,
sizeBytes: a.sizeBytes,
mimeType: a.mimeType,
})),
},
}),
},
})
@@ -121,23 +141,44 @@ export const newsRouter = router({
update: adminProcedure
.input(z.object({ id: z.string(), data: NewsInput.partial() }))
.mutation(async ({ ctx, input }) => {
const wasUnpublished = await ctx.prisma.news.findFirst({
where: { id: input.id, orgId: ctx.orgId, publishedAt: null },
const existing = await ctx.prisma.news.findFirst({
where: { id: input.id, orgId: ctx.orgId },
})
const news = await ctx.prisma.news.updateMany({
where: { id: input.id, orgId: ctx.orgId },
if (!existing) {
throw new Error('News not found or access denied')
}
const wasUnpublished = !existing.publishedAt
const { attachments, ...restData } = input.data
const news = await ctx.prisma.news.update({
where: { id: input.id },
data: {
...input.data,
publishedAt: input.data.publishedAt
? new Date(input.data.publishedAt)
: undefined,
...restData,
publishedAt: restData.publishedAt
? new Date(restData.publishedAt)
: restData.publishedAt === null
? null
: undefined,
...(attachments && {
attachments: {
deleteMany: {},
create: attachments.map((a) => ({
name: a.name,
storagePath: a.storagePath,
sizeBytes: a.sizeBytes,
mimeType: a.mimeType,
})),
},
}),
},
})
// Trigger push if just published
if (wasUnpublished && input.data.publishedAt && input.data.title) {
sendPushNotifications(ctx.orgId, input.data.title).catch(console.error)
if (wasUnpublished && news.publishedAt && news.title) {
sendPushNotifications(ctx.orgId, news.title).catch(console.error)
}
return news

View File

@@ -43,6 +43,7 @@ export const protectedProcedure = t.procedure.use(({ ctx, next }) => {
export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const userRole = await ctx.prisma.userRole.findFirst({
where: { userId: ctx.session.user.id },
orderBy: { createdAt: 'asc' },
})
if (!userRole) {
throw new TRPCError({
@@ -65,6 +66,7 @@ export const memberProcedure = protectedProcedure.use(async ({ ctx, next }) => {
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
const userRole = await ctx.prisma.userRole.findFirst({
where: { userId: ctx.session.user.id, role: 'admin' },
orderBy: { createdAt: 'asc' },
})
if (!userRole) {
throw new TRPCError({