import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client' import { z } from 'astro/zod' import { ActionError } from 'astro:actions' import slugify from 'slugify' import { defineProtectedAction } from '../../lib/defineProtectedAction' import { saveFileLocally } from '../../lib/fileStorage' import { prisma } from '../../lib/prisma' import { imageFileSchema, stringListOfUrlsSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber, } from '../../lib/zodUtils' const serviceSchemaBase = z.object({ id: z.number().int().positive(), slug: z .string() .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') .optional(), name: z.string().min(1).max(20), description: z.string().min(1), serviceUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired, onionUrls: stringListOfUrlsSchema, kycLevel: z.coerce.number().int().min(0).max(4), attributes: z.array(z.coerce.number().int().positive()), categories: z.array(z.coerce.number().int().positive()).min(1), verificationStatus: z.nativeEnum(VerificationStatus), verificationSummary: z.string().optional().nullable().default(null), verificationProofMd: z.string().optional().nullable().default(null), acceptedCurrencies: z.array(z.nativeEnum(Currency)), referral: z .string() .regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL') .optional() .nullable() .default(null), imageFile: imageFileSchema, overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), serviceVisibility: z.nativeEnum(ServiceVisibility), internalNote: z.string().optional(), }) const addSlugIfMissing = < T extends { slug?: string | null | undefined name: string }, >( input: T ) => ({ ...input, slug: input.slug ?? slugify(input.name, { lower: true, strict: true, remove: /[^a-zA-Z0-9\-._]/g, replacement: '-', }), }) export const adminServiceActions = { create: defineProtectedAction({ accept: 'form', permissions: 'admin', input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing), handler: async (input, context) => { const existing = await prisma.service.findUnique({ where: { slug: input.slug, }, }) if (existing) { throw new ActionError({ code: 'CONFLICT', message: 'A service with this slug already exists', }) } const imageUrl = input.imageFile ? await saveFileLocally(input.imageFile, input.imageFile.name) : undefined const service = await prisma.service.create({ data: { name: input.name, description: input.description, serviceUrls: input.serviceUrls, tosUrls: input.tosUrls, onionUrls: input.onionUrls, kycLevel: input.kycLevel, verificationStatus: input.verificationStatus, verificationSummary: input.verificationSummary, verificationProofMd: input.verificationProofMd, acceptedCurrencies: input.acceptedCurrencies, referral: input.referral, serviceVisibility: input.serviceVisibility, slug: input.slug, overallScore: input.overallScore, categories: { connect: input.categories.map((id) => ({ id })), }, attributes: { create: input.attributes.map((attributeId) => ({ attribute: { connect: { id: attributeId }, }, })), }, imageUrl, internalNotes: input.internalNote ? { create: { content: input.internalNote, addedByUserId: context.locals.user.id, }, } : undefined, }, select: { id: true, slug: true, }, }) return { service } }, }), update: defineProtectedAction({ accept: 'form', permissions: 'admin', input: serviceSchemaBase .extend({ removeImage: z.boolean().optional(), }) .transform(addSlugIfMissing), handler: async (input) => { const anotherServiceWithNewSlug = await prisma.service.findUnique({ where: { slug: input.slug, NOT: { id: input.id }, }, }) if (anotherServiceWithNewSlug) { throw new ActionError({ code: 'CONFLICT', message: 'A service with this slug already exists', }) } const imageUrl = input.removeImage ? null : input.imageFile ? await saveFileLocally(input.imageFile, input.imageFile.name) : undefined const existingService = await prisma.service.findUnique({ where: { id: input.id }, include: { categories: true, attributes: { include: { attribute: true, }, }, }, }) if (!existingService) { throw new ActionError({ code: 'NOT_FOUND', message: 'Service not found', }) } const existingCategoryIds = existingService.categories.map((c) => c.id) const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId)) const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId)) const existingAttributeIds = existingService.attributes.map((a) => a.attributeId) const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId)) const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId)) const service = await prisma.service.update({ where: { id: input.id }, data: { name: input.name, description: input.description, serviceUrls: input.serviceUrls, tosUrls: input.tosUrls, onionUrls: input.onionUrls, kycLevel: input.kycLevel, verificationStatus: input.verificationStatus, verificationSummary: input.verificationSummary, verificationProofMd: input.verificationProofMd, acceptedCurrencies: input.acceptedCurrencies, referral: input.referral, serviceVisibility: input.serviceVisibility, slug: input.slug, overallScore: input.overallScore, imageUrl, categories: { connect: categoriesToAdd.map((id) => ({ id })), disconnect: categoriesToRemove.map((id) => ({ id })), }, attributes: { create: attributesToAdd.map((attributeId) => ({ attribute: { connect: { id: attributeId }, }, })), deleteMany: attributesToRemove.map((attributeId) => ({ attributeId, })), }, }, }) return { service } }, }), contactMethod: { add: defineProtectedAction({ accept: 'form', permissions: 'admin', input: z.object({ label: z.string().min(1).max(50).nullable(), value: z.string().url(), serviceId: z.number().int().positive(), }), handler: async (input) => { const contactMethod = await prisma.serviceContactMethod.create({ data: { label: input.label, value: input.value, serviceId: input.serviceId, }, }) return { contactMethod } }, }), update: defineProtectedAction({ accept: 'form', permissions: 'admin', input: z.object({ id: z.number().int().positive(), label: z.string().min(1).max(50).nullable(), value: z.string().url(), serviceId: z.number().int().positive(), }), handler: async (input) => { const contactMethod = await prisma.serviceContactMethod.update({ where: { id: input.id }, data: { label: input.label, value: input.value, serviceId: input.serviceId, }, }) return { contactMethod } }, }), delete: defineProtectedAction({ accept: 'form', permissions: 'admin', input: z.object({ id: z.number().int().positive(), }), handler: async (input) => { await prisma.serviceContactMethod.delete({ where: { id: input.id }, }) return { success: true } }, }), }, internalNote: { add: defineProtectedAction({ accept: 'form', permissions: 'admin', input: z.object({ serviceId: z.number().int().positive(), content: z.string().min(1), }), handler: async (input, { locals }) => { const service = await prisma.service.findUnique({ where: { id: input.serviceId }, }) if (!service) { throw new ActionError({ code: 'NOT_FOUND', message: 'Service not found', }) } await prisma.internalServiceNote.create({ data: { content: input.content, serviceId: input.serviceId, addedByUserId: locals.user.id, }, }) }, }), update: defineProtectedAction({ accept: 'form', permissions: 'admin', input: z.object({ noteId: z.number().int().positive(), content: z.string().min(1), }), handler: async (input) => { const note = await prisma.internalServiceNote.findUnique({ where: { id: input.noteId }, }) if (!note) { throw new ActionError({ code: 'NOT_FOUND', message: 'Note not found', }) } await prisma.internalServiceNote.update({ where: { id: input.noteId }, data: { content: input.content }, }) }, }), delete: defineProtectedAction({ accept: 'form', permissions: 'admin', input: z.object({ noteId: z.number().int().positive(), }), handler: async (input) => { const note = await prisma.internalServiceNote.findUnique({ where: { id: input.noteId }, }) if (!note) { throw new ActionError({ code: 'NOT_FOUND', message: 'Note not found', }) } await prisma.internalServiceNote.delete({ where: { id: input.noteId }, }) }, }), }, }