467 lines
13 KiB
TypeScript
467 lines
13 KiB
TypeScript
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
|
|
import { z } from 'astro/zod'
|
|
import { ActionError } from 'astro:actions'
|
|
import { uniq } from 'lodash-es'
|
|
import slugify from 'slugify'
|
|
|
|
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
|
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
|
import { prisma } from '../../lib/prisma'
|
|
import { separateServiceUrlsByType } from '../../lib/urls'
|
|
import {
|
|
imageFileSchema,
|
|
stringListOfUrlsSchemaRequired,
|
|
zodCohercedNumber,
|
|
zodContactMethod,
|
|
} from '../../lib/zodUtils'
|
|
|
|
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: '-',
|
|
}),
|
|
})
|
|
|
|
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(40),
|
|
description: z.string().min(1),
|
|
allServiceUrls: stringListOfUrlsSchemaRequired,
|
|
tosUrls: stringListOfUrlsSchemaRequired,
|
|
kycLevel: z.coerce.number().int().min(0).max(4),
|
|
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
|
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(),
|
|
})
|
|
|
|
// 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)
|
|
|
|
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'),
|
|
})
|
|
|
|
export const adminServiceActions = {
|
|
create: defineProtectedAction({
|
|
accept: 'form',
|
|
permissions: 'admin',
|
|
input: createServiceInputSchema,
|
|
handler: async (input: z.infer<typeof createServiceInputSchema>, 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 {
|
|
web: serviceUrls,
|
|
onion: onionUrls,
|
|
i2p: i2pUrls,
|
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
|
|
|
const service = await prisma.service.create({
|
|
data: {
|
|
name: input.name,
|
|
description: input.description,
|
|
serviceUrls,
|
|
tosUrls: input.tosUrls,
|
|
onionUrls,
|
|
i2pUrls,
|
|
kycLevel: input.kycLevel,
|
|
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
|
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: updateServiceInputSchema,
|
|
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
|
|
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 existingService = await prisma.service.findUnique({
|
|
where: { id: input.id },
|
|
select: {
|
|
slug: true,
|
|
previousSlugs: true,
|
|
categories: {
|
|
select: {
|
|
id: true,
|
|
},
|
|
},
|
|
attributes: {
|
|
select: {
|
|
attributeId: true,
|
|
attribute: {
|
|
select: {
|
|
id: 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 imageUrl = input.removeImage
|
|
? null
|
|
: input.imageFile
|
|
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
|
: undefined
|
|
|
|
const {
|
|
web: serviceUrls,
|
|
onion: onionUrls,
|
|
i2p: i2pUrls,
|
|
} = separateServiceUrlsByType(input.allServiceUrls)
|
|
|
|
const service = await prisma.service.update({
|
|
where: { id: input.id },
|
|
data: {
|
|
name: input.name,
|
|
description: input.description,
|
|
serviceUrls,
|
|
tosUrls: input.tosUrls,
|
|
onionUrls,
|
|
i2pUrls,
|
|
kycLevel: input.kycLevel,
|
|
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
|
verificationStatus: input.verificationStatus,
|
|
verificationSummary: input.verificationSummary,
|
|
verificationProofMd: input.verificationProofMd,
|
|
acceptedCurrencies: input.acceptedCurrencies,
|
|
referral: input.referral,
|
|
serviceVisibility: input.serviceVisibility,
|
|
slug: input.slug,
|
|
overallScore: input.overallScore,
|
|
previousSlugs:
|
|
existingService.slug !== input.slug
|
|
? {
|
|
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
|
|
(slug) => slug !== input.slug
|
|
),
|
|
}
|
|
: undefined,
|
|
|
|
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: zodContactMethod,
|
|
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: zodContactMethod,
|
|
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 },
|
|
})
|
|
},
|
|
}),
|
|
},
|
|
|
|
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 }
|
|
},
|
|
}),
|
|
},
|
|
}
|