Release 202505302029
This commit is contained in:
@@ -12,6 +12,7 @@ import { formatDistanceStrict } from 'date-fns'
|
|||||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
|
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { separateServiceUrlsByType } from '../lib/urls'
|
import { separateServiceUrlsByType } from '../lib/urls'
|
||||||
@@ -29,11 +30,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
|||||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||||
|
|
||||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
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: {
|
where: {
|
||||||
name: {
|
id: {
|
||||||
contains: input.name,
|
in: matches.map(({ id }) => id),
|
||||||
mode: 'insensitive',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
@@ -43,8 +45,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
|||||||
description: true,
|
description: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return possibleDuplicates
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ const {
|
|||||||
class={cn(
|
class={cn(
|
||||||
// Check the scam filter when there is a text quey and the user has checked verified and approved
|
// 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]: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
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -80,16 +81,20 @@ const {
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
</select>
|
</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]" />
|
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||||
Ties randomly sorted
|
Ties randomly sorted
|
||||||
</p>
|
</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>
|
</fieldset>
|
||||||
|
|
||||||
<!-- Text Search -->
|
<!-- Text Search -->
|
||||||
<fieldset class="mb-6">
|
<fieldset class="mb-6">
|
||||||
<legend class="font-title mb-3 leading-none text-green-500">
|
<legend class="font-title mb-3 leading-none text-green-500">
|
||||||
<label for="q">Text</label>
|
<label for="q">Name</label>
|
||||||
</legend>
|
</legend>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{
|
{
|
||||||
countCommunityOnly && (
|
!!countCommunityOnly && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
@@ -192,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
|||||||
inlineIcon={inlineIcons}
|
inlineIcon={inlineIcons}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{countCommunityOnly && (
|
{!!countCommunityOnly && (
|
||||||
<Button
|
<Button
|
||||||
as="a"
|
as="a"
|
||||||
href={urlIfIncludingCommunity}
|
href={urlIfIncludingCommunity}
|
||||||
|
|||||||
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 }))
|
||||||
|
}
|
||||||
@@ -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 = {
|
// type ModelsWithCustomMethods = {
|
||||||
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||||
findMany: (...args: any[]) => Promise<any>
|
// findMany: (...args: any[]) => Promise<any>
|
||||||
}
|
// }
|
||||||
? PrismaClient[Model] & {
|
// ? PrismaClient[Model] & {
|
||||||
findManyAndCount: FindManyAndCountType
|
// findManyAndCount: FindManyAndCountType
|
||||||
}
|
// }
|
||||||
: PrismaClient[Model]
|
// : PrismaClient[Model]
|
||||||
}
|
// }
|
||||||
|
|
||||||
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||||
|
|
||||||
function prismaClientSingleton(): ExtendedPrismaClient {
|
function prismaClientSingleton() {
|
||||||
const prisma = new PrismaClient().$extends(findManyAndCount)
|
return new PrismaClient().$extends(findManyAndCount)
|
||||||
|
|
||||||
return prisma as unknown as ExtendedPrismaClient
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
} from '../constants/verificationStatus'
|
} from '../constants/verificationStatus'
|
||||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||||
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
|
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
|
||||||
|
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||||
import { parseIntWithFallback } from '../lib/numbers'
|
import { parseIntWithFallback } from '../lib/numbers'
|
||||||
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
import { areEqualObjectsWithoutOrder } from '../lib/objects'
|
||||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||||
@@ -213,7 +214,10 @@ const groupedAttributes = groupBy(
|
|||||||
'value'
|
'value'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
|
||||||
|
|
||||||
const where = {
|
const where = {
|
||||||
|
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||||
listedAt: {
|
listedAt: {
|
||||||
lte: new Date(),
|
lte: new Date(),
|
||||||
},
|
},
|
||||||
@@ -243,16 +247,6 @@ const where = {
|
|||||||
} satisfies Prisma.ServiceWhereInput,
|
} 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
|
...(filters.networks.length
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
@@ -338,33 +332,45 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
|||||||
[
|
[
|
||||||
'Unable to load services.',
|
'Unable to load services.',
|
||||||
async () => {
|
async () => {
|
||||||
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
|
const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
|
||||||
where,
|
{
|
||||||
select: {
|
where,
|
||||||
id: true,
|
select: {
|
||||||
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
id: true,
|
||||||
(typeof sortOptions)[number]['orderBy']['key'],
|
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
|
||||||
true
|
(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 rng = seedrandom(filters['sort-seed'])
|
||||||
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
|
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
|
||||||
|
|
||||||
const sortedServices = orderBy(
|
const sortedServices = orderBy(
|
||||||
unsortedServices,
|
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)
|
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
|
||||||
|
|
||||||
const unsortedServicesWithInfo = await prisma.service.findMany({
|
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
in: sortedServices.map((service) => service.id),
|
in: sortedServices.map((service) => service.id),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
|
id: true,
|
||||||
name: true,
|
name: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
description: true,
|
description: true,
|
||||||
@@ -398,14 +404,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(
|
const sortedServicesWithInfo = orderBy(
|
||||||
unsortedServicesWithInfo,
|
unsortedServicesWithInfo,
|
||||||
[
|
[
|
||||||
|
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
|
||||||
selectedSort.orderBy.key,
|
selectedSort.orderBy.key,
|
||||||
// Now we can shuffle indeternimistically, because the pagination was already applied
|
// Now we can shuffle indeternimistically, because the pagination was already applied
|
||||||
() => Math.random(),
|
() => Math.random(),
|
||||||
],
|
],
|
||||||
[selectedSort.orderBy.direction, 'asc']
|
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
|
||||||
)
|
)
|
||||||
|
|
||||||
return [sortedServicesWithInfo, totalServices] as const
|
return [sortedServicesWithInfo, totalServices] as const
|
||||||
|
|||||||
Reference in New Issue
Block a user