Release 202506041937
This commit is contained in:
46
.env.example
46
.env.example
@@ -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
|
||||||
@@ -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
|
||||||
@@ -21,18 +19,15 @@ services:
|
|||||||
build:
|
build:
|
||||||
context: ./pyworker
|
context: ./pyworker
|
||||||
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 +48,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:
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "NotificationType" ADD VALUE 'TEST';
|
||||||
@@ -128,6 +128,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
|
||||||
|
|||||||
@@ -1,80 +1,36 @@
|
|||||||
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 { sendNotifications } from '../../lib/sendNotifications'
|
||||||
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 },
|
||||||
|
})
|
||||||
|
|
||||||
|
const results = await sendNotifications(notifications.map((notification) => notification.id))
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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 +88,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 +182,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 +209,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': {
|
||||||
@@ -280,6 +288,9 @@ export function makeNotificationLink(
|
|||||||
origin: string
|
origin: string
|
||||||
): string | null {
|
): string | null {
|
||||||
switch (notification.type) {
|
switch (notification.type) {
|
||||||
|
case 'TEST': {
|
||||||
|
return `${origin}/notifications`
|
||||||
|
}
|
||||||
case 'COMMENT_STATUS_CHANGE':
|
case 'COMMENT_STATUS_CHANGE':
|
||||||
case 'REPLY_COMMENT_CREATED':
|
case 'REPLY_COMMENT_CREATED':
|
||||||
case 'COMMUNITY_NOTE_ADDED':
|
case 'COMMUNITY_NOTE_ADDED':
|
||||||
|
|||||||
@@ -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 { sendNotifications } 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 sendNotifications([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.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed`
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||||
|
|||||||
185
web/src/lib/sendNotifications.ts
Normal file
185
web/src/lib/sendNotifications.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { sum } from 'lodash-es'
|
||||||
|
|
||||||
|
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
|
||||||
|
import { prisma } from './prisma'
|
||||||
|
import { getServerEnvVariable } from './serverEnvVariables'
|
||||||
|
import { type NotificationData, sendPushNotification } from './webPush'
|
||||||
|
|
||||||
|
import type { AstroIntegrationLogger } from 'astro'
|
||||||
|
|
||||||
|
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||||
|
|
||||||
|
export async function sendNotifications(
|
||||||
|
notificationIds: number[],
|
||||||
|
logger: AstroIntegrationLogger | Console = console
|
||||||
|
) {
|
||||||
|
const notifications = await prisma.notification.findMany({
|
||||||
|
where: { id: { in: notificationIds } },
|
||||||
|
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 (notifications.length < notificationIds.length) {
|
||||||
|
const missingNotificationIds = notificationIds.filter(
|
||||||
|
(id) => !notifications.some((notification) => notification.id === id)
|
||||||
|
)
|
||||||
|
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
notifications.map(async (notification) => {
|
||||||
|
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 subscriptionResults = 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
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
|
||||||
|
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
|
||||||
|
total: subscriptionResults.length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscriptions: {
|
||||||
|
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)),
|
||||||
|
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)),
|
||||||
|
total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)),
|
||||||
|
},
|
||||||
|
notifications: {
|
||||||
|
success: results.filter((r) => r.status === 'fulfilled').length,
|
||||||
|
failure: results.filter((r) => r.status !== 'fulfilled').length,
|
||||||
|
total: results.length,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user