diff --git a/pyworker/pyworker/cli.py b/pyworker/pyworker/cli.py index a8ca5af..f8edd43 100644 --- a/pyworker/pyworker/cli.py +++ b/pyworker/pyworker/cli.py @@ -95,6 +95,12 @@ def parse_args(args: List[str]) -> argparse.Namespace: help="Recalculate scores for all services (ignores --service-id)", ) + # Service Score Recalculation task for all services + subparsers.add_parser( + "service-score-recalc-all", + help="Recalculate service scores for all services", + ) + return parser.parse_args(args) @@ -358,6 +364,13 @@ def run_service_score_recalc_task( close_db_pool() +def run_service_score_recalc_all_task() -> int: + """ + Run the service score recalculation task for all services. + """ + return run_service_score_recalc_task(all_services=True) + + def run_worker_mode() -> int: """ Run in worker mode, scheduling tasks to run periodically. @@ -396,6 +409,12 @@ def run_worker_mode() -> int: 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") @@ -405,6 +424,12 @@ def run_worker_mode() -> int: "*/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 if scheduler.tasks: @@ -457,6 +482,8 @@ def main() -> int: return run_service_score_recalc_task( args.service_id, getattr(args, "all", False) ) + elif args.task == "service-score-recalc-all": + return run_service_score_recalc_all_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 b764949..7d4227e 100644 --- a/pyworker/pyworker/scheduler.py +++ b/pyworker/pyworker/scheduler.py @@ -62,25 +62,27 @@ class TaskScheduler: cron_expression: Cron expression defining the schedule. task_func: Function to execute. *args: Arguments to pass to the task function. - **kwargs: Keyword arguments to pass to the task function. + **kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg. """ + instantiate = kwargs.pop("instantiate", True) # Declare task_instance variable with type annotation upfront task_instance: Any = None - # Initialize the appropriate task class based on the task name - if task_name.lower() == "tosreview": - task_instance = TosReviewTask() - elif task_name.lower() == "user_sentiment": - task_instance = UserSentimentTask() - elif task_name.lower() == "comment_moderation": - task_instance = CommentModerationTask() - elif task_name.lower() == "force_triggers": - task_instance = ForceTriggersTask() - elif task_name.lower() == "service_score_recalc": - task_instance = ServiceScoreRecalculationTask() - else: - self.logger.warning(f"Unknown task '{task_name}', skipping") - return + if instantiate: + # Initialize the appropriate task class based on the task name + if task_name.lower() == "tosreview": + task_instance = TosReviewTask() + elif task_name.lower() == "user_sentiment": + task_instance = UserSentimentTask() + elif task_name.lower() == "comment_moderation": + task_instance = CommentModerationTask() + elif task_name.lower() == "force_triggers": + task_instance = ForceTriggersTask() + elif task_name.lower() == "service_score_recalc": + task_instance = ServiceScoreRecalculationTask() + else: + self.logger.warning(f"Unknown task '{task_name}', skipping") + return self.tasks[task_name] = { "cron": cron_expression, @@ -126,8 +128,12 @@ class TaskScheduler: self.logger.info(f"Running task '{task_name}'") # Use task instance as a context manager to ensure # a single database connection is used for the entire task - with task_info["instance"]: - # Execute the registered task function with its arguments + if task_info["instance"]: + with task_info["instance"]: + # Execute the registered task function with its arguments + task_info["func"](*task_info["args"], **task_info["kwargs"]) + else: + # Execute the registered task function without a context manager task_info["func"](*task_info["args"], **task_info["kwargs"]) self.logger.info(f"Task '{task_name}' completed") except Exception as e: diff --git a/web/prisma/migrations/20250618081020_add_operating_since/migration.sql b/web/prisma/migrations/20250618081020_add_operating_since/migration.sql new file mode 100644 index 0000000..4a495d6 --- /dev/null +++ b/web/prisma/migrations/20250618081020_add_operating_since/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3); diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 3fc31d1..70f9a90 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -349,6 +349,8 @@ model Service { categories Category[] @relation("ServiceToCategory") kycLevel Int @default(4) kycLevelClarification KycLevelClarification @default(NONE) + /// The first known date when the service started operating. Used for New/Mature service attributes. + operatingSince DateTime? overallScore Int @default(0) privacyScore Int @default(0) trustScore Int @default(0) diff --git a/web/prisma/triggers/01_karma_tx.sql b/web/prisma/triggers/01_karma_tx.sql index 741d5a9..eab5ad3 100644 --- a/web/prisma/triggers/01_karma_tx.sql +++ b/web/prisma/triggers/01_karma_tx.sql @@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval( NEW RECORD, OLD RECORD ) RETURNS VOID AS $$ +DECLARE + is_user_related_to_service BOOLEAN; + is_user_admin_or_moderator BOOLEAN; BEGIN IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN - PERFORM insert_karma_transaction( - NEW."authorId", - 1, - 'COMMENT_APPROVED', - NEW.id, - format('Your comment #comment-%s in %s has been approved!', - NEW.id, - (SELECT name FROM "Service" WHERE id = NEW."serviceId")) - ); - PERFORM update_user_karma(NEW."authorId", 1); + -- Check if the user is related to the service (e.g., owns/manages it) + SELECT EXISTS( + SELECT 1 FROM "ServiceUser" + WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId" + ) INTO is_user_related_to_service; + + -- Check if the user is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = NEW."authorId" + INTO is_user_admin_or_moderator; + + -- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator + IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN + PERFORM insert_karma_transaction( + NEW."authorId", + 1, + 'COMMENT_APPROVED', + NEW.id, + format('Your comment #comment-%s in %s has been approved!', + NEW.id, + (SELECT name FROM "Service" WHERE id = NEW."serviceId")) + ); + PERFORM update_user_karma(NEW."authorId", 1); + END IF; END IF; END; $$ LANGUAGE plpgsql; @@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification( NEW RECORD, OLD RECORD ) RETURNS VOID AS $$ +DECLARE + is_user_admin_or_moderator BOOLEAN; BEGIN IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN - PERFORM insert_karma_transaction( - NEW."authorId", - 5, - 'COMMENT_VERIFIED', - NEW.id, - format('Your comment #comment-%s in %s has been verified!', - NEW.id, - (SELECT name FROM "Service" WHERE id = NEW."serviceId")) - ); - PERFORM update_user_karma(NEW."authorId", 5); + -- Check if the comment author is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = NEW."authorId" + 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 + PERFORM insert_karma_transaction( + NEW."authorId", + 5, + 'COMMENT_VERIFIED', + NEW.id, + format('Your comment #comment-%s in %s has been verified!', + NEW.id, + (SELECT name FROM "Service" WHERE id = NEW."serviceId")) + ); + PERFORM update_user_karma(NEW."authorId", 5); + END IF; END IF; END; $$ LANGUAGE plpgsql; @@ -146,12 +175,19 @@ DECLARE comment_author_id INT; service_name TEXT; upvote_change INT := 0; -- Variable to track change in upvotes + is_author_admin_or_moderator BOOLEAN; BEGIN -- Get comment author and service info SELECT c."authorId", s.name INTO comment_author_id, service_name FROM "Comment" c JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id; + -- Check if the comment author is an admin or moderator + SELECT (admin = true OR moderator = true) + FROM "User" + WHERE id = comment_author_id + INTO is_author_admin_or_moderator; + -- Calculate karma impact based on vote type IF TG_OP = 'INSERT' THEN -- New vote @@ -181,16 +217,19 @@ BEGIN upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote END IF; - -- Record karma transaction and update user karma - PERFORM insert_karma_transaction( - comment_author_id, - karma_points, - vote_action, - COALESCE(NEW."commentId", OLD."commentId"), - vote_description - ); - - PERFORM update_user_karma(comment_author_id, karma_points); + -- Only award karma if the author is NOT an admin/moderator + IF NOT COALESCE(is_author_admin_or_moderator, false) THEN + -- Record karma transaction and update user karma + PERFORM insert_karma_transaction( + comment_author_id, + karma_points, + vote_action, + COALESCE(NEW."commentId", OLD."commentId"), + vote_description + ); + + PERFORM update_user_karma(comment_author_id, karma_points); + END IF; -- Update comment's upvotes count incrementally UPDATE "Comment" @@ -236,26 +275,36 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change() RETURNS TRIGGER AS $$ DECLARE service_name TEXT; + 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 - -- Fetch service name for the description - SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; + -- 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 + -- Fetch service name for the description + SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId"; - -- 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; RETURN NEW; -- Result is ignored since this is an AFTER trigger diff --git a/web/prisma/triggers/02_service_score.sql b/web/prisma/triggers/02_service_score.sql index 1cbd7d2..e47e653 100644 --- a/web/prisma/triggers/02_service_score.sql +++ b/web/prisma/triggers/02_service_score.sql @@ -95,6 +95,7 @@ DECLARE attributes_score INT := 0; recently_approved_factor INT := 0; tos_penalty_factor INT := 0; + operating_since_factor INT := 0; BEGIN -- Get verification status factor SELECT @@ -148,8 +149,20 @@ BEGIN tos_penalty_factor := -3; END IF; + -- Determine trust adjustment based on operatingSince + SELECT + CASE + WHEN "operatingSince" IS NULL THEN 0 + WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty + WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus + ELSE 0 + END + INTO operating_since_factor + FROM "Service" + WHERE id = service_id; + -- Calculate final trust score (base 100) - trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor; + trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor; -- Ensure the score is in reasonable bounds (0-100) trust_score := GREATEST(0, LEAST(100, trust_score)); diff --git a/web/src/actions/account.ts b/web/src/actions/account.ts index 56fc5d1..bd96709 100644 --- a/web/src/actions/account.ts +++ b/web/src/actions/account.ts @@ -1,5 +1,6 @@ import { ActionError } from 'astro:actions' import { z } from 'astro:content' +import { pick } from 'lodash-es' import { karmaUnlocksById } from '../constants/karmaUnlocks' import { createAccount } from '../lib/accountCreate' @@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li import { defineProtectedAction } from '../lib/defineProtectedAction' import { saveFileLocally } from '../lib/fileStorage' import { handleHoneypotTrap } from '../lib/honeypot' -import { startImpersonating } from '../lib/impersonation' +import { startImpersonating, stopImpersonating } from '../lib/impersonation' import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks' import { prisma } from '../lib/prisma' import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens' @@ -225,4 +226,36 @@ export const accountActions = { return { user } }, }), + + delete: defineProtectedAction({ + accept: 'form', + permissions: 'user', + input: z + .object({ + ...captchaFormSchemaProperties, + }) + .superRefine(captchaFormSchemaSuperRefine), + handler: async (_input, context) => { + if (context.locals.user.admin || context.locals.user.moderator) { + throw new ActionError({ + code: 'FORBIDDEN', + message: 'Admins and moderators cannot delete their own accounts.', + }) + } + + await prisma.user.delete({ + where: { id: context.locals.user.id }, + }) + + const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture']) + + if (context.locals.actualUser) { + await stopImpersonating(context) + } else { + await logout(context) + } + + return { deletedUser } + }, + }), } diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 91ed637..199db64 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -58,6 +58,7 @@ const serviceSchemaBase = z.object({ .optional() .nullable() .default(null), + operatingSince: z.coerce.date().optional().nullable(), imageFile: imageFileSchema, overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), serviceVisibility: z.nativeEnum(ServiceVisibility), @@ -150,6 +151,7 @@ export const adminServiceActions = { }, } : undefined, + operatingSince: input.operatingSince, }, select: { id: true, @@ -258,7 +260,6 @@ export const adminServiceActions = { ), } : undefined, - imageUrl, categories: { connect: categoriesToAdd.map((id) => ({ id })), @@ -275,6 +276,7 @@ export const adminServiceActions = { attributeId, })), }, + operatingSince: input.operatingSince, }, }) diff --git a/web/src/components/ScoreGauge.astro b/web/src/components/ScoreGauge.astro index 568b4c1..9c427a5 100644 --- a/web/src/components/ScoreGauge.astro +++ b/web/src/components/ScoreGauge.astro @@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers' import { KYCNOTME_SCHEMA_MINI } from '../lib/schema' import { transformCase } from '../lib/strings' -import type { HTMLAttributes } from 'astro/types' +import type { HTMLTag, Polymorphic } from 'astro/types' import type { Review, WithContext } from 'schema-dts' -export type Props = HTMLAttributes<'div'> & { +type Props = Polymorphic<{ + as: HTMLTag score: number label: string total?: number itemReviewedId?: string -} + showInfo?: boolean + children?: never +}> -const { score, label, total = 100, class: className, itemReviewedId, ...htmlProps } = Astro.props +const { + as: Tag = 'div', + score, + label, + total = 100, + class: className, + itemReviewedId, + showInfo = false, + ...htmlProps +} = Astro.props const progress = total === 0 ? 0 : Math.min(Math.max(score / total, 0), 1) @@ -65,13 +77,13 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total) ) } -
Admins and moderators cannot be deleted.
+ ) : ( + + ) + } +This user can't be deleted
+ ) : ( ++ To delete this user, + + impersonate it and go to /account/delete + +
+ ) + } +