Compare commits

...

2 Commits

Author SHA1 Message Date
pluja
490433b002 Release 202506011511 2025-06-01 15:11:37 +00:00
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
11 changed files with 181 additions and 56 deletions

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateIndex
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");

View File

@@ -336,6 +336,7 @@ model Service {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
slug String @unique slug String @unique
previousSlugs String[] @default([])
description String description String
categories Category[] @relation("ServiceToCategory") categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4) kycLevel Int @default(4)
@@ -396,6 +397,7 @@ model Service {
@@index([createdAt]) @@index([createdAt])
@@index([updatedAt]) @@index([updatedAt])
@@index([slug]) @@index([slug])
@@index([previousSlugs])
} }
model ServiceContactMethod { model ServiceContactMethod {

View File

@@ -612,6 +612,7 @@ const generateFakeService = (users: User[]) => {
return { return {
name, name,
slug, slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions), description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)), kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
overallScore: 0, overallScore: 0,

View File

@@ -1,6 +1,7 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client' import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
import slugify from 'slugify' import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
@@ -156,19 +157,24 @@ export const adminServiceActions = {
}) })
} }
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const existingService = await prisma.service.findUnique({ const existingService = await prisma.service.findUnique({
where: { id: input.id }, where: { id: input.id },
include: { select: {
categories: true, slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: { attributes: {
include: { select: {
attribute: true, attributeId: true,
attribute: {
select: {
id: true,
},
},
}, },
}, },
}, },
@@ -189,6 +195,12 @@ export const adminServiceActions = {
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId)) const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.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 { const {
web: serviceUrls, web: serviceUrls,
onion: onionUrls, onion: onionUrls,
@@ -213,6 +225,14 @@ export const adminServiceActions = {
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
slug: input.slug, slug: input.slug,
overallScore: input.overallScore, overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
imageUrl, imageUrl,
categories: { categories: {

View File

@@ -44,7 +44,30 @@ export const apiServiceActions = {
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`]) .flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined : undefined
const service = await prisma.service.findFirst({ const select = {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() }, listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
@@ -61,30 +84,21 @@ export const apiServiceActions = {
: []), : []),
], ],
}, },
select: { select,
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
},
}) })
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
},
select,
})
}
if ( if (
!service || !service ||
(service.serviceVisibility !== 'PUBLIC' && (service.serviceVisibility !== 'PUBLIC' &&

View File

@@ -271,6 +271,7 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: false,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -305,8 +306,8 @@ if (toggleResult?.error) {
label="Status" label="Status"
error={createInputErrors.isActive} error={createInputErrors.isActive}
options={[ options={[
{ label: 'Active', value: 'true' }, { label: 'Active', value: 'true', noTransitionPersist: true },
{ label: 'Inactive', value: 'false' }, { label: 'Inactive', value: 'false', noTransitionPersist: true },
]} ]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'} selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"
@@ -627,6 +628,7 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: true,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -659,8 +661,8 @@ if (toggleResult?.error) {
name="isActive" name="isActive"
label="Status" label="Status"
options={[ options={[
{ label: 'Active', value: 'true' }, { label: 'Active', value: 'true', noTransitionPersist: true },
{ label: 'Inactive', value: 'false' }, { label: 'Inactive', value: 'false', noTransitionPersist: true },
]} ]}
selectedValue={announcement.isActive ? 'true' : 'false'} selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"

View File

@@ -183,7 +183,21 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
], ],
]) ])
if (!service) return Astro.rewrite('/404') if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: { previousSlugs: { has: slug } },
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/admin/services/${serviceWithOldSlug.slug}/edit`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
const apiCalls = await Astro.locals.banners.try( const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls', 'Error fetching api calls',
@@ -264,7 +278,9 @@ const apiCalls = await Astro.locals.banners.try(
<InputText <InputText
label="Slug" label="Slug"
description="Auto-generated if empty" description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug" name="slug"
inputProps={{ inputProps={{
value: service.slug, value: service.slug,
@@ -390,6 +406,7 @@ const apiCalls = await Astro.locals.banners.try(
value: kycLevel.id.toString(), value: kycLevel.id.toString(),
icon: kycLevel.icon, icon: kycLevel.icon,
description: kycLevel.description, description: kycLevel.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.kycLevel.toString()} selectedValue={service.kycLevel.toString()}
iconSize="md" iconSize="md"
@@ -407,6 +424,7 @@ const apiCalls = await Astro.locals.banners.try(
icon: status.icon, icon: status.icon,
iconClass: status.classNames.icon, iconClass: status.classNames.icon,
description: status.description, description: status.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.verificationStatus} selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus} error={serviceInputErrors.verificationStatus}
@@ -422,6 +440,7 @@ const apiCalls = await Astro.locals.banners.try(
label: currency.name, label: currency.name,
value: currency.id, value: currency.id,
icon: currency.icon, icon: currency.icon,
noTransitionPersist: true,
}))} }))}
selectedValue={service.acceptedCurrencies} selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies} error={serviceInputErrors.acceptedCurrencies}
@@ -462,6 +481,7 @@ const apiCalls = await Astro.locals.banners.try(
icon: visibility.icon, icon: visibility.icon,
iconClass: visibility.iconClass, iconClass: visibility.iconClass,
description: visibility.description, description: visibility.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.serviceVisibility} selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility} error={serviceInputErrors.serviceVisibility}

View File

@@ -226,9 +226,24 @@ if (!user) return Astro.rewrite('/404')
name="type" name="type"
label="Type" label="Type"
options={[ options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' }, {
{ label: 'Moderator', value: 'moderator', icon: 'ri:graduation-cap-fill' }, label: 'Admin',
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' }, value: 'admin',
icon: 'ri:shield-star-fill',
noTransitionPersist: true,
},
{
label: 'Moderator',
value: 'moderator',
icon: 'ri:graduation-cap-fill',
noTransitionPersist: true,
},
{
label: 'Spammer',
value: 'spammer',
icon: 'ri:alert-fill',
noTransitionPersist: true,
},
{ {
label: 'Verified', label: 'Verified',
value: 'verified', value: 'verified',
@@ -419,6 +434,7 @@ if (!user) return Astro.rewrite('/404')
label: role.label, label: role.label,
value: role.value, value: role.value,
icon: role.icon, icon: role.icon,
noTransitionPersist: true,
}))} }))}
required required
cardSize="sm" cardSize="sm"

View File

@@ -13,6 +13,7 @@ import {
getEventTypeInfo, getEventTypeInfo,
getEventTypeInfoBySlug, getEventTypeInfoBySlug,
} from '../constants/eventTypes' } from '../constants/eventTypes'
import { getServiceVisibilityInfo } from '../constants/serviceVisibility'
import { getVerificationStatusInfo } from '../constants/verificationStatus' import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
@@ -44,6 +45,8 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
async () => async () =>
prisma.service.findMany({ prisma.service.findMany({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
events: { events: {
some: { some: {
visible: true, visible: true,
@@ -72,8 +75,15 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
createdAt: { createdAt: {
lte: params.now, lte: params.now,
}, },
...(params.service ? { service: { slug: params.service } } : {}), service: {
...(params.type ? { type: getEventTypeInfoBySlug(params.type).id } : {}), slug: params.service ?? undefined,
listedAt: params.service ? undefined : { lte: new Date() },
serviceVisibility: {
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
},
},
type: params.type ? getEventTypeInfoBySlug(params.type).id : undefined,
...(params.from || params.to ...(params.from || params.to
? { ? {
OR: [ OR: [
@@ -105,6 +115,7 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
name: true, name: true,
imageUrl: true, imageUrl: true,
verificationStatus: true, verificationStatus: true,
serviceVisibility: true,
}, },
}, },
}, },
@@ -126,6 +137,7 @@ const events = orderBy(
service: { service: {
...event.service, ...event.service,
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus), verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
serviceVisibilityInfo: getServiceVisibilityInfo(event.service.serviceVisibility),
}, },
})), })),
['actualEndedAt', 'startedAt'], ['actualEndedAt', 'startedAt'],
@@ -416,6 +428,16 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
)} )}
/> />
)} )}
{event.service.serviceVisibility === 'ARCHIVED' && (
<Icon
name={event.service.serviceVisibilityInfo.icon}
class={cn(
'ms-1 inline-block size-3 shrink-0',
event.service.serviceVisibilityInfo.iconClass
)}
/>
)}
</a> </a>
{event.source && ( {event.source && (

View File

@@ -182,6 +182,7 @@ const {
'min-score': { if: 'default' }, 'min-score': { if: 'default' },
'user-rating': { if: 'default' }, 'user-rating': { if: 'default' },
'max-kyc': { if: 'default' }, 'max-kyc': { if: 'default' },
'sort-seed': { if: 'another-is-unset', prop: 'page' },
}, },
}, },
} }

View File

@@ -67,7 +67,11 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
'Error fetching service', 'Error fetching service',
async () => async () =>
prisma.service.findUnique({ prisma.service.findUnique({
where: { slug }, where: {
slug,
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { select: {
id: true, id: true,
slug: true, slug: true,
@@ -219,6 +223,34 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
], ],
]) ])
if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: {
previousSlugs: { has: slug },
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/service/${serviceWithOldSlug.slug}`, 301)
}
} catch (error) {
console.error(error)
}
return Astro.rewrite('/404')
}
if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404')
}
const makeWatchingDetails = ( const makeWatchingDetails = (
dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{ dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{
select: { select: {
@@ -254,17 +286,7 @@ const makeWatchingDetails = (
} as const } as const
} }
const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service?.id) const watchingDetails = makeWatchingDetails(dbNotificationPreferences, service.id)
if (!service) return Astro.rewrite('/404')
if (
service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'UNLISTED' &&
service.serviceVisibility !== 'ARCHIVED'
) {
return Astro.rewrite('/404')
}
const statusIcon = { const statusIcon = {
...verificationStatusesByValue, ...verificationStatusesByValue,