Compare commits

...

4 Commits

Author SHA1 Message Date
pluja
8b90b3eef6 Release 202506061009 2025-06-06 10:09:59 +00:00
pluja
2489e94b0e Release 202506042153 2025-06-04 21:53:07 +00:00
pluja
144af17a70 Release 202506042038 2025-06-04 20:38:49 +00:00
pluja
02e52d7351 Release 202506041937 2025-06-04 19:37:33 +00:00
28 changed files with 1450 additions and 1179 deletions

View File

@@ -0,0 +1,46 @@
# Database
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_db_password
POSTGRES_DATABASE=your_db_name
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
DATABASE_UI_URL="https://db.example.com"
# Generic Config
UPLOAD_DIR=/app/uploads
SITE_URL="https://your-site.example.com"
SOURCE_CODE_URL="https://your-source-code.example.com"
TIME_TRAP_SECRET=your_time_trap_secret
LOGS_UI_URL="https://logs.example.com"
# Release Info
RELEASE_NUMBER=
RELEASE_DATE=
# Redis
REDIS_URL="redis://redis:6379"
# Crawl4AI
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
CRAWL4AI_API_TOKEN=your_crawl4ai_token
# Tor and I2P
ONION_ADDRESS="http://youronionaddress.onion"
I2P_ADDRESS="http://youri2paddress.b32.i2p"
I2P_PASS=your_i2p_password
# Push Notifications
VAPID_PUBLIC_KEY=your_vapid_public_key
VAPID_PRIVATE_KEY=your_vapid_private_key
VAPID_SUBJECT="mailto:your-email@example.com"
# OpenAI
OPENAI_API_KEY=your_openai_api_key
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

View File

@@ -7,10 +7,8 @@ services:
volumes:
- database:/var/lib/postgresql/data:z
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
interval: 10s
@@ -20,19 +18,17 @@ services:
pyworker:
build:
context: ./pyworker
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
restart: always
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
env_file:
- .env
crawl4ai:
image: unclecode/crawl4ai:basic-amd64
expose:
- "11235"
environment:
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
MAX_CONCURRENT_TASKS: 10
env_file:
- .env
volumes:
- /dev/shm:/dev/shm
deploy:
@@ -53,15 +49,9 @@ services:
astro:
build:
context: ./web
dockerfile: web/Dockerfile
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on:

View File

@@ -184,16 +184,16 @@ Be concise but thorough, and make sure your output is properly formatted JSON.
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
You will be given a list of user comments to a service.
Your task is to summarize the comments in a way that is easy to understand and to the point.
The summary should be concise and to the point, no more than 150 words.
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
You must format your response as a valid JSON object with the following structure:
interface CommentSummary {
summary: string;
summary: string; // Concise, 100 words max
sentiment: 'positive'|'negative'|'neutral';
whatUsersLike: string[]; // Concise, 2-3 words, max 4
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
whatUsersLike: string[]; // Concise, 2-3 words max
whatUsersDislike: string[]; // Concise, 2-3 words max
}
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.

View File

@@ -1,17 +1,20 @@
FROM node:lts AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
COPY .env .env
COPY web/package.json web/package-lock.json ./
COPY .npmrc .npmrc
COPY web/.npmrc .npmrc
RUN npm ci
COPY . .
COPY web/ .
ARG ASTRO_BUILD_MODE=production
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
@@ -20,7 +23,7 @@ ENV PORT=4321
EXPOSE 4321
# Add knm-migrate command script and make it executable
COPY migrate.sh /usr/local/bin/knm-migrate
COPY web/migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"]

1265
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,19 +23,19 @@
},
"dependencies": {
"@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14",
"@astrojs/mdx": "4.2.6",
"@astrojs/node": "9.2.1",
"@astrojs/sitemap": "3.4.0",
"@fontsource-variable/space-grotesk": "5.2.7",
"@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.2",
"@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7",
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.9.0",
"@tailwindcss/vite": "4.1.8",
"@types/mime-types": "3.0.0",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro": "5.9.0",
"astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0",
@@ -43,59 +43,59 @@
"clsx": "2.1.1",
"htmx.org": "1.9.12",
"javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.8",
"libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",
"redis": "5.5.6",
"schema-dts": "1.1.5",
"seedrandom": "3.0.5",
"sharp": "0.34.1",
"sharp": "0.34.2",
"slugify": "1.6.6",
"tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0",
"tailwindcss": "4.1.7",
"tailwindcss": "4.1.8",
"typescript": "5.8.3",
"unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7"
},
"devDependencies": {
"@eslint/js": "9.27.0",
"@eslint/js": "9.28.0",
"@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.21",
"@iconify-json/material-symbols": "1.2.24",
"@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.2.0",
"@stylistic/eslint-plugin": "4.4.1",
"@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4",
"@types/react": "19.1.6",
"@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1",
"@typescript-eslint/parser": "8.33.1",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",
"eslint": "9.27.0",
"eslint-import-resolver-typescript": "4.3.5",
"eslint": "9.28.0",
"eslint-import-resolver-typescript": "4.4.3",
"eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.1.0",
"globals": "16.2.0",
"prettier": "3.5.3",
"prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.11",
"prisma": "6.8.2",
"prisma-json-types-generator": "3.4.1",
"prettier-plugin-tailwindcss": "0.6.12",
"prisma": "6.9.0",
"prisma-json-types-generator": "3.4.2",
"tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4",
"ts-toolbelt": "9.6.0",
"tsx": "4.19.4",
"typescript-eslint": "8.32.1"
"typescript-eslint": "8.33.1"
}
}

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "NotificationType" ADD VALUE 'TEST';

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';

View File

@@ -25,6 +25,7 @@ enum OrderIdStatus {
PENDING
APPROVED
REJECTED
WITHDRAWN
}
model Comment {
@@ -128,6 +129,7 @@ enum AccountStatusChange {
}
enum NotificationType {
TEST
COMMENT_STATUS_CHANGE
REPLY_COMMENT_CREATED
COMMUNITY_NOTE_ADDED

View File

@@ -6,7 +6,11 @@
// @ts-expect-error
const typedSelf = self
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
const CACHE_NAME = 'kycnot-sw-push-notifications-v2'
/** @typedef {import('../src/lib/webPush').NotificationPayload} NotificationPayload */
/** @typedef {{defaultActionUrl: string, payload: NotificationPayload | null}} NotificationData */
/** @typedef {NotificationOptions & { actions: { action: string; title: string; icon?: string }[], timestamp: number, data: NotificationData } } CustomNotificationOptions */
typedSelf.addEventListener('install', (event) => {
console.log('Service Worker installing')
@@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => {
console.log('Push event received:', event)
if (!event.data) {
console.log('Push event but no data')
console.error('Push event but no data')
return
}
let notificationData
try {
notificationData = event.data.json()
} catch (error) {
console.error('Error parsing push data:', error)
notificationData = {
title: 'New Notification',
options: {
body: event.data.text() || 'You have a new notification',
},
}
}
const { title, options } = notificationData
const notificationOptions = {
body: options.body || '',
icon: options.icon || '/favicon.svg',
badge: options.badge || '/favicon.svg',
data: options.data || {},
let title = 'New Notification'
/** @type {CustomNotificationOptions} */
let options = {
body: 'You have a new notification',
lang: 'en-US',
icon: '/favicon.svg',
badge: '/favicon.svg',
requireInteraction: false,
silent: false,
...options,
actions: [
{
action: 'view',
title: 'View',
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
},
],
timestamp: Date.now(),
data: {
defaultActionUrl: '/notifications',
payload: null,
},
}
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
try {
/** @type {NotificationPayload} */
const rawData = event.data.json()
if (typeof rawData !== 'object' || rawData === null) throw new Error('Invalid push data, not an object')
if (!('title' in rawData) || typeof rawData.title !== 'string')
throw new Error('Invalid push data, no title')
title = rawData.title
options = {
...options,
body: rawData.body || undefined,
actions: rawData.actions.map((action) => ({
action: action.action,
title: action.title,
icon: action.icon,
})),
data: {
...options.data,
payload: rawData,
},
}
} catch (error) {
console.error('Error parsing push data:', error)
}
event.waitUntil(typedSelf.registration.showNotification(title, options))
})
typedSelf.addEventListener('notificationclick', (event) => {
@@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => {
event.notification.close()
const url = event.notification.data?.url || '/'
/** @type {NotificationData} */
const data = event.notification.data
// @ts-expect-error I already use optional chaining
const url = data.payload?.[event.action]?.url || data.defaultActionUrl
event.waitUntil(
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {

View File

@@ -1,80 +1,33 @@
import { z } from 'astro/zod'
import { sumBy } from 'lodash-es'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma'
import { sendPushNotification } from '../../lib/webPush'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
webPush: {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
title: z.string().min(1).nullable(),
body: z.string().nullable(),
url: z.string().url().optional(),
}),
handler: async (input) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { user: { name: { in: input.userNames } } },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
userAgent: true,
user: {
select: {
id: true,
name: true,
},
},
},
})
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
{
title: input.title ?? 'Test Notification',
body: input.body ?? 'This is a test push notification from KYCNot.me',
url: input.url ?? '/',
}
)
// If subscription is invalid, remove it from database
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({
where: { id: subscription.id },
})
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
}
return result.success
})
)
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
const now = new Date()
return {
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
totalSubscriptions: subscriptions.length,
successCount,
failureCount,
sentAt: now,
}
},
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
}),
},
handler: async (input) => {
const users = await prisma.user.findMany({
where: { name: { in: input.userNames } },
select: { id: true },
})
const notifications = await prisma.notification.createManyAndReturn({
data: users.map((user) => ({
type: 'TEST',
userId: user.id,
})),
select: { id: true },
})
return {
message: `Created ${notifications.length.toString()} notifications.`,
}
},
}),
}

View File

@@ -1,12 +1,10 @@
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
import { type Prisma, type ServiceUserRole } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
import { prisma } from '../../lib/prisma'
const selectUserReturnFields = {
id: true,

View File

@@ -345,10 +345,11 @@ export const commentActions = {
'order-id-status',
'kyc-requested',
'funds-blocked',
'toggle-rating-active',
]),
value: z.union([
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
z.boolean(),
z.string(),
]),
@@ -411,7 +412,7 @@ export const commentActions = {
updateData.privateContext = input.value as string
break
case 'order-id-status':
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
break
case 'kyc-requested':
updateData.kycRequested = !!input.value
@@ -419,6 +420,9 @@ export const commentActions = {
case 'funds-blocked':
updateData.fundsBlocked = !!input.value
break
case 'toggle-rating-active':
updateData.ratingActive = !!input.value
break
}
// Update the comment

View File

@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
privateContext: true
orderId: true
orderIdStatus: true
rating: true
ratingActive: true
}
}>
}
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
<div
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
>
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2">
<div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}
<Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.suspicious ? 'Not Spam' : 'Spam'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'VERIFIED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}
<Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
<Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
<span>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.kycRequested
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}
<Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.fundsBlocked
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}
<Icon name="ri:lock-line" class="h-3.5 w-3.5" />
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
{
comment.rating && (
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.ratingActive
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
data-action="toggle-rating-active"
data-value={!comment.ratingActive}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
</button>
)
}
</div>
<div class="mt-2 space-y-1.5">
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.moderator) return null
<div class="flex gap-1">
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'APPROVED'
? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Approve
<Icon name="ri:check-line" class="h-3.5 w-3.5" />
<span>Approve</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Reject
<Icon name="ri:close-line" class="h-3.5 w-3.5" />
<span>Reject</span>
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
Pending
<Icon name="ri:time-line" class="h-3.5 w-3.5" />
<span>Pending</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'WITHDRAWN'
? 'border-night-400 bg-night-500/50 text-night-300 border'
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
)}
data-action="order-id-status"
data-value="WITHDRAWN"
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
<span>Withdrawn</span>
</button>
</div>
</div>
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
action === 'suspicious' ||
action === 'requires-admin-review' ||
action === 'kyc-requested' ||
action === 'funds-blocked'
action === 'funds-blocked' ||
action === 'toggle-rating-active'
? value === 'true'
: value,
})
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
if (action === 'status') {
window.location.reload()
} else if (action === 'suspicious') {
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
btn.classList.toggle('bg-yellow-500/20')
btn.classList.toggle('text-yellow-400')
btn.classList.toggle('border-yellow-500/30')
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.moderator) return null
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'requires-admin-review') {
btn.textContent = value === 'true' ? 'No Review' : 'Review'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
btn.classList.toggle('bg-purple-500/20')
btn.classList.toggle('text-purple-400')
btn.classList.toggle('border-purple-500/30')
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.moderator) return null
// Refresh to show updated order ID status
window.location.reload()
} else if (action === 'kyc-requested') {
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.moderator) return null
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'funds-blocked') {
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'toggle-rating-active') {
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
btn.classList.toggle('bg-blue-500/20')
btn.classList.toggle('text-blue-400')
btn.classList.toggle('border-blue-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
}
} else {
console.error('Error moderating comment:', error)

View File

@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label>
<label for={inputId} transition:persist>
{label}
</label>
{required && '*'}
</legend>
{!!descriptionLabel && (

View File

@@ -20,6 +20,11 @@ export const {
icon: 'ri:notification-line',
}),
[
{
id: 'TEST',
label: 'Test notification',
icon: 'ri:flask-line',
},
{
id: 'COMMENT_STATUS_CHANGE',
label: 'Comment status changed',

View File

@@ -8,7 +8,7 @@ type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> =
slug: string
label: string
icon: string
iconClass: string
color: string
default: boolean
}
@@ -28,7 +28,7 @@ export const {
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line',
iconClass: 'text-current/60',
color: 'gray',
default: false,
}),
[
@@ -37,7 +37,7 @@ export const {
slug: 'pending',
label: 'Pending',
icon: 'ri:time-line',
iconClass: 'text-yellow-400',
color: 'yellow',
default: true,
},
{
@@ -45,7 +45,7 @@ export const {
slug: 'approved',
label: 'Approved',
icon: 'ri:check-line',
iconClass: 'text-green-400',
color: 'green',
default: false,
},
{
@@ -53,7 +53,7 @@ export const {
slug: 'rejected',
label: 'Rejected',
icon: 'ri:close-line',
iconClass: 'text-red-400',
color: 'red',
default: false,
},
{
@@ -61,7 +61,7 @@ export const {
slug: 'withdrawn',
label: 'Withdrawn',
icon: 'ri:arrow-left-line',
iconClass: 'text-gray-400',
color: 'gray',
default: false,
},
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]

View File

@@ -1,11 +1,14 @@
import { prisma } from './prisma'
import type { Prisma } from '@prisma/client'
import type { Prisma, PrismaClient } from '@prisma/client'
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
userId: number,
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
tx: Prisma.TransactionClient = prisma
tx:
| Parameters<Parameters<(typeof prisma)['$transaction']>[0]>[0]
| Prisma.TransactionClient
| PrismaClient = prisma
) {
return (
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??

View File

@@ -7,11 +7,13 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
import { makeCommentUrl } from './commentsWithReplies'
import type { NotificationAction } from './webPush'
import type { Prisma } from '@prisma/client'
export function makeNotificationTitle(
notification: Prisma.NotificationGetPayload<{
select: {
id: true
type: true
aboutAccountStatusChange: true
aboutCommentStatusChange: true
@@ -87,6 +89,9 @@ export function makeNotificationTitle(
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
): string {
switch (notification.type) {
case 'TEST': {
return `Test notification #${notification.id.toString()}`
}
case 'COMMENT_STATUS_CHANGE': {
if (!notification.aboutComment) return 'A comment you are watching had a status change'
@@ -178,6 +183,7 @@ export function makeNotificationTitle(
export function makeNotificationContent(
notification: Prisma.NotificationGetPayload<{
select: {
createdAt: true
type: true
aboutKarmaTransaction: {
select: {
@@ -204,6 +210,9 @@ export function makeNotificationContent(
}>
): string | null {
switch (notification.type) {
case 'TEST': {
return `Created on ${notification.createdAt.toLocaleString()}`
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK':
case 'KARMA_CHANGE': {
@@ -236,7 +245,7 @@ export function makeNotificationContent(
}
}
export function makeNotificationLink(
export function makeNotificationActions(
notification: Prisma.NotificationGetPayload<{
select: {
type: true
@@ -278,44 +287,120 @@ export function makeNotificationLink(
}
}>,
origin: string
): string | null {
): NotificationAction[] {
switch (notification.type) {
case 'TEST': {
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/notifications`,
},
{
action: 'profile',
title: 'Profile',
...iconNameAndUrl('ri:user-line'),
url: `${origin}/account`,
},
]
}
case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED':
case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return null
return makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
})
if (!notification.aboutComment) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
}),
},
]
}
case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return null
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`
if (!notification.aboutServiceSuggestionMessage) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`,
},
]
}
case 'SUGGESTION_STATUS_CHANGE': {
if (!notification.aboutServiceSuggestionId) return null
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`
if (!notification.aboutServiceSuggestionId) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
},
]
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': {
// return `${origin}/account#karma-unlocks`
// return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
// }
case 'KARMA_CHANGE': {
return `${origin}/account#karma-transactions`
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/account#karma-transactions`,
},
]
}
case 'ACCOUNT_STATUS_CHANGE': {
return `${origin}/account#account-status`
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/account#account-status`,
},
]
}
case 'EVENT_CREATED': {
if (!notification.aboutEvent) return null
return `${origin}/service/${notification.aboutEvent.service.slug}#events`
if (!notification.aboutEvent) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
},
]
}
case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
if (!notification.aboutService) return null
return `${origin}/service/${notification.aboutService.slug}#verification`
if (!notification.aboutService) return []
return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutService.slug}#verification`,
},
]
}
}
}
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
}
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
return {
iconName,
icon: iconUrl(iconName),
} as const
}

View File

@@ -2,15 +2,12 @@ import { z } from 'astro/zod'
import { Client } from 'pg'
import { zodParseJSON } from './json'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { sendNotification } from './sendNotifications'
import { getServerEnvVariable } from './serverEnvVariables'
import { sendPushNotification, type NotificationData } from './webPush'
import type { AstroIntegration, HookParameters } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
const SITE_URL = getServerEnvVariable('SITE_URL')
let pgClient: Client | null = null
@@ -24,155 +21,10 @@ async function handleNotificationCreated(
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
select: {
id: true,
type: true,
userId: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: { select: { id: true } },
status: true,
content: true,
communityNote: true,
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
user: {
select: {
id: true,
name: true,
},
},
},
})
if (!notification) {
logger.warn(`Notification with ID ${String(notificationId)} not found`)
return
}
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length
const failureCount = results.filter((r) => !(r.status === 'fulfilled' && r.value)).length
const results = await sendNotification(notificationId, logger)
logger.info(
`Push notification sent for notification ${String(notificationId)} to user ${notification.user.name}: ${String(successCount)} successful, ${String(failureCount)} failed`
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)

View File

@@ -0,0 +1,165 @@
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables'
import { type NotificationPayload, sendPushNotification } from './webPush'
import type { AstroIntegrationLogger } from 'astro'
const SITE_URL = getServerEnvVariable('SITE_URL')
export async function sendNotification(
notificationId: number,
logger: AstroIntegrationLogger | Console = console
) {
const notification = await prisma.notification.findUnique({
where: { id: notificationId },
select: {
id: true,
type: true,
userId: true,
createdAt: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: { select: { id: true } },
status: true,
content: true,
communityNote: true,
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
user: {
select: {
id: true,
name: true,
},
},
},
})
if (!notification) {
logger.error(`Notification with ID ${notificationId.toString()} not found`)
return { success: 0, failure: 0, total: 0 }
}
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return { success: 0, failure: 0, total: 0 }
}
const notificationPayload = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification),
actions: makeNotificationActions(notification, SITE_URL),
} satisfies NotificationPayload
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationPayload
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
return {
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
total: subscriptionResults.length,
}
}

View File

@@ -12,12 +12,19 @@ webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
export { webpush }
export type NotificationData = {
export type NotificationAction = {
action: string
title: string
body?: string
icon?: string
badge?: string
url?: string
url: string | null
iconName?: string
}
export type NotificationPayload = {
title: string
body: string | null
actions: NotificationAction[]
}
export async function sendPushNotification(
@@ -28,26 +35,13 @@ export async function sendPushNotification(
auth: string
}
},
data: NotificationData
payload: NotificationPayload
) {
try {
const result = await webpush.sendNotification(
subscription,
JSON.stringify({
title: data.title,
options: {
body: data.body,
icon: data.icon ?? '/favicon.svg',
badge: data.badge ?? '/favicon.svg',
data: {
url: data.url,
},
},
}),
{
TTL: 24 * 60 * 60, // 24 hours
}
)
// NOTE: View sw.js to see how the notification is handled
const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
TTL: 24 * 60 * 60, // 24 hours
})
return { success: true, result } as const
} catch (error) {
console.error('Error sending push notification:', error)

View File

@@ -76,6 +76,7 @@ To list a new service, it must fulfill these requirements:
- Terms of service or FAQ document
For examples:
- Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process
@@ -147,7 +148,7 @@ The privacy score measures how well a service protects user privacy, using a tra
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
7. **Final Score Range:** The final score is always kept between 0 and 100.
#### Trust Score
@@ -160,7 +161,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
- **Approved:** +5 points
- **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score.
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
5. **Final Score Range:** The final score is always kept between 0 and 100.

View File

@@ -3,8 +3,7 @@ import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import Button from '../../components/Button.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn'
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied')
}
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
const testResult = Astro.getActionResult(actions.admin.notification.test)
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
@@ -104,7 +103,7 @@ const stats = [
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
<form method="POST" action={actions.admin.notification.test} class="space-y-4">
<InputTextArea
label="Users"
name="userNames"
@@ -121,35 +120,6 @@ const stats = [
error={testInputErrors.userNames}
/>
<InputText
label="Title"
name="title"
inputProps={{
value: 'Test Notification',
required: true,
}}
error={testInputErrors.title}
/>
<InputTextArea
label="Body"
name="body"
inputProps={{
value: 'This is a test push notification from KYCNot.me',
}}
error={testInputErrors.body}
/>
<InputText
label="Action URL"
name="url"
inputProps={{
placeholder: 'https://example.com/path',
}}
description="URL to open when the notification is clicked"
error={testInputErrors.url}
/>
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
<Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
</form>
</MiniLayout>

View File

@@ -1,10 +1,11 @@
---
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro'
import InputSelect from '../../../components/InputSelect.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
import UserBadge from '../../../components/UserBadge.astro'
import {
@@ -17,12 +18,20 @@ import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
import { formatDateShort } from '../../../lib/timeAgo'
import BadgeStandard from '../../../components/BadgeStandard.astro'
const user = Astro.locals.user
if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
}
const serviceSuggestionUpdateResult = Astro.getActionResult(actions.admin.serviceSuggestions.update)
Astro.locals.banners.addIfSuccess(serviceSuggestionUpdateResult, 'Service suggestion updated successfully')
const serviceSuggestionUpdateInputErrors = isInputError(serviceSuggestionUpdateResult?.error)
? serviceSuggestionUpdateResult.error.fields
: {}
const { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) {
@@ -100,114 +109,88 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
<BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
htmx
description="View and manage service suggestion"
widthClassName="max-w-screen-md"
htmx
>
<div class="mb-4 flex items-center gap-4">
<Button
as="a"
href="/admin/service-suggestions"
color="success"
variant="faded"
size="md"
icon="ri:arrow-left-s-line"
label="Back"
/>
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
<div>
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div>
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<span class="font-title text-gray-400">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<span class="font-title text-gray-400">Status:</span>
<span
class={cn(
'inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
<span class="font-title text-gray-400">Submitted by:</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
<span class="font-title text-gray-400">Submitted at:</span>
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
<span class="font-title text-gray-400">Service page:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
View Service <Icon
name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]"
/>
</a>
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
<div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm sm:grid-cols-3">
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Status:</span>
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Author:</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Submitted:</span>
<span>
{
formatDateShort(serviceSuggestion.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Service:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
</a>
</div>
</div>
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<span class="font-title block font-bold">Notes for moderators:</span>
{
serviceSuggestion.notes && (
<div class="mb-4">
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3>
<div
class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
set:text={serviceSuggestion.notes}
/>
</div>
serviceSuggestion.notes ? (
<div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
) : (
<div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
)
}
</div>
</div>
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
<div class="flex items-center justify-between">
<h2 class="font-title text-day-200 text-lg">Messages</h2>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="mt-6 flex items-end gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<InputSelect
name="status"
label="Update status"
options={serviceSuggestionStatuses.map((status) => ({
label: status.label,
value: status.value,
}))}
selectProps={{ value: serviceSuggestion.status }}
class="flex-1"
error={serviceSuggestionUpdateInputErrors.status}
/>
<Button as="button" type="submit" color="success" size="md" icon="ri:save-line" label="Update" />
</form>
</section>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<select
name="status"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50"
>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={serviceSuggestion.status === status.value}>
{status.label}
</option>
))
}
</select>
<Button
as="button"
type="submit"
color="success"
variant="faded"
size="md"
icon="ri:save-line"
label="Update"
/>
</form>
</div>
<Chat
messages={serviceSuggestion.messages}
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
/>
</div>
<Chat
messages={serviceSuggestion.messages}
title="Chat with moderators"
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
class="mt-12"
/>
</BaseLayout>

View File

@@ -27,11 +27,11 @@ Fetches details for a single service by various lookup criteria.
### Request Parameters
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `id` | number | No* | Service ID |
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | --------------------------------------------------------------------------- |
| `id` | number | No\* | Service ID |
| `slug` | string | No\* | Service URL slug (lowercase letters, numbers, and hyphens only) |
| `serviceUrl` | string | No\* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
\* At least one of the marked parameters is required.
@@ -79,41 +79,46 @@ type ServiceResponse = {
#### KYC Levels
<ul>
{kycLevels.map((level) => (
<li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description}
</li>
))}
{kycLevels.map((level) => (
<li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description}
</li>
))}
</ul>
#### Verification Status
<ul>
{verificationStatuses.map((status) => (
<li key={status.value}>
<strong>{status.value}</strong>: {status.description}
</li>
))}
{verificationStatuses.map((status) => (
<li key={status.value}>
<strong>{status.value}</strong>: {status.description}
</li>
))}
</ul>
#### Service Visibility
<ul>
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
{serviceVisibilities
.filter(
(visibility) =>
visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED'
)
.map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
</ul>
#### KYC Level Clarifications
<ul>
{kycLevelClarifications.map((clarification) => (
<li key={clarification.value}>
<strong>{clarification.value}</strong>: {clarification.description}
</li>
))}
{kycLevelClarifications.map((clarification) => (
<li key={clarification.value}>
<strong>{clarification.value}</strong>: {clarification.description}
</li>
))}
</ul>
### Examples

View File

@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from '../lib/notifications'
import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from '../lib/notifications'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls'
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
typeInfo: getNotificationTypeInfo(notification.type),
title: makeNotificationTitle(notification, user),
content: makeNotificationContent(notification),
link: makeNotificationLink(notification, Astro.url.origin),
actions: makeNotificationActions(notification, Astro.url.origin),
}))
---
@@ -285,18 +285,21 @@ const notifications = dbNotifications.map((notification) => ({
type="submit"
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
>
<Icon name={notification.read ? 'ri:eye-close-line' : 'ri:eye-line'} class="size-4" />
<Icon name={notification.read ? 'ri:close-line' : 'ri:check-line'} class="size-4" />
</Tooltip>
</form>
{notification.link && (
<a
href={notification.link}
{notification.actions.map((action) => (
<Tooltip
as="a"
href={action.url}
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
text={action.title}
position="left"
>
<Icon name="ri:arrow-right-line" class="size-4" />
<span class="sr-only">View details</span>
</a>
)}
<Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
<span class="sr-only">{action.title}</span>
</Tooltip>
))}
</div>
</div>
</div>

View File

@@ -111,9 +111,9 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
},
]}
>
<div class="mt-12 mb-6 flex flex-col items-center justify-center gap-3">
<div class="flex items-center gap-2">
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<div class="mt-12 mb-6 text-center">
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
<div class="flex items-center justify-center gap-2">
<AdminOnly>
<Button
as="a"
@@ -124,29 +124,24 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
/>
</AdminOnly>
</div>
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
</div>
<ServiceCard service={serviceSuggestion.service} class="mb-6" />
<section class="border-night-400 bg-night-600 rounded-lg border p-6">
<div class="text-day-200 grid grid-cols-2 gap-6 text-sm">
<div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm">
<div class="flex flex-wrap items-center gap-2">
<span>Status:</span>
<span
class={cn(
'border-night-500 bg-night-800 box-content inline-flex h-8 items-center justify-center gap-1 rounded-full border px-2',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="size-4" />
{statusInfo.label}
</span>
<span class="font-title font-bold">Status:</span>
<BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span>Submitted:</span>
<span class="font-title font-bold">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Submitted:</span>
<span>
{
formatDateShort(serviceSuggestion.createdAt, {
@@ -157,15 +152,22 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Service:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
</a>
</div>
</div>
<div class="mt-6">
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div>
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<span class="font-title block font-bold">Notes for moderators:</span>
{
serviceSuggestion.notes ? (
<div class="text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
<div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
) : (
<span class="text-sm italic">Empty</span>
<div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
)
}
</div>