2026-01-31 14:13:16 +01:00
|
|
|
import crypto from 'crypto'
|
2026-01-30 13:41:32 +01:00
|
|
|
import { z } from 'zod'
|
|
|
|
|
import { TRPCError } from '@trpc/server'
|
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
|
|
|
import type { Prisma } from '@prisma/client'
|
2026-01-30 13:41:32 +01:00
|
|
|
import { router, protectedProcedure, adminProcedure, superAdminProcedure, publicProcedure } from '../trpc'
|
|
|
|
|
import { sendInvitationEmail, sendMagicLinkEmail } from '@/lib/email'
|
|
|
|
|
import { hashPassword, validatePassword } from '@/lib/password'
|
2026-02-02 13:19:28 +01:00
|
|
|
import { attachAvatarUrls } from '@/server/utils/avatar-url'
|
2026-01-30 13:41:32 +01:00
|
|
|
|
2026-01-31 14:13:16 +01:00
|
|
|
const INVITE_TOKEN_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000 // 7 days
|
|
|
|
|
|
|
|
|
|
function generateInviteToken(): string {
|
|
|
|
|
return crypto.randomBytes(32).toString('hex')
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
export const userRouter = router({
|
|
|
|
|
/**
|
|
|
|
|
* Get current user profile
|
|
|
|
|
*/
|
|
|
|
|
me: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
return ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
email: true,
|
|
|
|
|
name: true,
|
|
|
|
|
role: true,
|
|
|
|
|
status: true,
|
|
|
|
|
expertiseTags: true,
|
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
|
|
|
metadataJson: true,
|
|
|
|
|
phoneNumber: true,
|
|
|
|
|
notificationPreference: true,
|
|
|
|
|
profileImageKey: true,
|
2026-01-30 13:41:32 +01:00
|
|
|
createdAt: true,
|
|
|
|
|
lastLoginAt: true,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
2026-01-31 14:13:16 +01:00
|
|
|
/**
|
|
|
|
|
* Validate an invitation token (public, no auth required)
|
|
|
|
|
*/
|
|
|
|
|
validateInviteToken: publicProcedure
|
|
|
|
|
.input(z.object({ token: z.string().min(1) }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const user = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { inviteToken: input.token },
|
|
|
|
|
select: { id: true, name: true, email: true, role: true, status: true, inviteTokenExpiresAt: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user) {
|
|
|
|
|
return { valid: false, error: 'INVALID_TOKEN' as const }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (user.status !== 'INVITED') {
|
|
|
|
|
return { valid: false, error: 'ALREADY_ACCEPTED' as const }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (user.inviteTokenExpiresAt && user.inviteTokenExpiresAt < new Date()) {
|
|
|
|
|
return { valid: false, error: 'EXPIRED_TOKEN' as const }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
valid: true,
|
|
|
|
|
user: { name: user.name, email: user.email, role: user.role },
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
/**
|
|
|
|
|
* Update current user profile
|
|
|
|
|
*/
|
|
|
|
|
updateProfile: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
name: z.string().min(1).max(255).optional(),
|
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
|
|
|
bio: z.string().max(1000).optional(),
|
|
|
|
|
phoneNumber: z.string().max(20).optional().nullable(),
|
|
|
|
|
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
|
|
|
const { bio, ...directFields } = input
|
|
|
|
|
|
|
|
|
|
// If bio is provided, merge it into metadataJson
|
|
|
|
|
let metadataJson: Prisma.InputJsonValue | undefined
|
|
|
|
|
if (bio !== undefined) {
|
|
|
|
|
const currentUser = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
select: { metadataJson: true },
|
|
|
|
|
})
|
|
|
|
|
const currentMeta = (currentUser.metadataJson as Record<string, string>) || {}
|
|
|
|
|
metadataJson = { ...currentMeta, bio } as Prisma.InputJsonValue
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return ctx.prisma.user.update({
|
|
|
|
|
where: { id: ctx.user.id },
|
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
|
|
|
data: {
|
|
|
|
|
...directFields,
|
|
|
|
|
...(metadataJson !== undefined && { metadataJson }),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete own account (requires password confirmation)
|
|
|
|
|
*/
|
|
|
|
|
deleteAccount: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
password: z.string().min(1),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Get current user with password hash
|
|
|
|
|
const user = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
select: { id: true, email: true, passwordHash: true, role: true },
|
2026-01-30 13:41:32 +01:00
|
|
|
})
|
Add profile settings page, mentor management, and S3 email logos
- Add universal /settings/profile page accessible to all roles with
avatar upload, bio, phone, password change, and account deletion
- Expand updateProfile endpoint to accept bio (metadataJson), phone,
and notification preference
- Add deleteAccount endpoint with password confirmation
- Add Profile Settings link to all nav components (admin, jury, mentor,
observer)
- Add /admin/mentors list page and /admin/mentors/[id] detail page for
mentor management
- Add Mentors nav item to admin sidebar
- Update email logo URLs to S3 (s3.monaco-opc.com/public/)
- Add ocean.png background image to email wrapper
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-30 19:57:12 +01:00
|
|
|
|
|
|
|
|
// Prevent super admins from self-deleting
|
|
|
|
|
if (user.role === 'SUPER_ADMIN') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Super admins cannot delete their own account',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!user.passwordHash) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No password set. Please set a password first.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify password
|
|
|
|
|
const { verifyPassword } = await import('@/lib/password')
|
|
|
|
|
const isValid = await verifyPassword(input.password, user.passwordHash)
|
|
|
|
|
if (!isValid) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'UNAUTHORIZED',
|
|
|
|
|
message: 'Password is incorrect',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audit log before deletion
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE_OWN_ACCOUNT',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: ctx.user.id,
|
|
|
|
|
detailsJson: { email: user.email },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Delete the user
|
|
|
|
|
await ctx.prisma.user.delete({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* List all users (admin only)
|
|
|
|
|
*/
|
|
|
|
|
list: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
role: z.enum(['SUPER_ADMIN', 'PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
|
|
|
|
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
|
|
|
|
search: z.string().optional(),
|
|
|
|
|
page: z.number().int().min(1).default(1),
|
|
|
|
|
perPage: z.number().int().min(1).max(100).default(20),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const { role, status, search, page, perPage } = input
|
|
|
|
|
const skip = (page - 1) * perPage
|
|
|
|
|
|
|
|
|
|
const where: Record<string, unknown> = {}
|
|
|
|
|
|
|
|
|
|
if (role) where.role = role
|
|
|
|
|
if (status) where.status = status
|
|
|
|
|
if (search) {
|
|
|
|
|
where.OR = [
|
|
|
|
|
{ email: { contains: search, mode: 'insensitive' } },
|
|
|
|
|
{ name: { contains: search, mode: 'insensitive' } },
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const [users, total] = await Promise.all([
|
|
|
|
|
ctx.prisma.user.findMany({
|
|
|
|
|
where,
|
|
|
|
|
skip,
|
|
|
|
|
take: perPage,
|
|
|
|
|
orderBy: { createdAt: 'desc' },
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
email: true,
|
|
|
|
|
name: true,
|
|
|
|
|
role: true,
|
|
|
|
|
status: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
maxAssignments: true,
|
2026-02-02 13:19:28 +01:00
|
|
|
profileImageKey: true,
|
|
|
|
|
profileImageProvider: true,
|
2026-01-30 13:41:32 +01:00
|
|
|
createdAt: true,
|
|
|
|
|
lastLoginAt: true,
|
|
|
|
|
_count: {
|
|
|
|
|
select: { assignments: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
}),
|
|
|
|
|
ctx.prisma.user.count({ where }),
|
|
|
|
|
])
|
|
|
|
|
|
2026-02-02 13:19:28 +01:00
|
|
|
const usersWithAvatars = await attachAvatarUrls(users)
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
return {
|
2026-02-02 13:19:28 +01:00
|
|
|
users: usersWithAvatars,
|
2026-01-30 13:41:32 +01:00
|
|
|
total,
|
|
|
|
|
page,
|
|
|
|
|
perPage,
|
|
|
|
|
totalPages: Math.ceil(total / perPage),
|
|
|
|
|
}
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get a single user (admin only)
|
|
|
|
|
*/
|
|
|
|
|
get: adminProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
return ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
include: {
|
|
|
|
|
_count: {
|
|
|
|
|
select: { assignments: true },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Create/invite a new user (admin only)
|
|
|
|
|
*/
|
|
|
|
|
create: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
|
|
|
|
expertiseTags: z.array(z.string()).optional(),
|
|
|
|
|
maxAssignments: z.number().int().min(1).max(100).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Check if user already exists
|
|
|
|
|
const existing = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: input.email },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (existing) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'CONFLICT',
|
|
|
|
|
message: 'A user with this email already exists',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prevent non-super-admins from creating admins
|
|
|
|
|
if (input.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only super admins can create program admins',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await ctx.prisma.user.create({
|
|
|
|
|
data: {
|
|
|
|
|
...input,
|
|
|
|
|
status: 'INVITED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CREATE',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: user.id,
|
|
|
|
|
detailsJson: { email: input.email, role: input.role },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Update a user (admin only)
|
|
|
|
|
*/
|
|
|
|
|
update: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
id: z.string(),
|
|
|
|
|
name: z.string().optional().nullable(),
|
|
|
|
|
role: z.enum(['PROGRAM_ADMIN', 'JURY_MEMBER', 'MENTOR', 'OBSERVER']).optional(),
|
|
|
|
|
status: z.enum(['INVITED', 'ACTIVE', 'SUSPENDED']).optional(),
|
|
|
|
|
expertiseTags: z.array(z.string()).optional(),
|
|
|
|
|
maxAssignments: z.number().int().min(1).max(100).optional().nullable(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const { id, ...data } = input
|
|
|
|
|
|
|
|
|
|
// Prevent changing super admin role
|
|
|
|
|
const targetUser = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (targetUser.role === 'SUPER_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Cannot modify super admin',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Prevent non-super-admins from assigning admin role
|
|
|
|
|
if (data.role === 'PROGRAM_ADMIN' && ctx.user.role !== 'SUPER_ADMIN') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'FORBIDDEN',
|
|
|
|
|
message: 'Only super admins can assign admin role',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await ctx.prisma.user.update({
|
|
|
|
|
where: { id },
|
|
|
|
|
data,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'UPDATE',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: id,
|
|
|
|
|
detailsJson: data,
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Delete a user (super admin only)
|
|
|
|
|
*/
|
|
|
|
|
delete: superAdminProcedure
|
|
|
|
|
.input(z.object({ id: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Prevent self-deletion
|
|
|
|
|
if (input.id === ctx.user.id) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Cannot delete yourself',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const user = await ctx.prisma.user.delete({
|
|
|
|
|
where: { id: input.id },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'DELETE',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: input.id,
|
|
|
|
|
detailsJson: { email: user.email },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Bulk import users (admin only)
|
|
|
|
|
*/
|
|
|
|
|
bulkCreate: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
users: z.array(
|
|
|
|
|
z.object({
|
|
|
|
|
email: z.string().email(),
|
|
|
|
|
name: z.string().optional(),
|
|
|
|
|
role: z.enum(['JURY_MEMBER', 'MENTOR', 'OBSERVER']).default('JURY_MEMBER'),
|
|
|
|
|
expertiseTags: z.array(z.string()).optional(),
|
|
|
|
|
})
|
|
|
|
|
),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Deduplicate input by email (keep first occurrence)
|
|
|
|
|
const seenEmails = new Set<string>()
|
|
|
|
|
const uniqueUsers = input.users.filter((u) => {
|
|
|
|
|
const email = u.email.toLowerCase()
|
|
|
|
|
if (seenEmails.has(email)) return false
|
|
|
|
|
seenEmails.add(email)
|
|
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Get existing emails from database
|
|
|
|
|
const existingUsers = await ctx.prisma.user.findMany({
|
|
|
|
|
where: { email: { in: uniqueUsers.map((u) => u.email.toLowerCase()) } },
|
|
|
|
|
select: { email: true },
|
|
|
|
|
})
|
|
|
|
|
const existingEmails = new Set(existingUsers.map((u) => u.email.toLowerCase()))
|
|
|
|
|
|
|
|
|
|
// Filter out existing users
|
|
|
|
|
const newUsers = uniqueUsers.filter((u) => !existingEmails.has(u.email.toLowerCase()))
|
|
|
|
|
|
|
|
|
|
const duplicatesInInput = input.users.length - uniqueUsers.length
|
|
|
|
|
const skipped = existingEmails.size + duplicatesInInput
|
|
|
|
|
|
|
|
|
|
if (newUsers.length === 0) {
|
|
|
|
|
return { created: 0, skipped }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const created = await ctx.prisma.user.createMany({
|
|
|
|
|
data: newUsers.map((u) => ({
|
|
|
|
|
...u,
|
|
|
|
|
email: u.email.toLowerCase(),
|
|
|
|
|
status: 'INVITED',
|
|
|
|
|
})),
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'BULK_CREATE',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
detailsJson: { count: created.count, skipped, duplicatesInInput },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-31 14:13:16 +01:00
|
|
|
// Auto-send invitation emails to newly created users
|
|
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
|
|
|
|
const createdUsers = await ctx.prisma.user.findMany({
|
|
|
|
|
where: { email: { in: newUsers.map((u) => u.email.toLowerCase()) } },
|
|
|
|
|
select: { id: true, email: true, name: true, role: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
let emailsSent = 0
|
|
|
|
|
const emailErrors: string[] = []
|
|
|
|
|
|
|
|
|
|
for (const user of createdUsers) {
|
|
|
|
|
try {
|
|
|
|
|
const token = generateInviteToken()
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: {
|
|
|
|
|
inviteToken: token,
|
|
|
|
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
|
|
|
|
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'JURY_INVITATION',
|
|
|
|
|
status: 'SENT',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
emailsSent++
|
|
|
|
|
} catch (e) {
|
|
|
|
|
emailErrors.push(user.email)
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'JURY_INVITATION',
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
errorMsg: e instanceof Error ? e.message : 'Unknown error',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { created: created.count, skipped, emailsSent, emailErrors }
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get jury members for assignment
|
|
|
|
|
*/
|
|
|
|
|
getJuryMembers: adminProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
roundId: z.string().optional(),
|
|
|
|
|
search: z.string().optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.query(async ({ ctx, input }) => {
|
|
|
|
|
const where: Record<string, unknown> = {
|
|
|
|
|
role: 'JURY_MEMBER',
|
|
|
|
|
status: 'ACTIVE',
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (input.search) {
|
|
|
|
|
where.OR = [
|
|
|
|
|
{ email: { contains: input.search, mode: 'insensitive' } },
|
|
|
|
|
{ name: { contains: input.search, mode: 'insensitive' } },
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const users = await ctx.prisma.user.findMany({
|
|
|
|
|
where,
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
email: true,
|
|
|
|
|
name: true,
|
|
|
|
|
expertiseTags: true,
|
|
|
|
|
maxAssignments: true,
|
2026-02-02 13:19:28 +01:00
|
|
|
profileImageKey: true,
|
|
|
|
|
profileImageProvider: true,
|
2026-01-30 13:41:32 +01:00
|
|
|
_count: {
|
|
|
|
|
select: {
|
|
|
|
|
assignments: input.roundId
|
|
|
|
|
? { where: { roundId: input.roundId } }
|
|
|
|
|
: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
orderBy: { name: 'asc' },
|
|
|
|
|
})
|
|
|
|
|
|
2026-02-02 13:19:28 +01:00
|
|
|
const mapped = users.map((u) => ({
|
2026-01-30 13:41:32 +01:00
|
|
|
...u,
|
|
|
|
|
currentAssignments: u._count.assignments,
|
|
|
|
|
availableSlots:
|
|
|
|
|
u.maxAssignments !== null
|
|
|
|
|
? Math.max(0, u.maxAssignments - u._count.assignments)
|
|
|
|
|
: null,
|
|
|
|
|
}))
|
2026-02-02 13:19:28 +01:00
|
|
|
|
|
|
|
|
return attachAvatarUrls(mapped)
|
2026-01-30 13:41:32 +01:00
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send invitation email to a user
|
|
|
|
|
*/
|
|
|
|
|
sendInvitation: adminProcedure
|
|
|
|
|
.input(z.object({ userId: z.string() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const user = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: input.userId },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (user.status !== 'INVITED') {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'User has already accepted their invitation',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-31 14:13:16 +01:00
|
|
|
// Generate invite token and store on user
|
|
|
|
|
const token = generateInviteToken()
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: {
|
|
|
|
|
inviteToken: token,
|
|
|
|
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
2026-01-30 13:41:32 +01:00
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
2026-01-31 14:13:16 +01:00
|
|
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
2026-01-30 13:41:32 +01:00
|
|
|
|
|
|
|
|
// Send invitation email
|
|
|
|
|
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
|
|
|
|
|
|
|
|
|
// Log notification
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'JURY_INVITATION',
|
|
|
|
|
status: 'SENT',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'SEND_INVITATION',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: user.id,
|
|
|
|
|
detailsJson: { email: user.email },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true, email: user.email }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send invitation emails to multiple users
|
|
|
|
|
*/
|
|
|
|
|
bulkSendInvitations: adminProcedure
|
|
|
|
|
.input(z.object({ userIds: z.array(z.string()) }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const users = await ctx.prisma.user.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
id: { in: input.userIds },
|
|
|
|
|
status: 'INVITED',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (users.length === 0) {
|
|
|
|
|
return { sent: 0, skipped: input.userIds.length }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
|
|
|
|
let sent = 0
|
|
|
|
|
const errors: string[] = []
|
|
|
|
|
|
|
|
|
|
for (const user of users) {
|
|
|
|
|
try {
|
2026-01-31 14:13:16 +01:00
|
|
|
// Generate invite token for each user
|
|
|
|
|
const token = generateInviteToken()
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: {
|
|
|
|
|
inviteToken: token,
|
|
|
|
|
inviteTokenExpiresAt: new Date(Date.now() + INVITE_TOKEN_EXPIRY_MS),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
const inviteUrl = `${baseUrl}/accept-invite?token=${token}`
|
2026-01-30 13:41:32 +01:00
|
|
|
await sendInvitationEmail(user.email, user.name, inviteUrl, user.role)
|
|
|
|
|
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'JURY_INVITATION',
|
|
|
|
|
status: 'SENT',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
sent++
|
|
|
|
|
} catch (e) {
|
|
|
|
|
errors.push(user.email)
|
|
|
|
|
await ctx.prisma.notificationLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: user.id,
|
|
|
|
|
channel: 'EMAIL',
|
|
|
|
|
provider: 'SMTP',
|
|
|
|
|
type: 'JURY_INVITATION',
|
|
|
|
|
status: 'FAILED',
|
|
|
|
|
errorMsg: e instanceof Error ? e.message : 'Unknown error',
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'BULK_SEND_INVITATIONS',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
detailsJson: { sent, errors },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { sent, skipped: input.userIds.length - users.length, errors }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Complete onboarding for current user
|
|
|
|
|
*/
|
|
|
|
|
completeOnboarding: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
name: z.string().min(1).max(255),
|
|
|
|
|
phoneNumber: z.string().optional(),
|
|
|
|
|
expertiseTags: z.array(z.string()).optional(),
|
|
|
|
|
notificationPreference: z.enum(['EMAIL', 'WHATSAPP', 'BOTH', 'NONE']).optional(),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
const user = await ctx.prisma.user.update({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
data: {
|
|
|
|
|
name: input.name,
|
|
|
|
|
phoneNumber: input.phoneNumber,
|
|
|
|
|
expertiseTags: input.expertiseTags || [],
|
|
|
|
|
notificationPreference: input.notificationPreference || 'EMAIL',
|
|
|
|
|
onboardingCompletedAt: new Date(),
|
|
|
|
|
status: 'ACTIVE', // Activate user after onboarding
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'COMPLETE_ONBOARDING',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: ctx.user.id,
|
|
|
|
|
detailsJson: { name: input.name },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return user
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if current user needs onboarding
|
|
|
|
|
*/
|
|
|
|
|
needsOnboarding: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const user = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
select: { onboardingCompletedAt: true, role: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Only jury members need onboarding
|
|
|
|
|
if (user.role !== 'JURY_MEMBER') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return user.onboardingCompletedAt === null
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Check if current user needs to set a password
|
|
|
|
|
*/
|
|
|
|
|
needsPasswordSetup: protectedProcedure.query(async ({ ctx }) => {
|
|
|
|
|
const user = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
select: { mustSetPassword: true, passwordHash: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return user.mustSetPassword || user.passwordHash === null
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Set password for current user
|
|
|
|
|
*/
|
|
|
|
|
setPassword: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
password: z.string().min(8),
|
|
|
|
|
confirmPassword: z.string().min(8),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Validate passwords match
|
|
|
|
|
if (input.password !== input.confirmPassword) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'Passwords do not match',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate password requirements
|
|
|
|
|
const validation = validatePassword(input.password)
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: validation.errors.join('. '),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hash the password
|
|
|
|
|
const passwordHash = await hashPassword(input.password)
|
|
|
|
|
|
|
|
|
|
// Update user with new password
|
|
|
|
|
const user = await ctx.prisma.user.update({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
data: {
|
|
|
|
|
passwordHash,
|
|
|
|
|
passwordSetAt: new Date(),
|
|
|
|
|
mustSetPassword: false,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'SET_PASSWORD',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: ctx.user.id,
|
|
|
|
|
detailsJson: { timestamp: new Date().toISOString() },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true, email: user.email }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Change password for current user (requires current password)
|
|
|
|
|
*/
|
|
|
|
|
changePassword: protectedProcedure
|
|
|
|
|
.input(
|
|
|
|
|
z.object({
|
|
|
|
|
currentPassword: z.string().min(1),
|
|
|
|
|
newPassword: z.string().min(8),
|
|
|
|
|
confirmNewPassword: z.string().min(8),
|
|
|
|
|
})
|
|
|
|
|
)
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Get current user with password hash
|
|
|
|
|
const user = await ctx.prisma.user.findUniqueOrThrow({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
select: { passwordHash: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!user.passwordHash) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'No password set. Please use magic link to sign in.',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Verify current password
|
|
|
|
|
const { verifyPassword } = await import('@/lib/password')
|
|
|
|
|
const isValid = await verifyPassword(input.currentPassword, user.passwordHash)
|
|
|
|
|
if (!isValid) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'UNAUTHORIZED',
|
|
|
|
|
message: 'Current password is incorrect',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate new passwords match
|
|
|
|
|
if (input.newPassword !== input.confirmNewPassword) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: 'New passwords do not match',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Validate new password requirements
|
|
|
|
|
const validation = validatePassword(input.newPassword)
|
|
|
|
|
if (!validation.valid) {
|
|
|
|
|
throw new TRPCError({
|
|
|
|
|
code: 'BAD_REQUEST',
|
|
|
|
|
message: validation.errors.join('. '),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Hash the new password
|
|
|
|
|
const passwordHash = await hashPassword(input.newPassword)
|
|
|
|
|
|
|
|
|
|
// Update user with new password
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: ctx.user.id },
|
|
|
|
|
data: {
|
|
|
|
|
passwordHash,
|
|
|
|
|
passwordSetAt: new Date(),
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Audit log
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: ctx.user.id,
|
|
|
|
|
action: 'CHANGE_PASSWORD',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: ctx.user.id,
|
|
|
|
|
detailsJson: { timestamp: new Date().toISOString() },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true }
|
|
|
|
|
}),
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Request password reset (public - no auth required)
|
|
|
|
|
* Sends a magic link and marks user for password reset
|
|
|
|
|
*/
|
|
|
|
|
requestPasswordReset: publicProcedure
|
|
|
|
|
.input(z.object({ email: z.string().email() }))
|
|
|
|
|
.mutation(async ({ ctx, input }) => {
|
|
|
|
|
// Find user by email
|
|
|
|
|
const user = await ctx.prisma.user.findUnique({
|
|
|
|
|
where: { email: input.email },
|
|
|
|
|
select: { id: true, email: true, status: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Always return success to prevent email enumeration
|
|
|
|
|
if (!user || user.status === 'SUSPENDED') {
|
|
|
|
|
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mark user for password reset
|
|
|
|
|
await ctx.prisma.user.update({
|
|
|
|
|
where: { id: user.id },
|
|
|
|
|
data: { mustSetPassword: true },
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Generate a callback URL for the magic link
|
|
|
|
|
const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'
|
|
|
|
|
const callbackUrl = `${baseUrl}/set-password`
|
|
|
|
|
|
|
|
|
|
// We don't send the email here - the user will use the magic link form
|
|
|
|
|
// This just marks them for password reset
|
|
|
|
|
// The actual email is sent through NextAuth's email provider
|
|
|
|
|
|
|
|
|
|
// Audit log (without user ID since this is public)
|
|
|
|
|
await ctx.prisma.auditLog.create({
|
|
|
|
|
data: {
|
|
|
|
|
userId: null, // No authenticated user
|
|
|
|
|
action: 'REQUEST_PASSWORD_RESET',
|
|
|
|
|
entityType: 'User',
|
|
|
|
|
entityId: user.id,
|
|
|
|
|
detailsJson: { email: input.email, timestamp: new Date().toISOString() },
|
|
|
|
|
ipAddress: ctx.ip,
|
|
|
|
|
userAgent: ctx.userAgent,
|
|
|
|
|
},
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return { success: true, message: 'If an account exists with this email, a password reset link will be sent.' }
|
|
|
|
|
}),
|
|
|
|
|
})
|