Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
a545726abf Release 202507030838 2025-07-03 08:38:11 +00:00
pluja
01488b8b3b Release 202507010806 2025-07-01 08:06:36 +00:00
pluja
b456af9448 Release 202507010759 2025-07-01 07:59:22 +00:00
pluja
b7ae6dc22c Release 202507010740 2025-07-01 07:40:26 +00:00
41 changed files with 1565 additions and 769 deletions

View File

@@ -39,8 +39,11 @@ OPENAI_BASE_URL="https://your-openai-base-url.example.com"
OPENAI_MODEL=your_openai_model OPENAI_MODEL=your_openai_model
OPENAI_RETRY=3 OPENAI_RETRY=3
# Pyworker Crons # Task schedules ---------------------------------------------------
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily

View File

@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
OPENAI_MODEL="xxxxxxxxx" OPENAI_MODEL="xxxxxxxxx"
OPENAI_RETRY=3 OPENAI_RETRY=3
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month # Task schedules ---------------------------------------------------
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily

View File

@@ -38,6 +38,7 @@ Required environment variables:
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task - `CRON_MODERATION_TASK`: Cron expression for comment moderation task
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task - `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task - `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
## Usage ## Usage
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
# Run service score recalculation task # Run service score recalculation task
uv run -m pyworker service-score-recalc [--service-id ID] uv run -m pyworker service-score-recalc [--service-id ID]
# Run inactive users cleanup task
uv run -m pyworker inactive-users
``` ```
### Worker Mode ### Worker Mode
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
- Calculates privacy, trust, and overall scores - Calculates privacy, trust, and overall scores
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK` - Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
### Inactive Users Task
- Handles cleanup of inactive user accounts
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
- Deletes accounts that remain inactive after the warning period
- Cancels deletion for users who become active again
- Scheduled via `CRON_INACTIVE_USERS_TASK`
## Development ## Development
### Project Structure ### Project Structure
@@ -124,6 +137,7 @@ pyworker/
│ │ ├── base.py │ │ ├── base.py
│ │ ├── comment_moderation.py │ │ ├── comment_moderation.py
│ │ ├── force_triggers.py │ │ ├── force_triggers.py
│ │ ├── inactive_users.py
│ │ ├── service_score_recalc.py │ │ ├── service_score_recalc.py
│ │ ├── tos_review.py │ │ ├── tos_review.py
│ │ └── user_sentiment.py │ │ └── user_sentiment.py

View File

@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
from .tasks import ( from .tasks import (
CommentModerationTask, CommentModerationTask,
ForceTriggersTask, ForceTriggersTask,
InactiveUsersTask,
ServiceScoreRecalculationTask, ServiceScoreRecalculationTask,
TosReviewTask, TosReviewTask,
UserSentimentTask, UserSentimentTask,
@@ -101,6 +102,12 @@ def parse_args(args: List[str]) -> argparse.Namespace:
help="Recalculate service scores for all services", help="Recalculate service scores for all services",
) )
# Inactive users task
subparsers.add_parser(
"inactive-users",
help="Handle inactive users - send deletion warnings and clean up accounts",
)
return parser.parse_args(args) return parser.parse_args(args)
@@ -371,6 +378,30 @@ def run_service_score_recalc_all_task() -> int:
return run_service_score_recalc_task(all_services=True) return run_service_score_recalc_task(all_services=True)
def run_inactive_users_task() -> int:
"""
Run the inactive users task.
Returns:
Exit code.
"""
logger.info("Starting inactive users task")
try:
# Initialize task and use as context manager
with InactiveUsersTask() as task: # type: ignore
result = task.run() # type: ignore
logger.info(f"Inactive users task completed. Results: {result}")
return 0
except Exception as e:
logger.exception(f"Error running inactive users task: {e}")
return 1
finally:
# Ensure connection pool is closed even if an error occurs
close_db_pool()
def run_worker_mode() -> int: def run_worker_mode() -> int:
""" """
Run in worker mode, scheduling tasks to run periodically. Run in worker mode, scheduling tasks to run periodically.
@@ -382,54 +413,37 @@ def run_worker_mode() -> int:
# Get task schedules from config # Get task schedules from config
task_schedules = config.task_schedules task_schedules = config.task_schedules
if not task_schedules: logger.info(
"Found %s cron schedule%s from environment variables: %s",
len(task_schedules),
"s" if len(task_schedules) != 1 else "",
", ".join(task_schedules.keys()) if task_schedules else "<none>",
)
required_tasks: dict[str, Any] = {
"tosreview": run_tos_task,
"user_sentiment": run_sentiment_task,
"comment_moderation": run_moderation_task,
"force_triggers": run_force_triggers_task,
"inactive_users": run_inactive_users_task,
"service_score_recalc": run_service_score_recalc_task,
"service_score_recalc_all": run_service_score_recalc_all_task,
}
missing_tasks = [t for t in required_tasks if t not in task_schedules]
if missing_tasks:
logger.error( logger.error(
"No task schedules defined. Set CRON_TASKNAME_TASK environment variables." "Missing cron schedule for task%s: %s. Set the corresponding CRON_<TASKNAME>_TASK environment variable%s.",
"s" if len(missing_tasks) != 1 else "",
", ".join(missing_tasks),
"s" if len(missing_tasks) != 1 else "",
) )
return 1 return 1
logger.info(
f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}"
)
# Initialize the scheduler
scheduler = TaskScheduler() scheduler = TaskScheduler()
# Register tasks with their schedules for task_name, task_callable in required_tasks.items():
for task_name, cron_expression in task_schedules.items(): scheduler.register_task(task_name, task_schedules[task_name], task_callable)
if task_name.lower() == "tosreview":
scheduler.register_task(task_name, cron_expression, run_tos_task)
elif task_name.lower() == "user_sentiment":
scheduler.register_task(task_name, cron_expression, run_sentiment_task)
elif task_name.lower() == "comment_moderation":
scheduler.register_task(task_name, cron_expression, run_moderation_task)
elif task_name.lower() == "force_triggers":
scheduler.register_task(task_name, cron_expression, run_force_triggers_task)
elif task_name.lower() == "service_score_recalc":
scheduler.register_task(
task_name, cron_expression, run_service_score_recalc_task
)
elif task_name.lower() == "service_score_recalc_all":
scheduler.register_task(
task_name,
cron_expression,
run_service_score_recalc_all_task,
)
else:
logger.warning(f"Unknown task '{task_name}', skipping")
# Register service score recalculation task (every 5 minutes)
scheduler.register_task(
"service_score_recalc",
"*/5 * * * *",
run_service_score_recalc_task,
)
# Register daily service score recalculation for all services
scheduler.register_task(
"service_score_recalc_all",
"0 0 * * *",
run_service_score_recalc_all_task,
)
# Start the scheduler if tasks were registered # Start the scheduler if tasks were registered
if scheduler.tasks: if scheduler.tasks:
@@ -484,6 +498,8 @@ def main() -> int:
) )
elif args.task == "service-score-recalc-all": elif args.task == "service-score-recalc-all":
return run_service_score_recalc_all_task() return run_service_score_recalc_all_task()
elif args.task == "inactive-users":
return run_inactive_users_task()
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

@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
from .tasks import ( from .tasks import (
CommentModerationTask, CommentModerationTask,
ForceTriggersTask, ForceTriggersTask,
InactiveUsersTask,
ServiceScoreRecalculationTask, ServiceScoreRecalculationTask,
TosReviewTask, TosReviewTask,
UserSentimentTask, UserSentimentTask,
@@ -80,6 +81,8 @@ class TaskScheduler:
task_instance = ForceTriggersTask() task_instance = ForceTriggersTask()
elif task_name.lower() == "service_score_recalc": elif task_name.lower() == "service_score_recalc":
task_instance = ServiceScoreRecalculationTask() task_instance = ServiceScoreRecalculationTask()
elif task_name.lower() == "inactive_users":
task_instance = InactiveUsersTask()
else: else:
self.logger.warning(f"Unknown task '{task_name}', skipping") self.logger.warning(f"Unknown task '{task_name}', skipping")
return return

View File

@@ -3,6 +3,7 @@
from .base import Task from .base import Task
from .comment_moderation import CommentModerationTask from .comment_moderation import CommentModerationTask
from .force_triggers import ForceTriggersTask from .force_triggers import ForceTriggersTask
from .inactive_users import InactiveUsersTask
from .service_score_recalc import ServiceScoreRecalculationTask from .service_score_recalc import ServiceScoreRecalculationTask
from .tos_review import TosReviewTask from .tos_review import TosReviewTask
from .user_sentiment import UserSentimentTask from .user_sentiment import UserSentimentTask
@@ -11,6 +12,7 @@ __all__ = [
"Task", "Task",
"CommentModerationTask", "CommentModerationTask",
"ForceTriggersTask", "ForceTriggersTask",
"InactiveUsersTask",
"ServiceScoreRecalculationTask", "ServiceScoreRecalculationTask",
"TosReviewTask", "TosReviewTask",
"UserSentimentTask", "UserSentimentTask",

View File

@@ -0,0 +1,258 @@
"""
Task for handling inactive users - sending deletion warnings and cleaning up accounts.
"""
from datetime import datetime, timedelta, date
from typing import Any, Dict, List, Optional
from pyworker.database import execute_db_command, run_db_query
from pyworker.tasks.base import Task
class InactiveUsersTask(Task):
"""Task for handling inactive users"""
def __init__(self):
"""Initialize the inactive users task."""
super().__init__("inactive_users")
def run(self) -> Dict[str, Any]:
"""
Run the inactive users task.
This task:
1. Identifies users who have been inactive for 1 year
2. Schedules them for deletion
3. Sends warning notifications at 30, 15, 5, and 1 day intervals
4. Deletes accounts that have reached their deletion date
"""
results = {
"users_scheduled_for_deletion": 0,
"notifications_sent": 0,
"accounts_deleted": 0,
"deletions_cancelled": 0,
}
# Step 1: Cancel deletion for users who became active again
results["deletions_cancelled"] = self._cancel_deletion_for_active_users()
# Step 2: Schedule new inactive users for deletion
results["users_scheduled_for_deletion"] = self._schedule_inactive_users_for_deletion()
# Step 3: Send warning notifications
results["notifications_sent"] = self._send_deletion_warnings()
# Step 4: Delete accounts that have reached their deletion date
results["accounts_deleted"] = self._delete_scheduled_accounts()
self.logger.info(
f"Inactive users task completed. "
f"Deletions cancelled: {results['deletions_cancelled']}, "
f"Scheduled: {results['users_scheduled_for_deletion']}, "
f"Notifications sent: {results['notifications_sent']}, "
f"Accounts deleted: {results['accounts_deleted']}"
)
return results
def _schedule_inactive_users_for_deletion(self) -> int:
"""
Schedule inactive users for deletion.
A user is considered inactive if:
- Account was created more than 1 year ago
- Has 0 karma
- Has no comments, comment votes, or service suggestions
- Is not scheduled for deletion already
- Is not an admin or moderator
"""
one_year_ago = datetime.now() - timedelta(days=365)
deletion_date = date.today() + timedelta(days=30) # 30 days from today
# Find inactive users
query = """
UPDATE "User"
SET "scheduledDeletionAt" = %s, "updatedAt" = NOW()
WHERE "id" IN (
SELECT u."id"
FROM "User" u
WHERE u."createdAt" < %s
AND u."scheduledDeletionAt" IS NULL
AND u."admin" = false
AND u."moderator" = false
AND u."totalKarma" = 0
AND NOT EXISTS (
SELECT 1 FROM "Comment" c WHERE c."authorId" = u."id"
)
AND NOT EXISTS (
SELECT 1 FROM "CommentVote" cv WHERE cv."userId" = u."id"
)
AND NOT EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss WHERE ss."userId" = u."id"
)
)
"""
count = execute_db_command(query, (deletion_date, one_year_ago))
self.logger.info(f"Scheduled {count} inactive users for deletion on {deletion_date}")
return count
def _send_deletion_warnings(self) -> int:
"""
Send deletion warning notifications to users at appropriate intervals.
"""
today = date.today()
notifications_sent = 0
# Define warning intervals and their corresponding notification types
warning_intervals = [
(30, 'ACCOUNT_DELETION_WARNING_30_DAYS'),
(15, 'ACCOUNT_DELETION_WARNING_15_DAYS'),
(5, 'ACCOUNT_DELETION_WARNING_5_DAYS'),
(1, 'ACCOUNT_DELETION_WARNING_1_DAY'),
]
for days_before, notification_type in warning_intervals:
# Find users who should receive this warning (exact date match)
target_date = today + timedelta(days=days_before)
# Check if user is still inactive (no recent activity)
users_query = """
SELECT u."id", u."name", u."scheduledDeletionAt"
FROM "User" u
WHERE u."scheduledDeletionAt" = %s
AND u."admin" = false
AND u."moderator" = false
AND u."totalKarma" = 0
AND NOT EXISTS (
SELECT 1 FROM "Notification" n
WHERE n."userId" = u."id"
AND n."type" = %s
AND n."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
-- Still check if user is inactive (no activity since being scheduled)
AND NOT EXISTS (
SELECT 1 FROM "Comment" c
WHERE c."authorId" = u."id"
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "CommentVote" cv
WHERE cv."userId" = u."id"
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss
WHERE ss."userId" = u."id"
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
"""
users = run_db_query(users_query, (target_date, notification_type))
# Create notifications for these users
for user in users:
insert_notification_query = """
INSERT INTO "Notification" ("userId", "type", "createdAt", "updatedAt")
VALUES (%s, %s, NOW(), NOW())
ON CONFLICT DO NOTHING
"""
execute_db_command(insert_notification_query, (user['id'], notification_type))
notifications_sent += 1
self.logger.info(
f"Sent {notification_type} notification to user {user['name']} "
f"(ID: {user['id']}) scheduled for deletion on {user['scheduledDeletionAt']}"
)
return notifications_sent
def _delete_scheduled_accounts(self) -> int:
"""
Delete accounts that have reached their scheduled deletion date and are still inactive.
"""
today = date.today()
# Find users scheduled for deletion who are still inactive
users_to_delete_query = """
SELECT u."id", u."name", u."scheduledDeletionAt"
FROM "User" u
WHERE u."scheduledDeletionAt" <= %s
AND u."admin" = false
AND u."moderator" = false
AND u."totalKarma" = 0
-- Double-check they're still inactive
AND NOT EXISTS (
SELECT 1 FROM "Comment" c
WHERE c."authorId" = u."id"
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "CommentVote" cv
WHERE cv."userId" = u."id"
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
AND NOT EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss
WHERE ss."userId" = u."id"
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
)
"""
users_to_delete = run_db_query(users_to_delete_query, (today,))
deleted_count = 0
for user in users_to_delete:
try:
# Delete the user (this will cascade and delete related records)
delete_query = 'DELETE FROM "User" WHERE "id" = %s'
execute_db_command(delete_query, (user['id'],))
deleted_count += 1
self.logger.info(
f"Deleted inactive user {user['name']} (ID: {user['id']}) "
f"scheduled for deletion on {user['scheduledDeletionAt']}"
)
except Exception as e:
self.logger.error(
f"Failed to delete user {user['name']} (ID: {user['id']}): {e}"
)
return deleted_count
def _cancel_deletion_for_active_users(self) -> int:
"""
Cancel scheduled deletion for users who have become active again.
"""
# Find users scheduled for deletion who have recent activity or gained karma
query = """
UPDATE "User"
SET "scheduledDeletionAt" = NULL, "updatedAt" = NOW()
WHERE "scheduledDeletionAt" IS NOT NULL
AND (
"totalKarma" > 0
OR EXISTS (
SELECT 1 FROM "Comment" c
WHERE c."authorId" = "User"."id"
AND c."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
)
OR EXISTS (
SELECT 1 FROM "CommentVote" cv
WHERE cv."userId" = "User"."id"
AND cv."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
)
OR EXISTS (
SELECT 1 FROM "ServiceSuggestion" ss
WHERE ss."userId" = "User"."id"
AND ss."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
)
)
"""
count = execute_db_command(query)
if count > 0:
self.logger.info(f"Cancelled deletion for {count} users who became active again or gained karma")
return count

835
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -31,26 +31,26 @@
"@astrojs/rss": "4.0.12", "@astrojs/rss": "4.0.12",
"@astrojs/sitemap": "3.4.1", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.6",
"@fontsource/space-grotesk": "5.2.8", "@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0", "@prisma/client": "6.10.1",
"@tailwindcss/vite": "4.1.8", "@tailwindcss/vite": "4.1.11",
"@types/mime-types": "3.0.0", "@types/mime-types": "3.0.1",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.9.0", "astro": "5.10.1",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
"canvas": "3.1.0", "canvas": "3.1.2",
"clsx": "2.1.1", "clsx": "2.1.1",
"htmx.org": "1.9.12", "htmx.org": "2.0.6",
"javascript-time-ago": "2.5.11", "javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.9", "libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0", "pg": "8.16.3",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.5.6", "redis": "5.5.6",
@@ -58,52 +58,51 @@
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.2", "sharp": "0.34.2",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.1",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.8", "tailwindcss": "4.1.11",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7", "web-push": "3.6.7"
"zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.28.0", "@eslint/js": "9.30.0",
"@faker-js/faker": "9.8.0", "@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.24", "@iconify-json/material-symbols": "1.2.28",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.4.1", "@stylistic/eslint-plugin": "5.1.0",
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.6", "@types/react": "19.1.8",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.33.1", "@typescript-eslint/parser": "8.35.1",
"@vite-pwa/assets-generator": "1.0.0", "@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0", "@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"esbuild": "0.25.5", "esbuild": "0.25.5",
"eslint": "9.28.0", "eslint": "9.30.0",
"eslint-import-resolver-typescript": "4.4.3", "eslint-import-resolver-typescript": "4.4.4",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.32.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.2.0", "globals": "16.2.0",
"prettier": "3.5.3", "prettier": "3.6.2",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.12", "prettier-plugin-tailwindcss": "0.6.13",
"prisma": "6.9.0", "prisma": "6.10.1",
"prisma-json-types-generator": "3.4.2", "prisma-json-types-generator": "3.5.0",
"tailwind-htmx": "0.1.2", "tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4", "ts-essentials": "10.1.1",
"ts-toolbelt": "9.6.0", "ts-toolbelt": "9.6.0",
"tsx": "4.19.4", "tsx": "4.20.3",
"typescript-eslint": "8.33.1", "typescript-eslint": "8.35.1",
"vite-plugin-devtools-json": "0.1.1", "vite-plugin-devtools-json": "0.2.1",
"workbox-core": "7.3.0", "workbox-core": "7.3.0",
"workbox-precaching": "7.3.0" "workbox-precaching": "7.3.0"
} }

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;

View File

@@ -0,0 +1,15 @@
-- AlterEnum
-- This migration adds more than one value to an enum.
-- With PostgreSQL versions 11 and earlier, this is not possible
-- in a single migration. This can be worked around by creating
-- multiple migrations, each migration adding only one value to
-- the enum.
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_30_DAYS';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_15_DAYS';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_5_DAYS';
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_1_DAY';
-- AlterTable
ALTER TABLE "User" ADD COLUMN "scheduledDeletionAt" DATE;

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT;

View File

@@ -144,6 +144,10 @@ enum NotificationType {
ACCOUNT_STATUS_CHANGE ACCOUNT_STATUS_CHANGE
EVENT_CREATED EVENT_CREATED
SERVICE_VERIFICATION_STATUS_CHANGE SERVICE_VERIFICATION_STATUS_CHANGE
ACCOUNT_DELETION_WARNING_30_DAYS
ACCOUNT_DELETION_WARNING_15_DAYS
ACCOUNT_DELETION_WARNING_5_DAYS
ACCOUNT_DELETION_WARNING_1_DAY
} }
enum CommentStatusChange { enum CommentStatusChange {
@@ -349,8 +353,8 @@ model Service {
categories Category[] @relation("ServiceToCategory") categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4) kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE) kycLevelClarification KycLevelClarification @default(NONE)
/// The first known date when the service started operating. Used for New/Mature service attributes. /// Date only, no time.
operatingSince DateTime? operatingSince DateTime? @db.Date
overallScore Int @default(0) overallScore Int @default(0)
privacyScore Int @default(0) privacyScore Int @default(0)
trustScore Int @default(0) trustScore Int @default(0)
@@ -498,20 +502,22 @@ model InternalServiceNote {
} }
model User { model User {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
displayName String? displayName String?
link String? link String?
picture String? picture String?
spammer Boolean @default(false) spammer Boolean @default(false)
verified Boolean @default(false) verified Boolean @default(false)
admin Boolean @default(false) admin Boolean @default(false)
moderator Boolean @default(false) moderator Boolean @default(false)
verifiedLink String? verifiedLink String?
secretTokenHash String @unique secretTokenHash String @unique
feedId String @unique @default(cuid(2)) feedId String @unique @default(cuid(2))
/// Computed via trigger. Do not update through prisma. /// Computed via trigger. Do not update through prisma.
totalKarma Int @default(0) totalKarma Int @default(0)
/// Date when user is scheduled for deletion due to inactivity
scheduledDeletionAt DateTime? @db.Date
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt
@@ -609,10 +615,11 @@ model VerificationStep {
} }
model Category { model Category {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String @unique name String @unique
icon String namePluralLong String?
slug String @unique icon String
slug String @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt updatedAt DateTime @default(now()) @updatedAt

View File

@@ -141,81 +141,97 @@ const generateFakeAttribute = () => {
const categoriesToCreate = [ const categoriesToCreate = [
{ {
name: 'Exchange', name: 'Exchange',
namePluralLong: 'Exchanges',
slug: 'exchange', slug: 'exchange',
icon: 'ri:arrow-left-right-fill', icon: 'ri:arrow-left-right-fill',
}, },
{ {
name: 'VPN', name: 'VPN',
namePluralLong: 'VPNs',
slug: 'vpn', slug: 'vpn',
icon: 'ri:door-lock-fill', icon: 'ri:door-lock-fill',
}, },
{ {
name: 'Email', name: 'Email',
namePluralLong: 'Email providers',
slug: 'email', slug: 'email',
icon: 'ri:mail-fill', icon: 'ri:mail-fill',
}, },
{ {
name: 'Hosting', name: 'Hosting',
namePluralLong: 'Hostings',
slug: 'hosting', slug: 'hosting',
icon: 'ri:server-fill', icon: 'ri:server-fill',
}, },
{ {
name: 'VPS', name: 'VPS',
namePluralLong: 'VPS providers',
slug: 'vps', slug: 'vps',
icon: 'ri:function-add-fill', icon: 'ri:function-add-fill',
}, },
{ {
name: 'Gift Cards', name: 'Gift Cards',
namePluralLong: 'Gift cards',
slug: 'gift-cards', slug: 'gift-cards',
icon: 'ri:gift-line', icon: 'ri:gift-line',
}, },
{ {
name: 'Goods', name: 'Goods',
namePluralLong: 'Goods providers',
slug: 'goods', slug: 'goods',
icon: 'ri:shopping-basket-fill', icon: 'ri:shopping-basket-fill',
}, },
{ {
name: 'Travel', name: 'Travel',
namePluralLong: 'Travel services',
slug: 'travel', slug: 'travel',
icon: 'ri:plane-fill', icon: 'ri:plane-fill',
}, },
{ {
name: 'SMS', name: 'SMS',
namePluralLong: 'SMS providers',
slug: 'sms', slug: 'sms',
icon: 'ri:message-2-fill', icon: 'ri:message-2-fill',
}, },
{ {
name: 'Store', name: 'Store',
namePluralLong: 'Stores',
slug: 'store', slug: 'store',
icon: 'ri:store-2-line', icon: 'ri:store-2-line',
}, },
{ {
name: 'Tool', name: 'Tool',
namePluralLong: 'Tools',
slug: 'tool', slug: 'tool',
icon: 'ri:tools-fill', icon: 'ri:tools-fill',
}, },
{ {
name: 'Market', name: 'Market',
namePluralLong: 'Markets',
slug: 'market', slug: 'market',
icon: 'ri:price-tag-3-line', icon: 'ri:price-tag-3-line',
}, },
{ {
name: 'Aggregator', name: 'Aggregator',
namePluralLong: 'Aggregators',
slug: 'aggregator', slug: 'aggregator',
icon: 'ri:list-ordered', icon: 'ri:list-ordered',
}, },
{ {
name: 'AI', name: 'AI',
namePluralLong: 'AI services',
slug: 'ai', slug: 'ai',
icon: 'ri:ai-generate-2', icon: 'ri:ai-generate-2',
}, },
{ {
name: 'CEX', name: 'CEX',
namePluralLong: 'CEXs',
slug: 'cex', slug: 'cex',
icon: 'ri:rotate-lock-fill', icon: 'ri:rotate-lock-fill',
}, },
{ {
name: 'DEX', name: 'DEX',
namePluralLong: 'DEXs',
slug: 'dex', slug: 'dex',
icon: 'ri:fediverse-line', icon: 'ri:fediverse-line',
}, },

View File

@@ -275,35 +275,39 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
DECLARE DECLARE
service_name TEXT; service_name TEXT;
service_visibility "ServiceVisibility";
is_user_admin_or_moderator BOOLEAN; is_user_admin_or_moderator BOOLEAN;
BEGIN BEGIN
-- Award karma for first approval -- Award karma for first approval
-- Check that OLD.status is not NULL to handle the initial creation case if needed, -- Check that OLD.status is not NULL to handle the initial creation case if needed,
-- and ensure it wasn't already APPROVED. -- and ensure it wasn't already APPROVED.
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
-- Check if the user is an admin or moderator -- Fetch service details for the description
SELECT (admin = true OR moderator = true) SELECT name, visibility INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
FROM "User"
WHERE id = NEW."userId"
INTO is_user_admin_or_moderator;
-- Only award karma if the user is NOT an admin/moderator -- Only award karma if the service is public
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN IF service_visibility = 'PUBLIC' THEN
-- Fetch service name for the description -- Check if the user is an admin or moderator
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; SELECT (admin = true OR moderator = true)
FROM "User"
WHERE id = NEW."userId"
INTO is_user_admin_or_moderator;
-- Insert karma transaction, linking it to the suggestion -- Only award karma if the user is NOT an admin/moderator
PERFORM insert_karma_transaction( IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
NEW."userId", -- Insert karma transaction, linking it to the suggestion
10, PERFORM insert_karma_transaction(
'SUGGESTION_APPROVED', NEW."userId",
NULL, -- p_comment_id (not applicable) 10,
format('Your suggestion for service ''%s'' has been approved!', service_name), 'SUGGESTION_APPROVED',
NEW.id -- p_suggestion_id NULL, -- p_comment_id (not applicable)
); format('Your suggestion for service ''%s'' has been approved!', service_name),
NEW.id -- p_suggestion_id
);
-- Update user's total karma -- Update user's total karma
PERFORM update_user_karma(NEW."userId", 10); PERFORM update_user_karma(NEW."userId", 10);
END IF;
END IF; END IF;
END IF; END IF;

View File

@@ -133,7 +133,7 @@ export const apiServiceActions = {
kycLevelClarification: service.kycLevelClarification, kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [ kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value', 'value',
'name', 'label',
'description', 'description',
]), ]),
categories: service.categories, categories: service.categories,

View File

@@ -49,33 +49,51 @@ const findPossibleDuplicates = async (input: { name: string }) => {
}) })
} }
const serializeExtraNotes = <T extends Record<string, unknown>>( const serializeExtraNotes = async <T extends Record<string, unknown>, NK extends keyof T>(
input: T, input: T,
skipKeys: (keyof T)[] = [] skipKeys: NK[] = [],
): string => { mapKeys: { [P in Exclude<Extract<keyof T, string>, NK>]?: (value: T[P]) => unknown } = {}
return Object.entries(input) ): Promise<string> => {
.filter(([key]) => !skipKeys.includes(key as keyof T)) return (
.map(([key, value]) => { await Promise.all(
let serializedValue = '' Object.entries(input)
if (typeof value === 'string') { .filter(
serializedValue = value (
} else if (value === undefined || value === null) { entry
serializedValue = '' ): entry is [Exclude<Extract<keyof T, string>, NK>, T[Exclude<Extract<keyof T, string>, NK>]] =>
} else if (Array.isArray(value)) { !skipKeys.some((k) => k === entry[0])
serializedValue = value.map((item) => String(item)).join(', ') )
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') { .map(async ([key, originalValue]) => {
// eslint-disable-next-line @typescript-eslint/no-base-to-string const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue
serializedValue = value.toString() let serializedValue = ''
} else { if (typeof value === 'string') {
try { serializedValue = value
serializedValue = JSON.stringify(value) } else if (value === undefined || value === null) {
} catch (error) { serializedValue = ''
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}` } else if (Array.isArray(value)) {
} serializedValue = value.map((item) => String(item)).join(', ')
} } else if (typeof value === 'object' && value instanceof Date) {
return `- ${key}: ${serializedValue}` serializedValue = value.toISOString()
}) } else if (
.join('\n') typeof value === 'object' &&
'toString' in value &&
typeof value.toString === 'function' &&
// eslint-disable-next-line @typescript-eslint/no-base-to-string
value.toString() !== '[object Object]'
) {
// eslint-disable-next-line @typescript-eslint/no-base-to-string
serializedValue = value.toString()
} else {
try {
serializedValue = JSON.stringify(value)
} catch (error) {
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
}
}
return `- ${key}: ${serializedValue}`
})
)
).join('\n')
} }
export const serviceSuggestionActions = { export const serviceSuggestionActions = {
@@ -165,6 +183,7 @@ export const serviceSuggestionActions = {
message: 'You must accept the suggestion rules and process to continue', message: 'You must accept the suggestion rules and process to continue',
}), }),
}), }),
operatingSince: z.coerce.date().optional(),
/** @deprecated Honey pot field, do not use */ /** @deprecated Honey pot field, do not use */
message: z.unknown().optional(), message: z.unknown().optional(),
skipDuplicateCheck: z skipDuplicateCheck: z
@@ -199,14 +218,37 @@ export const serviceSuggestionActions = {
return { return {
hasDuplicates: true, hasDuplicates: true,
possibleDuplicates, possibleDuplicates,
extraNotes: serializeExtraNotes(input, [ extraNotes: await serializeExtraNotes(
'skipDuplicateCheck', input,
'message', [
'imageFile', 'skipDuplicateCheck',
'captcha-value', 'message',
'captcha-solution-hash', 'imageFile',
'rulesConfirm', 'captcha-value',
]), 'captcha-solution-hash',
'rulesConfirm',
],
{
attributes: async (value) => {
const dbAttributes = await prisma.attribute.findMany({
select: {
title: true,
},
where: { id: { in: value } },
})
return dbAttributes.map((attribute) => `\n - ${attribute.title}`).join('')
},
categories: async (value) => {
const dbCategories = await prisma.category.findMany({
select: {
name: true,
},
where: { id: { in: value } },
})
return dbCategories.map((category) => category.name)
},
}
),
serviceSuggestion: undefined, serviceSuggestion: undefined,
service: undefined, service: undefined,
} as const } as const
@@ -239,6 +281,7 @@ export const serviceSuggestionActions = {
name: input.name, name: input.name,
slug: input.slug, slug: input.slug,
description: input.description, description: input.description,
operatingSince: input.operatingSince,
serviceUrls, serviceUrls,
tosUrls: input.tosUrls, tosUrls: input.tosUrls,
onionUrls, onionUrls,

View File

@@ -0,0 +1,24 @@
---
if (!Astro.locals.user?.admin) return
---
<script>
/////////////////////////////////////////////////////////////////////////////////////////////
// Script that adds data-astro-reload to all admin links or all links if on an admin page. //
// This is a workaround to prevent the client router messing up inputs. //
/////////////////////////////////////////////////////////////////////////////////////////////
document.addEventListener('astro:page-load', () => {
document.querySelectorAll<HTMLAnchorElement | HTMLFormElement>('a,form').forEach((element) => {
const isAdminPage = window.location.pathname.startsWith('/admin')
if (isAdminPage) {
element.setAttribute('data-astro-reload', '')
}
const url = element.href ? new URL(element.href) : null
if (url?.pathname.startsWith('/admin')) {
element.setAttribute('data-astro-reload', '')
}
})
})
</script>

View File

@@ -6,20 +6,24 @@ import { cn } from '../lib/cn'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types' import type { HTMLAttributes } from 'astro/types'
import type { O } from 'ts-toolbelt'
type Props = HTMLAttributes<'div'> & { type Props = HTMLAttributes<'div'> & {
announcement: Prisma.AnnouncementGetPayload<{ announcement: O.Optional<
select: { Prisma.AnnouncementGetPayload<{
id: true select: {
content: true id: true
type: true content: true
link: true type: true
linkText: true link: true
startDate: true linkText: true
endDate: true startDate: true
isActive: true endDate: true
} isActive: true
}> }
}>,
'link' | 'linkText'
>
} }
const { announcement, class: className, ...props } = Astro.props const { announcement, class: className, ...props } = Astro.props
@@ -31,7 +35,9 @@ const Tag = announcement.link ? 'a' : 'div'
<Tag <Tag
href={announcement.link} href={announcement.link}
target={announcement.link ? '_blank' : undefined} target={announcement.link && new URL(announcement.link, Astro.url.origin).origin !== Astro.url.origin
? '_blank'
: undefined}
rel="noopener noreferrer" rel="noopener noreferrer"
class={cn( class={cn(
'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5', 'group xs:px-6 2xs:px-4 relative isolate z-50 flex items-center justify-center gap-x-2 overflow-hidden border-b border-zinc-800 bg-black px-2 py-2 focus-visible:outline-none sm:gap-x-6 sm:px-3.5',
@@ -78,15 +84,15 @@ const Tag = announcement.link ? 'a' : 'div'
</span> </span>
</div> </div>
<div {
class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]" !!announcement.linkText && (
> <div class="text-day-300 group-focus-visible:outline-primary transition-background 2xs:px-4 relative inline-flex h-full shrink-0 cursor-pointer items-center justify-center gap-1.5 overflow-hidden rounded-full border border-white/20 bg-black/10 p-[1px] px-1 py-1 text-sm font-medium shadow-sm backdrop-blur-3xl transition-colors group-hover:bg-white/5 group-focus-visible:ring-2 group-focus-visible:ring-blue-500 group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-black/80 sm:min-w-[120px]">
<span class="2xs:inline-block hidden"> <span class="2xs:inline-block hidden">{announcement.linkText}</span>
{announcement.linkText} <Icon
</span> name="ri:arrow-right-line"
<Icon class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5"
name="ri:arrow-right-line" />
class="size-4 shrink-0 transition-transform group-hover:translate-x-0.5" </div>
/> )
</div> }
</Tag> </Tag>

View File

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

View File

@@ -3,7 +3,7 @@
--- ---
<script> <script>
import * as htmx from 'htmx.org' import htmx from 'htmx.org'
htmx.config.globalViewTransitions = false htmx.config.globalViewTransitions = false

View File

@@ -6,7 +6,6 @@ import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore' import { makeOverallScoreInfo } from '../lib/overallScore'
import { transformCase } from '../lib/strings'
import MyPicture from './MyPicture.astro' import MyPicture from './MyPicture.astro'
import Tooltip from './Tooltip.astro' import Tooltip from './Tooltip.astro'
@@ -23,6 +22,8 @@ type Props = HTMLAttributes<'a'> & {
slug: true slug: true
description: true description: true
overallScore: true overallScore: true
privacyScore: true
trustScore: true
kycLevel: true kycLevel: true
imageUrl: true imageUrl: true
verificationStatus: true verificationStatus: true
@@ -45,6 +46,8 @@ const {
slug, slug,
description, description,
overallScore, overallScore,
privacyScore,
trustScore,
kycLevel, kycLevel,
imageUrl, imageUrl,
categories, categories,
@@ -163,7 +166,7 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold', 'inline-flex size-6 items-center justify-center rounded-sm text-lg font-bold',
overallScoreInfo.classNameBg overallScoreInfo.classNameBg
)} )}
text={`${transformCase(overallScoreInfo.text, 'sentence')} score (${overallScoreInfo.formattedScore}/10)`} text={`${Math.round(privacyScore).toLocaleString()}% Privacy | ${Math.round(trustScore).toLocaleString()}% Trust`}
> >
{overallScoreInfo.formattedScore} {overallScoreInfo.formattedScore}
</Tooltip> </Tooltip>

View File

@@ -11,9 +11,19 @@ type Props = HTMLAttributes<'a'> & {
searchParamValue?: string searchParamValue?: string
icon?: string icon?: string
iconClass?: string iconClass?: string
inlineIcons?: boolean
} }
const { text, searchParamName, searchParamValue, icon, iconClass, class: className, ...aProps } = Astro.props const {
text,
searchParamName,
searchParamValue,
icon,
iconClass,
inlineIcons = true,
class: className,
...aProps
} = Astro.props
const makeUrlWithoutFilter = (filter: string, value?: string) => { const makeUrlWithoutFilter = (filter: string, value?: string) => {
const url = new URL(Astro.url) const url = new URL(Astro.url)
@@ -30,7 +40,7 @@ const makeUrlWithoutFilter = (filter: string, value?: string) => {
className className
)} )}
> >
{icon && <Icon name={icon} class={cn('size-4', iconClass)} />} {icon && <Icon name={icon} class={cn('size-4', iconClass)} is:inline={inlineIcons} />}
{text} {text}
<Icon name="ri:close-large-line" class="text-day-400 size-4" /> <Icon name="ri:close-large-line" class="text-day-400 size-4" is:inline={inlineIcons} />
</a> </a>

View File

@@ -0,0 +1,182 @@
---
import { orderBy } from 'lodash-es'
import { getCurrencyInfo } from '../constants/currencies'
import { getNetworkInfo } from '../constants/networks'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import { areEqualArraysWithoutOrder } from '../lib/arrays'
import { cn } from '../lib/cn'
import { transformCase } from '../lib/strings'
import ServiceFiltersPill from './ServiceFiltersPill.astro'
import type { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } from '../pages/index.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
filters: ServicesFiltersObject
filtersOptions: ServicesFiltersOptions
categories: Prisma.CategoryGetPayload<{
select: {
name: true
slug: true
icon: true
}
}>[]
attributes: Prisma.AttributeGetPayload<{
select: {
title: true
id: true
}
}>[]
attributeOptions: AttributeOption[]
}
const {
class: className,
filters,
filtersOptions,
categories,
attributes,
attributeOptions,
...divProps
} = Astro.props
---
<div
class={cn(
'not-pointer-coarse:no-scrollbar -ml-4 flex shrink grow items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4',
className
)}
{...divProps}
>
{
filters.q && (
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} />
)
}
{
filters.categories.map((categorySlug) => {
const category = categories.find((c) => c.slug === categorySlug)
if (!category) return null
return (
<ServiceFiltersPill
text={category.name}
icon={category.icon}
searchParamName="categories"
searchParamValue={categorySlug}
/>
)
})
}
{
filters.currencies.map((currencyId) => {
const currency = getCurrencyInfo(currencyId)
return (
<ServiceFiltersPill
text={currency.name}
searchParamName="currencies"
searchParamValue={currency.slug}
icon={currency.icon}
/>
)
})
}
{
filters.networks.map((network) => {
const networkOption = getNetworkInfo(network)
return (
<ServiceFiltersPill
text={networkOption.name}
icon={networkOption.icon}
searchParamName="networks"
searchParamValue={network}
/>
)
})
}
{
filters['max-kyc'] < 4 && (
<ServiceFiltersPill
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
icon="ri:shield-keyhole-line"
searchParamName="max-kyc"
/>
)
}
{
filters['user-rating'] > 0 && (
<ServiceFiltersPill
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
icon="ri:star-fill"
searchParamName="user-rating"
/>
)
}
{
filters['min-score'] > 0 && (
<ServiceFiltersPill
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
icon="ri:medal-line"
searchParamName="min-score"
/>
)
}
{
filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
<ServiceFiltersPill
text="Attributes: AND"
icon="ri:filter-3-line"
searchParamName="attribute-mode"
searchParamValue="and"
/>
)
}
{
filters.attr &&
Object.entries(filters.attr)
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
return (
<ServiceFiltersPill
text={`${prefix}: ${attribute.title}`}
searchParamName={`attr-${attributeId}`}
searchParamValue={attributeValue}
/>
)
})
}
{
!areEqualArraysWithoutOrder(
filters.verification,
filtersOptions.verification
.filter((verification) => verification.default)
.map((verification) => verification.value)
) &&
orderBy(
filters.verification.map((verificationStatus) => getVerificationStatusInfo(verificationStatus)),
'order',
'desc'
).map((verificationStatusInfo) => {
return (
<ServiceFiltersPill
text={verificationStatusInfo.label}
icon={verificationStatusInfo.icon}
iconClass={verificationStatusInfo.classNames.icon}
searchParamName="verification"
searchParamValue={verificationStatusInfo.slug}
/>
)
})
}
</div>

View File

@@ -1,16 +1,22 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { uniq } from 'lodash-es' import { uniq, orderBy } from 'lodash-es'
import { verificationStatusesByValue } from '../constants/verificationStatus' import { getCurrencyInfo } from '../constants/currencies'
import { getKycLevelInfo } from '../constants/kycLevels'
import { verificationStatusesByValue, getVerificationStatusInfo } from '../constants/verificationStatus'
import { areEqualArraysWithoutOrder } from '../lib/arrays'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize' import { pluralize } from '../lib/pluralize'
import { transformCase } from '../lib/strings'
import { createPageUrl, urlWithParams } from '../lib/urls' import { createPageUrl, urlWithParams } from '../lib/urls'
import Button from './Button.astro' import Button from './Button.astro'
import ServiceCard from './ServiceCard.astro' import ServiceCard from './ServiceCard.astro'
import ServiceFiltersPillsRow from './ServiceFiltersPillsRow.astro'
import type { ServicesFiltersObject } from '../pages/index.astro' import type { AttributeOption, ServicesFiltersObject, ServicesFiltersOptions } from '../pages/index.astro'
import type { Prisma } from '@prisma/client'
import type { ComponentProps, HTMLAttributes } from 'astro/types' import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & { type Props = HTMLAttributes<'div'> & {
@@ -23,6 +29,22 @@ type Props = HTMLAttributes<'div'> & {
filters: ServicesFiltersObject filters: ServicesFiltersObject
countCommunityOnly: number | null countCommunityOnly: number | null
inlineIcons?: boolean inlineIcons?: boolean
filtersOptions: ServicesFiltersOptions
categories: Prisma.CategoryGetPayload<{
select: {
name: true
namePluralLong: true
slug: true
icon: true
}
}>[]
attributes: Prisma.AttributeGetPayload<{
select: {
title: true
id: true
}
}>[]
attributeOptions: AttributeOption[]
} }
const { const {
@@ -36,6 +58,10 @@ const {
filters, filters,
countCommunityOnly, countCommunityOnly,
inlineIcons, inlineIcons,
categories,
filtersOptions,
attributes,
attributeOptions,
...divProps ...divProps
} = Astro.props } = Astro.props
@@ -55,9 +81,117 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug, verificationStatusesByValue.COMMUNITY_CONTRIBUTED.slug,
]), ]),
}) })
const searchTitle = (() => {
if (filters.q) {
return `Search results for “${filters.q}”`
}
const listOrformatter = new Intl.ListFormat('en', { style: 'long', type: 'disjunction' })
const listAndformatter = new Intl.ListFormat('en', { style: 'long', type: 'conjunction' })
let [prefix, base, attributesPart, currencies, kycLevel, score] = ['', 'services', '', '', '', '']
if (!hasDefaultFilters) {
prefix = 'filtered'
}
const attributesFilters = Object.entries(filters.attr ?? {})
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
const prefixWith = valueInfo?.prefixWith ?? transformCase(attributeValue, 'title')
return {
prefix,
prefixWith,
attribute,
valueInfo,
value: attributeValue,
}
})
.filter((attr) => !!attr)
if (attributesFilters.length === 1 || attributesFilters.length === 2) {
const formatter = filters['attribute-mode'] === 'and' ? listAndformatter : listOrformatter
attributesPart = formatter.format(
attributesFilters.map((attr) => `${attr.prefixWith} ${attr.attribute.title}`)
)
prefix = ''
}
if (
filters.verification.length === 1 ||
(!attributesFilters.length &&
!filters.currencies.length &&
!(filters['max-kyc'] <= 3) &&
!(filters['min-score'] >= 1) &&
areEqualArraysWithoutOrder(filters.verification, ['APPROVED', 'VERIFICATION_SUCCESS']))
) {
base = `${listAndformatter.format(
orderBy(
filters.verification.map((v) => getVerificationStatusInfo(v)),
'order',
'desc'
).map((v) => v.label)
)} services`
prefix = ''
}
if (filters.categories.length >= 1) {
base = listAndformatter.format(
filters.categories.map((c) => {
const cat = categories.find((cat) => cat.slug === c)
if (!cat) return c
return cat.namePluralLong ?? cat.name
})
)
prefix = ''
}
if (filters.currencies.length >= 1) {
const currenciesList = filters.currencies.map((c) => getCurrencyInfo(c).name)
const formatter = filters['currency-mode'] === 'and' ? listAndformatter : listOrformatter
currencies = `that accept ${formatter.format(currenciesList)}`
prefix = ''
}
if (filters['max-kyc'] === 0) {
const kycLevelInfo = getKycLevelInfo(String(filters['max-kyc']))
kycLevel = `with ${kycLevelInfo.name}`
prefix = ''
} else if (filters['max-kyc'] <= 3) {
kycLevel = `with KYC level ${filters['max-kyc']} or better`
prefix = ''
}
if (filters['min-score'] >= 1) {
score = `with score ${filters['min-score'].toLocaleString()} or more`
prefix = ''
}
const finalArray = [attributesPart, currencies, kycLevel, score].filter((str) => !!str)
const title = transformCase(`${prefix} ${base} ${finalArray.join('; ')}`.trim(), 'first-upper')
return title.length > 60 ? 'Filtered Services' : title
})()
--- ---
<div {...divProps} class={cn('flex-1', className)}> <div {...divProps} class={cn('min-w-0 flex-1', className)}>
<div class="hidden flex-wrap items-center justify-between gap-x-4 sm:flex">
<h1 class="font-title text-day-100 mb-2 text-xl leading-tight lg:text-2xl">
{searchTitle}
</h1>
<ServiceFiltersPillsRow
class="mask-l-from-[calc(100%-var(--spacing)*4)] pb-2"
filters={filters}
filtersOptions={filtersOptions}
categories={categories}
attributes={attributes}
attributeOptions={attributeOptions}
/>
</div>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6"> <span class="text-day-500 xs:gap-x-3 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm sm:gap-x-6">
{total.toLocaleString()} {total.toLocaleString()}

View File

@@ -76,5 +76,25 @@ export const {
label: 'Service verification changed', label: 'Service verification changed',
icon: 'ri:verified-badge-line', icon: 'ri:verified-badge-line',
}, },
{
id: 'ACCOUNT_DELETION_WARNING_30_DAYS',
label: 'Account deletion warning - 30 days',
icon: 'ri:alarm-warning-line',
},
{
id: 'ACCOUNT_DELETION_WARNING_15_DAYS',
label: 'Account deletion warning - 15 days',
icon: 'ri:alarm-warning-line',
},
{
id: 'ACCOUNT_DELETION_WARNING_5_DAYS',
label: 'Account deletion warning - 5 days',
icon: 'ri:alarm-warning-line',
},
{
id: 'ACCOUNT_DELETION_WARNING_1_DAY',
label: 'Account deletion warning - 1 day',
icon: 'ri:alarm-warning-line',
},
] as const satisfies NotificationTypeInfo<NotificationType>[] ] as const satisfies NotificationTypeInfo<NotificationType>[]
) )

2
web/src/env.d.ts vendored
View File

@@ -3,7 +3,7 @@
import type { ErrorBanners } from './lib/errorBanners' import type { ErrorBanners } from './lib/errorBanners'
import type { KarmaUnlocks } from './lib/karmaUnlocks' import type { KarmaUnlocks } from './lib/karmaUnlocks'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type * as htmx from 'htmx.org' import type htmx from 'htmx.org'
declare global { declare global {
namespace App { namespace App {

View File

@@ -1,12 +1,16 @@
--- ---
import { differenceInCalendarDays } from 'date-fns'
import AnnouncementBanner from '../components/AnnouncementBanner.astro' import AnnouncementBanner from '../components/AnnouncementBanner.astro'
import BaseHead from '../components/BaseHead.astro' import BaseHead from '../components/BaseHead.astro'
import Footer from '../components/Footer.astro' import Footer from '../components/Footer.astro'
import Header from '../components/Header.astro' import Header from '../components/Header.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { pluralize } from '../lib/pluralize'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import type { AstroChildren } from '../lib/astro' import type { AstroChildren } from '../lib/astro'
import type { Prisma } from '@prisma/client'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
import '@fontsource-variable/space-grotesk' import '@fontsource-variable/space-grotesk'
@@ -71,6 +75,26 @@ const announcement = await Astro.locals.banners.try(
}), }),
null null
) )
function getDeletionAnnouncement(
user: Prisma.UserGetPayload<{ select: { scheduledDeletionAt: true } }> | null,
currentDate: Date = new Date()
) {
if (!user?.scheduledDeletionAt) return null
const daysUntilDeletion = differenceInCalendarDays(user.scheduledDeletionAt, currentDate)
return {
id: 0,
content: `Your account will be deleted ${daysUntilDeletion <= 0 ? 'today' : `in ${daysUntilDeletion.toLocaleString()} ${pluralize('day', daysUntilDeletion)}`} due to inactivity.`,
type: 'ALERT' as const,
link: '/account',
linkText: 'Prevent deletion',
startDate: currentDate,
endDate: null,
isActive: true,
}
}
const deletionAnnouncement = getDeletionAnnouncement(Astro.locals.user, currentDate)
--- ---
<html lang="en" transition:name="root" transition:animate="none"> <html lang="en" transition:name="root" transition:animate="none">
@@ -85,6 +109,14 @@ const announcement = await Astro.locals.banners.try(
data-is-logged-in={Astro.locals.user !== null ? '' : 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" />}
{
deletionAnnouncement && (
<AnnouncementBanner
announcement={deletionAnnouncement}
transition:name="deletion-warning-announcement"
/>
)
}
<Header <Header
classNames={{ classNames={{
nav: cn( nav: cn(

View File

@@ -268,13 +268,12 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
trustPoints: -4, trustPoints: -4,
links: [], links: [],
customize: (service) => { customize: (service) => {
const started = service.operatingSince as unknown as Date | null if (!service.operatingSince) return { show: false }
if (!started) return { show: false }
const yearsOperated = differenceInYears(new Date(), started) const yearsOperated = differenceInYears(new Date(), service.operatingSince)
if (yearsOperated >= 1) return { show: false } if (yearsOperated >= 1) return { show: false }
const monthsOperated = differenceInMonths(new Date(), started) const monthsOperated = differenceInMonths(new Date(), service.operatingSince)
return { return {
show: true, show: true,
description: `The service started operations ${ description: `The service started operations ${
@@ -296,10 +295,9 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
trustPoints: 5, trustPoints: 5,
links: [], links: [],
customize: (service) => { customize: (service) => {
const started = service.operatingSince as unknown as Date | null if (!service.operatingSince) return { show: false }
if (!started) return { show: false }
const yearsOperated = differenceInYears(new Date(), started) const yearsOperated = differenceInYears(new Date(), service.operatingSince)
return { return {
show: yearsOperated >= 2, show: yearsOperated >= 2,
description: `This service has been operational for **${String( description: `This service has been operational for **${String(

View File

@@ -185,6 +185,18 @@ export function makeNotificationTitle(
serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange] serviceVerificationStatusChangesById[notification.aboutServiceVerificationStatusChange]
return `${serviceName} ${statusChange.notificationTitle}` return `${serviceName} ${statusChange.notificationTitle}`
} }
case 'ACCOUNT_DELETION_WARNING_30_DAYS': {
return 'Account deletion warning - 30 days remaining'
}
case 'ACCOUNT_DELETION_WARNING_15_DAYS': {
return 'Account deletion warning - 15 days remaining'
}
case 'ACCOUNT_DELETION_WARNING_5_DAYS': {
return 'Account deletion warning - 5 days remaining'
}
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
return 'Account deletion warning - 1 day remaining'
}
} }
} }
@@ -251,6 +263,12 @@ export function makeNotificationContent(
if (!notification.aboutEvent) return null if (!notification.aboutEvent) return null
return notification.aboutEvent.title return notification.aboutEvent.title
} }
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
return 'Your account will be deleted due to inactivity. Log in and perform any activity (comment, vote, or create a suggestion) to prevent deletion.'
}
} }
} }
@@ -411,6 +429,19 @@ export function makeNotificationActions(
}, },
] ]
} }
case 'ACCOUNT_DELETION_WARNING_30_DAYS':
case 'ACCOUNT_DELETION_WARNING_15_DAYS':
case 'ACCOUNT_DELETION_WARNING_5_DAYS':
case 'ACCOUNT_DELETION_WARNING_1_DAY': {
return [
{
action: 'login',
title: 'Login & Stay Active',
...iconNameAndUrl('ri:login-box-line'),
url: `${origin}/login`,
},
]
}
} }
} }

View File

@@ -25,6 +25,10 @@ const knownPlurals = {
singular: 'Request', singular: 'Request',
plural: 'Requests', plural: 'Requests',
}, },
day: {
singular: 'Day',
plural: 'Days',
},
something: { something: {
singular: 'Something', singular: 'Something',
plural: 'Somethings', plural: 'Somethings',

View File

@@ -45,7 +45,6 @@ function prismaClientSingleton() {
} }
declare global { declare global {
// eslint-disable-next-line no-var
var prisma: ReturnType<typeof prismaClientSingleton> | undefined var prisma: ReturnType<typeof prismaClientSingleton> | undefined
} }

View File

@@ -20,7 +20,7 @@ export const areSameNormalized = (str1: string, str2: string): boolean => {
return normalize(str1) === normalize(str2) return normalize(str1) === normalize(str2)
} }
export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'upper' export type TransformCaseType = 'first-upper' | 'lower' | 'original' | 'sentence' | 'title' | 'upper'
/** /**
* Transform a string to a different case. * Transform a string to a different case.
@@ -31,6 +31,7 @@ export type TransformCaseType = 'lower' | 'original' | 'sentence' | 'title' | 'u
* transformCase('hello WORLD', 'sentence') // 'Hello world' * transformCase('hello WORLD', 'sentence') // 'Hello world'
* transformCase('hello WORLD', 'title') // 'Hello World' * transformCase('hello WORLD', 'title') // 'Hello World'
* transformCase('hello WORLD', 'original') // 'hello WORLD' * transformCase('hello WORLD', 'original') // 'hello WORLD'
* transformCase('Hello WORLD', 'first-upper') // 'Hello WORLD'
*/ */
export const transformCase = <T extends string, C extends TransformCaseType>( export const transformCase = <T extends string, C extends TransformCaseType>(
str: T, str: T,
@@ -43,7 +44,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
? Capitalize<Lowercase<T>> ? Capitalize<Lowercase<T>>
: C extends 'title' : C extends 'title'
? Capitalize<Lowercase<T>> ? Capitalize<Lowercase<T>>
: T => { : C extends 'first-upper'
? Capitalize<T>
: T => {
switch (caseType) { switch (caseType) {
case 'lower': case 'lower':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
@@ -54,6 +57,9 @@ export const transformCase = <T extends string, C extends TransformCaseType>(
case 'sentence': case 'sentence':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any return (str.charAt(0).toUpperCase() + str.slice(1).toLowerCase()) as any
case 'first-upper':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
return (str.charAt(0).toUpperCase() + str.slice(1)) as any
case 'title': case 'title':
// eslint-disable-next-line @typescript-eslint/no-unsafe-return // eslint-disable-next-line @typescript-eslint/no-unsafe-return
return str return str

View File

@@ -1,6 +1,7 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions } from 'astro:actions'
import { differenceInCalendarDays } from 'date-fns'
import { sortBy } from 'lodash-es' import { sortBy } from 'lodash-es'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
@@ -19,6 +20,7 @@ import { verificationStatusesByValue } from '../../constants/verificationStatus'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks' import { makeUserWithKarmaUnlocks } from '../../lib/karmaUnlocks'
import { pluralize } from '../../lib/pluralize'
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'
@@ -49,6 +51,7 @@ const user = await Astro.locals.banners.try('user', async () => {
verifiedLink: true, verifiedLink: true,
totalKarma: true, totalKarma: true,
createdAt: true, createdAt: true,
scheduledDeletionAt: true,
_count: { _count: {
select: { select: {
comments: true, comments: true,
@@ -158,6 +161,10 @@ const user = await Astro.locals.banners.try('user', async () => {
}) })
if (!user) return Astro.rewrite('/404') if (!user) return Astro.rewrite('/404')
const daysUntilDeletion = user.scheduledDeletionAt
? differenceInCalendarDays(user.scheduledDeletionAt, new Date())
: null
--- ---
<BaseLayout <BaseLayout
@@ -394,6 +401,33 @@ if (!user) return Astro.rewrite('/404')
</div> </div>
</li> </li>
{
daysUntilDeletion && (
<li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2">
<Icon name="ri:delete-bin-line" class="size-4" />
</span>
<div>
<p class="text-day-500 text-xs">Deletion Status</p>
<span class="rounded-full border border-red-500/50 bg-red-500/20 px-2 py-0.5 text-xs text-red-400">
Scheduled for deletion
{daysUntilDeletion <= 0 ? (
'today'
) : (
<>
in {daysUntilDeletion.toLocaleString()}&nbsp;{pluralize('day', daysUntilDeletion)}
</>
)}
</span>
<p class="text-day-400 mt-2 text-xs">
To prevent deletion, take any action such as voting, commenting, or suggesting a
service.
</p>
</div>
</li>
)
}
<li class="flex items-start"> <li class="flex items-start">
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span> <span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:calendar-line" class="size-4" /></span>
<div> <div>

View File

@@ -367,13 +367,8 @@ const apiCalls = await Astro.locals.banners.try(
description="Date the service started operating" description="Date the service started operating"
inputProps={{ inputProps={{
type: 'date', type: 'date',
value: service.operatingSince value: service.operatingSince?.toISOString().slice(0, 10),
? new Date( max: new Date().toISOString().slice(0, 10),
service.operatingSince.getTime() - service.operatingSince.getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 10)
: '',
}} }}
error={serviceInputErrors.operatingSince} error={serviceInputErrors.operatingSince}
/> />

View File

@@ -319,7 +319,7 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<span <span
class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`} class={`font-medium ${user.totalKarma >= 100 ? 'text-green-400' : user.totalKarma >= 0 ? 'text-zinc-300' : 'text-red-400'}`}
> >
{user.totalKarma} {user.totalKarma.toLocaleString()}
</span> </span>
</td> </td>
<td class="px-4 py-3 text-center text-sm"> <td class="px-4 py-3 text-center text-sm">

View File

@@ -62,7 +62,7 @@ type ServiceResponse = {
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ... kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
kycLevelClarificationInfo: { kycLevelClarificationInfo: {
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ... value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
name: string label: string
description: string description: string
} }
categories: { categories: {
@@ -135,8 +135,10 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
```json ```json
{ {
"id": 123,
"name": "My Example Service", "name": "My Example Service",
"description": "This is a description of my example service", "description": "This is a description of my example service",
"slug": "my-example-service",
"serviceVisibility": "PUBLIC", "serviceVisibility": "PUBLIC",
"verificationStatus": "VERIFICATION_SUCCESS", "verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": { "verificationStatusInfo": {
@@ -157,6 +159,7 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"kycLevelClarification": "NONE", "kycLevelClarification": "NONE",
"kycLevelClarificationInfo": { "kycLevelClarificationInfo": {
"value": "NONE", "value": "NONE",
"label": "None",
"description": "No clarification needed." "description": "No clarification needed."
}, },
"categories": [ "categories": [

View File

@@ -15,13 +15,11 @@ import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
There are several ways to earn karma points: There are several ways to earn karma points:
1. **Comment Approval** (+1 point) 1. **Comment Approval** (+1 point)
- When your comment moves from 'unmoderated' to 'approved' status. - When your comment moves from 'unmoderated' to 'approved' status.
- This is the basic reward for contributing a valid comment. - This is the basic reward for contributing a valid comment.
- Users related to the service (e.g. owners, admins, etc.) do not get karma for their comments. - Users related to the service (e.g. owners, admins, etc.) do not get karma for their comments.
2. **Comment Verification** (+5 points) 2. **Comment Verification** (+5 points)
- When your comment is marked as 'verified'. - When your comment is marked as 'verified'.
- This is a significant reward for providing particularly valuable or verified information. - This is a significant reward for providing particularly valuable or verified information.
@@ -30,6 +28,11 @@ There are several ways to earn karma points:
- Similarly, each downvote reduces your karma by -1. - Similarly, each downvote reduces your karma by -1.
- This allows the community to reward helpful contributions. - This allows the community to reward helpful contributions.
4. **Suggestion Approval** (+10 points)
- When your suggestion to add or edit a service is approved and the service is listed publicly.
- Suggestions on non-listed services do not earn karma.
- This rewards users for helping to expand and improve the service directory.
## Karma Penalties ## Karma Penalties
The system also includes penalties to discourage spam and low-quality content: The system also includes penalties to discourage spam and low-quality content:
@@ -44,7 +47,6 @@ The system also includes penalties to discourage spam and low-quality content:
The system maintains a detailed record of all karma changes through: The system maintains a detailed record of all karma changes through:
1. **Karma Transactions** 1. **Karma Transactions**
- Every karma change is recorded as a transaction. - Every karma change is recorded as a transaction.
- Each transaction includes: - Each transaction includes:
- The action that triggered it. - The action that triggered it.

View File

@@ -6,20 +6,14 @@ import seedrandom from 'seedrandom'
import Button from '../components/Button.astro' import Button from '../components/Button.astro'
import InputText from '../components/InputText.astro' import InputText from '../components/InputText.astro'
import Pagination from '../components/Pagination.astro' import Pagination from '../components/Pagination.astro'
import ServiceFiltersPill from '../components/ServiceFiltersPill.astro' import ServiceFiltersPillsRow from '../components/ServiceFiltersPillsRow.astro'
import ServicesFilters from '../components/ServicesFilters.astro' import ServicesFilters from '../components/ServicesFilters.astro'
import ServicesSearchResults from '../components/ServicesSearchResults.astro' import ServicesSearchResults from '../components/ServicesSearchResults.astro'
import { getAttributeCategoryInfo } from '../constants/attributeCategories' import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes' import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { currencies, currenciesZodEnumBySlug, currencySlugToId } from '../constants/currencies'
import { networks } from '../constants/networks'
import { import {
currencies,
currenciesZodEnumBySlug,
currencySlugToId,
getCurrencyInfo,
} from '../constants/currencies'
import { getNetworkInfo, networks } from '../constants/networks'
import {
getVerificationStatusInfo,
verificationStatuses, verificationStatuses,
verificationStatusesZodEnumBySlug, verificationStatusesZodEnumBySlug,
verificationStatusSlugToId, verificationStatusSlugToId,
@@ -32,7 +26,6 @@ import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { makeSortSeed } from '../lib/sortSeed' import { makeSortSeed } from '../lib/sortSeed'
import { transformCase } from '../lib/strings'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
@@ -115,23 +108,29 @@ const modeOptions = [
label: string label: string
}[] }[]
export type AttributeOption = {
value: string
prefix: string
prefixWith: string
}
const attributeOptions = [ const attributeOptions = [
{ {
value: 'yes', value: 'yes',
prefix: 'Has', prefix: 'Has',
prefixWith: 'with',
}, },
{ {
value: 'no', value: 'no',
prefix: 'Not', prefix: 'Not',
prefixWith: 'without',
}, },
{ {
value: '', value: '',
prefix: '', prefix: '',
prefixWith: '',
}, },
] as const satisfies { ] as const satisfies AttributeOption[]
value: string
prefix: string
}[]
const ignoredKeysForDefaultData = ['sort-seed'] const ignoredKeysForDefaultData = ['sort-seed']
@@ -309,6 +308,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
prisma.category.findMany({ prisma.category.findMany({
select: { select: {
name: true, name: true,
namePluralLong: true,
slug: true, slug: true,
icon: true, icon: true,
_count: { _count: {
@@ -322,6 +322,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
}, },
}, },
}), }),
[],
], ],
[ [
'Unable to load services.', 'Unable to load services.',
@@ -507,7 +508,7 @@ const attributesByCategory = orderBy(
) )
const categoriesSorted = orderBy( const categoriesSorted = orderBy(
categories?.map((category) => { categories.map((category) => {
const checked = filters.categories.includes(category.slug) const checked = filters.categories.includes(category.slug)
return { return {
@@ -584,114 +585,13 @@ const showFiltersId = 'show-filters'
/> />
</form> </form>
) : ( ) : (
<div class="-ml-4 flex flex-1 items-center gap-2 overflow-x-auto mask-r-from-[calc(100%-var(--spacing)*16)] pr-12 pl-4"> <ServiceFiltersPillsRow
{filters.q && ( filters={filters}
<ServiceFiltersPill text={`"${filters.q}"`} searchParamName="q" searchParamValue={filters.q} /> filtersOptions={filtersOptions}
)} categories={categories}
attributes={attributes}
{!areEqualArraysWithoutOrder( attributeOptions={attributeOptions}
filters.verification, />
filtersOptions.verification
.filter((verification) => verification.default)
.map((verification) => verification.value)
) &&
filters.verification.map((verificationStatus) => {
const verificationStatusInfo = getVerificationStatusInfo(verificationStatus)
return (
<ServiceFiltersPill
text={verificationStatusInfo.label}
icon={verificationStatusInfo.icon}
iconClass={verificationStatusInfo.classNames.icon}
searchParamName="verification"
searchParamValue={verificationStatusInfo.slug}
/>
)
})}
{filters.categories.map((categorySlug) => {
const category = categories?.find((c) => c.slug === categorySlug)
if (!category) return null
return (
<ServiceFiltersPill
text={category.name}
icon={category.icon}
searchParamName="categories"
searchParamValue={categorySlug}
/>
)
})}
{filters.currencies.map((currencyId) => {
const currency = getCurrencyInfo(currencyId)
return (
<ServiceFiltersPill
text={currency.name}
searchParamName="currencies"
searchParamValue={currency.slug}
icon={currency.icon}
/>
)
})}
{filters.networks.map((network) => {
const networkOption = getNetworkInfo(network)
return (
<ServiceFiltersPill
text={networkOption.name}
icon={networkOption.icon}
searchParamName="networks"
searchParamValue={network}
/>
)
})}
{filters['max-kyc'] < 4 && (
<ServiceFiltersPill
text={`KYC Lvl ≤ ${filters['max-kyc'].toLocaleString()}`}
icon="ri:shield-keyhole-line"
searchParamName="max-kyc"
/>
)}
{filters['user-rating'] > 0 && (
<ServiceFiltersPill
text={`Rating ≥ ${filters['user-rating'].toLocaleString()}★`}
icon="ri:star-fill"
searchParamName="user-rating"
/>
)}
{filters['min-score'] > 0 && (
<ServiceFiltersPill
text={`Score ≥ ${filters['min-score'].toLocaleString()}`}
icon="ri:medal-line"
searchParamName="min-score"
/>
)}
{filters['attribute-mode'] === 'and' && filters.attr && Object.keys(filters.attr).length > 1 && (
<ServiceFiltersPill
text="Attributes: AND"
icon="ri:filter-3-line"
searchParamName="attribute-mode"
searchParamValue="and"
/>
)}
{filters.attr &&
Object.entries(filters.attr)
.filter((entry): entry is [string, 'no' | 'yes'] => entry[1] === 'yes' || entry[1] === 'no')
.map(([attributeId, attributeValue]) => {
const attribute = attributes.find((attr) => String(attr.id) === attributeId)
if (!attribute) return null
const valueInfo = attributeOptions.find((option) => option.value === attributeValue)
const prefix = valueInfo?.prefix ?? transformCase(attributeValue, 'title')
return (
<ServiceFiltersPill
text={`${prefix}: ${attribute.title}`}
searchParamName={`attr-${attributeId}`}
searchParamValue={attributeValue}
/>
)
})}
</div>
) )
} }
@@ -739,6 +639,10 @@ const showFiltersId = 'show-filters'
filters={filters} filters={filters}
countCommunityOnly={countCommunityOnly} countCommunityOnly={countCommunityOnly}
inlineIcons inlineIcons
categories={categories}
filtersOptions={filtersOptions}
attributes={attributes}
attributeOptions={attributeOptions}
/> />
</div> </div>
{ {

View File

@@ -238,27 +238,29 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
/> />
</div> </div>
<InputTextArea <div class="grid grid-cols-1 gap-4 md:grid-cols-2">
label="ToS URLs" <InputTextArea
description="One per line. AI review uses the first working URL only." label="ToS URLs"
name="tosUrls" description="One per line. AI review uses the first working URL only."
inputProps={{ name="tosUrls"
placeholder: 'example.com/tos', inputProps={{
required: true, placeholder: 'example.com/tos',
class: 'min-h-10', required: true,
}} class: 'min-h-10',
error={inputErrors.tosUrls} }}
/> error={inputErrors.tosUrls}
/>
<InputText <InputText
label="Operating since" label="Operating since"
name="operatingSince" name="operatingSince"
description="Date the service started operating" inputProps={{
inputProps={{ type: 'date',
type: 'date', max: new Date().toISOString().slice(0, 10),
}} }}
error={(inputErrors as Record<string, unknown>).operatingSince} error={inputErrors.operatingSince}
/> />
</div>
<InputCardGroup <InputCardGroup
name="kycLevel" name="kycLevel"
@@ -321,6 +323,7 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon], icon: [attribute.categoryInfo.icon, attribute.typeInfo.icon],
iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon], iconClassName: [attribute.categoryInfo.classNames.icon, attribute.typeInfo.classNames.icon],
}))} }))}
description="See list of [all attributes](/attributes) and their scoring."
error={inputErrors.attributes} error={inputErrors.attributes}
size="lg" size="lg"
/> />

View File

@@ -110,6 +110,16 @@
} }
} }
@utility no-scrollbar {
&::-webkit-scrollbar {
display: none;
}
& {
-ms-overflow-style: none;
scrollbar-width: none;
}
}
@theme { @theme {
--animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running; --animate-text-gradient: text-gradient 4s linear 0s infinite normal forwards running;