Compare commits

...

6 Commits

Author SHA1 Message Date
pluja
c7ee1606e4 Release 202506151416 2025-06-15 14:16:59 +00:00
pluja
f3c9b92ddb Release 202506151318 2025-06-15 13:18:22 +00:00
pluja
effb6689d7 Release 202506141856 2025-06-14 18:56:58 +00:00
pluja
cf5f3b3228 Release 202506131423 2025-06-13 14:23:14 +00:00
pluja
5a41816ac8 Release 202506131339 2025-06-13 13:39:12 +00:00
pluja
bf30a6cb2b Release 202506130639 2025-06-13 06:39:29 +00:00
60 changed files with 511 additions and 304 deletions

View File

@@ -96,7 +96,7 @@ Tasks will run according to their configured cron schedules.
### Force Triggers Task ### Force Triggers Task
- Maintains database triggers by forcing them to run under certain conditions - Maintains database triggers by forcing them to run under certain conditions
- Currently handles updating the "isRecentlyListed" flag for services after 15 days - Currently handles updating the "isRecentlyApproved" flag for services after 15 days
- Scheduled via `CRON_FORCE-TRIGGERS_TASK` - Scheduled via `CRON_FORCE-TRIGGERS_TASK`
### Service Score Recalculation Task ### Service Score Recalculation Task

View File

@@ -89,6 +89,11 @@ def parse_args(args: List[str]) -> argparse.Namespace:
score_recalc_parser.add_argument( score_recalc_parser.add_argument(
"--service-id", type=int, help="Specific service ID to process (optional)" "--service-id", type=int, help="Specific service ID to process (optional)"
) )
score_recalc_parser.add_argument(
"--all",
action="store_true",
help="Recalculate scores for all services (ignores --service-id)",
)
return parser.parse_args(args) return parser.parse_args(args)
@@ -295,12 +300,15 @@ def run_force_triggers_task() -> int:
close_db_pool() close_db_pool()
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int: def run_service_score_recalc_task(
service_id: Optional[int] = None, all_services: bool = False
) -> int:
""" """
Run the service score recalculation task. Run the service score recalculation task.
Args: Args:
service_id: Optional specific service ID to process. service_id: Optional specific service ID to process.
all_services: Whether to recalculate scores for all services.
Returns: Returns:
Exit code. Exit code.
@@ -310,7 +318,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
try: try:
# Initialize task and use as context manager # Initialize task and use as context manager
with ServiceScoreRecalculationTask() as task: # type: ignore with ServiceScoreRecalculationTask() as task: # type: ignore
result = task.run(service_id) # type: ignore if all_services:
queued = task.recalculate_all_services() # type: ignore
if not queued:
logger.warning(
"Failed to queue recalculation jobs for all services"
)
# Continuously process queued jobs in batches until none remain
while True:
_ = task.run() # type: ignore
# Check if there are still unprocessed jobs
remaining = 0
if task.conn:
with task.conn.cursor() as cursor:
cursor.execute(
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
)
remaining = cursor.fetchone()[0]
if remaining == 0:
break
result = True # All jobs processed successfully
else:
result = task.run(service_id) # type: ignore
if result: if result:
logger.info("Successfully recalculated service scores") logger.info("Successfully recalculated service scores")
else: else:
@@ -419,7 +454,9 @@ def main() -> int:
elif args.task == "force-triggers": elif args.task == "force-triggers":
return run_force_triggers_task() return run_force_triggers_task()
elif args.task == "service-score-recalc": elif args.task == "service-score-recalc":
return run_service_score_recalc_task(args.service_id) return run_service_score_recalc_task(
args.service_id, getattr(args, "all", False)
)
elif args.task: elif args.task:
logger.error(f"Unknown task: {args.task}") logger.error(f"Unknown task: {args.task}")
return 1 return 1

View File

@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
Force triggers to run under certain conditions. Force triggers to run under certain conditions.
""" """
RECENT_LISTED_INTERVAL_DAYS = 15 RECENT_APPROVED_INTERVAL_DAYS = 15
def __init__(self): def __init__(self):
super().__init__("force_triggers") super().__init__("force_triggers")
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
update_query = f""" update_query = f"""
UPDATE "Service" UPDATE "Service"
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW() SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
WHERE "isRecentlyListed" = TRUE WHERE "isRecentlyApproved" = TRUE
AND "listedAt" IS NOT NULL AND "approvedAt" IS NOT NULL
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days' AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
""" """
try: try:
with self.conn.cursor() as cursor: with self.conn.cursor() as cursor:

View File

@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
cursor.execute( cursor.execute(
""" """
SELECT id SELECT id
FROM "Service" FROM "Service"
WHERE "isActive" = TRUE
""" """
) )
services = cursor.fetchall() services = cursor.fetchall()

View File

@@ -34,8 +34,8 @@ class TosReviewTask(Task):
service_name = service["name"] service_name = service["name"]
verification_status = service.get("verificationStatus") verification_status = service.get("verificationStatus")
# Only process verified or approved services # Only process verified, approved, or community contributed services
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]: if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
self.logger.info( self.logger.info(
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}" f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
) )

View File

@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
{"role": "user", "content": content}, {"role": "user", "content": content},
] ]
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini") result_dict = query_openai_json(
messages, model="openai/gemini-2.5-flash-preview-05-20"
)
return cast(TosReviewCheck, result_dict) return cast(TosReviewCheck, result_dict)

View File

@@ -35,9 +35,11 @@ export default defineConfig({
registerType: 'autoUpdate', registerType: 'autoUpdate',
manifest: { manifest: {
name: 'KYCnot.me', name: 'KYCnot.me',
short_name: 'KYCnot.me',
description: 'Find services that respect your privacy', description: 'Find services that respect your privacy',
theme_color: '#040505', theme_color: '#040505',
background_color: '#171c1b', background_color: '#171c1b',
display: 'minimal-ui',
}, },
pwaAssets: { pwaAssets: {
image: './public/favicon.svg', image: './public/favicon.svg',

48
web/package-lock.json generated
View File

@@ -2532,9 +2532,9 @@
} }
}, },
"node_modules/@eslint/config-array/node_modules/brace-expansion": { "node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2603,9 +2603,9 @@
} }
}, },
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": { "node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -7196,9 +7196,9 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9325,9 +9325,9 @@
} }
}, },
"node_modules/eslint-plugin-import/node_modules/brace-expansion": { "node_modules/eslint-plugin-import/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9399,9 +9399,9 @@
} }
}, },
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": { "node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -9476,9 +9476,9 @@
} }
}, },
"node_modules/eslint/node_modules/brace-expansion": { "node_modules/eslint/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -10379,9 +10379,9 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -11671,9 +11671,9 @@
} }
}, },
"node_modules/jake/node_modules/brace-expansion": { "node_modules/jake/node_modules/brace-expansion": {
"version": "1.1.11", "version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';

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

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";

View File

@@ -353,8 +353,6 @@ model Service {
privacyScore Int @default(0) privacyScore Int @default(0)
trustScore Int @default(0) trustScore Int @default(0)
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
isRecentlyListed Boolean @default(false)
/// Computed via trigger. Do not update through prisma.
averageUserRating Float? averageUserRating Float?
serviceVisibility ServiceVisibility @default(PUBLIC) serviceVisibility ServiceVisibility @default(PUBLIC)
serviceInfoBanner ServiceInfoBanner @default(NONE) serviceInfoBanner ServiceInfoBanner @default(NONE)
@@ -363,8 +361,6 @@ model Service {
verificationSummary String? verificationSummary String?
verificationRequests ServiceVerificationRequest[] verificationRequests ServiceVerificationRequest[]
verificationProofMd String? verificationProofMd String?
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
verifiedAt DateTime?
/// [UserSentiment] /// [UserSentiment]
userSentiment Json? userSentiment Json?
userSentimentAt DateTime? userSentimentAt DateTime?
@@ -380,7 +376,16 @@ model Service {
tosReviewAt DateTime? tosReviewAt DateTime?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt 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? 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[] comments Comment[]
events Event[] events Event[]
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod") contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
@@ -396,6 +401,9 @@ model Service {
affiliatedUsers ServiceUser[] @relation("ServiceUsers") affiliatedUsers ServiceUser[] @relation("ServiceUsers")
@@index([listedAt]) @@index([listedAt])
@@index([approvedAt])
@@index([verifiedAt])
@@index([spamAt])
@@index([overallScore]) @@index([overallScore])
@@index([privacyScore]) @@index([privacyScore])
@@index([trustScore]) @@index([trustScore])
@@ -407,6 +415,7 @@ model Service {
@@index([updatedAt]) @@index([updatedAt])
@@index([slug]) @@index([slug])
@@index([previousSlugs]) @@index([previousSlugs])
@@index([serviceVisibility])
} }
model ServiceContactMethod { model ServiceContactMethod {
@@ -578,6 +587,7 @@ enum VerificationStepStatus {
IN_PROGRESS IN_PROGRESS
PASSED PASSED
FAILED FAILED
WARNING
} }
model VerificationStep { model VerificationStep {
@@ -677,8 +687,6 @@ model PushSubscription {
p256dh String p256dh String
/// Authentication secret /// Authentication secret
auth String auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

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

View File

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

View File

@@ -1,48 +1,60 @@
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps CREATE OR REPLACE FUNCTION manage_service_visibility_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()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
-- Manage listedAt timestamp IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
IF OLD."listedAt" IS NULL THEN IF OLD."listedAt" IS NULL THEN
NEW."listedAt" := NOW(); NEW."listedAt" := NOW();
NEW."isRecentlyListed" := TRUE;
END IF; END IF;
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN ELSE
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
NEW."listedAt" := NULL; 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; END IF;
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql; $$ 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 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"; 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" BEFORE UPDATE OF "verificationStatus" ON "Service"
FOR EACH ROW FOR EACH ROW
-- Only execute the function if the verificationStatus value has actually changed EXECUTE FUNCTION manage_service_verification_timestamps();
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
EXECUTE FUNCTION manage_service_timestamps();

View File

@@ -126,7 +126,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
slug: input.slug, slug: input.slug,
overallScore: input.overallScore, overallScore: input.overallScore,
@@ -244,7 +245,8 @@ export const adminServiceActions = {
verificationSummary: input.verificationSummary, verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd, verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies, acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
referral: input.referral || null,
serviceVisibility: input.serviceVisibility, serviceVisibility: input.serviceVisibility,
slug: input.slug, slug: input.slug,
overallScore: input.overallScore, overallScore: input.overallScore,

View File

@@ -65,13 +65,13 @@ export const apiServiceActions = {
tosUrls: true, tosUrls: true,
referral: true, referral: true,
listedAt: true, listedAt: true,
approvedAt: true,
verifiedAt: true, verifiedAt: true,
serviceVisibility: true, serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect } as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({ let service = await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [ OR: [
@@ -92,7 +92,6 @@ export const apiServiceActions = {
if (!service && input.slug) { if (!service && input.slug) {
service = await prisma.service.findFirst({ service = await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug }, previousSlugs: { has: input.slug },
@@ -105,9 +104,7 @@ export const apiServiceActions = {
!service || !service ||
(service.serviceVisibility !== 'PUBLIC' && (service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' && service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') || service.serviceVisibility !== 'UNLISTED')
!service.listedAt ||
service.listedAt > new Date()
) { ) {
throw new ActionError({ throw new ActionError({
code: 'NOT_FOUND', code: 'NOT_FOUND',
@@ -130,6 +127,7 @@ export const apiServiceActions = {
'description', 'description',
]), ]),
verifiedAt: service.verifiedAt, verifiedAt: service.verifiedAt,
approvedAt: service.approvedAt,
kycLevel: service.kycLevel, kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']), kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification, kycLevelClarification: service.kycLevelClarification,

View File

@@ -1,3 +1,4 @@
import { ActionError } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { defineProtectedAction } from '../lib/defineProtectedAction' import { defineProtectedAction } from '../lib/defineProtectedAction'
@@ -32,7 +33,6 @@ export const notificationActions = {
endpoint: z.string(), endpoint: z.string(),
p256dhKey: z.string(), p256dhKey: z.string(),
authKey: z.string(), authKey: z.string(),
userAgent: z.string().optional(),
}), }),
handler: async (input, context) => { handler: async (input, context) => {
await prisma.pushSubscription.upsert({ await prisma.pushSubscription.upsert({
@@ -43,14 +43,12 @@ export const notificationActions = {
update: { update: {
p256dh: input.p256dhKey, p256dh: input.p256dhKey,
auth: input.authKey, auth: input.authKey,
userAgent: input.userAgent,
}, },
create: { create: {
userId: context.locals.user.id, userId: context.locals.user.id,
endpoint: input.endpoint, endpoint: input.endpoint,
p256dh: input.p256dhKey, p256dh: input.p256dhKey,
auth: input.authKey, auth: input.authKey,
userAgent: input.userAgent,
}, },
}) })
}, },
@@ -58,7 +56,7 @@ export const notificationActions = {
unsubscribe: defineProtectedAction({ unsubscribe: defineProtectedAction({
accept: 'json', accept: 'json',
permissions: 'user', permissions: 'guest',
input: z.object({ input: z.object({
endpoint: z.string().optional(), endpoint: z.string().optional(),
}), }),
@@ -66,16 +64,21 @@ export const notificationActions = {
if (input.endpoint) { if (input.endpoint) {
await prisma.pushSubscription.deleteMany({ await prisma.pushSubscription.deleteMany({
where: { where: {
userId: context.locals.user.id, userId: context.locals.user?.id ?? undefined,
endpoint: input.endpoint, endpoint: input.endpoint,
}, },
}) })
} else { } else if (context.locals.user) {
await prisma.pushSubscription.deleteMany({ await prisma.pushSubscription.deleteMany({
where: { where: {
userId: context.locals.user.id, userId: context.locals.user.id,
}, },
}) })
} else {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Endpoint is required when user is not logged in.',
})
} }
}, },
}), }),

View File

@@ -36,7 +36,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
id: { id: {
in: matches.map(({ id }) => id), in: matches.map(({ id }) => id),
}, },
listedAt: { lte: new Date() },
serviceVisibility: { serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'], in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
}, },
@@ -252,7 +251,6 @@ export const serviceSuggestionActions = {
overallScore: 0, overallScore: 0,
privacyScore: 0, privacyScore: 0,
trustScore: 0, trustScore: 0,
listedAt: new Date(),
serviceVisibility: 'UNLISTED', serviceVisibility: 'UNLISTED',
categories: { categories: {
connect: input.categories.map((id) => ({ id })), connect: input.categories.map((id) => ({ id })),

View File

@@ -6,7 +6,7 @@ import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info' import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays' import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import DevToolsMessageScript from './DevToolsMessageScript.astro' import DevToolsMessageScript from './DevToolsMessageScript.astro'
import DynamicFavicon from './DynamicFavicon.astro' import DynamicFavicon from './DynamicFavicon.astro'
@@ -107,8 +107,8 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<DynamicFavicon /> <DynamicFavicon />
<!-- Components -->
<ClientRouter /> <ClientRouter />
<LoadingIndicator color="green" /> <LoadingIndicator color="green" />
<TailwindJsPluggin /> <TailwindJsPluggin />
{htmx && <HtmxScript />} {htmx && <HtmxScript />}

View File

@@ -15,6 +15,7 @@ import {
} from '../lib/commentsWithReplies' } from '../lib/commentsWithReplies'
import { computeKarmaUnlocks } from '../lib/karmaUnlocks' import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
import { formatDateShort } from '../lib/timeAgo' import { formatDateShort } from '../lib/timeAgo'
import { urlDomain } from '../lib/urls'
import BadgeSmall from './BadgeSmall.astro' import BadgeSmall from './BadgeSmall.astro'
import CommentModeration from './CommentModeration.astro' import CommentModeration from './CommentModeration.astro'
@@ -170,7 +171,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.author.admin || comment.author.moderator comment.author.admin || comment.author.moderator
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}` ? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
: '' : ''
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`} }${comment.author.verifiedLink ? `Related to ${urlDomain(comment.author.verifiedLink)}` : ''}`}
> >
<Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" /> <Icon name="ri:verified-badge-fill" class="size-4 text-cyan-300" />
</Tooltip> </Tooltip>

View File

@@ -71,12 +71,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
'Failed to fetch comments', 'Failed to fetch comments',
async () => async () =>
await prisma.comment.findMany( await prisma.comment.findMany(
makeCommentsNestedQuery({ await makeCommentsNestedQuery({
depth: MAX_COMMENT_DEPTH, depth: MAX_COMMENT_DEPTH,
user, user,
showPending: params.showPending, showPending: params.showPending,
serviceId: service.id, serviceId: service.id,
sort: params.sort, sort: params.sort,
highlightedCommentId: params.comment,
}) })
), ),
[], [],

View File

@@ -29,7 +29,15 @@
font-family: cursive; font-family: cursive;
font-size: 2rem; font-size: 2rem;
font-weight: bold; font-weight: bold;
background: repeating-linear-gradient(90deg, #14ffe9 0%, #ffc800 16.66666%, #ff00e0 33.33333%, #14ffe9 50.0%) -100%/ 200%; background: repeating-linear-gradient(
90deg,
#d97706 0%,
#f59e0b 20%,
#f97316 40%,
#ea580c 60%,
#f97316 80%,
#f59e0b 100%
) -100%/ 200%;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text; background-clip: text;
color: transparent; color: transparent;

View File

@@ -1,5 +1,5 @@
--- ---
import { DEPLOYMENT_MODE } from '../lib/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
const user = Astro.locals.user const user = Astro.locals.user

View File

@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
import { sample } from 'lodash-es' import { sample } from 'lodash-es'
import { splashTexts } from '../constants/splashTexts' import { splashTexts } from '../constants/splashTexts'
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls' import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
import AdminOnly from './AdminOnly.astro' import AdminOnly from './AdminOnly.astro'

View File

@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
iconClass?: string iconClass?: string
description?: MarkdownString description?: MarkdownString
disabled?: boolean disabled?: boolean
noTransitionPersist?: boolean
}[] }[]
disabled?: boolean disabled?: boolean
selectedValue?: Multiple extends true ? string[] : string selectedValue?: Multiple extends true ? string[] : string
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)} )}
> >
<input <input
transition:persist={option.noTransitionPersist || !multiple ? undefined : true} transition:persist
type={multiple ? 'checkbox' : 'radio'} type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name} name={wrapperProps.name}
value={option.value} value={option.value}

View File

@@ -4,14 +4,17 @@
<script> <script>
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications' import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions' import {
makeBrowserNotificationOptions,
makeBrowserNotificationTitle,
} from '../lib/client/notificationOptions'
document.addEventListener('sse:new-notification', (event) => { document.addEventListener('sse:new-notification', (event) => {
if (isBrowserNotificationsEnabled()) { if (isBrowserNotificationsEnabled()) {
const payload = event.detail const payload = event.detail
const notification = showBrowserNotification( const notification = showBrowserNotification(
payload.title, makeBrowserNotificationTitle(payload.title),
makeNotificationOptions(payload, { removeActions: true }) makeBrowserNotificationOptions(payload, { removeActions: true })
) )
// Handle notification click // Handle notification click

View File

@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{ pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: { select: {
endpoint: true endpoint: true
userAgent: true
} }
}>[] }>[]
} }

View File

@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
} }
eventSource.onmessage = (event) => { eventSource.onmessage = (event) => {
// NOTE: Disable sse: events when user is not logged in
if (!document.body.hasAttribute('data-is-logged-in')) {
stopServerEventsListener()
return
}
try { try {
const data = JSON.parse(event.data as string) const data = JSON.parse(event.data as string)

View File

@@ -4,6 +4,7 @@
<script> <script>
import { registerSW } from 'virtual:pwa-register' import { registerSW } from 'virtual:pwa-register'
import { unsubscribeFromPushNotifications } from '../lib/client/clientPushNotifications'
const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[] const NO_AUTO_RELOAD_ROUTES = ['/account/welcome', '/500', '/404'] as const satisfies `/${string}`[]
@@ -33,7 +34,7 @@
function shouldSkipAutoReload() { function shouldSkipAutoReload() {
const currentPath = window.location.pathname const currentPath = window.location.pathname
const isErrorPage = document.querySelector('[data-is-error-page]') !== null const isErrorPage = document.body.hasAttribute('data-is-error-page')
return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route) return isErrorPage || NO_AUTO_RELOAD_ROUTES.some((route) => currentPath === route)
} }
@@ -44,4 +45,15 @@
void updateSW(true) void updateSW(true)
} }
} }
window.addEventListener('beforeinstallprompt', (event) => {
event.preventDefault()
})
document.addEventListener('astro:page-load', async () => {
if (!document.body.hasAttribute('data-is-logged-in')) {
await unsubscribeFromPushNotifications()
window.__SW_REGISTRATION__?.unregister()
}
})
</script> </script>

View File

@@ -1,23 +1,21 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { differenceInDays, isPast } from 'date-fns'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { formatDaysAgo } from '../lib/timeAgo'
import TimeFormatted from './TimeFormatted.astro'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
const RECENTLY_ADDED_DAYS = 7
type Props = { type Props = {
service: Prisma.ServiceGetPayload<{ service: Prisma.ServiceGetPayload<{
select: { select: {
verificationStatus: true verificationStatus: true
verificationProofMd: true verificationProofMd: true
verificationSummary: true verificationSummary: true
listedAt: true approvedAt: true
isRecentlyApproved: true
createdAt: true createdAt: true
verificationSteps: { verificationSteps: {
select: { select: {
@@ -29,9 +27,6 @@ type Props = {
} }
const { service } = Astro.props const { service } = Astro.props
const listedDate = service.listedAt ?? service.createdAt
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
--- ---
{ {
@@ -66,10 +61,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
</a> </a>
</span> </span>
</div> </div>
) : wasRecentlyAdded ? ( ) : service.isRecentlyApproved ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400"> <div class="mb-3 rounded-md bg-yellow-900/50 p-2 text-sm text-yellow-400">
This service was {service.listedAt === null ? 'added ' : 'listed '}{' '} This service was approved
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} /> {service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with {service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution. caution.
<a <a
@@ -86,7 +81,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
Basic checks passed, but not fully verified. Basic checks passed, but not fully verified.
<a <a
href="/about#suggestion-review-process" href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100" class="text-blue-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
> >
Learn more Learn more
</a> </a>
@@ -98,14 +93,29 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
{ {
service.verificationStatus !== 'VERIFICATION_FAILED' && service.verificationStatus !== 'VERIFICATION_FAILED' &&
service.verificationSteps.some((step) => step.status === 'FAILED') && ( service.verificationSteps.some((step) => step.status === 'FAILED') && (
<div class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400"> <a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-red-900/50 p-2 text-sm text-red-400 transition-colors hover:bg-red-900/60"
>
<Icon <Icon
name={verificationStatusesByValue.VERIFICATION_FAILED.icon} name={verificationStatusesByValue.VERIFICATION_FAILED.icon}
class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)} class={cn('size-5', verificationStatusesByValue.VERIFICATION_FAILED.classNames.icon)}
/> />
<span> <span>Some verification steps failed. Please review the details below.</span>
This service has failed one or more verification steps. Review the verification details carefully. </a>
</span> )
</div> }
{
service.verificationStatus !== 'VERIFICATION_FAILED' &&
!service.verificationSteps.some((step) => step.status === 'FAILED') &&
service.verificationSteps.some((step) => step.status === 'WARNING') && (
<a
href="#verification"
class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200 transition-colors hover:bg-yellow-600/40"
>
<Icon name={verificationStepStatusesByValue.WARNING.icon} class={cn('size-5 text-yellow-400')} />
<span>Some verification steps are marked as warnings.</span>
</a>
) )
} }

View File

@@ -42,6 +42,12 @@ export const {
icon: 'ri:alert-line', icon: 'ri:alert-line',
color: 'red', color: 'red',
}, },
{
value: 'WARNING',
label: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
},
{ {
value: 'PENDING', value: 'PENDING',
label: 'Pending', label: 'Pending',

View File

@@ -81,7 +81,8 @@ const announcement = await Astro.locals.banners.try(
</head> </head>
<body <body
class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)} class={cn('bg-night-700 text-day-300 flex min-h-dvh flex-col *:shrink-0', classNames?.body)}
data-is-error-page={isErrorPage} data-is-error-page={isErrorPage ? '' : undefined}
data-is-logged-in={Astro.locals.user !== null ? '' : undefined}
> >
{announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />} {announcement && <AnnouncementBanner announcement={announcement} transition:name="header-announcement" />}
<Header <Header

View File

@@ -7,7 +7,7 @@ import { kycLevels } from '../constants/kycLevels'
import { serviceVisibilitiesById } from '../constants/serviceVisibility' import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus' import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo' import { formatDaysAgo } from './timeAgo'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -35,8 +35,8 @@ type NonDbAttributeFull = NonDbAttribute & {
select: { select: {
verificationStatus: true verificationStatus: true
serviceVisibility: true serviceVisibility: true
isRecentlyListed: true isRecentlyApproved: true
listedAt: true approvedAt: true
createdAt: true createdAt: true
tosReviewAt: true tosReviewAt: true
tosReview: true tosReview: true
@@ -189,17 +189,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}), }),
}, },
{ {
slug: 'recently-listed', slug: 'recently-approved',
title: 'Recently listed', title: 'Recently approved',
type: 'WARNING', type: 'WARNING',
category: 'TRUST', category: 'TRUST',
description: 'Listed on KYCnot.me less than 15 days ago. Proceed with caution.', description: 'Approved on KYCnot.me less than 15 days ago. Proceed with caution.',
privacyPoints: 0, privacyPoints: 0,
trustPoints: -5, trustPoints: -5,
links: [], links: [],
customize: (service) => ({ customize: (service) => ({
show: service.isRecentlyListed, show: service.isRecentlyApproved,
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`, description: `Approved on KYCnot.me less than 15 days ago${service.approvedAt ? ` (${formatDaysAgo(service.approvedAt)})` : ''}. Proceed with caution.`,
}), }),
}, },
{ {
@@ -217,41 +217,22 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
}), }),
}, },
{ {
slug: 'has-onion-urls', slug: 'has-onion-or-i2p-urls',
title: 'Has Onion URLs', title: 'Has Onion or I2P URLs',
type: 'GOOD', type: 'GOOD',
category: 'PRIVACY', category: 'PRIVACY',
description: 'Onion (Tor) URLs enhance privacy and anonymity.', description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
privacyPoints: 5, privacyPoints: 5,
trustPoints: 0, trustPoints: 0,
links: [ links: [
{ {
url: '/?onion=true', url: '/?networks=onion&networks=i2p',
label: 'Search with this', label: 'Search with this',
icon: 'ri:search-line', icon: 'ri:search-line',
}, },
], ],
customize: (service) => ({ customize: (service) => ({
show: service.onionUrls.length > 0, show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
}),
},
{
slug: 'has-i2p-urls',
title: 'Has I2P URLs',
type: 'GOOD',
category: 'PRIVACY',
description: 'I2P URLs enhance privacy and anonymity.',
privacyPoints: 5,
trustPoints: 0,
links: [
{
url: '/?i2p=true',
label: 'Search with this',
icon: 'ri:search-line',
},
],
customize: (service) => ({
show: service.i2pUrls.length > 0,
}), }),
}, },
{ {

View File

@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
} }
export function isBrowserNotificationsEnabled() { export function isBrowserNotificationsEnabled() {
return ( const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
supportsBrowserNotifications() && if (!browserNotificationsEnabled) return false
Notification.permission === 'granted' &&
typedLocalStorage.browserNotificationsEnabled.get() if (!document.body.hasAttribute('data-is-logged-in')) {
) typedLocalStorage.browserNotificationsEnabled.set(false)
return false
}
return supportsBrowserNotifications() && Notification.permission === 'granted'
} }
export async function enableBrowserNotifications(): Promise<SafeResult> { export async function enableBrowserNotifications(): Promise<SafeResult> {

View File

@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
type ServerSubscription = { type ServerSubscription = {
endpoint: string endpoint: string
userAgent: string | null
} }
export type SafeResult = export type SafeResult =
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '', p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '', authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>), } satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
const currentSubscription = await getCurrentSubscription() const currentSubscription = await getCurrentSubscription()
if (!currentSubscription || serverSubscriptions.length === 0) return false if (!currentSubscription || serverSubscriptions.length === 0) return false
const currentEndpoint = currentSubscription.endpoint return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
} }
function urlB64ToUint8Array(base64String: string) { function urlB64ToUint8Array(base64String: string) {
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
function isServerSubscription(subscription: unknown): subscription is ServerSubscription { function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
if (typeof subscription !== 'object' || subscription === null) return false if (typeof subscription !== 'object' || subscription === null) return false
const s = subscription as Record<string, unknown> const s = subscription as Record<string, unknown>
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null) return typeof s.endpoint === 'string'
} }

View File

@@ -0,0 +1,7 @@
export const DEPLOYMENT_MODE = import.meta.env.PROD
? import.meta.env.MODE === 'development' ||
import.meta.env.MODE === 'staging' ||
import.meta.env.MODE === 'production'
? import.meta.env.MODE
: 'development'
: 'development'

View File

@@ -1,4 +1,6 @@
import type { NotificationData, NotificationPayload } from './serverEventsTypes' import { DEPLOYMENT_MODE } from './envVariables'
import type { NotificationData, NotificationPayload } from '../serverEventsTypes'
export type CustomNotificationOptions = NotificationOptions & { export type CustomNotificationOptions = NotificationOptions & {
actions?: { action: string; title: string; icon?: string }[] actions?: { action: string; title: string; icon?: string }[]
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
data: NotificationData data: NotificationData
} }
export function makeNotificationOptions( export function makeBrowserNotificationTitle(title?: string | null) {
const prefix = DEPLOYMENT_MODE === 'development' ? '[DEV] ' : DEPLOYMENT_MODE === 'staging' ? '[PRE] ' : ''
return `${prefix}${title ?? 'New Notification'}`
}
export function makeBrowserNotificationOptions(
payload: NotificationPayload | null, payload: NotificationPayload | null,
options: { removeActions?: boolean } = {} options: { removeActions?: boolean } = {}
) { ) {
const defaultOptions: CustomNotificationOptions = { const defaultOptions: CustomNotificationOptions = {
body: 'You have a new notification', body: 'You have a new notification',
lang: 'en-US', lang: 'en-US',
icon: '/favicon.svg', icon:
DEPLOYMENT_MODE === 'development'
? '/favicon-dev.svg'
: DEPLOYMENT_MODE === 'staging'
? '/favicon-stage.svg'
: '/favicon.svg',
badge: '/notification-icon.svg', badge: '/notification-icon.svg',
requireInteraction: false, requireInteraction: false,
silent: false, silent: false,

View File

@@ -1,5 +1,7 @@
import { z } from 'zod' import { z } from 'zod'
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
export const MAX_COMMENT_DEPTH = 12 export const MAX_COMMENT_DEPTH = 12
@@ -75,12 +77,13 @@ export type CommentWithRepliesPopulated = CommentWithReplies<{
export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest') export const commentSortSchema = z.enum(['newest', 'upvotes', 'status']).default('newest')
export type CommentSortOption = z.infer<typeof commentSortSchema> export type CommentSortOption = z.infer<typeof commentSortSchema>
export function makeCommentsNestedQuery({ export async function makeCommentsNestedQuery({
depth = 0, depth = 0,
user, user,
showPending, showPending,
serviceId, serviceId,
sort, sort,
highlightedCommentId,
}: { }: {
depth?: number depth?: number
user: Prisma.UserGetPayload<{ user: Prisma.UserGetPayload<{
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
showPending?: boolean showPending?: boolean
serviceId: number serviceId: number
sort: CommentSortOption sort: CommentSortOption
highlightedCommentId?: number | null
}) { }) {
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = [] const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
} }
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
const baseQuery = { const baseQuery = {
...commentReplyQuery, ...commentReplyQuery,
orderBy: orderByClause, orderBy: orderByClause,
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
: ({ : ({
status: { in: ['APPROVED', 'VERIFIED'] }, status: { in: ['APPROVED', 'VERIFIED'] },
} as const satisfies Prisma.CommentWhereInput), } as const satisfies Prisma.CommentWhereInput),
...(highlightedBranchIds.length > 0
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
: []),
], ],
parentId: null, parentId: null,
serviceId, serviceId,
@@ -161,6 +170,47 @@ export function makeRepliesQuery<T extends Prisma.CommentFindManyArgs>(
} }
} }
async function findAllParentIds(commentId: number, depth: number) {
const commentwithManyParents = await prisma.comment.findFirst({
where: { id: commentId },
select: makeParentQuerySelect(depth),
})
return extractParentIds(commentwithManyParents, [commentId])
}
type ParentQueryRecursive = {
parent: {
select: {
id: true
parent: false | { select: ParentQueryRecursive }
}
}
}
function makeParentQuerySelect(depth: number): ParentQueryRecursive {
return {
parent: {
select: {
id: true,
parent: depth <= 0 ? false : { select: makeParentQuerySelect(depth - 1) },
},
},
} as const satisfies Prisma.CommentSelect
}
function extractParentIds(
comment: Prisma.CommentGetPayload<{ select: ParentQueryRecursive }> | null,
acc: number[] = []
) {
if (!comment?.parent?.id) return acc
return extractParentIds(comment.parent as Prisma.CommentGetPayload<{ select: ParentQueryRecursive }>, [
...acc,
comment.parent.id,
])
}
export function makeCommentUrl({ export function makeCommentUrl({
serviceSlug, serviceSlug,
commentId, commentId,

View File

@@ -1,5 +0,0 @@
import { z } from 'astro/zod'
const schema = z.enum(['development', 'staging', 'production'])
export const DEPLOYMENT_MODE = schema.parse(import.meta.env.PROD ? import.meta.env.MODE : 'development')

View File

@@ -39,7 +39,6 @@ export async function getService(slug: string | undefined): Promise<
const service = const service =
(await prisma.service.findFirst({ (await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
slug, slug,
}, },
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
})) ?? })) ??
(await prisma.service.findFirst({ (await prisma.service.findFirst({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: slug }, previousSlugs: { has: slug },
}, },
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
where: { where: {
visible: true, visible: true,
service: { service: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
}, },
}, },

View File

@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
await redisImpersonationSessions.delete(sessionId) await redisImpersonationSessions.delete(sessionId)
context.cookies.delete(IMPERSONATION_SESSION_COOKIE) context.cookies.delete(IMPERSONATION_SESSION_COOKIE)
context.locals.user = context.locals.actualUser context.locals.user = context.locals.actualUser ?? context.locals.user
context.locals.actualUser = null context.locals.actualUser = null
} }

View File

@@ -1,4 +1,4 @@
import { addDays, format, isBefore, isToday, isYesterday } from 'date-fns' import { addDays, differenceInDays, format, isBefore, isToday, isYesterday } from 'date-fns'
import TimeAgo from 'javascript-time-ago' import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en' import en from 'javascript-time-ago/locale/en'
@@ -47,3 +47,10 @@ export function formatDateShort(
return transformCase(text, caseType) return transformCase(text, caseType)
} }
export function formatDaysAgo(approvedAt: Date) {
const days = differenceInDays(new Date(), approvedAt)
if (days === 0) return 'today'
if (days === 1) return 'yesterday'
return `${days.toLocaleString()} days ago`
}

View File

@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
cookies.delete(COOKIE_NAME, { path: '/' }) cookies.delete(COOKIE_NAME, { path: '/' })
} }
export async function logout(context: Pick<APIContext, 'cookies' | 'locals'>) { export async function logout(context: Pick<APIContext, 'cookies' | 'locals' | 'request' | 'url'>) {
await stopImpersonating(context) await stopImpersonating(context)
await removeUserSessionIdCookie(context.cookies) await removeUserSessionIdCookie(context.cookies)

View File

@@ -10,7 +10,7 @@ import {
} from '../constants/characters' } from '../constants/characters'
import { getRandom, typedJoin } from './arrays' import { getRandom, typedJoin } from './arrays'
import { DEPLOYMENT_MODE } from './envVariables' import { DEPLOYMENT_MODE } from './client/envVariables'
import { transformCase } from './strings' import { transformCase } from './strings'
const DIGEST = 'sha512' const DIGEST = 'sha512'

View File

@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project' import { SUPPORT_EMAIL } from '../constants/project'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../lib/envVariables' import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
type Props = { type Props = {

View File

@@ -165,7 +165,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
##### Overall Score ##### Overall Score
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust. The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` truncated. This provides a combined measure of privacy and trust.
#### Terms of Service Reviews #### Terms of Service Reviews

View File

@@ -22,6 +22,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls' import { makeLoginUrl } from '../../lib/redirectUrls'
import { formatDateShort } from '../../lib/timeAgo' import { formatDateShort } from '../../lib/timeAgo'
import { urlDomain } from '../../lib/urls'
const userId = Astro.locals.user?.id const userId = Astro.locals.user?.id
if (!userId) { if (!userId) {
@@ -423,7 +424,7 @@ if (!user) return Astro.rewrite('/404')
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-blue-400 hover:underline" class="text-blue-400 hover:underline"
> >
{user.verifiedLink} {urlDomain(user.verifiedLink)}
</a> </a>
</div> </div>
</li> </li>
@@ -857,12 +858,7 @@ if (!user) return Astro.rewrite('/404')
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span <span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" /> <Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label} {statusInfo.label}
</span> </span>

View File

@@ -271,7 +271,6 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: false,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -306,8 +305,8 @@ if (toggleResult?.error) {
label="Status" label="Status"
error={createInputErrors.isActive} error={createInputErrors.isActive}
options={[ options={[
{ label: 'Active', value: 'true', noTransitionPersist: true }, { label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false', noTransitionPersist: true }, { label: 'Inactive', value: 'false' },
]} ]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'} selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"
@@ -628,7 +627,6 @@ if (toggleResult?.error) {
label: type.label, label: type.label,
value: type.value, value: type.value,
icon: type.icon, icon: type.icon,
noTransitionPersist: true,
}))} }))}
cardSize="sm" cardSize="sm"
required required
@@ -661,8 +659,8 @@ if (toggleResult?.error) {
name="isActive" name="isActive"
label="Status" label="Status"
options={[ options={[
{ label: 'Active', value: 'true', noTransitionPersist: true }, { label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false', noTransitionPersist: true }, { label: 'Inactive', value: 'false' },
]} ]}
selectedValue={announcement.isActive ? 'true' : 'false'} selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm" cardSize="sm"

View File

@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
<div class="flex justify-end space-x-3"> <div class="flex justify-end space-x-3">
<Button <Button
as="button"
color="gray" color="gray"
variant="faded" variant="faded"
size="sm" size="sm"
label="Cancel" label="Cancel"
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`} data-cancel-button
data-cancel-form-id={`edit-form-${index}`}
/> />
<Button <Button
as="button" as="button"
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
</div> </div>
</BaseLayout> </BaseLayout>
<script>
////////////////////////////////////////////////////////////
// Optional script for cancel buttons in attribute forms. //
// Hides the edit form when cancel button is clicked. //
////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLButtonElement>('[data-cancel-button]').forEach((button) => {
button.addEventListener('click', (e) => {
e.preventDefault()
const formId = button.getAttribute('data-cancel-form-id')
if (!formId) throw new Error('Form ID not found')
const form = document.getElementById(formId)
if (!form) throw new Error('Form not found')
form.classList.add('hidden')
})
})
})
</script>

View File

@@ -35,7 +35,7 @@ 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 { DEPLOYMENT_MODE } from '../../../../lib/client/envVariables'
import { listFiles } from '../../../../lib/fileStorage' import { listFiles } from '../../../../lib/fileStorage'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo' import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize' import { pluralize } from '../../../../lib/pluralize'
@@ -441,7 +441,6 @@ const apiCalls = await Astro.locals.banners.try(
value: kycLevel.id.toString(), value: kycLevel.id.toString(),
icon: kycLevel.icon, icon: kycLevel.icon,
description: kycLevel.description, description: kycLevel.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.kycLevel.toString()} selectedValue={service.kycLevel.toString()}
iconSize="md" iconSize="md"
@@ -458,7 +457,6 @@ const apiCalls = await Astro.locals.banners.try(
value: clarification.value, value: clarification.value,
icon: clarification.icon, icon: clarification.icon,
description: clarification.description, description: clarification.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.kycLevelClarification} selectedValue={service.kycLevelClarification}
iconSize="sm" iconSize="sm"
@@ -475,7 +473,6 @@ const apiCalls = await Astro.locals.banners.try(
icon: status.icon, icon: status.icon,
iconClass: status.classNames.icon, iconClass: status.classNames.icon,
description: status.description, description: status.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.verificationStatus} selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus} error={serviceInputErrors.verificationStatus}
@@ -491,7 +488,6 @@ const apiCalls = await Astro.locals.banners.try(
label: currency.name, label: currency.name,
value: currency.id, value: currency.id,
icon: currency.icon, icon: currency.icon,
noTransitionPersist: true,
}))} }))}
selectedValue={service.acceptedCurrencies} selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies} error={serviceInputErrors.acceptedCurrencies}
@@ -532,7 +528,6 @@ const apiCalls = await Astro.locals.banners.try(
icon: visibility.icon, icon: visibility.icon,
iconClass: visibility.iconClass, iconClass: visibility.iconClass,
description: visibility.description, description: visibility.description,
noTransitionPersist: true,
}))} }))}
selectedValue={service.serviceVisibility} selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility} error={serviceInputErrors.serviceVisibility}

View File

@@ -230,26 +230,22 @@ if (!user) return Astro.rewrite('/404')
label: 'Admin', label: 'Admin',
value: 'admin', value: 'admin',
icon: 'ri:shield-star-fill', icon: 'ri:shield-star-fill',
noTransitionPersist: true,
}, },
{ {
label: 'Moderator', label: 'Moderator',
value: 'moderator', value: 'moderator',
icon: 'ri:graduation-cap-fill', icon: 'ri:graduation-cap-fill',
noTransitionPersist: true,
}, },
{ {
label: 'Spammer', label: 'Spammer',
value: 'spammer', value: 'spammer',
icon: 'ri:alert-fill', icon: 'ri:alert-fill',
noTransitionPersist: true,
}, },
{ {
label: 'Verified', label: 'Verified',
value: 'verified', value: 'verified',
icon: 'ri:verified-badge-fill', icon: 'ri:verified-badge-fill',
disabled: true, disabled: true,
noTransitionPersist: true,
}, },
]} ]}
selectedValue={[ selectedValue={[
@@ -434,7 +430,6 @@ if (!user) return Astro.rewrite('/404')
label: role.label, label: role.label,
value: role.value, value: role.value,
icon: role.icon, icon: role.icon,
noTransitionPersist: true,
}))} }))}
required required
cardSize="sm" cardSize="sm"

View File

@@ -52,6 +52,7 @@ type ServiceResponse = {
description: string description: string
} }
verifiedAt: Date | null verifiedAt: Date | null
approvedAt: 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
@@ -145,7 +146,8 @@ 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", "verifiedAt": "2025-06-14T11:02:39.294Z",
"approvedAt": "2025-05-31T19:09:18.043Z",
"kycLevel": 0, "kycLevel": 0,
"kycLevelInfo": { "kycLevelInfo": {
"value": 0, "value": 0,
@@ -163,7 +165,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"slug": "exchange" "slug": "exchange"
} }
], ],
"listedAt": "2025-05-31T19:09:18.043Z", "listedAt": "2025-04-20T07:12:29.393Z",
"serviceUrls": [ "serviceUrls": [
"https://example.com", "https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion" "http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"

View File

@@ -45,7 +45,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
async () => async () =>
prisma.service.findMany({ prisma.service.findMany({
where: { where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
}, },
select: { select: {
@@ -72,7 +71,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
}, },
service: { service: {
slug: params.service ?? undefined, slug: params.service ?? undefined,
listedAt: params.service ? undefined : { lte: new Date() },
serviceVisibility: { serviceVisibility: {
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'], in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
}, },

View File

@@ -219,7 +219,6 @@ 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: { 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,
@@ -317,7 +316,6 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
services: { services: {
where: { where: {
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
listedAt: { lte: new Date() },
}, },
}, },
}, },

View File

@@ -163,7 +163,6 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
where: { userId: user.id }, where: { userId: user.id },
select: { select: {
endpoint: true, endpoint: true,
userAgent: true,
}, },
}), }),
[], [],

View File

@@ -70,7 +70,6 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
where: { where: {
slug, slug,
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
}, },
select: { select: {
id: true, id: true,
@@ -93,6 +92,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
referral: true, referral: true,
imageUrl: true, imageUrl: true,
listedAt: true, listedAt: true,
approvedAt: true,
createdAt: true, createdAt: true,
acceptedCurrencies: true, acceptedCurrencies: true,
tosReview: true, tosReview: true,
@@ -100,7 +100,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
userSentiment: true, userSentiment: true,
userSentimentAt: true, userSentimentAt: true,
averageUserRating: true, averageUserRating: true,
isRecentlyListed: true, isRecentlyApproved: true,
contactMethods: { contactMethods: {
select: { select: {
value: true, value: true,
@@ -230,7 +230,6 @@ if (!service) {
where: { where: {
previousSlugs: { has: slug }, previousSlugs: { has: slug },
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] }, serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
listedAt: { lte: new Date() },
}, },
select: { slug: true }, select: { slug: true },
}) })
@@ -294,6 +293,8 @@ const statusIcon = {
APPROVED: undefined, APPROVED: undefined,
}[service.verificationStatus] }[service.verificationStatus]
const isScam = service.verificationStatus === 'VERIFICATION_FAILED'
const shuffledLinks = { const shuffledLinks = {
clearnet: shuffle(service.serviceUrls), clearnet: shuffle(service.serviceUrls),
onion: shuffle(service.onionUrls), onion: shuffle(service.onionUrls),
@@ -385,6 +386,13 @@ const getVerificationStepStatusInfo = (status: VerificationStepStatus) => {
color: 'red', color: 'red',
timelineIconClass: 'text-red-400', timelineIconClass: 'text-red-400',
} as const } as const
case VerificationStepStatus.WARNING:
return {
text: 'Warning',
icon: 'ri:alert-line',
color: 'yellow',
timelineIconClass: 'text-yellow-400',
} as const
default: default:
return { return {
text: 'Unknown', text: 'Unknown',
@@ -756,11 +764,18 @@ const activeEventToShow =
<ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2"> <ul aria-label="Service links" class="xs:justify-start mt-4 flex flex-wrap justify-center gap-2">
{shownLinks.map((url) => ( {shownLinks.map((url) => (
<li> <li>
<ServiceLinkButton {isScam ? (
url={url} <span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
referral={service.referral} <Icon name="ri:alert-line" class="size-4 text-red-400" />
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0} {urlDomain(url)}
/> </span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li> </li>
))} ))}
@@ -784,11 +799,18 @@ const activeEventToShow =
{hiddenLinks.map((url) => ( {hiddenLinks.map((url) => (
<li class="hidden peer-checked:block"> <li class="hidden peer-checked:block">
<ServiceLinkButton {isScam ? (
url={url} <span class="2xs:text-sm 2xs:h-8 2xs:gap-2 2xs:px-4 bg-day-800 inline-flex h-6 items-center gap-1 rounded-full px-2 text-xs whitespace-nowrap text-red-400">
referral={service.referral} <Icon name="ri:alert-line" class="size-4 text-red-400" />
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0} {urlDomain(url)}
/> </span>
) : (
<ServiceLinkButton
url={url}
referral={service.referral}
enableMinWidth={shuffledLinks.onion.length + shuffledLinks.i2p.length > 0}
/>
)}
</li> </li>
))} ))}
</ul> </ul>
@@ -982,15 +1004,23 @@ const activeEventToShow =
attribute.typeInfo.classNames.text attribute.typeInfo.classNames.text
)} )}
> >
<Icon <span class="mr-auto ml-1">{attribute.title}</span>
name={attribute.categoryInfo.icon} <>
class={cn('mr-2 size-4 flex-shrink-0', attribute.typeInfo.classNames.icon)} {weights.map((w) => (
/> <span
<span>{attribute.title}</span> class={cn(
'ml-2 text-center text-sm',
w.value === 0 ? 'text-current/30' : 'text-current/60'
)}
>
{formatNumber(w.value, w.formatOptions)}
</span>
))}
</>
<Icon <Icon
name="ri:arrow-down-s-line" name="ri:arrow-down-s-line"
class={cn( class={cn(
'ml-auto size-5 group-open/attribute:rotate-180', 'ml-1 size-5 group-open/attribute:rotate-180',
attribute.typeInfo.classNames.icon attribute.typeInfo.classNames.icon
)} )}
/> />
@@ -1057,19 +1087,16 @@ const activeEventToShow =
baseScoreType.classNames.text baseScoreType.classNames.text
)} )}
> >
<Icon <span class="font-title mr-auto ml-1">{baseScoreType.label}</span>
name={baseScoreType.icon} <span class="mr-2 text-current/60">+50</span>
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)} <span class="mr-1 text-current/60">+50</span>
/>
<span class="font-title">{baseScoreType.label}</span>
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
</li> </li>
</ul> </ul>
) )
} }
<p class="text-day-400 mt-3 text-center text-xs"> <p class="text-day-400 mt-3 text-center text-xs">
<span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Rounded)</span> <span class="hover:text-day-200 transition-colors">Overall = 60% Privacy + 40% Trust (Truncated)</span>
</p> </p>
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs"> <div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
<a <a

View File

@@ -25,6 +25,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema' import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema'
import { formatDateShort } from '../../lib/timeAgo' import { formatDateShort } from '../../lib/timeAgo'
import { urlDomain } from '../../lib/urls'
import type { ProfilePage, WithContext } from 'schema-dts' import type { ProfilePage, WithContext } from 'schema-dts'
@@ -96,9 +97,6 @@ const user = await Astro.locals.banners.try('user', async () => {
}, },
where: { where: {
service: { service: {
listedAt: {
lte: new Date(),
},
serviceVisibility: { serviceVisibility: {
in: ['PUBLIC', 'ARCHIVED'], in: ['PUBLIC', 'ARCHIVED'],
}, },
@@ -511,7 +509,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-blue-400 hover:underline" class="text-blue-400 hover:underline"
> >
{user.verifiedLink} {urlDomain(user.verifiedLink)}
</a> </a>
</div> </div>
</li> </li>
@@ -975,12 +973,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center"> <td class="px-4 py-3 text-center">
<span <span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
class={cn(
'border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" /> <Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label} {statusInfo.label}
</span> </span>

View File

@@ -5,7 +5,10 @@
import { clientsClaim } from 'workbox-core' import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching' import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { makeNotificationOptions } from './lib/notificationOptions' import {
makeBrowserNotificationOptions,
makeBrowserNotificationTitle,
} from './lib/client/notificationOptions'
import type { NotificationData, NotificationPayload } from './lib/serverEventsTypes' import type { NotificationData, NotificationPayload } from './lib/serverEventsTypes'
@@ -59,8 +62,8 @@ async function handleNotificationClick(url: string) {
async function showPushNotification(payload: NotificationPayload | null) { async function showPushNotification(payload: NotificationPayload | null) {
await self.registration.showNotification( await self.registration.showNotification(
payload?.title ?? 'New Notification', makeBrowserNotificationTitle(payload?.title),
makeNotificationOptions(payload) makeBrowserNotificationOptions(payload)
) )
} }