Compare commits

...

3 Commits

Author SHA1 Message Date
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
19 changed files with 569 additions and 483 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
@@ -21,18 +19,15 @@ services:
build:
context: ./pyworker
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 +48,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

@@ -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"]

View File

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

View File

@@ -128,6 +128,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

@@ -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

@@ -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

@@ -147,7 +147,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 +160,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

@@ -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>