Compare commits
2 Commits
release-79
...
release-81
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3c9b92ddb | ||
|
|
effb6689d7 |
@@ -96,7 +96,7 @@ Tasks will run according to their configured cron schedules.
|
||||
### Force Triggers Task
|
||||
|
||||
- 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`
|
||||
|
||||
### Service Score Recalculation Task
|
||||
|
||||
@@ -89,6 +89,11 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
||||
score_recalc_parser.add_argument(
|
||||
"--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)
|
||||
|
||||
@@ -295,12 +300,15 @@ def run_force_triggers_task() -> int:
|
||||
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.
|
||||
|
||||
Args:
|
||||
service_id: Optional specific service ID to process.
|
||||
all_services: Whether to recalculate scores for all services.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
@@ -310,7 +318,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
try:
|
||||
# Initialize task and use as context manager
|
||||
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:
|
||||
logger.info("Successfully recalculated service scores")
|
||||
else:
|
||||
@@ -419,7 +454,9 @@ def main() -> int:
|
||||
elif args.task == "force-triggers":
|
||||
return run_force_triggers_task()
|
||||
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:
|
||||
logger.error(f"Unknown task: {args.task}")
|
||||
return 1
|
||||
|
||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
||||
Force triggers to run under certain conditions.
|
||||
"""
|
||||
|
||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
||||
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("force_triggers")
|
||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
||||
|
||||
update_query = f"""
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyListed" = TRUE
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
||||
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyApproved" = TRUE
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||
"""
|
||||
try:
|
||||
with self.conn.cursor() as cursor:
|
||||
|
||||
@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM "Service"
|
||||
WHERE "isActive" = TRUE
|
||||
FROM "Service"
|
||||
"""
|
||||
)
|
||||
services = cursor.fetchall()
|
||||
|
||||
48
web/package-lock.json
generated
48
web/package-lock.json
generated
@@ -2532,9 +2532,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2603,9 +2603,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7196,9 +7196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9325,9 +9325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-import/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9399,9 +9399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-jsx-a11y/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -9476,9 +9476,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -10379,9 +10379,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -11671,9 +11671,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/jake/node_modules/brace-expansion": {
|
||||
"version": "1.1.11",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz",
|
||||
"integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==",
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
|
||||
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "spamAt" TIMESTAMP(3);
|
||||
@@ -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");
|
||||
@@ -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";
|
||||
@@ -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 {
|
||||
@@ -678,8 +687,6 @@ model PushSubscription {
|
||||
p256dh String
|
||||
/// Authentication secret
|
||||
auth String
|
||||
/// To identify different devices
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -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 })),
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -65,13 +65,13 @@ export const apiServiceActions = {
|
||||
tosUrls: true,
|
||||
referral: true,
|
||||
listedAt: true,
|
||||
approvedAt: true,
|
||||
verifiedAt: true,
|
||||
serviceVisibility: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
let service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
OR: [
|
||||
@@ -92,7 +92,6 @@ export const apiServiceActions = {
|
||||
if (!service && input.slug) {
|
||||
service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
previousSlugs: { has: input.slug },
|
||||
@@ -105,9 +104,7 @@ export const apiServiceActions = {
|
||||
!service ||
|
||||
(service.serviceVisibility !== 'PUBLIC' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' &&
|
||||
service.serviceVisibility !== 'UNLISTED') ||
|
||||
!service.listedAt ||
|
||||
service.listedAt > new Date()
|
||||
service.serviceVisibility !== 'UNLISTED')
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
@@ -130,6 +127,7 @@ export const apiServiceActions = {
|
||||
'description',
|
||||
]),
|
||||
verifiedAt: service.verifiedAt,
|
||||
approvedAt: service.approvedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
@@ -32,7 +33,6 @@ export const notificationActions = {
|
||||
endpoint: z.string(),
|
||||
p256dhKey: z.string(),
|
||||
authKey: z.string(),
|
||||
userAgent: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.pushSubscription.upsert({
|
||||
@@ -43,14 +43,12 @@ export const notificationActions = {
|
||||
update: {
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -58,7 +56,7 @@ export const notificationActions = {
|
||||
|
||||
unsubscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'user',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
endpoint: z.string().optional(),
|
||||
}),
|
||||
@@ -66,16 +64,21 @@ export const notificationActions = {
|
||||
if (input.endpoint) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
userId: context.locals.user?.id ?? undefined,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
} else if (context.locals.user) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Endpoint is required when user is not logged in.',
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -36,7 +36,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||
},
|
||||
@@ -252,7 +251,6 @@ export const serviceSuggestionActions = {
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: 'UNLISTED',
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
|
||||
@@ -6,7 +6,7 @@ import { pwaAssetsHead } from 'virtual:pwa-assets/head'
|
||||
import { pwaInfo } from 'virtual:pwa-info'
|
||||
|
||||
import { isNotArray } from '../lib/arrays'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
|
||||
import DevToolsMessageScript from './DevToolsMessageScript.astro'
|
||||
import DynamicFavicon from './DynamicFavicon.astro'
|
||||
@@ -107,12 +107,8 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
|
||||
|
||||
<DynamicFavicon />
|
||||
|
||||
<!-- Components -->
|
||||
{
|
||||
!Astro.url.pathname.startsWith('/admin') && (
|
||||
<ClientRouter />
|
||||
) /* Disable to prevent bugs in important admin forms */
|
||||
}
|
||||
<ClientRouter />
|
||||
|
||||
<LoadingIndicator color="green" />
|
||||
<TailwindJsPluggin />
|
||||
{htmx && <HtmxScript />}
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from '../lib/commentsWithReplies'
|
||||
import { computeKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { formatDateShort } from '../lib/timeAgo'
|
||||
import { urlDomain } from '../lib/urls'
|
||||
|
||||
import BadgeSmall from './BadgeSmall.astro'
|
||||
import CommentModeration from './CommentModeration.astro'
|
||||
@@ -170,7 +171,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
comment.author.admin || comment.author.moderator
|
||||
? `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" />
|
||||
</Tooltip>
|
||||
|
||||
@@ -71,12 +71,13 @@ const [dbComments, pendingCommentsCount, activeRatingComment] = await Astro.loca
|
||||
'Failed to fetch comments',
|
||||
async () =>
|
||||
await prisma.comment.findMany(
|
||||
makeCommentsNestedQuery({
|
||||
await makeCommentsNestedQuery({
|
||||
depth: MAX_COMMENT_DEPTH,
|
||||
user,
|
||||
showPending: params.showPending,
|
||||
serviceId: service.id,
|
||||
sort: params.sort,
|
||||
highlightedCommentId: params.comment,
|
||||
})
|
||||
),
|
||||
[],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { prisma } from '../lib/prisma'
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
@@ -3,8 +3,8 @@ import { Icon } from 'astro-icon/components'
|
||||
import { sample } from 'lodash-es'
|
||||
|
||||
import { splashTexts } from '../constants/splashTexts'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { cn } from '../lib/cn'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { makeLoginUrl, makeUnimpersonateUrl } from '../lib/redirectUrls'
|
||||
|
||||
import AdminOnly from './AdminOnly.astro'
|
||||
|
||||
@@ -20,7 +20,6 @@ type Props<Multiple extends boolean = false> = Omit<
|
||||
iconClass?: string
|
||||
description?: MarkdownString
|
||||
disabled?: boolean
|
||||
noTransitionPersist?: boolean
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValue?: Multiple extends true ? string[] : string
|
||||
@@ -70,7 +69,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||
transition:persist
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
|
||||
<script>
|
||||
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) => {
|
||||
if (isBrowserNotificationsEnabled()) {
|
||||
const payload = event.detail
|
||||
const notification = showBrowserNotification(
|
||||
payload.title,
|
||||
makeNotificationOptions(payload, { removeActions: true })
|
||||
makeBrowserNotificationTitle(payload.title),
|
||||
makeBrowserNotificationOptions(payload, { removeActions: true })
|
||||
)
|
||||
|
||||
// Handle notification click
|
||||
|
||||
@@ -16,7 +16,6 @@ type Props = HTMLAttributes<'div'> & {
|
||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||
select: {
|
||||
endpoint: true
|
||||
userAgent: true
|
||||
}
|
||||
}>[]
|
||||
}
|
||||
|
||||
@@ -27,6 +27,12 @@ if (!Astro.locals.user) return
|
||||
}
|
||||
|
||||
eventSource.onmessage = (event) => {
|
||||
// NOTE: Disable sse: events when user is not logged in
|
||||
if (!document.body.hasAttribute('data-is-logged-in')) {
|
||||
stopServerEventsListener()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(event.data as string)
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
<script>
|
||||
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}`[]
|
||||
|
||||
@@ -33,7 +34,7 @@
|
||||
|
||||
function shouldSkipAutoReload() {
|
||||
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)
|
||||
}
|
||||
@@ -48,4 +49,11 @@
|
||||
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>
|
||||
|
||||
@@ -1,24 +1,21 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { differenceInDays, isPast } from 'date-fns'
|
||||
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { verificationStepStatusesByValue } from '../constants/verificationStepStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import TimeFormatted from './TimeFormatted.astro'
|
||||
import { formatDaysAgo } from '../lib/timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
const RECENTLY_ADDED_DAYS = 7
|
||||
|
||||
type Props = {
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
verificationProofMd: true
|
||||
verificationSummary: true
|
||||
listedAt: true
|
||||
approvedAt: true
|
||||
isRecentlyApproved: true
|
||||
createdAt: true
|
||||
verificationSteps: {
|
||||
select: {
|
||||
@@ -30,9 +27,6 @@ type Props = {
|
||||
}
|
||||
|
||||
const { service } = Astro.props
|
||||
|
||||
const listedDate = service.listedAt ?? service.createdAt
|
||||
const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), listedDate) < RECENTLY_ADDED_DAYS
|
||||
---
|
||||
|
||||
{
|
||||
@@ -67,10 +61,10 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
) : service.isRecentlyApproved ? (
|
||||
<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 '}{' '}
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
This service was approved
|
||||
{service.approvedAt ? formatDaysAgo(service.approvedAt) : 'less than 15 days ago'}
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
<a
|
||||
@@ -87,7 +81,7 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
Basic checks passed, but not fully verified.
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
|
||||
@@ -81,7 +81,8 @@ const announcement = await Astro.locals.banners.try(
|
||||
</head>
|
||||
<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" />}
|
||||
<Header
|
||||
|
||||
@@ -7,7 +7,7 @@ import { kycLevels } from '../constants/kycLevels'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
import { formatDateShort } from './timeAgo'
|
||||
import { formatDaysAgo } from './timeAgo'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
@@ -35,8 +35,8 @@ type NonDbAttributeFull = NonDbAttribute & {
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
isRecentlyApproved: true
|
||||
approvedAt: true
|
||||
createdAt: true
|
||||
tosReviewAt: true
|
||||
tosReview: true
|
||||
@@ -189,17 +189,17 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
||||
}),
|
||||
},
|
||||
{
|
||||
slug: 'recently-listed',
|
||||
title: 'Recently listed',
|
||||
slug: 'recently-approved',
|
||||
title: 'Recently approved',
|
||||
type: 'WARNING',
|
||||
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,
|
||||
trustPoints: -5,
|
||||
links: [],
|
||||
customize: (service) => ({
|
||||
show: service.isRecentlyListed,
|
||||
description: `Listed on KYCnot.me ${formatDateShort(service.listedAt ?? service.createdAt)}. Proceed with caution.`,
|
||||
show: service.isRecentlyApproved,
|
||||
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',
|
||||
title: 'Has Onion URLs',
|
||||
slug: 'has-onion-or-i2p-urls',
|
||||
title: 'Has Onion or I2P URLs',
|
||||
type: 'GOOD',
|
||||
category: 'PRIVACY',
|
||||
description: 'Onion (Tor) URLs enhance privacy and anonymity.',
|
||||
description: 'Onion (Tor) and I2P URLs enhance privacy and anonymity.',
|
||||
privacyPoints: 5,
|
||||
trustPoints: 0,
|
||||
links: [
|
||||
{
|
||||
url: '/?onion=true',
|
||||
url: '/?networks=onion&networks=i2p',
|
||||
label: 'Search with this',
|
||||
icon: 'ri:search-line',
|
||||
},
|
||||
],
|
||||
customize: (service) => ({
|
||||
show: service.onionUrls.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,
|
||||
show: service.onionUrls.length > 0 || service.i2pUrls.length > 0,
|
||||
}),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -7,11 +7,15 @@ export function supportsBrowserNotifications() {
|
||||
}
|
||||
|
||||
export function isBrowserNotificationsEnabled() {
|
||||
return (
|
||||
supportsBrowserNotifications() &&
|
||||
Notification.permission === 'granted' &&
|
||||
typedLocalStorage.browserNotificationsEnabled.get()
|
||||
)
|
||||
const browserNotificationsEnabled = typedLocalStorage.browserNotificationsEnabled.get()
|
||||
if (!browserNotificationsEnabled) return false
|
||||
|
||||
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> {
|
||||
|
||||
@@ -5,7 +5,6 @@ import type { actions } from 'astro:actions'
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
export type SafeResult =
|
||||
@@ -45,7 +44,6 @@ export async function subscribeToPushNotifications(vapidPublicKey: string): Prom
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
@@ -131,13 +129,7 @@ export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubsc
|
||||
const currentSubscription = await getCurrentSubscription()
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
return serverSubscriptions.some((sub) => sub.endpoint === currentSubscription.endpoint)
|
||||
}
|
||||
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
@@ -183,5 +175,5 @@ export function parsePushSubscriptions(subscriptionsAsString: string | undefined
|
||||
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
|
||||
if (typeof subscription !== 'object' || subscription === null) return false
|
||||
const s = subscription as Record<string, unknown>
|
||||
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
|
||||
return typeof s.endpoint === 'string'
|
||||
}
|
||||
|
||||
7
web/src/lib/client/envVariables.ts
Normal file
7
web/src/lib/client/envVariables.ts
Normal 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'
|
||||
@@ -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 & {
|
||||
actions?: { action: string; title: string; icon?: string }[]
|
||||
@@ -6,14 +8,24 @@ export type CustomNotificationOptions = NotificationOptions & {
|
||||
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,
|
||||
options: { removeActions?: boolean } = {}
|
||||
) {
|
||||
const defaultOptions: CustomNotificationOptions = {
|
||||
body: 'You have a new notification',
|
||||
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',
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
@@ -1,5 +1,7 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
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 type CommentSortOption = z.infer<typeof commentSortSchema>
|
||||
|
||||
export function makeCommentsNestedQuery({
|
||||
export async function makeCommentsNestedQuery({
|
||||
depth = 0,
|
||||
user,
|
||||
showPending,
|
||||
serviceId,
|
||||
sort,
|
||||
highlightedCommentId,
|
||||
}: {
|
||||
depth?: number
|
||||
user: Prisma.UserGetPayload<{
|
||||
@@ -91,6 +94,7 @@ export function makeCommentsNestedQuery({
|
||||
showPending?: boolean
|
||||
serviceId: number
|
||||
sort: CommentSortOption
|
||||
highlightedCommentId?: number | null
|
||||
}) {
|
||||
const orderByClause: Prisma.CommentOrderByWithRelationInput[] = []
|
||||
|
||||
@@ -108,6 +112,8 @@ export function makeCommentsNestedQuery({
|
||||
}
|
||||
orderByClause.unshift({ suspicious: 'asc' }) // Always put suspicious comments last within a sort group
|
||||
|
||||
const highlightedBranchIds = highlightedCommentId ? await findAllParentIds(highlightedCommentId, depth) : []
|
||||
|
||||
const baseQuery = {
|
||||
...commentReplyQuery,
|
||||
orderBy: orderByClause,
|
||||
@@ -121,6 +127,9 @@ export function makeCommentsNestedQuery({
|
||||
: ({
|
||||
status: { in: ['APPROVED', 'VERIFIED'] },
|
||||
} as const satisfies Prisma.CommentWhereInput),
|
||||
...(highlightedBranchIds.length > 0
|
||||
? [{ id: { in: highlightedBranchIds } } as const satisfies Prisma.CommentWhereInput]
|
||||
: []),
|
||||
],
|
||||
parentId: null,
|
||||
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({
|
||||
serviceSlug,
|
||||
commentId,
|
||||
|
||||
@@ -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')
|
||||
@@ -39,7 +39,6 @@ export async function getService(slug: string | undefined): Promise<
|
||||
const service =
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
slug,
|
||||
},
|
||||
@@ -47,7 +46,6 @@ export async function getService(slug: string | undefined): Promise<
|
||||
})) ??
|
||||
(await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
previousSlugs: { has: slug },
|
||||
},
|
||||
@@ -175,7 +173,6 @@ export async function getEvents(): Promise<
|
||||
where: {
|
||||
visible: true,
|
||||
service: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
},
|
||||
|
||||
@@ -29,7 +29,7 @@ export async function stopImpersonating(context: Pick<APIContext, 'cookies' | 'l
|
||||
const sessionId = context.cookies.get(IMPERSONATION_SESSION_COOKIE)?.value
|
||||
await redisImpersonationSessions.delete(sessionId)
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 en from 'javascript-time-ago/locale/en'
|
||||
|
||||
@@ -47,3 +47,10 @@ export function formatDateShort(
|
||||
|
||||
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`
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ export async function removeUserSessionIdCookie(cookies: AstroCookies) {
|
||||
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 removeUserSessionIdCookie(context.cookies)
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from '../constants/characters'
|
||||
|
||||
import { getRandom, typedJoin } from './arrays'
|
||||
import { DEPLOYMENT_MODE } from './envVariables'
|
||||
import { DEPLOYMENT_MODE } from './client/envVariables'
|
||||
import { transformCase } from './strings'
|
||||
|
||||
const DIGEST = 'sha512'
|
||||
|
||||
@@ -5,7 +5,7 @@ import { LOGS_UI_URL } from 'astro:env/server'
|
||||
|
||||
import { SUPPORT_EMAIL } from '../constants/project'
|
||||
import BaseLayout from '../layouts/BaseLayout.astro'
|
||||
import { DEPLOYMENT_MODE } from '../lib/envVariables'
|
||||
import { DEPLOYMENT_MODE } from '../lib/client/envVariables'
|
||||
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -165,7 +165,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
|
||||
|
||||
##### 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
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
import { formatDateShort } from '../../lib/timeAgo'
|
||||
import { urlDomain } from '../../lib/urls'
|
||||
|
||||
const userId = Astro.locals.user?.id
|
||||
if (!userId) {
|
||||
@@ -423,7 +424,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:underline"
|
||||
>
|
||||
{user.verifiedLink}
|
||||
{urlDomain(user.verifiedLink)}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -857,12 +858,7 @@ if (!user) return Astro.rewrite('/404')
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
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
|
||||
)}
|
||||
>
|
||||
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
|
||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
|
||||
@@ -271,7 +271,6 @@ if (toggleResult?.error) {
|
||||
label: type.label,
|
||||
value: type.value,
|
||||
icon: type.icon,
|
||||
noTransitionPersist: false,
|
||||
}))}
|
||||
cardSize="sm"
|
||||
required
|
||||
@@ -306,8 +305,8 @@ if (toggleResult?.error) {
|
||||
label="Status"
|
||||
error={createInputErrors.isActive}
|
||||
options={[
|
||||
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
||||
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
||||
{ label: 'Active', value: 'true' },
|
||||
{ label: 'Inactive', value: 'false' },
|
||||
]}
|
||||
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
|
||||
cardSize="sm"
|
||||
@@ -628,7 +627,6 @@ if (toggleResult?.error) {
|
||||
label: type.label,
|
||||
value: type.value,
|
||||
icon: type.icon,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
cardSize="sm"
|
||||
required
|
||||
@@ -661,8 +659,8 @@ if (toggleResult?.error) {
|
||||
name="isActive"
|
||||
label="Status"
|
||||
options={[
|
||||
{ label: 'Active', value: 'true', noTransitionPersist: true },
|
||||
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
|
||||
{ label: 'Active', value: 'true' },
|
||||
{ label: 'Inactive', value: 'false' },
|
||||
]}
|
||||
selectedValue={announcement.isActive ? 'true' : 'false'}
|
||||
cardSize="sm"
|
||||
|
||||
@@ -692,12 +692,12 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
|
||||
<div class="flex justify-end space-x-3">
|
||||
<Button
|
||||
as="button"
|
||||
color="gray"
|
||||
variant="faded"
|
||||
size="sm"
|
||||
label="Cancel"
|
||||
onclick={`document.getElementById('edit-form-${index}').classList.toggle('hidden')`}
|
||||
data-cancel-button
|
||||
data-cancel-form-id={`edit-form-${index}`}
|
||||
/>
|
||||
<Button
|
||||
as="button"
|
||||
@@ -721,3 +721,22 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
verificationStepStatuses,
|
||||
} from '../../../../constants/verificationStepStatus'
|
||||
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 { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
|
||||
import { pluralize } from '../../../../lib/pluralize'
|
||||
@@ -441,7 +441,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value: kycLevel.id.toString(),
|
||||
icon: kycLevel.icon,
|
||||
description: kycLevel.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.kycLevel.toString()}
|
||||
iconSize="md"
|
||||
@@ -458,7 +457,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
value: clarification.value,
|
||||
icon: clarification.icon,
|
||||
description: clarification.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.kycLevelClarification}
|
||||
iconSize="sm"
|
||||
@@ -475,7 +473,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
icon: status.icon,
|
||||
iconClass: status.classNames.icon,
|
||||
description: status.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.verificationStatus}
|
||||
error={serviceInputErrors.verificationStatus}
|
||||
@@ -491,7 +488,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
label: currency.name,
|
||||
value: currency.id,
|
||||
icon: currency.icon,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.acceptedCurrencies}
|
||||
error={serviceInputErrors.acceptedCurrencies}
|
||||
@@ -532,7 +528,6 @@ const apiCalls = await Astro.locals.banners.try(
|
||||
icon: visibility.icon,
|
||||
iconClass: visibility.iconClass,
|
||||
description: visibility.description,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
selectedValue={service.serviceVisibility}
|
||||
error={serviceInputErrors.serviceVisibility}
|
||||
|
||||
@@ -230,26 +230,22 @@ if (!user) return Astro.rewrite('/404')
|
||||
label: 'Admin',
|
||||
value: 'admin',
|
||||
icon: 'ri:shield-star-fill',
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
{
|
||||
label: 'Moderator',
|
||||
value: 'moderator',
|
||||
icon: 'ri:graduation-cap-fill',
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
{
|
||||
label: 'Spammer',
|
||||
value: 'spammer',
|
||||
icon: 'ri:alert-fill',
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
{
|
||||
label: 'Verified',
|
||||
value: 'verified',
|
||||
icon: 'ri:verified-badge-fill',
|
||||
disabled: true,
|
||||
noTransitionPersist: true,
|
||||
},
|
||||
]}
|
||||
selectedValue={[
|
||||
@@ -434,7 +430,6 @@ if (!user) return Astro.rewrite('/404')
|
||||
label: role.label,
|
||||
value: role.value,
|
||||
icon: role.icon,
|
||||
noTransitionPersist: true,
|
||||
}))}
|
||||
required
|
||||
cardSize="sm"
|
||||
|
||||
@@ -52,6 +52,7 @@ type ServiceResponse = {
|
||||
description: string
|
||||
}
|
||||
verifiedAt: Date | null
|
||||
approvedAt: Date | null
|
||||
kycLevel: 0 | 1 | 2 | 3 | 4
|
||||
kycLevelInfo: {
|
||||
value: 0 | 1 | 2 | 3 | 4
|
||||
@@ -145,7 +146,8 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
"labelShort": "Verified",
|
||||
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
|
||||
},
|
||||
"verifiedAt": "2025-01-20T07:12:29.393Z",
|
||||
"verifiedAt": "2025-06-14T11:02:39.294Z",
|
||||
"approvedAt": "2025-05-31T19:09:18.043Z",
|
||||
"kycLevel": 0,
|
||||
"kycLevelInfo": {
|
||||
"value": 0,
|
||||
@@ -163,7 +165,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
|
||||
"slug": "exchange"
|
||||
}
|
||||
],
|
||||
"listedAt": "2025-05-31T19:09:18.043Z",
|
||||
"listedAt": "2025-04-20T07:12:29.393Z",
|
||||
"serviceUrls": [
|
||||
"https://example.com",
|
||||
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
|
||||
|
||||
@@ -45,7 +45,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
||||
async () =>
|
||||
prisma.service.findMany({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
},
|
||||
select: {
|
||||
@@ -72,7 +71,6 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
|
||||
},
|
||||
service: {
|
||||
slug: params.service ?? undefined,
|
||||
listedAt: params.service ? undefined : { lte: new Date() },
|
||||
serviceVisibility: {
|
||||
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
|
||||
@@ -219,7 +219,6 @@ const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : n
|
||||
|
||||
const where = {
|
||||
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
|
||||
listedAt: { lte: new Date() },
|
||||
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
|
||||
verificationStatus: {
|
||||
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
|
||||
@@ -317,7 +316,6 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
|
||||
services: {
|
||||
where: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -163,7 +163,6 @@ const [dbNotifications, notificationPreferences, totalNotifications, pushSubscri
|
||||
where: { userId: user.id },
|
||||
select: {
|
||||
endpoint: true,
|
||||
userAgent: true,
|
||||
},
|
||||
}),
|
||||
[],
|
||||
|
||||
@@ -70,7 +70,6 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
where: {
|
||||
slug,
|
||||
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -93,6 +92,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
referral: true,
|
||||
imageUrl: true,
|
||||
listedAt: true,
|
||||
approvedAt: true,
|
||||
createdAt: true,
|
||||
acceptedCurrencies: true,
|
||||
tosReview: true,
|
||||
@@ -100,7 +100,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
userSentiment: true,
|
||||
userSentimentAt: true,
|
||||
averageUserRating: true,
|
||||
isRecentlyListed: true,
|
||||
isRecentlyApproved: true,
|
||||
contactMethods: {
|
||||
select: {
|
||||
value: true,
|
||||
@@ -230,7 +230,6 @@ if (!service) {
|
||||
where: {
|
||||
previousSlugs: { has: slug },
|
||||
serviceVisibility: { in: ['PUBLIC', 'UNLISTED', 'ARCHIVED'] },
|
||||
listedAt: { lte: new Date() },
|
||||
},
|
||||
select: { slug: true },
|
||||
})
|
||||
@@ -1005,15 +1004,23 @@ const activeEventToShow =
|
||||
attribute.typeInfo.classNames.text
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={attribute.categoryInfo.icon}
|
||||
class={cn('mr-2 size-4 flex-shrink-0', attribute.typeInfo.classNames.icon)}
|
||||
/>
|
||||
<span>{attribute.title}</span>
|
||||
<span class="mr-auto ml-1">{attribute.title}</span>
|
||||
<>
|
||||
{weights.map((w) => (
|
||||
<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
|
||||
name="ri:arrow-down-s-line"
|
||||
class={cn(
|
||||
'ml-auto size-5 group-open/attribute:rotate-180',
|
||||
'ml-1 size-5 group-open/attribute:rotate-180',
|
||||
attribute.typeInfo.classNames.icon
|
||||
)}
|
||||
/>
|
||||
@@ -1080,19 +1087,16 @@ const activeEventToShow =
|
||||
baseScoreType.classNames.text
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
name={baseScoreType.icon}
|
||||
class={cn('mr-2 size-4 flex-shrink-0', baseScoreType.classNames.icon)}
|
||||
/>
|
||||
<span class="font-title">{baseScoreType.label}</span>
|
||||
<span class={cn('mr-1 ml-auto', baseScoreType.classNames.icon)}>+50</span>
|
||||
<span class="font-title mr-auto ml-1">{baseScoreType.label}</span>
|
||||
<span class="mr-2 text-current/60">+50</span>
|
||||
<span class="mr-1 text-current/60">+50</span>
|
||||
</li>
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
<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>
|
||||
<div class="xs:gap-x-6 mt-2 flex flex-wrap justify-center gap-x-4 gap-y-2 text-xs">
|
||||
<a
|
||||
|
||||
@@ -25,6 +25,7 @@ import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { KYCNOTME_SCHEMA_MINI } from '../../lib/schema'
|
||||
import { formatDateShort } from '../../lib/timeAgo'
|
||||
import { urlDomain } from '../../lib/urls'
|
||||
|
||||
import type { ProfilePage, WithContext } from 'schema-dts'
|
||||
|
||||
@@ -96,9 +97,6 @@ const user = await Astro.locals.banners.try('user', async () => {
|
||||
},
|
||||
where: {
|
||||
service: {
|
||||
listedAt: {
|
||||
lte: new Date(),
|
||||
},
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED'],
|
||||
},
|
||||
@@ -511,7 +509,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
rel="noopener noreferrer"
|
||||
class="text-blue-400 hover:underline"
|
||||
>
|
||||
{user.verifiedLink}
|
||||
{urlDomain(user.verifiedLink)}
|
||||
</a>
|
||||
</div>
|
||||
</li>
|
||||
@@ -975,12 +973,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<span
|
||||
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
|
||||
)}
|
||||
>
|
||||
<span class="border-night-500/20 bg-night-800/10 inline-flex items-center rounded-full border px-2 py-0.5 text-xs">
|
||||
<Icon name={statusInfo.icon} class="mr-1 size-3" />
|
||||
{statusInfo.label}
|
||||
</span>
|
||||
|
||||
@@ -5,7 +5,10 @@
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
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'
|
||||
|
||||
@@ -59,8 +62,8 @@ async function handleNotificationClick(url: string) {
|
||||
|
||||
async function showPushNotification(payload: NotificationPayload | null) {
|
||||
await self.registration.showNotification(
|
||||
payload?.title ?? 'New Notification',
|
||||
makeNotificationOptions(payload)
|
||||
makeBrowserNotificationTitle(payload?.title),
|
||||
makeBrowserNotificationOptions(payload)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user