diff --git a/web/prisma/migrations/20250531213850_add_previous_slugs_to_service/migration.sql b/web/prisma/migrations/20250531213850_add_previous_slugs_to_service/migration.sql new file mode 100644 index 0000000..67f04c3 --- /dev/null +++ b/web/prisma/migrations/20250531213850_add_previous_slugs_to_service/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[]; + +-- CreateIndex +CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs"); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 032c036..232b53f 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -336,6 +336,7 @@ model Service { id Int @id @default(autoincrement()) name String slug String @unique + previousSlugs String[] @default([]) description String categories Category[] @relation("ServiceToCategory") kycLevel Int @default(4) @@ -396,6 +397,7 @@ model Service { @@index([createdAt]) @@index([updatedAt]) @@index([slug]) + @@index([previousSlugs]) } model ServiceContactMethod { diff --git a/web/prisma/seed.ts b/web/prisma/seed.ts index 4a75079..865ebde 100755 --- a/web/prisma/seed.ts +++ b/web/prisma/seed.ts @@ -612,6 +612,7 @@ const generateFakeService = (users: User[]) => { return { name, slug, + previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }), description: faker.helpers.arrayElement(serviceDescriptions), kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)), overallScore: 0, diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 9710890..66a017d 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -1,6 +1,7 @@ import { Currency, ServiceVisibility, VerificationStatus } 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' @@ -164,11 +165,22 @@ export const adminServiceActions = { const existingService = await prisma.service.findUnique({ where: { id: input.id }, - include: { - categories: true, + select: { + slug: true, + previousSlugs: true, + categories: { + select: { + id: true, + }, + }, attributes: { - include: { - attribute: true, + select: { + attributeId: true, + attribute: { + select: { + id: true, + }, + }, }, }, }, @@ -213,6 +225,14 @@ export const adminServiceActions = { 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: { diff --git a/web/src/actions/api/service.ts b/web/src/actions/api/service.ts index 66470db..5a21dd7 100644 --- a/web/src/actions/api/service.ts +++ b/web/src/actions/api/service.ts @@ -44,7 +44,30 @@ export const apiServiceActions = { .flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`]) : 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: { listedAt: { lte: new Date() }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, @@ -61,30 +84,21 @@ export const apiServiceActions = { : []), ], }, - 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, - }, + select, }) + 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 ( !service || (service.serviceVisibility !== 'PUBLIC' && diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index 34cd55d..4e99c2e 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -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( 'Error fetching api calls', @@ -264,7 +278,9 @@ const apiCalls = await Astro.locals.banners.try( 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : '' + }`} name="slug" inputProps={{ value: service.slug, diff --git a/web/src/pages/service/[slug].astro b/web/src/pages/service/[slug].astro index 21a02c5..0e0e80a 100644 --- a/web/src/pages/service/[slug].astro +++ b/web/src/pages/service/[slug].astro @@ -67,7 +67,11 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany( 'Error fetching service', async () => prisma.service.findUnique({ - where: { slug }, + where: { + slug, + serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] }, + listedAt: { lte: new Date() }, + }, select: { id: 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 = ( dbNotificationPreferences: Prisma.NotificationPreferencesGetPayload<{ select: { @@ -254,17 +286,7 @@ const makeWatchingDetails = ( } as const } -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 watchingDetails = makeWatchingDetails(dbNotificationPreferences, service.id) const statusIcon = { ...verificationStatusesByValue,