Compare commits
6 Commits
release-43
...
release-49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17bc8a521 | ||
|
|
ec1215f2ae | ||
|
|
3afa824c18 | ||
|
|
9a68112e24 | ||
|
|
0c40d8eec5 | ||
|
|
e16c9b64ed |
@@ -0,0 +1,5 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -164,11 +165,22 @@ export const adminServiceActions = {
|
|||||||
|
|
||||||
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -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: {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export const apiServiceActions = {
|
|||||||
.optional(),
|
.optional(),
|
||||||
url: zodUrlOptionalProtocol.optional(),
|
url: zodUrlOptionalProtocol.optional(),
|
||||||
}),
|
}),
|
||||||
handler: async (input) => {
|
handler: async (input, context) => {
|
||||||
if (!input.id && !input.slug && !input.url) {
|
if (!input.id && !input.slug && !input.url) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'BAD_REQUEST',
|
code: 'BAD_REQUEST',
|
||||||
@@ -44,8 +44,34 @@ 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() },
|
||||||
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||||
|
|
||||||
OR: [
|
OR: [
|
||||||
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
|
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||||
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
|
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||||
@@ -58,28 +84,29 @@ 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,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!service) {
|
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' &&
|
||||||
|
service.serviceVisibility !== 'ARCHIVED' &&
|
||||||
|
service.serviceVisibility !== 'UNLISTED') ||
|
||||||
|
!service.listedAt ||
|
||||||
|
service.listedAt > new Date()
|
||||||
|
) {
|
||||||
throw new ActionError({
|
throw new ActionError({
|
||||||
code: 'NOT_FOUND',
|
code: 'NOT_FOUND',
|
||||||
message: 'Service not found',
|
message: 'Service not found',
|
||||||
@@ -91,6 +118,7 @@ export const apiServiceActions = {
|
|||||||
slug: service.slug,
|
slug: service.slug,
|
||||||
name: service.name,
|
name: service.name,
|
||||||
description: service.description,
|
description: service.description,
|
||||||
|
serviceVisibility: service.serviceVisibility,
|
||||||
verificationStatus: service.verificationStatus,
|
verificationStatus: service.verificationStatus,
|
||||||
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
|
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
|
||||||
'value',
|
'value',
|
||||||
@@ -99,14 +127,16 @@ export const apiServiceActions = {
|
|||||||
'labelShort',
|
'labelShort',
|
||||||
'description',
|
'description',
|
||||||
]),
|
]),
|
||||||
|
verifiedAt: service.verifiedAt,
|
||||||
kycLevel: service.kycLevel,
|
kycLevel: service.kycLevel,
|
||||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||||
categories: service.categories,
|
categories: service.categories,
|
||||||
|
listedAt: service.listedAt,
|
||||||
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
||||||
(url) => url + (service.referral ?? '')
|
(url) => url + (service.referral ?? '')
|
||||||
),
|
),
|
||||||
tosUrls: service.tosUrls,
|
tosUrls: service.tosUrls,
|
||||||
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`,
|
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
import {
|
import { Currency } from '@prisma/client'
|
||||||
Currency,
|
|
||||||
ServiceSuggestionStatus,
|
|
||||||
ServiceSuggestionType,
|
|
||||||
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 { formatDistanceStrict } from 'date-fns'
|
import { formatDistanceStrict } from 'date-fns'
|
||||||
@@ -118,9 +112,9 @@ export const serviceSuggestionActions = {
|
|||||||
|
|
||||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||||
data: {
|
data: {
|
||||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
type: 'EDIT_SERVICE',
|
||||||
notes: combinedNotes,
|
notes: combinedNotes,
|
||||||
status: ServiceSuggestionStatus.PENDING,
|
status: 'PENDING',
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
},
|
},
|
||||||
@@ -229,12 +223,12 @@ export const serviceSuggestionActions = {
|
|||||||
kycLevel: input.kycLevel,
|
kycLevel: input.kycLevel,
|
||||||
acceptedCurrencies: input.acceptedCurrencies,
|
acceptedCurrencies: input.acceptedCurrencies,
|
||||||
imageUrl,
|
imageUrl,
|
||||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||||
overallScore: 0,
|
overallScore: 0,
|
||||||
privacyScore: 0,
|
privacyScore: 0,
|
||||||
trustScore: 0,
|
trustScore: 0,
|
||||||
listedAt: new Date(),
|
listedAt: new Date(),
|
||||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
serviceVisibility: 'UNLISTED',
|
||||||
categories: {
|
categories: {
|
||||||
connect: input.categories.map((id) => ({ id })),
|
connect: input.categories.map((id) => ({ id })),
|
||||||
},
|
},
|
||||||
@@ -250,8 +244,8 @@ export const serviceSuggestionActions = {
|
|||||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||||
data: {
|
data: {
|
||||||
notes: input.notes,
|
notes: input.notes,
|
||||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
type: 'CREATE_SERVICE',
|
||||||
status: ServiceSuggestionStatus.PENDING,
|
status: 'PENDING',
|
||||||
userId: context.locals.user.id,
|
userId: context.locals.user.id,
|
||||||
serviceId: service.id,
|
serviceId: service.id,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
id={`comment-${comment.id.toString()}`}
|
id={`comment-${comment.id.toString()}`}
|
||||||
class={cn([
|
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',
|
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) &&
|
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
||||||
'bg-[#182a1f]',
|
'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.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 && (
|
comment.rating !== null && !comment.parentId && (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
@@ -320,6 +314,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
|||||||
color={commentStatusById.REJECTED.color}
|
color={commentStatusById.REJECTED.color}
|
||||||
text={commentStatusById.REJECTED.label}
|
text={commentStatusById.REJECTED.label}
|
||||||
inlineIcon
|
inlineIcon
|
||||||
|
endIcon="ri:lock-line"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||||
|
<BadgeSmall
|
||||||
|
icon="ri:alert-fill"
|
||||||
|
color="yellow"
|
||||||
|
text="Needs admin review"
|
||||||
|
inlineIcon
|
||||||
|
endIcon="ri:lock-line"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.moderator) return null
|
|||||||
data-comment-id={comment.id}
|
data-comment-id={comment.id}
|
||||||
data-user-id={user.id}
|
data-user-id={user.id}
|
||||||
>
|
>
|
||||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
|
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
|||||||
<div class="flex flex-wrap gap-4">
|
<div class="flex flex-wrap gap-4">
|
||||||
<InputRating name="rating" label="Rating" />
|
<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">
|
<label class="flex cursor-pointer items-center gap-2">
|
||||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||||
|
|||||||
@@ -27,6 +27,12 @@ const links = [
|
|||||||
icon: 'i2p',
|
icon: 'i2p',
|
||||||
external: true,
|
external: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
href: '/docs/api',
|
||||||
|
label: 'API',
|
||||||
|
icon: 'ri:plug-line',
|
||||||
|
external: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
href: '/about',
|
href: '/about',
|
||||||
label: 'About',
|
label: 'About',
|
||||||
|
|||||||
@@ -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 {
|
return {
|
||||||
type: 'clearnet' as const,
|
type: 'clearnet' as const,
|
||||||
url: urlWithReferral,
|
url: urlWithReferral,
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export const {
|
|||||||
value: 'ARCHIVED',
|
value: 'ARCHIVED',
|
||||||
slug: 'archived',
|
slug: 'archived',
|
||||||
label: 'Archived',
|
label: 'Archived',
|
||||||
description: 'No longer operational',
|
description: 'No longer operational.',
|
||||||
longDescription:
|
longDescription:
|
||||||
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||||
icon: 'ri:archive-line',
|
icon: 'ri:archive-line',
|
||||||
|
|||||||
@@ -114,7 +114,13 @@ export class ErrorBanners {
|
|||||||
return result
|
return result
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.handler(uiMessage)(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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
50
web/src/lib/makeAdminApiCallInfo.ts
Normal file
50
web/src/lib/makeAdminApiCallInfo.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
---
|
---
|
||||||
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
||||||
|
|
||||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
|
||||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
import { timeAgo } from '../../lib/timeAgo'
|
||||||
|
|
||||||
const releaseDate =
|
const releaseDate =
|
||||||
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
||||||
@@ -37,7 +37,7 @@ const releaseDate =
|
|||||||
{
|
{
|
||||||
!!releaseDate && (
|
!!releaseDate && (
|
||||||
<p class="text-day-500 mt-2">
|
<p class="text-day-500 mt-2">
|
||||||
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
|
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { Icon } from 'astro-icon/components'
|
import { Icon } from 'astro-icon/components'
|
||||||
import { Markdown } from 'astro-remote'
|
import { Markdown } from 'astro-remote'
|
||||||
import { actions, isInputError } from 'astro:actions'
|
import { actions, isInputError } from 'astro:actions'
|
||||||
|
import { Code } from 'astro:components'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import BadgeSmall from '../../../../components/BadgeSmall.astro'
|
import BadgeSmall from '../../../../components/BadgeSmall.astro'
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
verificationStepStatuses,
|
verificationStepStatuses,
|
||||||
} from '../../../../constants/verificationStepStatus'
|
} from '../../../../constants/verificationStepStatus'
|
||||||
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
import BaseLayout from '../../../../layouts/BaseLayout.astro'
|
||||||
|
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
|
||||||
|
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||||
import { pluralize } from '../../../../lib/pluralize'
|
import { pluralize } from '../../../../lib/pluralize'
|
||||||
import { prisma } from '../../../../lib/prisma'
|
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}`}>
|
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
|
||||||
@@ -247,7 +278,9 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
|
|
||||||
<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,
|
||||||
@@ -1100,5 +1133,27 @@ if (!service) return Astro.rewrite('/404')
|
|||||||
</form>
|
</form>
|
||||||
</FormSubSection>
|
</FormSubSection>
|
||||||
</FormSection>
|
</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>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ icon: 'ri:plug-line'
|
|||||||
import { SOURCE_CODE_URL } from 'astro:env/server'
|
import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||||
import { kycLevels } from '../../constants/kycLevels'
|
import { kycLevels } from '../../constants/kycLevels'
|
||||||
import { verificationStatuses } from '../../constants/verificationStatus'
|
import { verificationStatuses } from '../../constants/verificationStatus'
|
||||||
|
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
||||||
|
|
||||||
Access basic service data via our public API.
|
Access basic service data via our public API.
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ type ServiceResponse = {
|
|||||||
slug: string
|
slug: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
|
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
|
||||||
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
||||||
verificationStatusInfo: {
|
verificationStatusInfo: {
|
||||||
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
||||||
@@ -49,6 +51,7 @@ type ServiceResponse = {
|
|||||||
labelShort: string
|
labelShort: string
|
||||||
description: string
|
description: string
|
||||||
}
|
}
|
||||||
|
verifiedAt: Date | null
|
||||||
kycLevel: 0 | 1 | 2 | 3 | 4
|
kycLevel: 0 | 1 | 2 | 3 | 4
|
||||||
kycLevelInfo: {
|
kycLevelInfo: {
|
||||||
value: 0 | 1 | 2 | 3 | 4
|
value: 0 | 1 | 2 | 3 | 4
|
||||||
@@ -59,13 +62,14 @@ type ServiceResponse = {
|
|||||||
name: string
|
name: string
|
||||||
slug: string
|
slug: string
|
||||||
}[]
|
}[]
|
||||||
|
listedAt: Date
|
||||||
serviceUrls: string[]
|
serviceUrls: string[]
|
||||||
tosUrls: string[]
|
tosUrls: string[]
|
||||||
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
|
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### KYC Levels
|
#### KYC Levels
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{kycLevels.map((level) => (
|
{kycLevels.map((level) => (
|
||||||
@@ -75,7 +79,7 @@ type ServiceResponse = {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
### Verification Status
|
#### Verification Status
|
||||||
|
|
||||||
<ul>
|
<ul>
|
||||||
{verificationStatuses.map((status) => (
|
{verificationStatuses.map((status) => (
|
||||||
@@ -85,7 +89,19 @@ type ServiceResponse = {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
### Example Request
|
#### Service Visibility
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
|
||||||
|
<li key={visibility.value}>
|
||||||
|
<strong>{visibility.value}</strong>: {visibility.longDescription}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
#### Request
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
curl -X QUERY https://kycnot.me/api/v1/service/get \
|
curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||||
@@ -93,12 +109,13 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
-d '{"slug": "my-example-service"}'
|
-d '{"slug": "my-example-service"}'
|
||||||
```
|
```
|
||||||
|
|
||||||
### Example Response
|
#### Response
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"name": "My Example Service",
|
"name": "My Example Service",
|
||||||
"description": "This is a description of my example service",
|
"description": "This is a description of my example service",
|
||||||
|
"serviceVisibility": "PUBLIC",
|
||||||
"verificationStatus": "VERIFICATION_SUCCESS",
|
"verificationStatus": "VERIFICATION_SUCCESS",
|
||||||
"verificationStatusInfo": {
|
"verificationStatusInfo": {
|
||||||
"value": "VERIFICATION_SUCCESS",
|
"value": "VERIFICATION_SUCCESS",
|
||||||
@@ -107,6 +124,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
"labelShort": "Verified",
|
"labelShort": "Verified",
|
||||||
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
|
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
|
||||||
},
|
},
|
||||||
|
"verifiedAt": "2025-01-20T07:12:29.393Z",
|
||||||
"kycLevel": 0,
|
"kycLevel": 0,
|
||||||
"kycLevelInfo": {
|
"kycLevelInfo": {
|
||||||
"value": 0,
|
"value": 0,
|
||||||
@@ -119,6 +137,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
"slug": "exchange"
|
"slug": "exchange"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
"listedAt": "2025-05-31T19:09:18.043Z",
|
||||||
"serviceUrls": [
|
"serviceUrls": [
|
||||||
"https://example.com",
|
"https://example.com",
|
||||||
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
||||||
@@ -128,7 +147,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Error Responses
|
#### Error Responses
|
||||||
|
|
||||||
**404 Not Found**: Service not found
|
**404 Not Found**: Service not found
|
||||||
|
|
||||||
|
|||||||
@@ -218,16 +218,12 @@ const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : n
|
|||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||||
listedAt: {
|
listedAt: { lte: new Date() },
|
||||||
lte: new Date(),
|
|
||||||
},
|
|
||||||
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
||||||
verificationStatus: {
|
verificationStatus: {
|
||||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||||
},
|
},
|
||||||
serviceVisibility: {
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
in: ['PUBLIC', 'ARCHIVED'],
|
|
||||||
},
|
|
||||||
overallScore: { gte: filters['min-score'] },
|
overallScore: { gte: filters['min-score'] },
|
||||||
acceptedCurrencies: filters.currencies.length
|
acceptedCurrencies: filters.currencies.length
|
||||||
? filters['currency-mode'] === 'and'
|
? filters['currency-mode'] === 'and'
|
||||||
@@ -319,9 +315,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
select: {
|
select: {
|
||||||
services: {
|
services: {
|
||||||
where: {
|
where: {
|
||||||
serviceVisibility: {
|
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||||
in: ['PUBLIC', 'ARCHIVED'],
|
listedAt: { lte: new Date() },
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -94,7 +94,16 @@ const user = await Astro.locals.banners.try('user', async () => {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
where: { service: { serviceVisibility: 'PUBLIC' } },
|
where: {
|
||||||
|
service: {
|
||||||
|
listedAt: {
|
||||||
|
lte: new Date(),
|
||||||
|
},
|
||||||
|
serviceVisibility: {
|
||||||
|
in: ['PUBLIC', 'ARCHIVED'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
take: 5,
|
take: 5,
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user