Release 202506241430
This commit is contained in:
@@ -95,6 +95,12 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
|||||||
help="Recalculate scores for all services (ignores --service-id)",
|
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)
|
return parser.parse_args(args)
|
||||||
|
|
||||||
|
|
||||||
@@ -358,6 +364,13 @@ def run_service_score_recalc_task(
|
|||||||
close_db_pool()
|
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:
|
def run_worker_mode() -> int:
|
||||||
"""
|
"""
|
||||||
Run in worker mode, scheduling tasks to run periodically.
|
Run in worker mode, scheduling tasks to run periodically.
|
||||||
@@ -396,6 +409,12 @@ def run_worker_mode() -> int:
|
|||||||
scheduler.register_task(
|
scheduler.register_task(
|
||||||
task_name, cron_expression, run_service_score_recalc_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:
|
else:
|
||||||
logger.warning(f"Unknown task '{task_name}', skipping")
|
logger.warning(f"Unknown task '{task_name}', skipping")
|
||||||
|
|
||||||
@@ -405,6 +424,12 @@ def run_worker_mode() -> int:
|
|||||||
"*/5 * * * *",
|
"*/5 * * * *",
|
||||||
run_service_score_recalc_task,
|
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:
|
||||||
@@ -457,6 +482,8 @@ def main() -> int:
|
|||||||
return run_service_score_recalc_task(
|
return run_service_score_recalc_task(
|
||||||
args.service_id, getattr(args, "all", False)
|
args.service_id, getattr(args, "all", False)
|
||||||
)
|
)
|
||||||
|
elif args.task == "service-score-recalc-all":
|
||||||
|
return run_service_score_recalc_all_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
|
||||||
|
|||||||
@@ -62,11 +62,13 @@ class TaskScheduler:
|
|||||||
cron_expression: Cron expression defining the schedule.
|
cron_expression: Cron expression defining the schedule.
|
||||||
task_func: Function to execute.
|
task_func: Function to execute.
|
||||||
*args: Arguments to pass to the task function.
|
*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
|
# Declare task_instance variable with type annotation upfront
|
||||||
task_instance: Any = None
|
task_instance: Any = None
|
||||||
|
|
||||||
|
if instantiate:
|
||||||
# Initialize the appropriate task class based on the task name
|
# Initialize the appropriate task class based on the task name
|
||||||
if task_name.lower() == "tosreview":
|
if task_name.lower() == "tosreview":
|
||||||
task_instance = TosReviewTask()
|
task_instance = TosReviewTask()
|
||||||
@@ -126,9 +128,13 @@ class TaskScheduler:
|
|||||||
self.logger.info(f"Running task '{task_name}'")
|
self.logger.info(f"Running task '{task_name}'")
|
||||||
# Use task instance as a context manager to ensure
|
# Use task instance as a context manager to ensure
|
||||||
# a single database connection is used for the entire task
|
# a single database connection is used for the entire task
|
||||||
|
if task_info["instance"]:
|
||||||
with task_info["instance"]:
|
with task_info["instance"]:
|
||||||
# Execute the registered task function with its arguments
|
# Execute the registered task function with its arguments
|
||||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
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")
|
self.logger.info(f"Task '{task_name}' completed")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.exception(f"Error running task '{task_name}': {e}")
|
self.logger.exception(f"Error running task '{task_name}': {e}")
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);
|
||||||
@@ -349,6 +349,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.
|
||||||
|
operatingSince DateTime?
|
||||||
overallScore Int @default(0)
|
overallScore Int @default(0)
|
||||||
privacyScore Int @default(0)
|
privacyScore Int @default(0)
|
||||||
trustScore Int @default(0)
|
trustScore Int @default(0)
|
||||||
|
|||||||
@@ -65,8 +65,25 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
|
|||||||
NEW RECORD,
|
NEW RECORD,
|
||||||
OLD RECORD
|
OLD RECORD
|
||||||
) RETURNS VOID AS $$
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
is_user_related_to_service BOOLEAN;
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||||
|
-- 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(
|
PERFORM insert_karma_transaction(
|
||||||
NEW."authorId",
|
NEW."authorId",
|
||||||
1,
|
1,
|
||||||
@@ -78,6 +95,7 @@ BEGIN
|
|||||||
);
|
);
|
||||||
PERFORM update_user_karma(NEW."authorId", 1);
|
PERFORM update_user_karma(NEW."authorId", 1);
|
||||||
END IF;
|
END IF;
|
||||||
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
@@ -86,8 +104,18 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
|
|||||||
NEW RECORD,
|
NEW RECORD,
|
||||||
OLD RECORD
|
OLD RECORD
|
||||||
) RETURNS VOID AS $$
|
) RETURNS VOID AS $$
|
||||||
|
DECLARE
|
||||||
|
is_user_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||||
|
-- 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(
|
PERFORM insert_karma_transaction(
|
||||||
NEW."authorId",
|
NEW."authorId",
|
||||||
5,
|
5,
|
||||||
@@ -99,6 +127,7 @@ BEGIN
|
|||||||
);
|
);
|
||||||
PERFORM update_user_karma(NEW."authorId", 5);
|
PERFORM update_user_karma(NEW."authorId", 5);
|
||||||
END IF;
|
END IF;
|
||||||
|
END IF;
|
||||||
END;
|
END;
|
||||||
$$ LANGUAGE plpgsql;
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
@@ -146,12 +175,19 @@ DECLARE
|
|||||||
comment_author_id INT;
|
comment_author_id INT;
|
||||||
service_name TEXT;
|
service_name TEXT;
|
||||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||||
|
is_author_admin_or_moderator BOOLEAN;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get comment author and service info
|
-- Get comment author and service info
|
||||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||||
FROM "Comment" c
|
FROM "Comment" c
|
||||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
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
|
-- Calculate karma impact based on vote type
|
||||||
IF TG_OP = 'INSERT' THEN
|
IF TG_OP = 'INSERT' THEN
|
||||||
-- New vote
|
-- New vote
|
||||||
@@ -181,6 +217,8 @@ BEGIN
|
|||||||
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
|
-- 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
|
-- Record karma transaction and update user karma
|
||||||
PERFORM insert_karma_transaction(
|
PERFORM insert_karma_transaction(
|
||||||
comment_author_id,
|
comment_author_id,
|
||||||
@@ -191,6 +229,7 @@ BEGIN
|
|||||||
);
|
);
|
||||||
|
|
||||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||||
|
END IF;
|
||||||
|
|
||||||
-- Update comment's upvotes count incrementally
|
-- Update comment's upvotes count incrementally
|
||||||
UPDATE "Comment"
|
UPDATE "Comment"
|
||||||
@@ -236,11 +275,20 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
|||||||
RETURNS TRIGGER AS $$
|
RETURNS TRIGGER AS $$
|
||||||
DECLARE
|
DECLARE
|
||||||
service_name TEXT;
|
service_name TEXT;
|
||||||
|
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
|
||||||
|
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
|
-- Fetch service name for the description
|
||||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||||
|
|
||||||
@@ -257,6 +305,7 @@ BEGIN
|
|||||||
-- 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;
|
||||||
|
|
||||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||||
END;
|
END;
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ DECLARE
|
|||||||
attributes_score INT := 0;
|
attributes_score INT := 0;
|
||||||
recently_approved_factor INT := 0;
|
recently_approved_factor INT := 0;
|
||||||
tos_penalty_factor INT := 0;
|
tos_penalty_factor INT := 0;
|
||||||
|
operating_since_factor INT := 0;
|
||||||
BEGIN
|
BEGIN
|
||||||
-- Get verification status factor
|
-- Get verification status factor
|
||||||
SELECT
|
SELECT
|
||||||
@@ -148,8 +149,20 @@ BEGIN
|
|||||||
tos_penalty_factor := -3;
|
tos_penalty_factor := -3;
|
||||||
END IF;
|
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)
|
-- 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)
|
-- Ensure the score is in reasonable bounds (0-100)
|
||||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ActionError } from 'astro:actions'
|
import { ActionError } from 'astro:actions'
|
||||||
import { z } from 'astro:content'
|
import { z } from 'astro:content'
|
||||||
|
import { pick } from 'lodash-es'
|
||||||
|
|
||||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||||
import { createAccount } from '../lib/accountCreate'
|
import { createAccount } from '../lib/accountCreate'
|
||||||
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
|
|||||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||||
import { saveFileLocally } from '../lib/fileStorage'
|
import { saveFileLocally } from '../lib/fileStorage'
|
||||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||||
import { startImpersonating } from '../lib/impersonation'
|
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
||||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||||
import { prisma } from '../lib/prisma'
|
import { prisma } from '../lib/prisma'
|
||||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||||
@@ -225,4 +226,36 @@ export const accountActions = {
|
|||||||
return { user }
|
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 }
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ const serviceSchemaBase = z.object({
|
|||||||
.optional()
|
.optional()
|
||||||
.nullable()
|
.nullable()
|
||||||
.default(null),
|
.default(null),
|
||||||
|
operatingSince: z.coerce.date().optional().nullable(),
|
||||||
imageFile: imageFileSchema,
|
imageFile: imageFileSchema,
|
||||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||||
@@ -150,6 +151,7 @@ export const adminServiceActions = {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
},
|
},
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
@@ -258,7 +260,6 @@ export const adminServiceActions = {
|
|||||||
),
|
),
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
||||||
imageUrl,
|
imageUrl,
|
||||||
categories: {
|
categories: {
|
||||||
connect: categoriesToAdd.map((id) => ({ id })),
|
connect: categoriesToAdd.map((id) => ({ id })),
|
||||||
@@ -275,6 +276,7 @@ export const adminServiceActions = {
|
|||||||
attributeId,
|
attributeId,
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
|
operatingSince: input.operatingSince,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,17 +6,29 @@ import { interpolate } from '../lib/numbers'
|
|||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
import { transformCase } from '../lib/strings'
|
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'
|
import type { Review, WithContext } from 'schema-dts'
|
||||||
|
|
||||||
export type Props = HTMLAttributes<'div'> & {
|
type Props = Polymorphic<{
|
||||||
|
as: HTMLTag
|
||||||
score: number
|
score: number
|
||||||
label: string
|
label: string
|
||||||
total?: number
|
total?: number
|
||||||
itemReviewedId?: string
|
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)
|
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)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
<div
|
<Tag
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role={htmlProps.role ?? 'group'}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
class={cn('2xs:text-[2rem] mt-[25%] mb-1 text-[1.5rem] leading-none font-bold tracking-tight', {
|
||||||
@@ -166,10 +178,14 @@ const { text, step, angle, formattedScore } = makeScoreInfo(score, total)
|
|||||||
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
transform={angle !== undefined ? `rotate(${angle}, 48, 48)` : undefined}
|
||||||
class="stroke-night-700"></path>
|
class="stroke-night-700"></path>
|
||||||
|
|
||||||
<!-- Info icon -->
|
{
|
||||||
<!-- <path
|
showInfo && (
|
||||||
|
<path
|
||||||
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
d="M88 13C85.2386 13 83 10.7614 83 8C83 5.23857 85.2386 3 88 3C90.7614 3 93 5.23857 93 8C93 10.7614 90.7614 13 88 13ZM88 12C90.2092 12 92 10.2092 92 8C92 5.79086 90.2092 4 88 4C85.7909 4 84 5.79086 84 8C84 10.2092 85.7909 12 88 12ZM87.5 5.5H88.5V6.5H87.5V5.5ZM87.5 7.5H88.5V10.5H87.5V7.5Z"
|
||||||
fill="white"
|
class="text-current/60"
|
||||||
fill-opacity="0.67"></path> -->
|
fill="currentColor"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</Tag>
|
||||||
|
|||||||
@@ -6,27 +6,39 @@ import { makeOverallScoreInfo } from '../lib/overallScore'
|
|||||||
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
import { KYCNOTME_SCHEMA_MINI } from '../lib/schema'
|
||||||
import { transformCase } from '../lib/strings'
|
import { transformCase } from '../lib/strings'
|
||||||
|
|
||||||
import type { HTMLAttributes } from 'astro/types'
|
import type { HTMLTag, Polymorphic } from 'astro/types'
|
||||||
|
|
||||||
export type Props = HTMLAttributes<'div'> & {
|
type Props = Polymorphic<{
|
||||||
|
as: HTMLTag
|
||||||
score: number
|
score: number
|
||||||
label: string
|
label: string
|
||||||
total?: number
|
total?: number
|
||||||
itemReviewedId?: string
|
itemReviewedId?: string
|
||||||
}
|
showInfo?: boolean
|
||||||
|
children?: never
|
||||||
|
}>
|
||||||
|
|
||||||
const { score, label, total = 10, class: className, itemReviewedId, ...htmlProps } = Astro.props
|
const {
|
||||||
|
as: Tag = 'div',
|
||||||
|
score,
|
||||||
|
label,
|
||||||
|
total = 10,
|
||||||
|
class: className,
|
||||||
|
itemReviewedId,
|
||||||
|
showInfo = false,
|
||||||
|
...htmlProps
|
||||||
|
} = Astro.props
|
||||||
|
|
||||||
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
||||||
---
|
---
|
||||||
|
|
||||||
<div
|
<Tag
|
||||||
{...htmlProps}
|
{...htmlProps}
|
||||||
class={cn(
|
class={cn(
|
||||||
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
'2xs:size-24 relative flex aspect-square size-18 flex-col items-center justify-start text-white',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
role="group"
|
role={htmlProps.role ?? 'group'}
|
||||||
>
|
>
|
||||||
{
|
{
|
||||||
!!itemReviewedId && (
|
!!itemReviewedId && (
|
||||||
@@ -48,15 +60,17 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
<!-- <svg
|
{
|
||||||
|
showInfo && (
|
||||||
|
<svg
|
||||||
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
class="absolute top-0.5 left-[calc(50%+48px/2+2px)] size-3 text-current/60"
|
||||||
viewBox="0 0 12 12"
|
viewBox="0 0 12 12"
|
||||||
fill="currentColor"
|
fill="currentColor"
|
||||||
>
|
>
|
||||||
<path
|
<path d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z" />
|
||||||
d="M6 11C3.23857 11 1 8.7614 1 6C1 3.23857 3.23857 1 6 1C8.7614 1 11 3.23857 11 6C11 8.7614 8.7614 11 6 11ZM6 10C8.20915 10 10 8.20915 10 6C10 3.79086 8.20915 2 6 2C3.79086 2 2 3.79086 2 6C2 8.20915 3.79086 10 6 10ZM5.5 3.5H6.5V4.5H5.5V3.5ZM5.5 5.5H6.5V8.5H5.5V5.5Z"
|
</svg>
|
||||||
></path>
|
)
|
||||||
</svg> -->
|
}
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class={cn(
|
class={cn(
|
||||||
@@ -77,4 +91,4 @@ const { text, classNameBg, formattedScore } = makeOverallScoreInfo(score, total)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
<span class="text-xs leading-none tracking-wide text-current/80">{text}</span>
|
||||||
</div>
|
</Tag>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { differenceInMonths, differenceInYears } from 'date-fns'
|
||||||
import { orderBy } from 'lodash-es'
|
import { orderBy } from 'lodash-es'
|
||||||
|
|
||||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||||
@@ -45,6 +46,7 @@ type NonDbAttributeFull = NonDbAttribute & {
|
|||||||
acceptedCurrencies: true
|
acceptedCurrencies: true
|
||||||
kycLevel: true
|
kycLevel: true
|
||||||
kycLevelClarification: true
|
kycLevelClarification: true
|
||||||
|
operatingSince: true
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
) => Partial<Pick<NonDbAttribute, 'description' | 'title'>> & {
|
||||||
@@ -255,6 +257,57 @@ export const nonDbAttributes: NonDbAttributeFull[] = [
|
|||||||
show: service.acceptedCurrencies.includes('MONERO'),
|
show: service.acceptedCurrencies.includes('MONERO'),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
slug: 'new-service',
|
||||||
|
title: 'New service',
|
||||||
|
type: 'WARNING',
|
||||||
|
category: 'TRUST',
|
||||||
|
description:
|
||||||
|
'The service started operations less than a year ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: -4,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => {
|
||||||
|
const started = service.operatingSince as unknown as Date | null
|
||||||
|
if (!started) return { show: false }
|
||||||
|
|
||||||
|
const yearsOperated = differenceInYears(new Date(), started)
|
||||||
|
if (yearsOperated >= 1) return { show: false }
|
||||||
|
|
||||||
|
const monthsOperated = differenceInMonths(new Date(), started)
|
||||||
|
return {
|
||||||
|
show: true,
|
||||||
|
description: `The service started operations ${
|
||||||
|
monthsOperated === 0
|
||||||
|
? 'less than a month'
|
||||||
|
: `${String(monthsOperated)} month${monthsOperated > 1 ? 's' : ''}`
|
||||||
|
} ago and it does not have a proven track record. Use with caution and follow the [safe swapping rules](https://blog.kycnot.me/p/stay-safe-using-services#the-rules).`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
slug: 'mature-service',
|
||||||
|
title: 'Mature service',
|
||||||
|
type: 'GOOD',
|
||||||
|
category: 'TRUST',
|
||||||
|
description:
|
||||||
|
'This service has been operational for at least 2 years. While this indicates stability, it is not a future-proof guarantee.',
|
||||||
|
privacyPoints: 0,
|
||||||
|
trustPoints: 5,
|
||||||
|
links: [],
|
||||||
|
customize: (service) => {
|
||||||
|
const started = service.operatingSince as unknown as Date | null
|
||||||
|
if (!started) return { show: false }
|
||||||
|
|
||||||
|
const yearsOperated = differenceInYears(new Date(), started)
|
||||||
|
return {
|
||||||
|
show: yearsOperated >= 2,
|
||||||
|
description: `This service has been operational for **${String(
|
||||||
|
yearsOperated
|
||||||
|
)} years**. While this indicates stability, it is not a future-proof guarantee.`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function sortAttributes<
|
export function sortAttributes<
|
||||||
|
|||||||
@@ -86,12 +86,13 @@ To list a new service, it must fulfill these requirements:
|
|||||||
- Offer a service
|
- Offer a service
|
||||||
- Publicly available website explaining what the service is about
|
- Publicly available website explaining what the service is about
|
||||||
- Terms of service or FAQ document
|
- Terms of service or FAQ document
|
||||||
|
- The service must have been operating for at least 6 months.
|
||||||
|
|
||||||
Examples of non-valid services:
|
Examples of non-valid services:
|
||||||
|
|
||||||
- Just a Telegram link
|
|
||||||
- A cryptocurrency project
|
- A cryptocurrency project
|
||||||
- A cryptocurrency wallet
|
- A cryptocurrency wallet
|
||||||
|
- A chat link without any information about the service, or review page.
|
||||||
|
|
||||||
#### Suggestion Review Process
|
#### Suggestion Review Process
|
||||||
|
|
||||||
@@ -160,7 +161,7 @@ These categories **directly influence** a service's Privacy and Trust scores, wh
|
|||||||
|
|
||||||
#### Service Scores
|
#### Service Scores
|
||||||
|
|
||||||
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
|
Scores are calculated **automatically** using clear, fixed rules based on the attributes of the service ([See all attributes](/attributes)). We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
|
||||||
|
|
||||||
##### Privacy Score
|
##### Privacy Score
|
||||||
|
|
||||||
|
|||||||
141
web/src/pages/account/delete.astro
Normal file
141
web/src/pages/account/delete.astro
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
---
|
||||||
|
import { Icon } from 'astro-icon/components'
|
||||||
|
import { actions } from 'astro:actions'
|
||||||
|
|
||||||
|
import Captcha from '../../components/Captcha.astro'
|
||||||
|
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||||
|
import UserBadge from '../../components/UserBadge.astro'
|
||||||
|
import MiniLayout from '../../layouts/MiniLayout.astro'
|
||||||
|
import { prisma } from '../../lib/prisma'
|
||||||
|
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||||
|
|
||||||
|
const deleteResult = Astro.getActionResult(actions.account.delete)
|
||||||
|
if (deleteResult && !deleteResult.error) {
|
||||||
|
Astro.locals.banners.addIfSuccess(deleteResult, 'Account deleted successfully')
|
||||||
|
return Astro.redirect('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = Astro.locals.user?.id
|
||||||
|
if (!userId) {
|
||||||
|
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to delete your account' }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await Astro.locals.banners.try('Failed to load user profile', () =>
|
||||||
|
prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
admin: true,
|
||||||
|
moderator: true,
|
||||||
|
name: true,
|
||||||
|
displayName: true,
|
||||||
|
picture: true,
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
comments: true,
|
||||||
|
commentVotes: true,
|
||||||
|
suggestions: true,
|
||||||
|
suggestionMessages: true,
|
||||||
|
verificationRequests: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!user) return Astro.rewrite('/404')
|
||||||
|
---
|
||||||
|
|
||||||
|
<MiniLayout
|
||||||
|
pageTitle="Delete account"
|
||||||
|
description="Delete your account"
|
||||||
|
ogImage={{
|
||||||
|
template: 'generic',
|
||||||
|
title: 'Delete account',
|
||||||
|
description: 'Delete your account',
|
||||||
|
icon: 'ri:delete-bin-line',
|
||||||
|
}}
|
||||||
|
layoutHeader={{
|
||||||
|
icon: 'ri:delete-bin-line',
|
||||||
|
title: 'Delete account',
|
||||||
|
subtitle: 'Are you sure? This is irreversible.',
|
||||||
|
}}
|
||||||
|
breadcrumbs={[
|
||||||
|
{
|
||||||
|
name: 'Accounts',
|
||||||
|
url: '/account',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Delete account',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<h2 class="font-title text-day-200 mb-2 text-lg font-semibold">What will be deleted:</h2>
|
||||||
|
|
||||||
|
<a
|
||||||
|
href="/account"
|
||||||
|
class="group relative mb-2 flex flex-col items-center rounded-xl border border-red-500/10 bg-red-500/10 p-4"
|
||||||
|
>
|
||||||
|
<UserBadge user={user} size="lg" noLink classNames={{ text: 'group-hover:underline' }} />
|
||||||
|
<Icon
|
||||||
|
name="ri:forbid-line"
|
||||||
|
class="2xs:block absolute top-1/2 left-3 hidden size-8 -translate-y-1/2 text-red-300/10"
|
||||||
|
/>
|
||||||
|
<Icon
|
||||||
|
name="ri:forbid-2-line"
|
||||||
|
class="2xs:block absolute top-1/2 right-3 hidden size-8 -translate-y-1/2 text-red-300/10"
|
||||||
|
/>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<ul class="2xs:grid-cols-2 mb-8 grid grid-cols-1 gap-x-1 gap-y-0">
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold">1</span>
|
||||||
|
<span class="text-day-400 text-sm">User</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.comments.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Comments <span class="text-xs">(+ their replies)</span></span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.commentVotes.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Comment Votes</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.suggestions.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Service Suggestions</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.suggestionMessages.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Suggestion Messages</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<span class="text-day-200 me-1 align-[-0.1em] text-2xl font-bold"
|
||||||
|
>{user._count.verificationRequests.toLocaleString()}</span
|
||||||
|
>
|
||||||
|
<span class="text-day-400 text-sm">Verification Requests</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{
|
||||||
|
user.admin || user.moderator ? (
|
||||||
|
<p class="text-center text-balance text-red-300">Admins and moderators cannot be deleted.</p>
|
||||||
|
) : (
|
||||||
|
<form method="POST" action={actions.account.delete}>
|
||||||
|
<Captcha action={actions.account.delete} class="mb-6" />
|
||||||
|
<InputSubmitButton label="Delete account" color="danger" icon="ri:delete-bin-line" />
|
||||||
|
</form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</MiniLayout>
|
||||||
@@ -962,7 +962,7 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
|
<Icon name="ri:logout-box-r-line" class="size-4" /> Logout
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href={`mailto:${SUPPORT_EMAIL}`}
|
href="/account/delete"
|
||||||
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
class="inline-flex items-center gap-1 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-sm text-red-400 shadow-xs transition-colors duration-200 hover:bg-red-500/20 focus:ring-2 focus:ring-red-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"
|
||||||
>
|
>
|
||||||
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account
|
<Icon name="ri:delete-bin-line" class="size-4" /> Delete account
|
||||||
|
|||||||
@@ -361,6 +361,22 @@ const apiCalls = await Astro.locals.banners.try(
|
|||||||
value={service.tosUrls.join('\n')}
|
value={service.tosUrls.join('\n')}
|
||||||
error={serviceInputErrors.tosUrls}
|
error={serviceInputErrors.tosUrls}
|
||||||
/>
|
/>
|
||||||
|
<InputText
|
||||||
|
label="Operating since"
|
||||||
|
name="operatingSince"
|
||||||
|
description="Date the service started operating"
|
||||||
|
inputProps={{
|
||||||
|
type: 'date',
|
||||||
|
value: service.operatingSince
|
||||||
|
? new Date(
|
||||||
|
service.operatingSince.getTime() - service.operatingSince.getTimezoneOffset() * 60000
|
||||||
|
)
|
||||||
|
.toISOString()
|
||||||
|
.slice(0, 10)
|
||||||
|
: '',
|
||||||
|
}}
|
||||||
|
error={serviceInputErrors.operatingSince}
|
||||||
|
/>
|
||||||
<InputText
|
<InputText
|
||||||
label="Referral link path"
|
label="Referral link path"
|
||||||
name="referral"
|
name="referral"
|
||||||
|
|||||||
@@ -461,6 +461,25 @@ if (!user) return Astro.rewrite('/404')
|
|||||||
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
<InputSubmitButton label="Submit" icon="ri:add-line" hideCancel />
|
||||||
</form>
|
</form>
|
||||||
</FormSection>
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title="Delete User">
|
||||||
|
{
|
||||||
|
user.admin || user.moderator ? (
|
||||||
|
<p class="text-center text-balance">This user can't be deleted</p>
|
||||||
|
) : (
|
||||||
|
<p class="text-center text-balance">
|
||||||
|
To delete this user,
|
||||||
|
<a
|
||||||
|
href={`/account/impersonate?targetUserId=${user.id}&redirect=/account/delete`}
|
||||||
|
data-astro-prefetch="tap"
|
||||||
|
class="underline"
|
||||||
|
>
|
||||||
|
impersonate it and go to /account/delete
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</FormSection>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
@@ -16,27 +16,28 @@ 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.
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
3. **Upvotes**
|
3. **Upvotes**
|
||||||
- Each upvote on your comment adds +1 to your karma
|
- Each upvote on your comment adds +1 to your karma.
|
||||||
- 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.
|
||||||
|
|
||||||
## 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:
|
||||||
|
|
||||||
1. **Spam Detection** (-10 points)
|
1. **Spam Detection** (-10 points)
|
||||||
- If your comment is marked as suspicious/spam
|
- If your comment is marked as suspicious/spam.
|
||||||
- This is a significant penalty to discourage spam behavior
|
- This is a significant penalty to discourage spam behavior.
|
||||||
- If the spam mark is removed, the 10 points are restored
|
- If the spam mark is removed, the 10 points are restored.
|
||||||
|
|
||||||
## Karma Tracking
|
## Karma Tracking
|
||||||
|
|
||||||
@@ -44,26 +45,26 @@ 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.
|
||||||
- The number of points awarded/deducted
|
- The number of points awarded/deducted.
|
||||||
- A description of why the karma changed
|
- A description of why the karma changed.
|
||||||
- The related comment (if applicable)
|
- The related comment (if applicable).
|
||||||
|
|
||||||
2. **Total Karma**
|
2. **Total Karma**
|
||||||
- Your total karma is displayed on your profile
|
- Your total karma is displayed on your profile.
|
||||||
- It's the sum of all your karma transactions
|
- It's the sum of all your karma transactions.
|
||||||
- This score helps establish your reputation in the community
|
- This score helps establish your reputation in the community.
|
||||||
|
|
||||||
## Impact of Karma
|
## Impact of Karma
|
||||||
|
|
||||||
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
|
Your karma score is more than just a number - it's a reflection of your contributions to the community. Higher karma scores indicate:
|
||||||
|
|
||||||
- Active participation in the community
|
- Active participation in the community.
|
||||||
- History of providing valuable information
|
- History of providing valuable information.
|
||||||
- Trustworthiness of your contributions
|
- Trustworthiness of your contributions.
|
||||||
- Commitment to community standards
|
- Commitment to community standards.
|
||||||
|
|
||||||
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.
|
The karma system helps maintain the quality of discussions and encourages meaningful contributions to the platform.
|
||||||
|
|
||||||
|
|||||||
@@ -250,6 +250,16 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
|
|||||||
error={inputErrors.tosUrls}
|
error={inputErrors.tosUrls}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<InputText
|
||||||
|
label="Operating since"
|
||||||
|
name="operatingSince"
|
||||||
|
description="Date the service started operating"
|
||||||
|
inputProps={{
|
||||||
|
type: 'date',
|
||||||
|
}}
|
||||||
|
error={(inputErrors as Record<string, unknown>).operatingSince}
|
||||||
|
/>
|
||||||
|
|
||||||
<InputCardGroup
|
<InputCardGroup
|
||||||
name="kycLevel"
|
name="kycLevel"
|
||||||
label="KYC Level"
|
label="KYC Level"
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
|||||||
approvedAt: true,
|
approvedAt: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
acceptedCurrencies: true,
|
acceptedCurrencies: true,
|
||||||
|
operatingSince: true,
|
||||||
tosReview: true,
|
tosReview: true,
|
||||||
tosReviewAt: true,
|
tosReviewAt: true,
|
||||||
userSentiment: true,
|
userSentiment: true,
|
||||||
@@ -901,13 +902,34 @@ const activeEventToShow =
|
|||||||
</h2>
|
</h2>
|
||||||
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between">
|
<div class="xs:gap-8 flex flex-row flex-wrap items-stretch justify-center gap-4 sm:justify-between">
|
||||||
<div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4">
|
<div class="flex max-w-84 flex-grow flex-row items-center justify-evenly gap-4">
|
||||||
<ScoreSquare score={service.overallScore} label="Overall" itemReviewedId={itemReviewedId} />
|
<ScoreSquare
|
||||||
|
as="a"
|
||||||
|
href="/about#service-scores"
|
||||||
|
score={service.overallScore}
|
||||||
|
label="Overall"
|
||||||
|
itemReviewedId={itemReviewedId}
|
||||||
|
showInfo
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div>
|
<div class="bg-day-800 h-12 w-px flex-shrink-0 self-center"></div>
|
||||||
|
|
||||||
<ScoreGauge score={service.privacyScore} label="Privacy" itemReviewedId={itemReviewedId} />
|
<ScoreGauge
|
||||||
|
as="a"
|
||||||
|
href="/about#service-scores"
|
||||||
|
score={service.privacyScore}
|
||||||
|
label="Privacy"
|
||||||
|
itemReviewedId={itemReviewedId}
|
||||||
|
showInfo
|
||||||
|
/>
|
||||||
|
|
||||||
<ScoreGauge score={service.trustScore} label="Trust" itemReviewedId={itemReviewedId} />
|
<ScoreGauge
|
||||||
|
as="a"
|
||||||
|
href="/about#service-scores"
|
||||||
|
score={service.trustScore}
|
||||||
|
label="Trust"
|
||||||
|
itemReviewedId={itemReviewedId}
|
||||||
|
showInfo
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black"
|
class="@container flex max-w-112 flex-shrink-0 flex-grow-100 basis-64 flex-row items-start rounded-lg bg-white px-3 py-2 text-black"
|
||||||
|
|||||||
@@ -393,7 +393,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
|
<span class="text-day-500 mt-0.5 mr-2"><Icon name="ri:award-line" class="size-4" /></span>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-day-500 text-xs">Karma</p>
|
<p class="text-day-500 text-xs">Karma</p>
|
||||||
<p class="text-day-300">{user.totalKarma.toLocaleString()}</p>
|
<p class="text-day-300">
|
||||||
|
{!user.admin && !user.moderator ? user.totalKarma.toLocaleString() : '∞'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -575,6 +577,8 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</section>
|
</section>
|
||||||
</AdminOnly>
|
</AdminOnly>
|
||||||
|
|
||||||
|
{
|
||||||
|
!user.admin && !user.moderator && (
|
||||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||||
<header>
|
<header>
|
||||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||||
@@ -583,8 +587,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{
|
{user.serviceAffiliations.length > 0 ? (
|
||||||
user.serviceAffiliations.length > 0 ? (
|
|
||||||
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
|
<ul class="2xs:grid-cols-[repeat(auto-fit,minmax(200px,1fr))] grid gap-4">
|
||||||
{user.serviceAffiliations.map((affiliation) => {
|
{user.serviceAffiliations.map((affiliation) => {
|
||||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||||
@@ -638,10 +641,13 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</ul>
|
</ul>
|
||||||
) : (
|
) : (
|
||||||
<p class="text-day-400 mb-6">No service affiliations yet.</p>
|
<p class="text-day-400 mb-6">No service affiliations yet.</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</section>
|
|
||||||
|
|
||||||
|
{
|
||||||
|
!user.admin && !user.moderator && (
|
||||||
<section
|
<section
|
||||||
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
|
class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs"
|
||||||
id="karma-unlocks"
|
id="karma-unlocks"
|
||||||
@@ -654,10 +660,10 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
|
|
||||||
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
|
||||||
<p class="text-day-300">
|
<p class="text-day-300">
|
||||||
Earn karma to unlock features and privileges. <a
|
Earn karma to unlock features and privileges.{' '}
|
||||||
href="/docs/karma"
|
<a href="/docs/karma" class="text-day-200 hover:underline">
|
||||||
class="text-day-200 hover:underline">Learn about karma</a
|
Learn about karma
|
||||||
>
|
</a>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -674,8 +680,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
||||||
{
|
{sortBy(
|
||||||
sortBy(
|
|
||||||
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
|
karmaUnlocks.filter((unlock) => unlock.karma >= 0),
|
||||||
'karma'
|
'karma'
|
||||||
).map((unlock) => (
|
).map((unlock) => (
|
||||||
@@ -715,8 +720,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -732,8 +736,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
<div class="mt-3 hidden space-y-3 peer-checked:block md:block">
|
||||||
{
|
{sortBy(
|
||||||
sortBy(
|
|
||||||
karmaUnlocks.filter((unlock) => unlock.karma < 0),
|
karmaUnlocks.filter((unlock) => unlock.karma < 0),
|
||||||
'karma'
|
'karma'
|
||||||
)
|
)
|
||||||
@@ -748,7 +751,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<span class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}>
|
<span
|
||||||
|
class={cn('mr-3', user.karmaUnlocks[unlock.id] ? 'text-red-400' : 'text-day-500')}
|
||||||
|
>
|
||||||
<Icon name={unlock.icon} class="size-5" />
|
<Icon name={unlock.icon} class="size-5" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
@@ -775,18 +780,19 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))}
|
||||||
}
|
|
||||||
|
|
||||||
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
|
<p class="text-day-400 border-night-500/30 bg-night-800/70 mt-4 rounded-md border p-3 text-xs">
|
||||||
<Icon name="ri:information-line" class="inline-block size-4" />
|
<Icon name="ri:information-line" class="inline-block size-4" />
|
||||||
Negative karma leads to restrictions. <br class="hidden sm:block" />Keep interactions positive to
|
Negative karma leads to restrictions. <br class="hidden sm:block" />
|
||||||
avoid penalties.
|
Keep interactions positive to avoid penalties.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||||
@@ -929,6 +935,8 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
!user.admin && !user.moderator && (
|
||||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||||
@@ -937,8 +945,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</h2>
|
</h2>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{
|
{user.suggestions.length === 0 ? (
|
||||||
user.suggestions.length === 0 ? (
|
|
||||||
<p class="text-day-400">No suggestions yet.</p>
|
<p class="text-day-400">No suggestions yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -992,10 +999,13 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</section>
|
|
||||||
|
|
||||||
|
{
|
||||||
|
!user.admin && !user.moderator && (
|
||||||
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
<section class="border-night-400 bg-night-400/5 rounded-lg border p-6 shadow-sm backdrop-blur-xs">
|
||||||
<header class="flex items-center justify-between">
|
<header class="flex items-center justify-between">
|
||||||
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
<h2 class="font-title text-day-200 mb-4 text-xl font-bold">
|
||||||
@@ -1005,8 +1015,7 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
|
<span class="text-day-500">{user.totalKarma.toLocaleString()} karma</span>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{
|
{user.karmaTransactions.length === 0 ? (
|
||||||
user.karmaTransactions.length === 0 ? (
|
|
||||||
<p class="text-day-400">No karma transactions yet.</p>
|
<p class="text-day-400">No karma transactions yet.</p>
|
||||||
) : (
|
) : (
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
@@ -1060,8 +1069,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</BaseLayout>
|
</BaseLayout>
|
||||||
|
|||||||
Reference in New Issue
Block a user