Compare commits
10 Commits
release-39
...
release-49
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e17bc8a521 | ||
|
|
ec1215f2ae | ||
|
|
3afa824c18 | ||
|
|
9a68112e24 | ||
|
|
0c40d8eec5 | ||
|
|
e16c9b64ed | ||
|
|
22944fcdb3 | ||
|
|
f7f380c591 | ||
|
|
577c524ca2 | ||
|
|
da12e8de79 |
@@ -0,0 +1,2 @@
|
||||
-- Enable pg_trgm extension for similarity functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
@@ -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())
|
||||
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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
5
web/src/actions/api/index.ts
Normal file
5
web/src/actions/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { apiServiceActions } from './service'
|
||||
|
||||
export const apiActions = {
|
||||
service: apiServiceActions,
|
||||
}
|
||||
143
web/src/actions/api/service.ts
Normal file
143
web/src/actions/api/service.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { getKycLevelInfo } from '../../constants/kycLevels'
|
||||
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export const apiServiceActions = {
|
||||
get: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
url: zodUrlOptionalProtocol.optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (!input.id && !input.slug && !input.url) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'At least one of the following parameters is required: id, slug, url',
|
||||
})
|
||||
}
|
||||
|
||||
const urlVariants = input.url
|
||||
? [input.url]
|
||||
.flatMap((url) =>
|
||||
[
|
||||
url,
|
||||
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
|
||||
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
|
||||
].filter((url) => url !== undefined)
|
||||
)
|
||||
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
|
||||
: undefined
|
||||
|
||||
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'] },
|
||||
|
||||
OR: [
|
||||
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||
...(urlVariants
|
||||
? ([
|
||||
{ serviceUrls: { hasSome: urlVariants } },
|
||||
{ onionUrls: { hasSome: urlVariants } },
|
||||
{ i2pUrls: { hasSome: urlVariants } },
|
||||
] satisfies Prisma.ServiceWhereInput[])
|
||||
: []),
|
||||
],
|
||||
},
|
||||
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' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' &&
|
||||
service.serviceVisibility !== 'UNLISTED') ||
|
||||
!service.listedAt ||
|
||||
service.listedAt > new Date()
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
slug: service.slug,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
serviceVisibility: service.serviceVisibility,
|
||||
verificationStatus: service.verificationStatus,
|
||||
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
|
||||
'value',
|
||||
'slug',
|
||||
'label',
|
||||
'labelShort',
|
||||
'description',
|
||||
]),
|
||||
verifiedAt: service.verifiedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
categories: service.categories,
|
||||
listedAt: service.listedAt,
|
||||
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
||||
(url) => url + (service.referral ?? '')
|
||||
),
|
||||
tosUrls: service.tosUrls,
|
||||
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { accountActions } from './account'
|
||||
import { adminActions } from './admin'
|
||||
import { apiActions } from './api'
|
||||
import { commentActions } from './comment'
|
||||
import { notificationActions } from './notifications'
|
||||
import { serviceActions } from './service'
|
||||
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
|
||||
export const server = {
|
||||
account: accountActions,
|
||||
admin: adminActions,
|
||||
api: apiActions,
|
||||
comment: commentActions,
|
||||
notification: notificationActions,
|
||||
service: serviceActions,
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Currency,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceVisibility,
|
||||
VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { Currency } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
@@ -12,6 +6,7 @@ import { formatDistanceStrict } from 'date-fns'
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../lib/urls'
|
||||
@@ -29,11 +24,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||
|
||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
const possibleDuplicates = await prisma.service.findMany({
|
||||
const matches = await findServicesBySimilarity(input.name, 0.3)
|
||||
|
||||
return await prisma.service.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -43,8 +39,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
@@ -118,9 +112,9 @@ export const serviceSuggestionActions = {
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: {
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
type: 'EDIT_SERVICE',
|
||||
notes: combinedNotes,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
status: 'PENDING',
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
@@ -229,12 +223,12 @@ export const serviceSuggestionActions = {
|
||||
kycLevel: input.kycLevel,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
imageUrl,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
||||
serviceVisibility: 'UNLISTED',
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -250,8 +244,8 @@ export const serviceSuggestionActions = {
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: {
|
||||
notes: input.notes,
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
type: 'CREATE_SERVICE',
|
||||
status: 'PENDING',
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -27,6 +27,12 @@ const links = [
|
||||
icon: 'i2p',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: '/docs/api',
|
||||
label: 'API',
|
||||
icon: 'ri:plug-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/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 {
|
||||
type: 'clearnet' as const,
|
||||
url: urlWithReferral,
|
||||
|
||||
@@ -49,6 +49,7 @@ const {
|
||||
class={cn(
|
||||
// Check the scam filter when there is a text quey and the user has checked verified and approved
|
||||
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
|
||||
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -80,16 +81,20 @@ const {
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm">
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
|
||||
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Ties randomly sorted
|
||||
</p>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
|
||||
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Sorted by match first
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Text Search -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="q">Text</label>
|
||||
<label for="q">Name</label>
|
||||
</legend>
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -71,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
/>
|
||||
|
||||
{
|
||||
countCommunityOnly && (
|
||||
!!countCommunityOnly && (
|
||||
<>
|
||||
<Button
|
||||
as="a"
|
||||
@@ -192,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
)}
|
||||
{countCommunityOnly && (
|
||||
{!!countCommunityOnly && (
|
||||
<Button
|
||||
as="a"
|
||||
href={urlIfIncludingCommunity}
|
||||
|
||||
@@ -65,7 +65,7 @@ export const {
|
||||
value: 'ARCHIVED',
|
||||
slug: 'archived',
|
||||
label: 'Archived',
|
||||
description: 'No longer operational',
|
||||
description: 'No longer operational.',
|
||||
longDescription:
|
||||
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||
icon: 'ri:archive-line',
|
||||
|
||||
32
web/src/lib/endpoints.ts
Normal file
32
web/src/lib/endpoints.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { type ActionClient } from 'astro:actions'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
import type { z } from 'astro/zod'
|
||||
|
||||
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
|
||||
action: Action
|
||||
): APIRoute {
|
||||
return async (context) => {
|
||||
try {
|
||||
const input = await context.request.json()
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const result = await context.callAction(action, input)
|
||||
|
||||
if (result.error) {
|
||||
console.error('Error on endpoint', result.error)
|
||||
return new Response(JSON.stringify({ error: result.error.message }), {
|
||||
status: result.error.status,
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
status: 200,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error on endpoint', error)
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
16
web/src/lib/findServicesBySimilarity.ts
Normal file
16
web/src/lib/findServicesBySimilarity.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
|
||||
const data = await prisma.$queryRaw`
|
||||
SELECT id, similarity(name, ${value}) AS similarity_score
|
||||
FROM "Service"
|
||||
WHERE similarity(name, ${value}) >= ${similarityThreshold}
|
||||
ORDER BY similarity(name, ${value}) desc`
|
||||
|
||||
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
|
||||
const parsedData = schema.parse(data)
|
||||
|
||||
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
|
||||
}
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -25,24 +25,23 @@ const findManyAndCount = {
|
||||
},
|
||||
}
|
||||
|
||||
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||
// NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
|
||||
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||
|
||||
type ModelsWithCustomMethods = {
|
||||
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||
findMany: (...args: any[]) => Promise<any>
|
||||
}
|
||||
? PrismaClient[Model] & {
|
||||
findManyAndCount: FindManyAndCountType
|
||||
}
|
||||
: PrismaClient[Model]
|
||||
}
|
||||
// type ModelsWithCustomMethods = {
|
||||
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||
// findMany: (...args: any[]) => Promise<any>
|
||||
// }
|
||||
// ? PrismaClient[Model] & {
|
||||
// findManyAndCount: FindManyAndCountType
|
||||
// }
|
||||
// : PrismaClient[Model]
|
||||
// }
|
||||
|
||||
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||
|
||||
function prismaClientSingleton(): ExtendedPrismaClient {
|
||||
const prisma = new PrismaClient().$extends(findManyAndCount)
|
||||
|
||||
return prisma as unknown as ExtendedPrismaClient
|
||||
function prismaClientSingleton() {
|
||||
return new PrismaClient().$extends(findManyAndCount)
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -201,6 +201,12 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
|
||||
|
||||
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
|
||||
|
||||
## API
|
||||
|
||||
You can access basic service data via our public API.
|
||||
|
||||
See the [API page](/docs/api) for more details.
|
||||
|
||||
## Support
|
||||
|
||||
If you like this project, you can **support** it through these methods:
|
||||
|
||||
@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
disabled: !user.karmaUnlocks.displayName,
|
||||
}}
|
||||
description={!user.karmaUnlocks.displayName
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)`
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
disabled: !user.karmaUnlocks.websiteLink,
|
||||
}}
|
||||
description={!user.karmaUnlocks.websiteLink
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)`
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
|
||||
: undefined}
|
||||
/>
|
||||
|
||||
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
square
|
||||
disabled={!user.karmaUnlocks.profilePicture}
|
||||
description={!user.karmaUnlocks.profilePicture
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)`
|
||||
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
|
||||
: undefined}
|
||||
removeCheckbox={user.picture
|
||||
? {
|
||||
|
||||
@@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')
|
||||
|
||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||
<p class="text-day-300">
|
||||
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
|
||||
>Learn about karma</a
|
||||
Earn karma to unlock features and privileges. <a
|
||||
href="/docs/karma"
|
||||
class="text-day-200 hover:underline">Learn about karma</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
---
|
||||
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
|
||||
|
||||
import TimeFormatted from '../../components/TimeFormatted.astro'
|
||||
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||
import { timeAgo } from '../../lib/timeAgo'
|
||||
|
||||
const releaseDate =
|
||||
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
|
||||
@@ -37,7 +37,7 @@ const releaseDate =
|
||||
{
|
||||
!!releaseDate && (
|
||||
<p class="text-day-500 mt-2">
|
||||
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
|
||||
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
13
web/src/pages/api/[...catchAll].ts
Normal file
13
web/src/pages/api/[...catchAll].ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const ALL: APIRoute = (context) => {
|
||||
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Endpoint not found',
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
)
|
||||
}
|
||||
7
web/src/pages/api/v1/service/get.ts
Normal file
7
web/src/pages/api/v1/service/get.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { actions } from 'astro:actions'
|
||||
|
||||
import { makeEndpointFromAction } from '../../../../lib/endpoints'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const QUERY: APIRoute = makeEndpointFromAction(actions.api.service.get)
|
||||
174
web/src/pages/docs/api.mdx
Normal file
174
web/src/pages/docs/api.mdx
Normal file
@@ -0,0 +1,174 @@
|
||||
---
|
||||
layout: ../../layouts/MarkdownLayout.astro
|
||||
title: API
|
||||
author: KYCnot.me
|
||||
pubDate: 2025-05-31
|
||||
description: 'Access basic service data via our public API.'
|
||||
icon: 'ri:plug-line'
|
||||
---
|
||||
|
||||
import { SOURCE_CODE_URL } from 'astro:env/server'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import { verificationStatuses } from '../../constants/verificationStatus'
|
||||
import { serviceVisibilities } from '../../constants/serviceVisibility'
|
||||
|
||||
Access basic service data via our public API.
|
||||
|
||||
All endpoints should be prefixed with `/api/v1/`.
|
||||
|
||||
The endpoints <a href={SOURCE_CODE_URL}>source code</a> is available on the `/web/src/actions/api/index.ts` file.
|
||||
|
||||
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
|
||||
|
||||
## `QUERY` `/service/get`
|
||||
|
||||
Fetches details for a single service by various lookup criteria.
|
||||
|
||||
### Request Parameters
|
||||
|
||||
| Parameter | Type | Required | Description |
|
||||
| ------------ | ------ | -------- | ----------- |
|
||||
| `id` | number | No* | Service ID |
|
||||
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
|
||||
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
|
||||
|
||||
\* At least one of the marked parameters is required.
|
||||
|
||||
### Response Format
|
||||
|
||||
```typescript
|
||||
type ServiceResponse = {
|
||||
id: number
|
||||
slug: string
|
||||
name: string
|
||||
description: string
|
||||
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
|
||||
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
||||
verificationStatusInfo: {
|
||||
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
|
||||
slug: string
|
||||
label: string
|
||||
labelShort: string
|
||||
description: string
|
||||
}
|
||||
verifiedAt: Date | null
|
||||
kycLevel: 0 | 1 | 2 | 3 | 4
|
||||
kycLevelInfo: {
|
||||
value: 0 | 1 | 2 | 3 | 4
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
categories: {
|
||||
name: string
|
||||
slug: string
|
||||
}[]
|
||||
listedAt: Date
|
||||
serviceUrls: string[]
|
||||
tosUrls: string[]
|
||||
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
|
||||
}
|
||||
```
|
||||
|
||||
#### KYC Levels
|
||||
|
||||
<ul>
|
||||
{kycLevels.map((level) => (
|
||||
<li key={level.id}>
|
||||
<strong>{level.id}</strong>: {level.name} - {level.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
#### Verification Status
|
||||
|
||||
<ul>
|
||||
{verificationStatuses.map((status) => (
|
||||
<li key={status.value}>
|
||||
<strong>{status.value}</strong>: {status.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
#### 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
|
||||
curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"slug": "my-example-service"}'
|
||||
```
|
||||
|
||||
#### Response
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Example Service",
|
||||
"description": "This is a description of my example service",
|
||||
"serviceVisibility": "PUBLIC",
|
||||
"verificationStatus": "VERIFICATION_SUCCESS",
|
||||
"verificationStatusInfo": {
|
||||
"value": "VERIFICATION_SUCCESS",
|
||||
"slug": "verified",
|
||||
"label": "Verified",
|
||||
"labelShort": "Verified",
|
||||
"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,
|
||||
"kycLevelInfo": {
|
||||
"value": 0,
|
||||
"name": "Guaranteed no KYC",
|
||||
"description": "Terms explicitly state KYC will never be requested."
|
||||
},
|
||||
"categories": [
|
||||
{
|
||||
"name": "Exchange",
|
||||
"slug": "exchange"
|
||||
}
|
||||
],
|
||||
"listedAt": "2025-05-31T19:09:18.043Z",
|
||||
"serviceUrls": [
|
||||
"https://example.com",
|
||||
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
||||
],
|
||||
"tosUrls": ["https://example.com/terms-of-service"],
|
||||
"kycnotmeUrl": "https://kycnot.me/service/bisq"
|
||||
}
|
||||
```
|
||||
|
||||
#### Error Responses
|
||||
|
||||
**404 Not Found**: Service not found
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Service not found"
|
||||
}
|
||||
```
|
||||
|
||||
**400 Bad Request**: Invalid input parameters
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Validation error message"
|
||||
}
|
||||
```
|
||||
|
||||
**500 Internal Server Error**: Server error
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Internal server error"
|
||||
}
|
||||
```
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
layout: ../layouts/MarkdownLayout.astro
|
||||
layout: ../../layouts/MarkdownLayout.astro
|
||||
title: How does karma work?
|
||||
description: "KYCnot.me has a user karma system, here's how it works"
|
||||
icon: 'ri:hearts-line'
|
||||
@@ -7,7 +7,7 @@ author: KYCnot.me
|
||||
pubDate: 2025-05-15
|
||||
---
|
||||
|
||||
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro'
|
||||
import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
|
||||
|
||||
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../constants/verificationStatus'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
|
||||
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { parseIntWithFallback } from '../lib/numbers'
|
||||
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
@@ -213,17 +214,16 @@ const groupedAttributes = groupBy(
|
||||
'value'
|
||||
)
|
||||
|
||||
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
|
||||
|
||||
const where = {
|
||||
listedAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||
listedAt: { lte: new Date() },
|
||||
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
||||
verificationStatus: {
|
||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||
},
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
overallScore: { gte: filters['min-score'] },
|
||||
acceptedCurrencies: filters.currencies.length
|
||||
? filters['currency-mode'] === 'and'
|
||||
@@ -243,16 +243,6 @@ const where = {
|
||||
} satisfies Prisma.ServiceWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(filters.q
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ name: { contains: filters.q, mode: 'insensitive' as const } },
|
||||
{ description: { contains: filters.q, mode: 'insensitive' as const } },
|
||||
],
|
||||
} satisfies Prisma.ServiceWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(filters.networks.length
|
||||
? [
|
||||
{
|
||||
@@ -325,9 +315,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
select: {
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -338,33 +327,45 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
[
|
||||
'Unable to load services.',
|
||||
async () => {
|
||||
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||
(typeof sortOptions)[number]['orderBy']['key'],
|
||||
true
|
||||
>),
|
||||
},
|
||||
})
|
||||
const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
|
||||
{
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||
(typeof sortOptions)[number]['orderBy']['key'],
|
||||
true
|
||||
>),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
|
||||
...service,
|
||||
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
|
||||
}))
|
||||
|
||||
const rng = seedrandom(filters['sort-seed'])
|
||||
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
|
||||
|
||||
const sortedServices = orderBy(
|
||||
unsortedServices,
|
||||
[selectedSort.orderBy.key, () => rng()],
|
||||
[selectedSort.orderBy.direction, 'asc']
|
||||
[
|
||||
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||
selectedSort.orderBy.key,
|
||||
() => rng(),
|
||||
],
|
||||
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
|
||||
|
||||
const unsortedServicesWithInfo = await prisma.service.findMany({
|
||||
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
|
||||
where: {
|
||||
id: {
|
||||
in: sortedServices.map((service) => service.id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
@@ -398,14 +399,20 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
},
|
||||
})
|
||||
|
||||
const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
|
||||
...service,
|
||||
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
|
||||
}))
|
||||
|
||||
const sortedServicesWithInfo = orderBy(
|
||||
unsortedServicesWithInfo,
|
||||
[
|
||||
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||
selectedSort.orderBy.key,
|
||||
// Now we can shuffle indeternimistically, because the pagination was already applied
|
||||
() => Math.random(),
|
||||
],
|
||||
[selectedSort.orderBy.direction, 'asc']
|
||||
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||
)
|
||||
|
||||
return [sortedServicesWithInfo, totalServices] as const
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
take: 5,
|
||||
},
|
||||
@@ -647,8 +656,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
|
||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||
<p class="text-day-300">
|
||||
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
|
||||
>Learn about karma</a
|
||||
Earn karma to unlock features and privileges. <a
|
||||
href="/docs/karma"
|
||||
class="text-day-200 hover:underline">Learn about karma</a
|
||||
>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user