Release 2025-05-19

This commit is contained in:
pluja
2025-05-19 10:19:49 +00:00
parent 046c4559e5
commit 2657f936bc
267 changed files with 0 additions and 49432 deletions

View File

@@ -1,134 +0,0 @@
import { AttributeCategory, AttributeType } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import type { Prisma } from '@prisma/client'
const attributeInputSchema = z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
category: z.nativeEnum(AttributeCategory),
type: z.nativeEnum(AttributeType),
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
slug: z
.string()
.min(1, 'Slug is required')
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens'),
})
const attributeSelect = {
id: true,
slug: true,
title: true,
description: true,
category: true,
type: true,
privacyPoints: true,
trustPoints: true,
createdAt: true,
updatedAt: true,
} satisfies Prisma.AttributeSelect
export const adminAttributeActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
title: z.string().min(1, 'Title is required'),
description: z.string().min(1, 'Description is required'),
category: z.nativeEnum(AttributeCategory),
type: z.nativeEnum(AttributeType),
privacyPoints: z.coerce.number().int().min(-100).max(100).default(0),
trustPoints: z.coerce.number().int().min(-100).max(100).default(0),
}),
handler: async (input) => {
const slug = slugify(input.title, { lower: true, strict: true })
const attribute = await prisma.attribute.create({
data: {
...input,
slug,
},
select: attributeSelect,
})
return { attribute }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: attributeInputSchema.extend({
id: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const { id, title, slug, ...data } = input
const existingAttribute = await prisma.attribute.findUnique({
where: { id },
select: { title: true, slug: true },
})
if (!existingAttribute) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Attribute not found',
})
}
// Check for slug uniqueness (ignore current attribute)
const slugConflict = await prisma.attribute.findFirst({
where: { slug, NOT: { id } },
select: { id: true },
})
if (slugConflict) {
throw new ActionError({
code: 'CONFLICT',
message: 'Slug already in use',
})
}
const attribute = await prisma.attribute.update({
where: { id },
data: {
title,
slug,
...data,
},
select: attributeSelect,
})
return { attribute }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive('Attribute ID must be a positive integer.'),
}),
handler: async ({ id }) => {
try {
await prisma.attribute.delete({
where: { id },
})
return { success: true, message: 'Attribute deleted successfully.' }
} catch (error) {
// Prisma throws an error if the record to delete is not found,
// or if there are related records that prevent deletion (foreign key constraints).
// We can customize the error message based on the type of error if needed.
console.error('Error deleting attribute:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Failed to delete attribute. It might be in use or already deleted.',
})
}
},
}),
}

View File

@@ -1,129 +0,0 @@
import { EventType } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
export const adminEventActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z
.object({
serviceId: z.coerce.number().int().positive(),
title: z.string().min(1),
content: z.string().min(1),
icon: z.string().optional(),
source: z.string().optional(),
type: z.nativeEnum(EventType).default('NORMAL'),
startedAt: z.coerce.date(),
endedAt: z.coerce.date().optional(),
})
.superRefine((data, ctx) => {
if (data.endedAt && data.startedAt > data.endedAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endedAt'],
message: 'Ended at must be after started at',
})
}
}),
handler: async (input) => {
const event = await prisma.event.create({
data: {
...input,
visible: true,
},
select: {
id: true,
},
})
return { event }
},
}),
toggle: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
eventId: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const existingEvent = await prisma.event.findUnique({ where: { id: input.eventId } })
if (!existingEvent) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Event not found',
})
}
const event = await prisma.event.update({
where: { id: input.eventId },
data: {
visible: !existingEvent.visible,
},
select: {
id: true,
},
})
return { event }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z
.object({
eventId: z.coerce.number().int().positive(),
title: z.string().min(1),
content: z.string().min(1),
icon: z.string().optional(),
source: z.string().optional(),
type: z.nativeEnum(EventType).default('NORMAL'),
startedAt: z.coerce.date(),
endedAt: z.coerce.date().optional(),
})
.superRefine((data, ctx) => {
if (data.endedAt && data.startedAt > data.endedAt) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['endedAt'],
message: 'Ended at must be after started at',
})
}
}),
handler: async (input) => {
const { eventId, ...data } = input
const existingEvent = await prisma.event.findUnique({ where: { id: eventId } })
if (!existingEvent) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Event not found',
})
}
const event = await prisma.event.update({
where: { id: eventId },
data,
select: {
id: true,
},
})
return { event }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
eventId: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const event = await prisma.event.delete({ where: { id: input.eventId } })
return { event }
},
}),
}

View File

@@ -1,15 +0,0 @@
import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event'
import { adminServiceActions } from './service'
import { adminServiceSuggestionActions } from './serviceSuggestion'
import { adminUserActions } from './user'
import { verificationStep } from './verificationStep'
export const adminActions = {
attribute: adminAttributeActions,
event: adminEventActions,
service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions,
user: adminUserActions,
verificationStep,
}

View File

@@ -1,234 +0,0 @@
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(),
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().optional().nullable().default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
})
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 contactMethodSchema = z.object({
id: z.number().optional(),
label: z.string().min(1).max(50),
value: z.string().min(1).max(200),
iconId: z.string().min(1).max(50),
info: z.string().max(200).optional().default(''),
serviceId: z.number(),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
handler: async (input) => {
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 { imageFile, ...serviceData } = input
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
const service = await prisma.service.create({
data: {
...serviceData,
categories: {
connect: input.categories.map((id) => ({ id })),
},
attributes: {
create: input.attributes.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
},
imageUrl,
},
select: {
id: true,
slug: true,
},
})
return { service }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing),
handler: async (input) => {
const { id, categories, attributes, imageFile, ...data } = input
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
NOT: { id },
},
})
if (existing) {
throw new ActionError({
code: 'CONFLICT',
message: 'A service with this slug already exists',
})
}
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
// Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({
where: { id },
include: {
categories: true,
attributes: {
include: {
attribute: true,
},
},
},
})
if (!existingService) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
// Find categories to connect and disconnect
const existingCategoryIds = existingService.categories.map((c) => c.id)
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
// Find attributes to connect and disconnect
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
const service = await prisma.service.update({
where: { id },
data: {
...data,
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })),
},
attributes: {
// Connect new attributes
create: attributesToAdd.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
// Delete specific attributes that are no longer needed
deleteMany: attributesToRemove.map((attributeId) => ({
attributeId,
})),
},
},
})
return { service }
},
}),
createContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: contactMethodSchema.omit({ id: true }),
handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.create({
data: input,
})
return { contactMethod }
},
}),
updateContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: contactMethodSchema,
handler: async (input) => {
const { id, ...data } = input
const contactMethod = await prisma.serviceContactMethod.update({
where: { id },
data,
})
return { contactMethod }
},
}),
deleteContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number(),
}),
handler: async (input) => {
await prisma.serviceContactMethod.delete({
where: { id: input.id },
})
return { success: true }
},
}),
}

View File

@@ -1,71 +0,0 @@
import { ServiceSuggestionStatus } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
export const adminServiceSuggestionActions = {
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
suggestionId: z.coerce.number().int().positive(),
status: z.nativeEnum(ServiceSuggestionStatus),
}),
handler: async (input) => {
const suggestion = await prisma.serviceSuggestion.findUnique({
select: {
id: true,
status: true,
serviceId: true,
},
where: { id: input.suggestionId },
})
if (!suggestion) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Suggestion not found',
})
}
await prisma.serviceSuggestion.update({
where: { id: suggestion.id },
data: {
status: input.status,
},
})
},
}),
message: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
suggestionId: z.coerce.number().int().positive(),
content: z.string().min(1).max(1000),
}),
handler: async (input, context) => {
const suggestion = await prisma.serviceSuggestion.findUnique({
select: {
id: true,
userId: true,
},
where: { id: input.suggestionId },
})
if (!suggestion) {
throw new Error('Suggestion not found')
}
await prisma.serviceSuggestionMessage.create({
data: {
content: input.content,
suggestionId: suggestion.id,
userId: context.locals.user.id,
},
})
},
}),
}

View File

@@ -1,288 +0,0 @@
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectUserReturnFields = {
id: true,
name: true,
displayName: true,
link: true,
picture: true,
admin: true,
verified: true,
verifier: true,
verifiedLink: true,
secretTokenHash: true,
totalKarma: true,
createdAt: true,
updatedAt: true,
spammer: true,
} as const satisfies Prisma.UserSelect
export const adminUserActions = {
search: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
name: z.string().min(1, 'User name is required'),
}),
handler: async (input) => {
const user = await prisma.user.findUnique({
where: { name: input.name },
select: selectUserReturnFields,
})
return { user }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
name: z.string().min(1, 'Name is required').max(255, 'Name must be less than 255 characters'),
link: z
.string()
.url('Invalid URL')
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
picture: z.string().max(255, 'Picture URL must be less than 255 characters').nullable().default(null),
pictureFile: z.instanceof(File).optional(),
verifier: z.boolean().default(false),
admin: z.boolean().default(false),
spammer: z.boolean().default(false),
verifiedLink: z
.string()
.url('Invalid URL')
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
displayName: z
.string()
.max(50, 'Display Name must be less than 50 characters')
.nullable()
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
.transform((val) => val || null),
}),
handler: async ({ id, picture, pictureFile, ...valuesToUpdate }) => {
const user = await prisma.user.findUnique({
where: {
id,
},
select: {
id: true,
},
})
if (!user) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'User not found',
})
}
let pictureUrl = picture ?? null
if (pictureFile && pictureFile.size > 0) {
pictureUrl = await saveFileLocally(pictureFile, pictureFile.name, 'users/pictures/')
}
const updatedUser = await prisma.user.update({
where: { id: user.id },
data: {
...valuesToUpdate,
verified: !!valuesToUpdate.verifiedLink,
picture: pictureUrl,
},
select: selectUserReturnFields,
})
return {
updatedUser,
}
},
}),
internalNotes: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userId: z.coerce.number().int().positive(),
content: z.string().min(1).max(1000),
}),
handler: async (input, context) => {
const note = await prisma.internalUserNote.create({
data: {
content: input.content,
userId: input.userId,
addedByUserId: context.locals.user.id,
},
select: {
id: true,
},
})
return { note }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
noteId: z.coerce.number().int().positive(),
}),
handler: async (input) => {
await prisma.internalUserNote.delete({
where: {
id: input.noteId,
},
})
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
noteId: z.coerce.number().int().positive(),
content: z.string().min(1).max(1000),
}),
handler: async (input, context) => {
const note = await prisma.internalUserNote.update({
where: {
id: input.noteId,
},
data: {
content: input.content,
addedByUserId: context.locals.user.id,
},
select: {
id: true,
},
})
return { note }
},
}),
},
serviceAffiliations: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userId: z.coerce.number().int().positive(),
serviceId: z.coerce.number().int().positive(),
role: z.enum(['OWNER', 'ADMIN', 'MODERATOR', 'SUPPORT', 'TEAM_MEMBER']),
}),
handler: async (input) => {
// Check if the user exists
const user = await prisma.user.findUnique({
where: { id: input.userId },
select: { id: true },
})
if (!user) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'User not found',
})
}
// Check if the service exists
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { id: true, name: true },
})
if (!service) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Service not found',
})
}
try {
// Check if the service affiliation already exists
const existingAffiliation = await prisma.serviceUser.findUnique({
where: {
userId_serviceId: {
userId: input.userId,
serviceId: input.serviceId,
},
},
})
let serviceAffiliation
if (existingAffiliation) {
// Update existing affiliation
serviceAffiliation = await prisma.serviceUser.update({
where: {
userId_serviceId: {
userId: input.userId,
serviceId: input.serviceId,
},
},
data: {
role: input.role as ServiceUserRole,
},
})
return { serviceAffiliation, serviceName: service.name, updated: true }
} else {
// Create new affiliation
serviceAffiliation = await prisma.serviceUser.create({
data: {
userId: input.userId,
serviceId: input.serviceId,
role: input.role as ServiceUserRole,
},
})
return { serviceAffiliation, serviceName: service.name }
}
} catch (error) {
console.error('Error managing service affiliation:', error)
throw new ActionError({
code: 'INTERNAL_SERVER_ERROR',
message: 'Error managing service affiliation',
})
}
},
}),
remove: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.coerce.number().int().positive(),
}),
handler: async (input) => {
const serviceAffiliation = await prisma.serviceUser.delete({
where: {
id: input.id,
},
include: {
service: {
select: {
name: true,
},
},
},
})
return { serviceAffiliation }
},
}),
},
}

View File

@@ -1,118 +0,0 @@
import { VerificationStepStatus } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
const verificationStepSchemaBase = z.object({
title: z.string().min(1, 'Title is required'),
description: z
.string()
.min(1, 'Description is required')
.max(200, 'Description must be 200 characters or less'),
status: z.nativeEnum(VerificationStepStatus),
serviceId: z.coerce.number().int().positive(),
evidenceMd: z.string().optional().nullable().default(null),
})
const verificationStepUpdateSchema = z.object({
id: z.coerce.number().int().positive(),
title: z.string().min(1, 'Title is required').optional(),
description: z
.string()
.min(1, 'Description is required')
.max(200, 'Description must be 200 characters or less')
.optional(),
status: z.nativeEnum(VerificationStepStatus).optional(),
evidenceMd: z.string().optional().nullable(),
})
const verificationStepIdSchema = z.object({
id: z.coerce.number().int().positive(),
})
export const verificationStep = {
create: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: verificationStepSchemaBase,
handler: async (input) => {
const { serviceId, title, description, status, evidenceMd } = input
const service = await prisma.service.findUnique({
where: { id: serviceId },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
const newVerificationStep = await prisma.verificationStep.create({
data: {
title,
description,
status,
evidenceMd,
service: {
connect: { id: serviceId },
},
},
})
return { verificationStep: newVerificationStep }
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: verificationStepUpdateSchema,
handler: async (input) => {
const { id, ...dataToUpdate } = input
const existingStep = await prisma.verificationStep.findUnique({
where: { id },
})
if (!existingStep) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Verification step not found',
})
}
const updatedVerificationStep = await prisma.verificationStep.update({
where: { id },
data: dataToUpdate,
})
return { verificationStep: updatedVerificationStep }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: verificationStepIdSchema,
handler: async ({ id }) => {
const existingStep = await prisma.verificationStep.findUnique({
where: { id },
})
if (!existingStep) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Verification step not found',
})
}
await prisma.verificationStep.delete({ where: { id } })
return { success: true, deletedId: id }
},
}),
}