diff --git a/.env.example b/.env.example index b2c0b4a..bcd9e72 100644 --- a/.env.example +++ b/.env.example @@ -39,8 +39,11 @@ OPENAI_BASE_URL="https://your-openai-base-url.example.com" OPENAI_MODEL=your_openai_model OPENAI_RETRY=3 -# Pyworker Crons -CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month -CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day -CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour -CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day \ No newline at end of file +# Task schedules --------------------------------------------------- +CRON_TOSREVIEW_TASK="0 0 1 * *" # every month +CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily +CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly +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 diff --git a/pyworker/.env.example b/pyworker/.env.example index 012ad1b..0fce1c0 100644 --- a/pyworker/.env.example +++ b/pyworker/.env.example @@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1" OPENAI_MODEL="xxxxxxxxx" OPENAI_RETRY=3 -CRON_TOSREVIEW_TASK=0 0 1 * * # Every month -CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day -CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour -CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day -CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes \ No newline at end of file +# Task schedules --------------------------------------------------- +CRON_TOSREVIEW_TASK="0 0 1 * *" # every month +CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily +CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly +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 \ No newline at end of file diff --git a/pyworker/README.md b/pyworker/README.md index 6eb989b..8b6442d 100644 --- a/pyworker/README.md +++ b/pyworker/README.md @@ -38,6 +38,7 @@ Required environment variables: - `CRON_MODERATION_TASK`: Cron expression for comment moderation task - `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers 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 @@ -60,6 +61,9 @@ uv run -m pyworker force-triggers # Run service score recalculation task uv run -m pyworker service-score-recalc [--service-id ID] + +# Run inactive users cleanup task +uv run -m pyworker inactive-users ``` ### Worker Mode @@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules. - Calculates privacy, trust, and overall scores - 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 ### Project Structure @@ -124,6 +137,7 @@ pyworker/ │ │ ├── base.py │ │ ├── comment_moderation.py │ │ ├── force_triggers.py +│ │ ├── inactive_users.py │ │ ├── service_score_recalc.py │ │ ├── tos_review.py │ │ └── user_sentiment.py diff --git a/pyworker/pyworker/cli.py b/pyworker/pyworker/cli.py index f8edd43..3fddeaa 100644 --- a/pyworker/pyworker/cli.py +++ b/pyworker/pyworker/cli.py @@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler from .tasks import ( CommentModerationTask, ForceTriggersTask, + InactiveUsersTask, ServiceScoreRecalculationTask, TosReviewTask, UserSentimentTask, @@ -101,6 +102,12 @@ def parse_args(args: List[str]) -> argparse.Namespace: 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) @@ -371,6 +378,30 @@ def run_service_score_recalc_all_task() -> int: 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: """ Run in worker mode, scheduling tasks to run periodically. @@ -382,54 +413,37 @@ def run_worker_mode() -> int: # Get task schedules from config 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 "", + ) + + 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( - "No task schedules defined. Set CRON_TASKNAME_TASK environment variables." + "Missing cron schedule for task%s: %s. Set the corresponding CRON__TASK environment variable%s.", + "s" if len(missing_tasks) != 1 else "", + ", ".join(missing_tasks), + "s" if len(missing_tasks) != 1 else "", ) return 1 - logger.info( - f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}" - ) - - # Initialize the scheduler scheduler = TaskScheduler() - # Register tasks with their schedules - for task_name, cron_expression in task_schedules.items(): - 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, - ) + for task_name, task_callable in required_tasks.items(): + scheduler.register_task(task_name, task_schedules[task_name], task_callable) # Start the scheduler if tasks were registered if scheduler.tasks: @@ -484,6 +498,8 @@ def main() -> int: ) elif args.task == "service-score-recalc-all": return run_service_score_recalc_all_task() + elif args.task == "inactive-users": + return run_inactive_users_task() elif args.task: logger.error(f"Unknown task: {args.task}") return 1 diff --git a/pyworker/pyworker/scheduler.py b/pyworker/pyworker/scheduler.py index 7d4227e..9c46450 100644 --- a/pyworker/pyworker/scheduler.py +++ b/pyworker/pyworker/scheduler.py @@ -14,6 +14,7 @@ from pyworker.database import close_db_pool from .tasks import ( CommentModerationTask, ForceTriggersTask, + InactiveUsersTask, ServiceScoreRecalculationTask, TosReviewTask, UserSentimentTask, @@ -80,6 +81,8 @@ class TaskScheduler: task_instance = ForceTriggersTask() elif task_name.lower() == "service_score_recalc": task_instance = ServiceScoreRecalculationTask() + elif task_name.lower() == "inactive_users": + task_instance = InactiveUsersTask() else: self.logger.warning(f"Unknown task '{task_name}', skipping") return diff --git a/pyworker/pyworker/tasks/__init__.py b/pyworker/pyworker/tasks/__init__.py index 4c1143a..4616a18 100644 --- a/pyworker/pyworker/tasks/__init__.py +++ b/pyworker/pyworker/tasks/__init__.py @@ -3,6 +3,7 @@ from .base import Task from .comment_moderation import CommentModerationTask from .force_triggers import ForceTriggersTask +from .inactive_users import InactiveUsersTask from .service_score_recalc import ServiceScoreRecalculationTask from .tos_review import TosReviewTask from .user_sentiment import UserSentimentTask @@ -11,6 +12,7 @@ __all__ = [ "Task", "CommentModerationTask", "ForceTriggersTask", + "InactiveUsersTask", "ServiceScoreRecalculationTask", "TosReviewTask", "UserSentimentTask", diff --git a/pyworker/pyworker/tasks/inactive_users.py b/pyworker/pyworker/tasks/inactive_users.py new file mode 100644 index 0000000..2d58551 --- /dev/null +++ b/pyworker/pyworker/tasks/inactive_users.py @@ -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 \ No newline at end of file diff --git a/web/prisma/migrations/20250701091334_delete_inactive_users/migration.sql b/web/prisma/migrations/20250701091334_delete_inactive_users/migration.sql new file mode 100644 index 0000000..c66e715 --- /dev/null +++ b/web/prisma/migrations/20250701091334_delete_inactive_users/migration.sql @@ -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; diff --git a/web/prisma/migrations/20250702094135_plural_category_names/migration.sql b/web/prisma/migrations/20250702094135_plural_category_names/migration.sql new file mode 100644 index 0000000..9d5376f --- /dev/null +++ b/web/prisma/migrations/20250702094135_plural_category_names/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 19adf82..607f131 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -144,6 +144,10 @@ enum NotificationType { ACCOUNT_STATUS_CHANGE EVENT_CREATED 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 { @@ -498,20 +502,22 @@ model InternalServiceNote { } model User { - id Int @id @default(autoincrement()) - name String @unique - displayName String? - link String? - picture String? - spammer Boolean @default(false) - verified Boolean @default(false) - admin Boolean @default(false) - moderator Boolean @default(false) - verifiedLink String? - secretTokenHash String @unique - feedId String @unique @default(cuid(2)) + id Int @id @default(autoincrement()) + name String @unique + displayName String? + link String? + picture String? + spammer Boolean @default(false) + verified Boolean @default(false) + admin Boolean @default(false) + moderator Boolean @default(false) + verifiedLink String? + secretTokenHash String @unique + feedId String @unique @default(cuid(2)) /// 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()) updatedAt DateTime @default(now()) @updatedAt @@ -609,10 +615,11 @@ model VerificationStep { } model Category { - id Int @id @default(autoincrement()) - name String @unique - icon String - slug String @unique + id Int @id @default(autoincrement()) + name String @unique + namePluralLong String? + icon String + slug String @unique createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt diff --git a/web/prisma/seed.ts b/web/prisma/seed.ts index 3e28589..ebc33ca 100755 --- a/web/prisma/seed.ts +++ b/web/prisma/seed.ts @@ -141,81 +141,97 @@ const generateFakeAttribute = () => { const categoriesToCreate = [ { name: 'Exchange', + namePluralLong: 'Exchanges', slug: 'exchange', icon: 'ri:arrow-left-right-fill', }, { name: 'VPN', + namePluralLong: 'VPNs', slug: 'vpn', icon: 'ri:door-lock-fill', }, { name: 'Email', + namePluralLong: 'Email providers', slug: 'email', icon: 'ri:mail-fill', }, { name: 'Hosting', + namePluralLong: 'Hostings', slug: 'hosting', icon: 'ri:server-fill', }, { name: 'VPS', + namePluralLong: 'VPS providers', slug: 'vps', icon: 'ri:function-add-fill', }, { name: 'Gift Cards', + namePluralLong: 'Gift cards', slug: 'gift-cards', icon: 'ri:gift-line', }, { name: 'Goods', + namePluralLong: 'Goods providers', slug: 'goods', icon: 'ri:shopping-basket-fill', }, { name: 'Travel', + namePluralLong: 'Travel services', slug: 'travel', icon: 'ri:plane-fill', }, { name: 'SMS', + namePluralLong: 'SMS providers', slug: 'sms', icon: 'ri:message-2-fill', }, { name: 'Store', + namePluralLong: 'Stores', slug: 'store', icon: 'ri:store-2-line', }, { name: 'Tool', + namePluralLong: 'Tools', slug: 'tool', icon: 'ri:tools-fill', }, { name: 'Market', + namePluralLong: 'Markets', slug: 'market', icon: 'ri:price-tag-3-line', }, { name: 'Aggregator', + namePluralLong: 'Aggregators', slug: 'aggregator', icon: 'ri:list-ordered', }, { name: 'AI', + namePluralLong: 'AI services', slug: 'ai', icon: 'ri:ai-generate-2', }, { name: 'CEX', + namePluralLong: 'CEXs', slug: 'cex', icon: 'ri:rotate-lock-fill', }, { name: 'DEX', + namePluralLong: 'DEXs', slug: 'dex', icon: 'ri:fediverse-line', }, diff --git a/web/prisma/triggers/01_karma_tx.sql b/web/prisma/triggers/01_karma_tx.sql index eab5ad3..c2256f7 100644 --- a/web/prisma/triggers/01_karma_tx.sql +++ b/web/prisma/triggers/01_karma_tx.sql @@ -275,35 +275,39 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change() RETURNS TRIGGER AS $$ DECLARE service_name TEXT; + service_visibility "ServiceVisibility"; is_user_admin_or_moderator BOOLEAN; BEGIN -- Award karma for first approval -- Check that OLD.status is not NULL to handle the initial creation case if needed, -- and ensure it wasn't already APPROVED. IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN - -- Check if the user is an admin or moderator - SELECT (admin = true OR moderator = true) - FROM "User" - WHERE id = NEW."userId" - INTO is_user_admin_or_moderator; + -- Fetch service details for the description + SELECT name, visibility INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId"; - -- Only award karma if the user is NOT an admin/moderator - IF NOT COALESCE(is_user_admin_or_moderator, false) THEN - -- Fetch service name for the description - SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; + -- Only award karma if the service is public + IF service_visibility = 'PUBLIC' THEN + -- Check if the user is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = NEW."userId" + INTO is_user_admin_or_moderator; + + -- Only award karma if the user is NOT an admin/moderator + IF NOT COALESCE(is_user_admin_or_moderator, false) THEN + -- Insert karma transaction, linking it to the suggestion + PERFORM insert_karma_transaction( + NEW."userId", + 10, + 'SUGGESTION_APPROVED', + NULL, -- p_comment_id (not applicable) + format('Your suggestion for service ''%s'' has been approved!', service_name), + NEW.id -- p_suggestion_id + ); - -- Insert karma transaction, linking it to the suggestion - PERFORM insert_karma_transaction( - NEW."userId", - 10, - 'SUGGESTION_APPROVED', - 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 - PERFORM update_user_karma(NEW."userId", 10); + -- Update user's total karma + PERFORM update_user_karma(NEW."userId", 10); + END IF; END IF; END IF; diff --git a/web/src/actions/serviceSuggestion.ts b/web/src/actions/serviceSuggestion.ts index 22be8fd..643c030 100644 --- a/web/src/actions/serviceSuggestion.ts +++ b/web/src/actions/serviceSuggestion.ts @@ -49,33 +49,51 @@ const findPossibleDuplicates = async (input: { name: string }) => { }) } -const serializeExtraNotes = >( +const serializeExtraNotes = async , NK extends keyof T>( input: T, - skipKeys: (keyof T)[] = [] -): string => { - return Object.entries(input) - .filter(([key]) => !skipKeys.includes(key as keyof T)) - .map(([key, value]) => { - let serializedValue = '' - if (typeof value === 'string') { - serializedValue = value - } else if (value === undefined || value === null) { - serializedValue = '' - } else if (Array.isArray(value)) { - serializedValue = value.map((item) => String(item)).join(', ') - } else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') { - // 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') + skipKeys: NK[] = [], + mapKeys: { [P in Exclude, NK>]?: (value: T[P]) => unknown } = {} +): Promise => { + return ( + await Promise.all( + Object.entries(input) + .filter( + ( + entry + ): entry is [Exclude, NK>, T[Exclude, NK>]] => + !skipKeys.some((k) => k === entry[0]) + ) + .map(async ([key, originalValue]) => { + const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue + let serializedValue = '' + if (typeof value === 'string') { + serializedValue = value + } else if (value === undefined || value === null) { + serializedValue = '' + } else if (Array.isArray(value)) { + serializedValue = value.map((item) => String(item)).join(', ') + } else if (typeof value === 'object' && value instanceof Date) { + serializedValue = value.toISOString() + } else if ( + 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 = { @@ -200,14 +218,37 @@ export const serviceSuggestionActions = { return { hasDuplicates: true, possibleDuplicates, - extraNotes: serializeExtraNotes(input, [ - 'skipDuplicateCheck', - 'message', - 'imageFile', - 'captcha-value', - 'captcha-solution-hash', - 'rulesConfirm', - ]), + extraNotes: await serializeExtraNotes( + input, + [ + 'skipDuplicateCheck', + 'message', + 'imageFile', + '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, service: undefined, } as const diff --git a/web/src/components/AnnouncementBanner.astro b/web/src/components/AnnouncementBanner.astro index 13eef3c..24a070c 100644 --- a/web/src/components/AnnouncementBanner.astro +++ b/web/src/components/AnnouncementBanner.astro @@ -6,20 +6,24 @@ import { cn } from '../lib/cn' import type { Prisma } from '@prisma/client' import type { HTMLAttributes } from 'astro/types' +import type { O } from 'ts-toolbelt' type Props = HTMLAttributes<'div'> & { - announcement: Prisma.AnnouncementGetPayload<{ - select: { - id: true - content: true - type: true - link: true - linkText: true - startDate: true - endDate: true - isActive: true - } - }> + announcement: O.Optional< + Prisma.AnnouncementGetPayload<{ + select: { + id: true + content: true + type: true + link: true + linkText: true + startDate: true + endDate: true + isActive: true + } + }>, + 'link' | 'linkText' + > } const { announcement, class: className, ...props } = Astro.props @@ -31,7 +35,9 @@ const Tag = announcement.link ? 'a' : 'div' -
- - -
+ { + !!announcement.linkText && ( +
+ + +
+ ) + }
diff --git a/web/src/components/HtmxScript.astro b/web/src/components/HtmxScript.astro index 1681f87..1ec393f 100644 --- a/web/src/components/HtmxScript.astro +++ b/web/src/components/HtmxScript.astro @@ -3,7 +3,7 @@ ---