Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
pluja
ec1215f2ae Release 202505311848 2025-05-31 18:48:33 +00:00
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
13 changed files with 254 additions and 53 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())
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 {

View File

@@ -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,

View File

@@ -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: {

View File

@@ -24,7 +24,7 @@ export const apiServiceActions = {
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input) => {
handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
@@ -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' &&
@@ -122,7 +136,7 @@ export const apiServiceActions = {
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),

View File

@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
'group bg-night-700',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
@@ -270,12 +270,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
@@ -320,6 +314,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label}
inlineIcon
endIcon="ri:lock-line"
/>
)
}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall
icon="ri:alert-fill"
color="yellow"
text="Needs admin review"
inlineIcon
endIcon="ri:lock-line"
/>
)
}

View File

@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button>
<button

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags">
<InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400">

View File

@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
}
}
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
if (bitcointalkMatch) {
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal',
text: 'BitcoinTalk ',
},
{
style: 'irrelevant',
text: 'thread',
},
],
icon: networksBySlug.clearnet.icon,
}
}
return {
type: 'clearnet' as const,
url: urlWithReferral,

View File

@@ -114,7 +114,13 @@ export class ErrorBanners {
return result
} catch (error) {
this.handler(uiMessage)(error)
return fallback as F
return fallback as F extends never[]
? T extends [infer _First, ...infer _Rest]
? []
: T extends unknown[]
? T[number][]
: F
: F
}
}

View File

@@ -0,0 +1,50 @@
import type { Misc } from 'ts-toolbelt'
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
method,
path,
input,
baseUrl,
}: {
method: 'POST' | 'QUERY'
path: `/${string}`
input: T
baseUrl: URL | string
}) {
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
const fetchProsmise = fetch(fullPath, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((res) => {
try {
return res.json() as Promise<Misc.JSON.Value>
} catch (errJson: unknown) {
console.error(errJson)
try {
return res.text()
} catch (errText: unknown) {
console.error(errText)
return ''
}
}
})
let output: Misc.JSON.Value = ''
try {
output = await fetchProsmise
} catch (err: unknown) {
console.error(err)
output = err instanceof Error ? err.message : String(err)
}
return {
method,
path,
fullPath,
input,
output,
}
}

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro'
@@ -33,6 +34,8 @@ import {
verificationStepStatuses,
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma'
@@ -180,7 +183,35 @@ 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',
() =>
Promise.all([
makeAdminApiCallInfo({
method: 'QUERY',
path: '/service/get',
input: { slug: service.slug },
baseUrl: Astro.url,
}),
]),
[]
)
---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -247,7 +278,9 @@ if (!service) return Astro.rewrite('/404')
<InputText
label="Slug"
description="Auto-generated if empty"
description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug"
inputProps={{
value: service.slug,
@@ -1100,5 +1133,27 @@ if (!service) return Astro.rewrite('/404')
</form>
</FormSubSection>
</FormSection>
<FormSection title="API">
{
DEPLOYMENT_MODE === 'staging' && (
<p class="rounded-lg bg-red-900/30 p-4 text-sm text-red-200">
<Icon name="ri:alert-line" class="inline-block size-4 align-[-0.2em] text-red-400" />
This endpoints section doesn't work in PRE. Use curl commands instead.
</p>
)
}
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
<p class="text-day-400 text-sm">Input:</p>
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
<p class="text-day-400 text-sm">Output:</p>
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
</FormSubSection>
))
}
</FormSection>
</div>
</BaseLayout>

View File

@@ -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,