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: volumes:
- database:/var/lib/postgresql/data:z - database:/var/lib/postgresql/data:z
restart: unless-stopped restart: unless-stopped
environment: env_file:
POSTGRES_USER: ${POSTGRES_USER:-kycnot} - .env
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
interval: 10s interval: 10s
@@ -20,19 +18,17 @@ services:
pyworker: pyworker:
build: build:
context: ./pyworker context: ./pyworker
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
restart: always restart: always
environment: env_file:
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public" - .env
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
crawl4ai: crawl4ai:
image: unclecode/crawl4ai:basic-amd64 image: unclecode/crawl4ai:basic-amd64
expose: expose:
- "11235" - "11235"
environment: env_file:
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security - .env
MAX_CONCURRENT_TASKS: 10
volumes: volumes:
- /dev/shm:/dev/shm - /dev/shm:/dev/shm
deploy: deploy:
@@ -53,15 +49,9 @@ services:
astro: astro:
build: build:
context: ./web dockerfile: web/Dockerfile
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest} image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
restart: unless-stopped 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_file:
- .env - .env
depends_on: 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 = """ PROMPT_COMMENT_SENTIMENT_SUMMARY = """
You will be given a list of user comments to a service. 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. 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. 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: You must format your response as a valid JSON object with the following structure:
interface CommentSummary { interface CommentSummary {
summary: string; summary: string; // Concise, 100 words max
sentiment: 'positive'|'negative'|'neutral'; sentiment: 'positive'|'negative'|'neutral';
whatUsersLike: string[]; // Concise, 2-3 words, max 4 whatUsersLike: string[]; // Concise, 2-3 words max
whatUsersDislike: string[]; // Concise, 2-3 words, max 4 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. 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 FROM node:lts AS runtime
WORKDIR /app 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 RUN npm ci
COPY . . COPY web/ .
ARG ASTRO_BUILD_MODE=production ARG ASTRO_BUILD_MODE=production
# Generate Prisma client # Generate Prisma client
RUN npx prisma generate RUN npx prisma generate
# Build the application # Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE} RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
@@ -20,7 +23,7 @@ ENV PORT=4321
EXPOSE 4321 EXPOSE 4321
# Add knm-migrate command script and make it executable # 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 RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"] 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": { "dependencies": {
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.2.6", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.1", "@astrojs/node": "9.2.2",
"@astrojs/sitemap": "3.4.0", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.7", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7", "@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.8.2", "@prisma/client": "6.9.0",
"@tailwindcss/vite": "4.1.7", "@tailwindcss/vite": "4.1.8",
"@types/mime-types": "2.1.4", "@types/mime-types": "3.0.0",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.7.13", "astro": "5.9.0",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
@@ -43,59 +43,59 @@
"clsx": "2.1.1", "clsx": "2.1.1",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"javascript-time-ago": "2.5.11", "javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.8", "libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0", "pg": "8.16.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.0.1", "redis": "5.5.6",
"schema-dts": "1.1.5", "schema-dts": "1.1.5",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.1", "sharp": "0.34.2",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.7", "tailwindcss": "4.1.8",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7", "web-push": "3.6.7",
"zod-form-data": "2.0.7" "zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.27.0", "@eslint/js": "9.28.0",
"@faker-js/faker": "9.8.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/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.2.0", "@stylistic/eslint-plugin": "4.4.1",
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.6",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.33.1",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"eslint": "9.27.0", "eslint": "9.28.0",
"eslint-import-resolver-typescript": "4.3.5", "eslint-import-resolver-typescript": "4.4.3",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.1.0", "globals": "16.2.0",
"prettier": "3.5.3", "prettier": "3.5.3",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.11", "prettier-plugin-tailwindcss": "0.6.12",
"prisma": "6.8.2", "prisma": "6.9.0",
"prisma-json-types-generator": "3.4.1", "prisma-json-types-generator": "3.4.2",
"tailwind-htmx": "0.1.2", "tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4", "ts-essentials": "10.0.4",
"ts-toolbelt": "9.6.0", "ts-toolbelt": "9.6.0",
"tsx": "4.19.4", "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 PENDING
APPROVED APPROVED
REJECTED REJECTED
WITHDRAWN
} }
model Comment { model Comment {
@@ -128,6 +129,7 @@ enum AccountStatusChange {
} }
enum NotificationType { enum NotificationType {
TEST
COMMENT_STATUS_CHANGE COMMENT_STATUS_CHANGE
REPLY_COMMENT_CREATED REPLY_COMMENT_CREATED
COMMUNITY_NOTE_ADDED COMMUNITY_NOTE_ADDED

View File

@@ -6,7 +6,11 @@
// @ts-expect-error // @ts-expect-error
const typedSelf = self 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) => { typedSelf.addEventListener('install', (event) => {
console.log('Service Worker installing') console.log('Service Worker installing')
@@ -22,36 +26,59 @@ typedSelf.addEventListener('push', (event) => {
console.log('Push event received:', event) console.log('Push event received:', event)
if (!event.data) { if (!event.data) {
console.log('Push event but no data') console.error('Push event but no data')
return return
} }
let notificationData let title = 'New Notification'
try { /** @type {CustomNotificationOptions} */
notificationData = event.data.json() let options = {
} catch (error) { body: 'You have a new notification',
console.error('Error parsing push data:', error) lang: 'en-US',
notificationData = { icon: '/favicon.svg',
title: 'New Notification', badge: '/favicon.svg',
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 || {},
requireInteraction: false, requireInteraction: false,
silent: 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) => { typedSelf.addEventListener('notificationclick', (event) => {
@@ -59,7 +86,11 @@ typedSelf.addEventListener('notificationclick', (event) => {
event.notification.close() 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( event.waitUntil(
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => { typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {

View File

@@ -1,80 +1,33 @@
import { z } from 'astro/zod' import { z } from 'astro/zod'
import { sumBy } from 'lodash-es'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { sendPushNotification } from '../../lib/webPush'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils' import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = { export const adminNotificationActions = {
webPush: { test: defineProtectedAction({
test: defineProtectedAction({ accept: 'form',
accept: 'form', permissions: 'admin',
permissions: 'admin', input: z.object({
input: z.object({ userNames: stringListOfSlugsSchemaRequired,
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,
}
},
}), }),
}, 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 { ActionError } from 'astro:actions'
import { z } from 'zod' import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage' import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectUserReturnFields = { const selectUserReturnFields = {
id: true, id: true,

View File

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

View File

@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
privateContext: true privateContext: true
orderId: true orderId: true
orderIdStatus: true orderIdStatus: true
rating: true
ratingActive: true
} }
}> }>
} }
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
<div <div
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2" 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 <button
class={cn( 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' comment.status === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
data-user-id={user.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>
<button <button
class={cn( 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.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',
comment.status === 'VERIFIED' comment.status === 'VERIFIED'
? 'border border-green-500/30 bg-green-500/20 text-green-400' ? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover: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-comment-id={comment.id}
data-user-id={user.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>
<button <button
class={cn( 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' comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover: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-comment-id={comment.id}
data-user-id={user.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>
<button <button
class={cn( 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 comment.kycRequested
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
data-user-id={user.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>
<button <button
class={cn( 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 comment.fundsBlocked
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
data-user-id={user.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> </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>
<div class="mt-2 space-y-1.5"> <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"> <div class="flex gap-1">
<button <button
class={cn( 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' comment.orderIdStatus === 'APPROVED'
? 'border border-green-500/30 bg-green-500/20 text-green-400' ? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover: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-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
Approve <Icon name="ri:check-line" class="h-3.5 w-3.5" />
<span>Approve</span>
</button> </button>
<button <button
class={cn( 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' comment.orderIdStatus === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover: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-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
Reject <Icon name="ri:close-line" class="h-3.5 w-3.5" />
<span>Reject</span>
</button> </button>
<button <button
class={cn( 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' comment.orderIdStatus === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover: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-comment-id={comment.id}
data-user-id={user.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> </button>
</div> </div>
</div> </div>
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
action === 'suspicious' || action === 'suspicious' ||
action === 'requires-admin-review' || action === 'requires-admin-review' ||
action === 'kyc-requested' || action === 'kyc-requested' ||
action === 'funds-blocked' action === 'funds-blocked' ||
action === 'toggle-rating-active'
? value === 'true' ? value === 'true'
: value, : value,
}) })
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
if (action === 'status') { if (action === 'status') {
window.location.reload() window.location.reload()
} else if (action === 'suspicious') { } 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('bg-yellow-500/20')
btn.classList.toggle('text-yellow-400') btn.classList.toggle('text-yellow-400')
btn.classList.toggle('border-yellow-500/30') 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.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true') btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'requires-admin-review') { } 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('bg-purple-500/20')
btn.classList.toggle('text-purple-400') btn.classList.toggle('text-purple-400')
btn.classList.toggle('border-purple-500/30') 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 // Refresh to show updated order ID status
window.location.reload() window.location.reload()
} else if (action === 'kyc-requested') { } 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('bg-red-500/20')
btn.classList.toggle('text-red-400') btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30') 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.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true') btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'funds-blocked') { } 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('bg-red-500/20')
btn.classList.toggle('text-red-400') btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30') btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border') btn.classList.toggle('border')
btn.classList.toggle('bg-night-700') btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true') 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 { } else {
console.error('Error moderating comment:', error) 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')}> <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')}> <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]" />} {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 && '*'} {required && '*'}
</legend> </legend>
{!!descriptionLabel && ( {!!descriptionLabel && (

View File

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

View File

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

View File

@@ -1,11 +1,14 @@
import { prisma } from './prisma' 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>( export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
userId: number, userId: number,
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never }, 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 ( return (
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ?? (await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??

View File

@@ -7,11 +7,13 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
import { makeCommentUrl } from './commentsWithReplies' import { makeCommentUrl } from './commentsWithReplies'
import type { NotificationAction } from './webPush'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
export function makeNotificationTitle( export function makeNotificationTitle(
notification: Prisma.NotificationGetPayload<{ notification: Prisma.NotificationGetPayload<{
select: { select: {
id: true
type: true type: true
aboutAccountStatusChange: true aboutAccountStatusChange: true
aboutCommentStatusChange: true aboutCommentStatusChange: true
@@ -87,6 +89,9 @@ export function makeNotificationTitle(
user: Prisma.UserGetPayload<{ select: { id: true } }> | null user: Prisma.UserGetPayload<{ select: { id: true } }> | null
): string { ): string {
switch (notification.type) { switch (notification.type) {
case 'TEST': {
return `Test notification #${notification.id.toString()}`
}
case 'COMMENT_STATUS_CHANGE': { case 'COMMENT_STATUS_CHANGE': {
if (!notification.aboutComment) return 'A comment you are watching had a 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( export function makeNotificationContent(
notification: Prisma.NotificationGetPayload<{ notification: Prisma.NotificationGetPayload<{
select: { select: {
createdAt: true
type: true type: true
aboutKarmaTransaction: { aboutKarmaTransaction: {
select: { select: {
@@ -204,6 +210,9 @@ export function makeNotificationContent(
}> }>
): string | null { ): string | null {
switch (notification.type) { 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. // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': // case 'KARMA_UNLOCK':
case 'KARMA_CHANGE': { case 'KARMA_CHANGE': {
@@ -236,7 +245,7 @@ export function makeNotificationContent(
} }
} }
export function makeNotificationLink( export function makeNotificationActions(
notification: Prisma.NotificationGetPayload<{ notification: Prisma.NotificationGetPayload<{
select: { select: {
type: true type: true
@@ -278,44 +287,120 @@ export function makeNotificationLink(
} }
}>, }>,
origin: string origin: string
): string | null { ): NotificationAction[] {
switch (notification.type) { 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 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED': case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED': case 'COMMUNITY_NOTE_ADDED':
case 'ROOT_COMMENT_CREATED': { case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return null if (!notification.aboutComment) return []
return makeCommentUrl({ return [
serviceSlug: notification.aboutComment.service.slug, {
commentId: notification.aboutComment.id, action: 'view',
origin, title: 'View',
}) ...iconNameAndUrl('ri:arrow-right-line'),
url: makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
}),
},
]
} }
case 'SUGGESTION_MESSAGE': { case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return null if (!notification.aboutServiceSuggestionMessage) return []
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}` 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': { case 'SUGGESTION_STATUS_CHANGE': {
if (!notification.aboutServiceSuggestionId) return null if (!notification.aboutServiceSuggestionId) return []
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}` 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. // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': { // case 'KARMA_UNLOCK': {
// return `${origin}/account#karma-unlocks` // return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
// } // }
case 'KARMA_CHANGE': { 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': { 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': { case 'EVENT_CREATED': {
if (!notification.aboutEvent) return null if (!notification.aboutEvent) return []
return `${origin}/service/${notification.aboutEvent.service.slug}#events` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
},
]
} }
case 'SERVICE_VERIFICATION_STATUS_CHANGE': { case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
if (!notification.aboutService) return null if (!notification.aboutService) return []
return `${origin}/service/${notification.aboutService.slug}#verification` 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 { Client } from 'pg'
import { zodParseJSON } from './json' import { zodParseJSON } from './json'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications' import { sendNotification } from './sendNotifications'
import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables' import { getServerEnvVariable } from './serverEnvVariables'
import { sendPushNotification, type NotificationData } from './webPush'
import type { AstroIntegration, HookParameters } from 'astro' import type { AstroIntegration, HookParameters } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL') const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
const SITE_URL = getServerEnvVariable('SITE_URL')
let pgClient: Client | null = null let pgClient: Client | null = null
@@ -24,155 +21,10 @@ async function handleNotificationCreated(
try { try {
logger.info(`Processing notification with ID: ${String(notificationId)}`) logger.info(`Processing notification with ID: ${String(notificationId)}`)
const notification = await prisma.notification.findUnique({ const results = await sendNotification(notificationId, logger)
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
logger.info( 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) { } catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(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 { webpush }
export type NotificationData = { export type NotificationAction = {
action: string
title: string title: string
body?: string
icon?: 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( export async function sendPushNotification(
@@ -28,26 +35,13 @@ export async function sendPushNotification(
auth: string auth: string
} }
}, },
data: NotificationData payload: NotificationPayload
) { ) {
try { try {
const result = await webpush.sendNotification( // NOTE: View sw.js to see how the notification is handled
subscription, const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
JSON.stringify({ TTL: 24 * 60 * 60, // 24 hours
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
}
)
return { success: true, result } as const return { success: true, result } as const
} catch (error) { } catch (error) {
console.error('Error sending push notification:', 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 - Terms of service or FAQ document
For examples: For examples:
- Just a Telegram link or a criptocurrency itself is not a valid service. - Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process ### 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. 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. 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. 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. 7. **Final Score Range:** The final score is always kept between 0 and 100.
#### Trust Score #### Trust Score
@@ -160,7 +161,7 @@ The trust score represents how reliable and trustworthy a service is, based on o
- **Approved:** +5 points - **Approved:** +5 points
- **Community Contributed:** 0 points - **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 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. 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. 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 { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es' import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro' import Button from '../../components/Button.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro' import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro' import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn' import { cn } from '../../lib/cn'
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied') 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 : {} const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message) 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> <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 <InputTextArea
label="Users" label="Users"
name="userNames" name="userNames"
@@ -121,35 +120,6 @@ const stats = [
error={testInputErrors.userNames} error={testInputErrors.userNames}
/> />
<InputText <Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
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" />
</form> </form>
</MiniLayout> </MiniLayout>

View File

@@ -1,10 +1,11 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro' import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro' import Chat from '../../../components/Chat.astro'
import InputSelect from '../../../components/InputSelect.astro'
import ServiceCard from '../../../components/ServiceCard.astro' import ServiceCard from '../../../components/ServiceCard.astro'
import UserBadge from '../../../components/UserBadge.astro' import UserBadge from '../../../components/UserBadge.astro'
import { import {
@@ -17,12 +18,20 @@ import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers' import { parseIntWithFallback } from '../../../lib/numbers'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls' import { makeLoginUrl } from '../../../lib/redirectUrls'
import { formatDateShort } from '../../../lib/timeAgo'
import BadgeStandard from '../../../components/BadgeStandard.astro'
const user = Astro.locals.user const user = Astro.locals.user
if (!user?.admin) { if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' })) 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 { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw) const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) { if (!serviceSuggestionId) {
@@ -100,114 +109,88 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
<BaseLayout <BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`} pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
htmx description="View and manage service suggestion"
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
htmx
> >
<div class="mb-4 flex items-center gap-4"> <h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
<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 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} /> <section class="border-night-400 bg-night-600 rounded-lg border p-6">
</div> <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">
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <span class="font-title font-bold">Status:</span>
<div> <BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
<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>
</div> </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 && ( serviceSuggestion.notes ? (
<div class="mb-4"> <div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3> ) : (
<div <div class="text-day-400 my-4 text-center text-sm italic">Empty</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>
) )
} }
</div> </div>
</div>
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs"> <form method="POST" action={actions.admin.serviceSuggestions.update} class="mt-6 flex items-end gap-2">
<div class="flex items-center justify-between"> <input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<h2 class="font-title text-day-200 text-lg">Messages</h2> <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"> <Chat
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} /> messages={serviceSuggestion.messages}
<select title="Chat with moderators"
name="status" userId={user.id}
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" action={actions.admin.serviceSuggestions.message}
> formData={{
{ suggestionId: serviceSuggestion.id,
serviceSuggestionStatuses.map((status) => ( }}
<option value={status.value} selected={serviceSuggestion.status === status.value}> class="mt-12"
{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>
</BaseLayout> </BaseLayout>

View File

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

View File

@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences' 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 { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls' import { makeLoginUrl } from '../lib/redirectUrls'
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
typeInfo: getNotificationTypeInfo(notification.type), typeInfo: getNotificationTypeInfo(notification.type),
title: makeNotificationTitle(notification, user), title: makeNotificationTitle(notification, user),
content: makeNotificationContent(notification), 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" 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" 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> </Tooltip>
</form> </form>
{notification.link && ( {notification.actions.map((action) => (
<a <Tooltip
href={notification.link} 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" 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" /> <Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
<span class="sr-only">View details</span> <span class="sr-only">{action.title}</span>
</a> </Tooltip>
)} ))}
</div> </div>
</div> </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="mt-12 mb-6 text-center">
<div class="flex items-center gap-2"> <h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} /> <div class="flex items-center justify-center gap-2">
<AdminOnly> <AdminOnly>
<Button <Button
as="a" as="a"
@@ -124,29 +124,24 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
/> />
</AdminOnly> </AdminOnly>
</div> </div>
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
</div> </div>
<ServiceCard service={serviceSuggestion.service} class="mb-6" /> <ServiceCard service={serviceSuggestion.service} class="mb-6" />
<section class="border-night-400 bg-night-600 rounded-lg border p-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"> <div class="flex flex-wrap items-center gap-2">
<span>Status:</span> <span class="font-title font-bold">Status:</span>
<span <BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
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>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <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> <span>
{ {
formatDateShort(serviceSuggestion.createdAt, { formatDateShort(serviceSuggestion.createdAt, {
@@ -157,15 +152,22 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
} }
</span> </span>
</div> </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>
<div class="mt-6"> <div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div> <span class="font-title block font-bold">Notes for moderators:</span>
{ {
serviceSuggestion.notes ? ( 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> </div>