Release 202506141856

This commit is contained in:
pluja
2025-06-14 18:56:58 +00:00
parent cf5f3b3228
commit effb6689d7
37 changed files with 276 additions and 221 deletions

View File

@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
ADD COLUMN "spamAt" TIMESTAMP(3);

View File

@@ -0,0 +1,21 @@
/*
Warnings:
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
-- CreateIndex
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
-- CreateIndex
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
-- CreateIndex
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
-- CreateIndex
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");

View File

@@ -353,8 +353,6 @@ model Service {
privacyScore Int @default(0)
trustScore Int @default(0)
/// Computed via trigger. Do not update through prisma.
isRecentlyListed Boolean @default(false)
/// Computed via trigger. Do not update through prisma.
averageUserRating Float?
serviceVisibility ServiceVisibility @default(PUBLIC)
serviceInfoBanner ServiceInfoBanner @default(NONE)
@@ -363,8 +361,6 @@ model Service {
verificationSummary String?
verificationRequests ServiceVerificationRequest[]
verificationProofMd String?
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
/// [UserSentiment]
userSentiment Json?
userSentimentAt DateTime?
@@ -380,7 +376,16 @@ model Service {
tosReviewAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
listedAt DateTime?
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
approvedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
spamAt DateTime?
/// Computed via trigger. Do not update through prisma.
isRecentlyApproved Boolean @default(false)
comments Comment[]
events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
@@ -396,6 +401,9 @@ model Service {
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
@@index([listedAt])
@@index([approvedAt])
@@index([verifiedAt])
@@index([spamAt])
@@index([overallScore])
@@index([privacyScore])
@@index([trustScore])
@@ -407,6 +415,7 @@ model Service {
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
@@index([serviceVisibility])
}
model ServiceContactMethod {

View File

@@ -13,14 +13,15 @@ import {
PrismaClient,
ServiceSuggestionStatus,
ServiceUserRole,
VerificationStatus,
type Prisma,
type User,
type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
VerificationStepStatus,
type VerificationStatus,
} from '@prisma/client'
import { differenceInDays, isPast } from 'date-fns'
import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
@@ -614,6 +615,14 @@ const generateFakeService = (users: User[]) => {
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
const serviceVisibility = faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
])
const approvedAt =
status === 'APPROVED' || status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null
return {
name,
@@ -629,12 +638,7 @@ const generateFakeService = (users: User[]) => {
overallScore: 0,
privacyScore: 0,
trustScore: 0,
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
]),
serviceVisibility,
verificationStatus: status,
verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -677,8 +681,14 @@ const generateFakeService = (users: User[]) => {
{ count: { min: 0, max: 2 } }
),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
listedAt:
serviceVisibility === 'PUBLIC' || serviceVisibility === 'ARCHIVED'
? faker.date.recent({ days: 30 })
: null,
verifiedAt: status === 'VERIFICATION_SUCCESS' ? faker.date.recent({ days: 30 }) : null,
spamAt: status === 'VERIFICATION_FAILED' ? faker.date.recent({ days: 30 }) : null,
approvedAt,
isRecentlyApproved: !!approvedAt && isPast(approvedAt) && differenceInDays(new Date(), approvedAt) < 15,
tosReview,
tosReviewAt: tosReview
? faker.date.recent()
@@ -908,7 +918,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
},
{
label: faker.lorem.word({ length: 2 }),
label: 'Custom label',
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
},
{
@@ -918,7 +928,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
value: faker.internet.url(),
},
{
label: faker.lorem.word({ length: 2 }),
label: 'Custom label',
value: faker.internet.url(),
},
{
@@ -1307,7 +1317,7 @@ async function main() {
const service = await prisma.service.create({
data: {
...serviceData,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })),
},

View File

@@ -1,7 +1,7 @@
-- This script defines PostgreSQL functions and triggers for managing service scores:
-- 1. Automatically calculates and updates privacy, trust, and overall scores
-- for services when services or their attributes change.
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
-- when an "Attribute" definition (e.g., points) is updated, ensuring
-- efficient handling of widespread score updates.
@@ -25,12 +25,8 @@ DECLARE
privacy_score INT := 0;
kyc_factor INT;
clarification_factor INT := 0;
onion_factor INT := 0;
i2p_factor INT := 0;
onion_or_i2p_factor INT := 0;
monero_factor INT := 0;
open_source_factor INT := 0;
p2p_factor INT := 0;
decentralized_factor INT := 0;
attributes_score INT := 0;
BEGIN
-- Get service data
@@ -57,20 +53,12 @@ BEGIN
FROM "Service"
WHERE "id" = service_id;
-- Check for onion URLs
-- Check for onion or i2p URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
) THEN
onion_factor := 5;
END IF;
-- Check for i2p URLs
IF EXISTS (
SELECT 1 FROM "Service"
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
) THEN
i2p_factor := 5;
onion_or_i2p_factor := 5;
END IF;
-- Check for Monero acceptance
@@ -86,10 +74,10 @@ BEGIN
INTO attributes_score
FROM "ServiceAttribute" sa
JOIN "Attribute" a ON sa."attributeId" = a."id"
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
WHERE sa."serviceId" = service_id;
-- Calculate final privacy score (base 100)
privacy_score := 50 + kyc_factor + clarification_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
-- Ensure the score is in reasonable bounds (0-100)
privacy_score := GREATEST(0, LEAST(100, privacy_score));
@@ -105,7 +93,7 @@ DECLARE
trust_score INT := 0;
verification_factor INT;
attributes_score INT := 0;
recently_listed_factor INT := 0;
recently_approved_factor INT := 0;
tos_penalty_factor INT := 0;
BEGIN
-- Get verification status factor
@@ -126,26 +114,26 @@ BEGIN
INTO attributes_score
FROM "ServiceAttribute" sa
JOIN "Attribute" a ON sa."attributeId" = a.id
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
WHERE sa."serviceId" = service_id;
-- Apply penalty if service was listed within the last 15 days
-- Apply penalty if service was approved within the last 15 days
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "listedAt" IS NOT NULL
AND "approvedAt" IS NOT NULL
AND "verificationStatus" = 'APPROVED'
AND (NOW() - "listedAt") <= INTERVAL '15 days'
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
) THEN
recently_listed_factor := -10;
-- Update the isRecentlyListed flag to true
recently_approved_factor := -10;
-- Update the isRecentlyApproved flag to true
UPDATE "Service"
SET "isRecentlyListed" = TRUE
SET "isRecentlyApproved" = TRUE
WHERE id = service_id;
ELSE
-- Update the isRecentlyListed flag to false
-- Update the isRecentlyApproved flag to false
UPDATE "Service"
SET "isRecentlyListed" = FALSE
SET "isRecentlyApproved" = FALSE
WHERE id = service_id;
END IF;
@@ -161,7 +149,7 @@ BEGIN
END IF;
-- Calculate final trust score (base 100)
trust_score := 50 + verification_factor + attributes_score + recently_listed_factor + tos_penalty_factor;
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor;
-- Ensure the score is in reasonable bounds (0-100)
trust_score := GREATEST(0, LEAST(100, trust_score));
@@ -176,7 +164,7 @@ RETURNS INT AS $$
DECLARE
overall_score INT;
BEGIN
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
RETURN GREATEST(0, LEAST(10, overall_score));
END;
$$ LANGUAGE plpgsql;

View File

@@ -1,48 +1,60 @@
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
-- are set or cleared appropriately when a service's verification status is updated.
CREATE OR REPLACE FUNCTION manage_service_timestamps()
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
RETURNS TRIGGER AS $$
BEGIN
-- Manage listedAt timestamp
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
IF OLD."listedAt" IS NULL THEN
NEW."listedAt" := NOW();
NEW."isRecentlyListed" := TRUE;
END IF;
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
ELSE
NEW."listedAt" := NULL;
NEW."isRecentlyListed" := FALSE;
END IF;
-- Manage verifiedAt timestamp
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
NEW."verifiedAt" := NOW();
NEW."isRecentlyListed" := FALSE;
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
NEW."verifiedAt" := NULL;
NEW."isRecentlyListed" := FALSE;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the old trigger first if it exists under the old name
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
RETURNS TRIGGER AS $$
BEGIN
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
IF OLD."approvedAt" IS NULL THEN
NEW."approvedAt" := NOW();
NEW."isRecentlyApproved" := TRUE;
END IF;
ELSE
NEW."approvedAt" := NULL;
NEW."isRecentlyApproved" := FALSE;
END IF;
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
NEW."verifiedAt" := NOW();
ELSE
NEW."verifiedAt" := NULL;
END IF;
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
NEW."spamAt" := NOW();
ELSE
NEW."spamAt" := NULL;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the old triggers TODO: remove this some day
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
-- Drop the trigger if it exists under the new name
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
CREATE TRIGGER trigger_manage_service_timestamps
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
CREATE TRIGGER trigger_manage_service_visibility_timestamps
BEFORE UPDATE OF "serviceVisibility" ON "Service"
FOR EACH ROW
EXECUTE FUNCTION manage_service_visibility_timestamps();
CREATE TRIGGER trigger_manage_service_verification_timestamps
BEFORE UPDATE OF "verificationStatus" ON "Service"
FOR EACH ROW
-- Only execute the function if the verificationStatus value has actually changed
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
EXECUTE FUNCTION manage_service_timestamps();
EXECUTE FUNCTION manage_service_verification_timestamps();