Files
kycnotme/web/src/actions/admin/service.ts

467 lines
13 KiB
TypeScript
Raw Normal View History

2025-06-02 03:53:03 +00:00
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
2025-05-19 10:23:36 +00:00
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
2025-05-31 22:36:39 +00:00
import { uniq } from 'lodash-es'
2025-05-19 10:23:36 +00:00
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
2025-06-04 16:41:32 +00:00
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
2025-05-19 10:23:36 +00:00
import { prisma } from '../../lib/prisma'
2025-05-28 13:48:27 +00:00
import { separateServiceUrlsByType } from '../../lib/urls'
2025-06-04 16:41:32 +00:00
import {
imageFileSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils'
2025-05-19 10:23:36 +00:00
2025-06-02 03:53:03 +00:00
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: '-',
}),
})
2025-05-19 10:23:36 +00:00
const serviceSchemaBase = z.object({
2025-05-23 11:52:16 +00:00
id: z.number().int().positive(),
2025-05-19 10:23:36 +00:00
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
2025-05-23 14:56:00 +00:00
.optional(),
2025-05-28 13:48:27 +00:00
name: z.string().min(1).max(40),
2025-05-19 10:23:36 +00:00
description: z.string().min(1),
2025-05-28 13:48:27 +00:00
allServiceUrls: stringListOfUrlsSchemaRequired,
2025-05-19 10:23:36 +00:00
tosUrls: stringListOfUrlsSchemaRequired,
kycLevel: z.coerce.number().int().min(0).max(4),
2025-06-02 03:53:03 +00:00
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
2025-05-19 10:23:36 +00:00
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
2025-05-23 14:56:00 +00:00
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
2025-05-19 10:23:36 +00:00
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
2025-05-26 14:45:22 +00:00
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
2025-05-19 10:23:36 +00:00
imageFile: imageFileSchema,
2025-05-23 14:56:00 +00:00
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
2025-05-19 10:23:36 +00:00
serviceVisibility: z.nativeEnum(ServiceVisibility),
2025-05-26 14:45:22 +00:00
internalNote: z.string().optional(),
2025-05-19 10:23:36 +00:00
})
2025-06-02 03:53:03 +00:00
// Define schema for the create action input
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
// Define schema for the update action input
const updateServiceInputSchema = serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing)
2025-05-19 10:23:36 +00:00
2025-06-04 16:41:32 +00:00
const evidenceImageAddSchema = z.object({
serviceId: z.number().int().positive(),
imageFile: imageFileSchema,
})
const evidenceImageDeleteSchema = z.object({
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
})
2025-05-19 10:23:36 +00:00
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
2025-06-02 03:53:03 +00:00
input: createServiceInputSchema,
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
2025-05-19 10:23:36 +00:00
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',
})
}
2025-05-26 14:45:22 +00:00
const imageUrl = input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
2025-05-19 10:23:36 +00:00
2025-05-28 13:48:27 +00:00
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
2025-05-19 10:23:36 +00:00
const service = await prisma.service.create({
data: {
2025-05-26 14:45:22 +00:00
name: input.name,
description: input.description,
2025-05-28 13:48:27 +00:00
serviceUrls,
2025-05-26 14:45:22 +00:00
tosUrls: input.tosUrls,
2025-05-28 13:48:27 +00:00
onionUrls,
i2pUrls,
2025-05-26 14:45:22 +00:00
kycLevel: input.kycLevel,
2025-06-04 16:41:32 +00:00
kycLevelClarification: input.kycLevelClarification ?? undefined,
2025-05-26 14:45:22 +00:00
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
2025-05-19 10:23:36 +00:00
categories: {
connect: input.categories.map((id) => ({ id })),
},
attributes: {
create: input.attributes.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
},
imageUrl,
2025-05-26 14:45:22 +00:00
internalNotes: input.internalNote
? {
create: {
content: input.internalNote,
addedByUserId: context.locals.user.id,
},
}
: undefined,
2025-05-19 10:23:36 +00:00
},
select: {
id: true,
slug: true,
},
})
return { service }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
2025-06-02 03:53:03 +00:00
input: updateServiceInputSchema,
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
2025-05-26 14:45:22 +00:00
const anotherServiceWithNewSlug = await prisma.service.findUnique({
2025-05-19 10:23:36 +00:00
where: {
slug: input.slug,
2025-05-26 14:45:22 +00:00
NOT: { id: input.id },
2025-05-19 10:23:36 +00:00
},
})
2025-05-26 14:45:22 +00:00
if (anotherServiceWithNewSlug) {
2025-05-19 10:23:36 +00:00
throw new ActionError({
code: 'CONFLICT',
message: 'A service with this slug already exists',
})
}
const existingService = await prisma.service.findUnique({
2025-05-26 14:45:22 +00:00
where: { id: input.id },
2025-05-31 22:36:39 +00:00
select: {
slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
2025-05-19 10:23:36 +00:00
attributes: {
2025-05-31 22:36:39 +00:00
select: {
attributeId: true,
attribute: {
select: {
id: true,
},
},
2025-05-19 10:23:36 +00:00
},
},
},
})
if (!existingService) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
const existingCategoryIds = existingService.categories.map((c) => c.id)
2025-05-26 14:45:22 +00:00
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
2025-05-19 10:23:36 +00:00
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
2025-05-26 14:45:22 +00:00
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
2025-05-19 10:23:36 +00:00
2025-06-01 15:11:37 +00:00
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
2025-05-28 13:48:27 +00:00
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
2025-05-19 10:23:36 +00:00
const service = await prisma.service.update({
2025-05-26 14:45:22 +00:00
where: { id: input.id },
2025-05-19 10:23:36 +00:00
data: {
2025-05-26 14:45:22 +00:00
name: input.name,
description: input.description,
2025-05-28 13:48:27 +00:00
serviceUrls,
2025-05-26 14:45:22 +00:00
tosUrls: input.tosUrls,
2025-05-28 13:48:27 +00:00
onionUrls,
i2pUrls,
2025-05-26 14:45:22 +00:00
kycLevel: input.kycLevel,
2025-06-04 16:41:32 +00:00
kycLevelClarification: input.kycLevelClarification ?? undefined,
2025-05-26 14:45:22 +00:00
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
2025-05-31 22:36:39 +00:00
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
2025-05-26 14:45:22 +00:00
2025-05-19 10:23:36 +00:00
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })),
},
attributes: {
create: attributesToAdd.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
2025-05-26 14:45:22 +00:00
2025-05-19 10:23:36 +00:00
deleteMany: attributesToRemove.map((attributeId) => ({
attributeId,
})),
},
},
})
2025-05-26 14:45:22 +00:00
2025-05-19 10:23:36 +00:00
return { service }
},
}),
2025-05-26 14:45:22 +00:00
contactMethod: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
2025-06-04 16:41:32 +00:00
value: zodContactMethod,
2025-05-26 14:45:22 +00:00
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 }
},
2025-05-23 11:52:16 +00:00
}),
2025-05-19 10:23:36 +00:00
2025-05-26 14:45:22 +00:00
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 }
},
2025-05-23 11:52:16 +00:00
}),
2025-05-19 10:23:36 +00:00
2025-05-26 14:45:22 +00:00
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 }
},
2025-05-19 10:23:36 +00:00
}),
2025-05-26 14:45:22 +00:00
},
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 },
})
},
}),
},
2025-06-04 16:41:32 +00:00
evidenceImage: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageAddSchema,
handler: async (input) => {
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { slug: true },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found to associate image with.',
})
}
if (!input.imageFile) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Image file is required.',
})
}
const imageUrl = await saveFileLocally(
input.imageFile,
input.imageFile.name,
`evidence/${service.slug}`
)
return { imageUrl }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageDeleteSchema,
handler: async (input) => {
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
2025-05-19 10:23:36 +00:00
}