From 6a6908518d8fb4dc64df31bb6e49220a4c6a6364 Mon Sep 17 00:00:00 2001 From: pluja Date: Mon, 2 Jun 2025 03:53:03 +0000 Subject: [PATCH] Release 202506020353 --- web/.env.example | 4 + web/astro.config.mjs | 21 + web/package-lock.json | 123 +++++- web/package.json | 2 + .../migration.sql | 6 + .../migration.sql | 25 ++ web/prisma/schema.prisma | 26 ++ web/prisma/seed.ts | 12 +- web/public/sw.js | 83 ++++ web/src/actions/admin/announcement.ts | 6 +- web/src/actions/admin/index.ts | 4 +- web/src/actions/admin/notification.ts | 80 ++++ web/src/actions/admin/service.ts | 85 ++-- web/src/actions/notifications.ts | 59 ++- web/src/actions/serviceSuggestion.ts | 4 +- web/src/components/Button.astro | 7 +- web/src/components/InputSubmitButton.astro | 8 +- .../components/PushNotificationBanner.astro | 374 ++++++++++++++++++ web/src/constants/kycLevelClarifications.ts | 39 ++ web/src/lib/localstorage.ts | 77 ++++ web/src/lib/objects.ts | 13 + web/src/lib/webPush.ts | 52 +++ web/src/lib/zodUtils.ts | 5 + web/src/pages/admin/index.astro | 8 + web/src/pages/admin/notifications.astro | 155 ++++++++ .../pages/admin/services/[slug]/edit.astro | 17 + web/src/pages/internal-api/[...catchAll].ts | 13 + .../internal-api/notifications/subscribe.ts | 7 + .../internal-api/notifications/unsubscribe.ts | 7 + web/src/pages/notifications.astro | 369 +++++++++-------- web/src/pages/service-suggestion/new.astro | 16 + web/src/pages/service/[slug].astro | 30 +- 32 files changed, 1507 insertions(+), 230 deletions(-) create mode 100644 web/prisma/migrations/20250601172630_add_kyclevelclarification/migration.sql create mode 100644 web/prisma/migrations/20250601210540_add_push_subscriptions/migration.sql create mode 100644 web/public/sw.js create mode 100644 web/src/actions/admin/notification.ts create mode 100644 web/src/components/PushNotificationBanner.astro create mode 100644 web/src/constants/kycLevelClarifications.ts create mode 100644 web/src/lib/localstorage.ts create mode 100644 web/src/lib/webPush.ts create mode 100644 web/src/pages/admin/notifications.astro create mode 100644 web/src/pages/internal-api/[...catchAll].ts create mode 100644 web/src/pages/internal-api/notifications/subscribe.ts create mode 100644 web/src/pages/internal-api/notifications/unsubscribe.ts diff --git a/web/.env.example b/web/.env.example index 015618d..39b768d 100644 --- a/web/.env.example +++ b/web/.env.example @@ -7,3 +7,7 @@ ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.o I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p" RELEASE_NUMBER=123 RELEASE_DATE="2025-05-23T19:00:00.000Z" +# Generated with `npx web-push generate-vapid-keys` +VAPID_PUBLIC_KEY="" +VAPID_PRIVATE_KEY="" +VAPID_SUBJECT="mailto:no-reply@kycnot.me" diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 24cd6af..1caf909 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -195,6 +195,27 @@ export default defineConfig({ access: 'public', optional: true, }), + + // Generated with `npx web-push generate-vapid-keys` + VAPID_PUBLIC_KEY: envField.string({ + context: 'server', + access: 'public', + min: 1, + optional: false, + }), + // Generated with `npx web-push generate-vapid-keys` + VAPID_PRIVATE_KEY: envField.string({ + context: 'server', + access: 'secret', + min: 1, + optional: false, + }), + VAPID_SUBJECT: envField.string({ + context: 'server', + access: 'secret', + min: 1, + optional: false, + }), }, }, }) diff --git a/web/package-lock.json b/web/package-lock.json index 9901379..270f22a 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -44,6 +44,7 @@ "tailwindcss": "4.1.7", "typescript": "5.8.3", "unique-username-generator": "1.4.0", + "web-push": "3.6.7", "zod-form-data": "2.0.7" }, "devDependencies": { @@ -60,6 +61,7 @@ "@types/qrcode": "1.5.5", "@types/react": "19.1.4", "@types/seedrandom": "3.0.8", + "@types/web-push": "3.6.4", "@typescript-eslint/parser": "8.32.1", "astro-icon": "1.1.5", "date-fns": "4.1.0", @@ -3215,6 +3217,16 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/web-push": { + "version": "3.6.4", + "resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz", + "integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/ws": { "version": "8.5.13", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz", @@ -3797,6 +3809,15 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -4063,6 +4084,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "license": "MIT", + "dependencies": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -4822,6 +4855,12 @@ ], "license": "MIT" }, + "node_modules/bn.js": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz", + "integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==", + "license": "MIT" + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -4916,6 +4955,12 @@ "node": "*" } }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6157,6 +6202,15 @@ "node": ">= 0.4" } }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -8181,6 +8235,15 @@ "integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==", "license": "0BSD" }, + "node_modules/http_ece": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", + "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", @@ -8203,6 +8266,19 @@ "node": ">= 0.8" } }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", @@ -8935,6 +9011,27 @@ "node": ">=4.0" } }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -10556,6 +10653,12 @@ "mini-svg-data-uri": "cli.js" } }, + "node_modules/minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "license": "ISC" + }, "node_modules/minimatch": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", @@ -12696,7 +12799,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/sass-formatter": { @@ -14675,6 +14777,25 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/web-push": { + "version": "3.6.7", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", + "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", + "license": "MPL-2.0", + "dependencies": { + "asn1.js": "^5.3.0", + "http_ece": "1.2.0", + "https-proxy-agent": "^7.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5" + }, + "bin": { + "web-push": "src/cli.js" + }, + "engines": { + "node": ">= 16" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/web/package.json b/web/package.json index 039123b..35600d9 100644 --- a/web/package.json +++ b/web/package.json @@ -58,6 +58,7 @@ "tailwindcss": "4.1.7", "typescript": "5.8.3", "unique-username-generator": "1.4.0", + "web-push": "3.6.7", "zod-form-data": "2.0.7" }, "devDependencies": { @@ -74,6 +75,7 @@ "@types/qrcode": "1.5.5", "@types/react": "19.1.4", "@types/seedrandom": "3.0.8", + "@types/web-push": "3.6.4", "@typescript-eslint/parser": "8.32.1", "astro-icon": "1.1.5", "date-fns": "4.1.0", diff --git a/web/prisma/migrations/20250601172630_add_kyclevelclarification/migration.sql b/web/prisma/migrations/20250601172630_add_kyclevelclarification/migration.sql new file mode 100644 index 0000000..376024d --- /dev/null +++ b/web/prisma/migrations/20250601172630_add_kyclevelclarification/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS'); + +-- AlterTable +ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification", +ADD COLUMN "kycLevelDetailsId" INTEGER; diff --git a/web/prisma/migrations/20250601210540_add_push_subscriptions/migration.sql b/web/prisma/migrations/20250601210540_add_push_subscriptions/migration.sql new file mode 100644 index 0000000..3306846 --- /dev/null +++ b/web/prisma/migrations/20250601210540_add_push_subscriptions/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "PushSubscription" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "endpoint" TEXT NOT NULL, + "p256dh" TEXT NOT NULL, + "auth" TEXT NOT NULL, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint"); + +-- CreateIndex +CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId"); + +-- CreateIndex +CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint"); + +-- AddForeignKey +ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index 232b53f..e9be338 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -297,6 +297,11 @@ enum ServiceSuggestionType { EDIT_SERVICE } +enum KycLevelClarification { + NONE + DEPENDS_ON_PARTNERS +} + model ServiceSuggestion { id Int @id @default(autoincrement()) type ServiceSuggestionType @@ -340,6 +345,7 @@ model Service { description String categories Category[] @relation("ServiceToCategory") kycLevel Int @default(4) + kycLevelClarification KycLevelClarification? overallScore Int @default(0) privacyScore Int @default(0) trustScore Int @default(0) @@ -385,6 +391,7 @@ model Service { onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices") Notification Notification[] affiliatedUsers ServiceUser[] @relation("ServiceUsers") + kycLevelDetailsId Int? @@index([listedAt]) @@index([overallScore]) @@ -508,6 +515,7 @@ model User { notifications Notification[] @relation("NotificationOwner") notificationPreferences NotificationPreferences? serviceAffiliations ServiceUser[] @relation("UserServices") + pushSubscriptions PushSubscription[] @@index([createdAt]) @@index([totalKarma]) @@ -656,3 +664,21 @@ model Announcement { @@index([isActive, startDate, endDate]) } + +model PushSubscription { + id Int @id @default(autoincrement()) + userId Int + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + endpoint String @unique + /// Public key for encryption + p256dh String + /// Authentication secret + auth String + /// To identify different devices + userAgent String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([endpoint]) +} diff --git a/web/prisma/seed.ts b/web/prisma/seed.ts index 865ebde..d843805 100755 --- a/web/prisma/seed.ts +++ b/web/prisma/seed.ts @@ -18,8 +18,9 @@ import { type User, type ServiceVisibility, ServiceSuggestionType, + KycLevelClarification, } from '@prisma/client' -import { uniqBy } from 'lodash-es' +import { omit, uniqBy } from 'lodash-es' import { generateUsername } from 'unique-username-generator' import { kycLevels } from '../src/constants/kycLevels' @@ -615,6 +616,11 @@ const generateFakeService = (users: User[]) => { previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }), description: faker.helpers.arrayElement(serviceDescriptions), kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)), + kycLevelClarification: faker.helpers.maybe( + () => + faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])), + { probability: 0.25 } + ), overallScore: 0, privacyScore: 0, trustScore: 0, @@ -1135,7 +1141,7 @@ async function main() { // ---- Create services ---- const services = await Promise.all( Array.from({ length: numServices }, async () => { - const serviceData = generateFakeService(users) + const serviceData = generateFakeService([...users, ...Object.values(specialUsers)]) const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 }) const service = await prisma.service.create({ @@ -1275,7 +1281,7 @@ async function main() { // ---- Create service suggestions for normal_dev user ---- // First create 3 CREATE_SERVICE suggestions with their services for (let i = 0; i < 3; i++) { - const serviceData = generateFakeService(users) + const serviceData = generateFakeService([...users, ...Object.values(specialUsers)]) const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 }) const service = await prisma.service.create({ diff --git a/web/public/sw.js b/web/public/sw.js new file mode 100644 index 0000000..fdac2c6 --- /dev/null +++ b/web/public/sw.js @@ -0,0 +1,83 @@ +// @ts-check + +/// + +/** @type {ServiceWorkerGlobalScope} */ +// @ts-expect-error +const typedSelf = self + +const CACHE_NAME = 'kycnot-sw-push-notifications-v1' + +typedSelf.addEventListener('install', (event) => { + console.log('Service Worker installing') + typedSelf.skipWaiting() +}) + +typedSelf.addEventListener('activate', (event) => { + console.log('Service Worker activating') + event.waitUntil(typedSelf.clients.claim()) +}) + +typedSelf.addEventListener('push', (event) => { + console.log('Push event received:', event) + + if (!event.data) { + console.log('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 || {}, + requireInteraction: false, + silent: false, + ...options, + } + + event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions)) +}) + +typedSelf.addEventListener('notificationclick', (event) => { + console.log('Notification clicked:', event) + + event.notification.close() + + const url = event.notification.data?.url || '/' + + event.waitUntil( + typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => { + // If a window is already open, focus it + for (const client of clientList) { + if (client.url === url && 'focus' in client) { + return client.focus() + } + } + + // Otherwise, open a new window + if (typedSelf.clients.openWindow) { + return typedSelf.clients.openWindow(url) + } + }) + ) +}) + +typedSelf.addEventListener('notificationclose', (event) => { + console.log('Notification closed:', event) +}) diff --git a/web/src/actions/admin/announcement.ts b/web/src/actions/admin/announcement.ts index 3f9adbc..91bc42e 100644 --- a/web/src/actions/admin/announcement.ts +++ b/web/src/actions/admin/announcement.ts @@ -1,11 +1,9 @@ -import { type Prisma, type PrismaClient } from '@prisma/client' +import { type Prisma } from '@prisma/client' import { ActionError } from 'astro:actions' import { z } from 'zod' import { defineProtectedAction } from '../../lib/defineProtectedAction' -import { prisma as prismaInstance } from '../../lib/prisma' - -const prisma = prismaInstance as PrismaClient +import { prisma } from '../../lib/prisma' const selectAnnouncementReturnFields = { id: true, diff --git a/web/src/actions/admin/index.ts b/web/src/actions/admin/index.ts index 68a14c9..fead5b2 100644 --- a/web/src/actions/admin/index.ts +++ b/web/src/actions/admin/index.ts @@ -1,15 +1,17 @@ import { adminAnnouncementActions } from './announcement' import { adminAttributeActions } from './attribute' import { adminEventActions } from './event' +import { adminNotificationActions } from './notification' import { adminServiceActions } from './service' import { adminServiceSuggestionActions } from './serviceSuggestion' import { adminUserActions } from './user' import { verificationStep } from './verificationStep' export const adminActions = { - attribute: adminAttributeActions, announcement: adminAnnouncementActions, + attribute: adminAttributeActions, event: adminEventActions, + notification: adminNotificationActions, service: adminServiceActions, serviceSuggestions: adminServiceSuggestionActions, user: adminUserActions, diff --git a/web/src/actions/admin/notification.ts b/web/src/actions/admin/notification.ts new file mode 100644 index 0000000..f790a93 --- /dev/null +++ b/web/src/actions/admin/notification.ts @@ -0,0 +1,80 @@ +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, + } + }, + }), + }, +} diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 482b34d..38bbde4 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -1,4 +1,4 @@ -import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client' +import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client' import { z } from 'astro/zod' import { ActionError } from 'astro:actions' import { uniq } from 'lodash-es' @@ -10,35 +10,6 @@ import { prisma } from '../../lib/prisma' import { separateServiceUrlsByType } from '../../lib/urls' import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils' -const serviceSchemaBase = z.object({ - id: z.number().int().positive(), - slug: z - .string() - .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') - .optional(), - name: z.string().min(1).max(40), - description: z.string().min(1), - allServiceUrls: stringListOfUrlsSchemaRequired, - tosUrls: stringListOfUrlsSchemaRequired, - kycLevel: z.coerce.number().int().min(0).max(4), - attributes: z.array(z.coerce.number().int().positive()), - categories: z.array(z.coerce.number().int().positive()).min(1), - verificationStatus: z.nativeEnum(VerificationStatus), - verificationSummary: z.string().optional().nullable().default(null), - verificationProofMd: z.string().optional().nullable().default(null), - acceptedCurrencies: z.array(z.nativeEnum(Currency)), - referral: z - .string() - .regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL') - .optional() - .nullable() - .default(null), - imageFile: imageFileSchema, - overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), - serviceVisibility: z.nativeEnum(ServiceVisibility), - internalNote: z.string().optional(), -}) - const addSlugIfMissing = < T extends { slug?: string | null | undefined @@ -58,12 +29,52 @@ const addSlugIfMissing = < }), }) +const serviceSchemaBase = z.object({ + id: z.number().int().positive(), + slug: z + .string() + .regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens') + .optional(), + name: z.string().min(1).max(40), + description: z.string().min(1), + allServiceUrls: stringListOfUrlsSchemaRequired, + tosUrls: stringListOfUrlsSchemaRequired, + kycLevel: z.coerce.number().int().min(0).max(4), + kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null), + attributes: z.array(z.coerce.number().int().positive()), + categories: z.array(z.coerce.number().int().positive()).min(1), + verificationStatus: z.nativeEnum(VerificationStatus), + verificationSummary: z.string().optional().nullable().default(null), + verificationProofMd: z.string().optional().nullable().default(null), + acceptedCurrencies: z.array(z.nativeEnum(Currency)), + referral: z + .string() + .regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL') + .optional() + .nullable() + .default(null), + imageFile: imageFileSchema, + overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(), + serviceVisibility: z.nativeEnum(ServiceVisibility), + internalNote: z.string().optional(), +}) + +// Define schema for the create action input +const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing) + +// Define schema for the update action input +const updateServiceInputSchema = serviceSchemaBase + .extend({ + removeImage: z.boolean().optional(), + }) + .transform(addSlugIfMissing) + export const adminServiceActions = { create: defineProtectedAction({ accept: 'form', permissions: 'admin', - input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing), - handler: async (input, context) => { + input: createServiceInputSchema, + handler: async (input: z.infer, context) => { const existing = await prisma.service.findUnique({ where: { slug: input.slug, @@ -96,6 +107,7 @@ export const adminServiceActions = { onionUrls, i2pUrls, kycLevel: input.kycLevel, + kycLevelClarification: input.kycLevelClarification, verificationStatus: input.verificationStatus, verificationSummary: input.verificationSummary, verificationProofMd: input.verificationProofMd, @@ -137,12 +149,8 @@ export const adminServiceActions = { update: defineProtectedAction({ accept: 'form', permissions: 'admin', - input: serviceSchemaBase - .extend({ - removeImage: z.boolean().optional(), - }) - .transform(addSlugIfMissing), - handler: async (input) => { + input: updateServiceInputSchema, + handler: async (input: z.infer) => { const anotherServiceWithNewSlug = await prisma.service.findUnique({ where: { slug: input.slug, @@ -217,6 +225,7 @@ export const adminServiceActions = { onionUrls, i2pUrls, kycLevel: input.kycLevel, + kycLevelClarification: input.kycLevelClarification, verificationStatus: input.verificationStatus, verificationSummary: input.verificationSummary, verificationProofMd: input.verificationProofMd, diff --git a/web/src/actions/notifications.ts b/web/src/actions/notifications.ts index b415f17..3a039bf 100644 --- a/web/src/actions/notifications.ts +++ b/web/src/actions/notifications.ts @@ -23,6 +23,63 @@ export const notificationActions = { }) }, }), + + webPush: { + subscribe: defineProtectedAction({ + accept: 'json', + permissions: 'user', + input: z.object({ + endpoint: z.string(), + p256dhKey: z.string(), + authKey: z.string(), + userAgent: z.string().optional(), + }), + handler: async (input, context) => { + await prisma.pushSubscription.upsert({ + where: { + userId: context.locals.user.id, + endpoint: input.endpoint, + }, + update: { + p256dh: input.p256dhKey, + auth: input.authKey, + userAgent: input.userAgent, + }, + create: { + userId: context.locals.user.id, + endpoint: input.endpoint, + p256dh: input.p256dhKey, + auth: input.authKey, + userAgent: input.userAgent, + }, + }) + }, + }), + + unsubscribe: defineProtectedAction({ + accept: 'json', + permissions: 'user', + input: z.object({ + endpoint: z.string().optional(), + }), + handler: async (input, context) => { + if (input.endpoint) { + await prisma.pushSubscription.deleteMany({ + where: { + userId: context.locals.user.id, + endpoint: input.endpoint, + }, + }) + } else { + await prisma.pushSubscription.deleteMany({ + where: { + userId: context.locals.user.id, + }, + }) + } + }, + }), + }, preferences: { update: defineProtectedAction({ accept: 'form', @@ -31,7 +88,7 @@ export const notificationActions = { enableOnMyCommentStatusChange: z.coerce.boolean().optional(), enableAutowatchMyComments: z.coerce.boolean().optional(), enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(), - karmaNotificationThreshold: z.coerce.number().int().min(1).optional(), + karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(), }), handler: async (input, context) => { await prisma.notificationPreferences.upsert({ diff --git a/web/src/actions/serviceSuggestion.ts b/web/src/actions/serviceSuggestion.ts index c595345..3a0dd2f 100644 --- a/web/src/actions/serviceSuggestion.ts +++ b/web/src/actions/serviceSuggestion.ts @@ -1,4 +1,4 @@ -import { Currency } from '@prisma/client' +import { Currency, KycLevelClarification } from '@prisma/client' import { z } from 'astro/zod' import { ActionError } from 'astro:actions' import { formatDistanceStrict } from 'date-fns' @@ -154,6 +154,7 @@ export const serviceSuggestionActions = { allServiceUrls: stringListOfUrlsSchemaRequired, tosUrls: stringListOfUrlsSchemaRequired, kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)), + kycLevelClarification: z.nativeEnum(KycLevelClarification), attributes: z.array(z.coerce.number().int().positive()), categories: z.array(z.coerce.number().int().positive()).min(1), acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1), @@ -221,6 +222,7 @@ export const serviceSuggestionActions = { onionUrls, i2pUrls, kycLevel: input.kycLevel, + kycLevelClarification: input.kycLevelClarification, acceptedCurrencies: input.acceptedCurrencies, imageUrl, verificationStatus: 'COMMUNITY_CONTRIBUTED', diff --git a/web/src/components/Button.astro b/web/src/components/Button.astro index caafc26..186c3c0 100644 --- a/web/src/components/Button.astro +++ b/web/src/components/Button.astro @@ -6,7 +6,7 @@ import { cn } from '../lib/cn' import type { HTMLAttributes, Polymorphic } from 'astro/types' -type Props = Polymorphic< +type Props = Polymorphic< Required, Tag extends 'label' ? 'for' : never>> & VariantProps & { as: Tag @@ -249,7 +249,7 @@ const button = tv({ }) const { - as: Tag = 'button' as 'a' | 'button' | 'label', + as: Tag = 'button' as 'a' | 'button' | 'label' | 'span', label, icon, endIcon, @@ -286,8 +286,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag & { hideCancel?: boolean icon?: string label?: string + disabled?: boolean + color?: ComponentProps['color'] } const { hideCancel = false, icon = 'ri:send-plane-2-line', label = 'Submit', + disabled = false, class: className, + color = 'success', ...htmlProps } = Astro.props ---
{!hideCancel &&
diff --git a/web/src/components/PushNotificationBanner.astro b/web/src/components/PushNotificationBanner.astro new file mode 100644 index 0000000..c8d2ed7 --- /dev/null +++ b/web/src/components/PushNotificationBanner.astro @@ -0,0 +1,374 @@ +--- +import { Icon } from 'astro-icon/components' +import { VAPID_PUBLIC_KEY } from 'astro:env/server' + +import { cn } from '../lib/cn' + +import Button from './Button.astro' + +import type { Prisma } from '@prisma/client' +import type { HTMLAttributes } from 'astro/types' + +type Props = HTMLAttributes<'div'> & { + dismissable?: boolean + hideIfEnabled?: boolean + pushSubscriptions: Prisma.PushSubscriptionGetPayload<{ + select: { + endpoint: true + userAgent: true + } + }>[] +} + +const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props + +// TODO: Feature flag, enabled only for admins +if (!Astro.locals.user?.admin) { + return null +} +--- + +
+ + +
+
+ +
+
+

+ Push notifications enabled + Turn on push notifications? +

+

+ Turn notifications off for this device? + Get notifications on this device. +

+
+
+ +
+ {dismissable &&
+
+ + + + + + diff --git a/web/src/constants/kycLevelClarifications.ts b/web/src/constants/kycLevelClarifications.ts new file mode 100644 index 0000000..b041457 --- /dev/null +++ b/web/src/constants/kycLevelClarifications.ts @@ -0,0 +1,39 @@ +import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' +import { transformCase } from '../lib/strings' + +import type { KycLevelClarification } from '@prisma/client' + +type KycLevelClarificationInfo = { + value: T + label: string + description: string + icon: string +} + +export const { + dataArray: kycLevelClarifications, + dataObject: kycLevelClarificationsById, + getFn: getKycLevelClarificationInfo, +} = makeHelpersForOptions( + 'value', + (value): KycLevelClarificationInfo => ({ + value, + label: value ? transformCase(value.replace('_', ' '), 'title') : String(value), + description: '', + icon: 'ri:question-line', + }), + [ + { + value: 'NONE', + label: 'None', + description: 'No clarification needed.', + icon: 'ri:file-copy-line', + }, + { + value: 'DEPENDS_ON_PARTNERS', + label: 'Depends on partners', + description: 'May vary across partners.', + icon: 'ri:share-forward-line', + }, + ] as const satisfies KycLevelClarificationInfo[] +) diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts new file mode 100644 index 0000000..b73ab44 --- /dev/null +++ b/web/src/lib/localstorage.ts @@ -0,0 +1,77 @@ +import { z } from 'astro:schema' + +import { typedObjectEntries } from './objects' + +// eslint-disable-next-line @typescript-eslint/consistent-type-definitions +interface JSONObject { + [k: string]: JSONValue +} +type JSONList = JSONValue[] +type JSONPrimitive = boolean | number | string | null +type JSONValue = Date | JSONList | JSONObject | JSONPrimitive + +function makeTypedLocalStorage< + Schemas extends Record>, + T extends { + [K in keyof Schemas]: { + schema: Schemas[K] + default?: z.output | undefined + key?: string + } + }, +>(options: T) { + return Object.fromEntries( + typedObjectEntries(options).map(([originalKey, option]) => { + const key = option.key ?? originalKey + + return [ + key, + { + get: () => { + const stringValue = localStorage.getItem(key) + if (!stringValue) return option.default + + let jsonValue: z.output | undefined = option.default + try { + jsonValue = JSON.parse(stringValue) + } catch (error) { + console.error(error) + return option.default + } + + const parsedValue = option.schema.safeParse(jsonValue) + if (!parsedValue.success) { + console.error(parsedValue.error) + return option.default + } + + return parsedValue.data + }, + + set: (value: z.input) => { + localStorage.setItem(key, JSON.stringify(value)) + }, + + remove: () => { + localStorage.removeItem(key) + }, + + default: option.default, + }, + ] + }) + ) as { + [K in keyof T]: { + get: () => z.output | (T[K] extends { default: infer D } ? D : undefined) + set: (value: z.input) => void + remove: () => void + default: z.output | undefined + } + } +} + +export const typedLocalStorage = makeTypedLocalStorage({ + pushNotificationsBannerDismissedAt: { + schema: z.coerce.date(), + }, +}) diff --git a/web/src/lib/objects.ts b/web/src/lib/objects.ts index 7a593ec..40afc2f 100644 --- a/web/src/lib/objects.ts +++ b/web/src/lib/objects.ts @@ -162,3 +162,16 @@ export function areEqualObjectsWithoutOrder>( return undefined }) } + +/** + * Same as {@link Object.entries}, but with proper typing. + * @example + * typedObjectEntries({ a: 1, b: 2 }) // [['a', 1], ['b', 2]] + */ +export function typedObjectEntries>(obj: T) { + return Object.entries(obj) as Prettify< + { + [K in Extract]: [K, T[K]] + }[Extract] + >[] +} diff --git a/web/src/lib/webPush.ts b/web/src/lib/webPush.ts new file mode 100644 index 0000000..50f51ff --- /dev/null +++ b/web/src/lib/webPush.ts @@ -0,0 +1,52 @@ +/* eslint-disable import/no-named-as-default-member */ +import { VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY, VAPID_SUBJECT } from 'astro:env/server' +import webpush, { WebPushError } from 'web-push' + +// Configure VAPID keys +webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) + +export { webpush } + +export async function sendPushNotification( + subscription: { + endpoint: string + keys: { + p256dh: string + auth: string + } + }, + data: { + title: string + body?: string + icon?: string + badge?: string + url?: string + } +) { + 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 + } + ) + return { success: true, result } as const + } catch (error) { + console.error('Error sending push notification:', error) + return { + success: false, + error: error instanceof WebPushError ? error : undefined, + } as const + } +} diff --git a/web/src/lib/zodUtils.ts b/web/src/lib/zodUtils.ts index b2f5a3f..a199022 100644 --- a/web/src/lib/zodUtils.ts +++ b/web/src/lib/zodUtils.ts @@ -34,6 +34,11 @@ const stringToArrayFactory = (delimiter: RegExp | string = ',') => { .filter((item) => item !== '') } +export const stringListOfSlugsSchemaRequired = z.preprocess( + stringToArrayFactory(/[\s,\n]+/), + z.array(z.string().regex(/^[a-z0-9-_A-Z]+$/)).min(1) +) + export const stringListOfUrlsSchema = z.preprocess( stringToArrayFactory(/[\s,\n]+/), z.array(zodUrlOptionalProtocol).default([]) diff --git a/web/src/pages/admin/index.astro b/web/src/pages/admin/index.astro index 8e7012d..051d7df 100644 --- a/web/src/pages/admin/index.astro +++ b/web/src/pages/admin/index.astro @@ -65,6 +65,14 @@ const adminLinks: AdminLink[] = [ base: 'text-pink-300', }, }, + { + icon: 'ri:notification-3-line', + title: 'Notifications', + href: '/admin/notifications', + classNames: { + base: 'text-indigo-300', + }, + }, { icon: 'ri:rocket-2-line', title: 'Releases', diff --git a/web/src/pages/admin/notifications.astro b/web/src/pages/admin/notifications.astro new file mode 100644 index 0000000..1417612 --- /dev/null +++ b/web/src/pages/admin/notifications.astro @@ -0,0 +1,155 @@ +--- +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 InputTextArea from '../../components/InputTextArea.astro' +import MiniLayout from '../../layouts/MiniLayout.astro' +import { cn } from '../../lib/cn' +import { prisma } from '../../lib/prisma' + +// Check if user is admin +if (!Astro.locals.user?.admin) { + return Astro.redirect('/access-denied') +} + +const testResult = Astro.getActionResult(actions.admin.notification.webPush.test) +const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {} + +Astro.locals.banners.addIfSuccess(testResult, (data) => data.message) + +const subscriptions = await Astro.locals.banners.try( + 'Error while fetching subscriptions by user', + () => + prisma.pushSubscription.findMany({ + select: { + id: true, + user: { + select: { + id: true, + name: true, + }, + }, + }, + }), + [] as [] +) +const totalSubscriptions = subscriptions.length +const subscriptionsByUser = groupBy(subscriptions, 'user.id') + +const totalUsers = Object.keys(subscriptionsByUser).length + +const adminUsers = await prisma.user.findMany({ + where: { + admin: true, + }, + select: { + name: true, + }, +}) + +const stats = [ + { + icon: 'ri:notification-4-line', + iconClass: 'text-blue-400', + title: 'Total Subscriptions', + value: totalSubscriptions.toLocaleString(), + }, + { + icon: 'ri:user-3-line', + iconClass: 'text-green-400', + title: 'Subscribed Users', + value: totalUsers.toLocaleString(), + }, + { + icon: 'ri:smartphone-line', + iconClass: 'text-purple-400', + title: 'Avg Devices/User', + value: (totalUsers > 0 ? round(totalSubscriptions / totalUsers, 1) : 0).toLocaleString(), + }, +] satisfies { + icon: string + iconClass: string + title: string + value: string +}[] +--- + + +
+ { + stats.map((stat) => ( +
+
+ {stat.value} + +
+ + {stat.title} + +
+ )) + } +
+ +

Send Test Notification

+ +
+ user.name)).join('\n')} + description={[ + '- Comma-separated list of user names.', + '- Minimum 1 user name.', + '- By default, all admin users are selected.', + ].join('\n')} + error={testInputErrors.userNames} + /> + + + + + + + + + +
diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index b52d1d8..00a60f7 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -26,6 +26,7 @@ import { getAttributeTypeInfo } from '../../../../constants/attributeTypes' import { formatContactMethod } from '../../../../constants/contactMethods' import { currencies } from '../../../../constants/currencies' import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes' +import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications' import { kycLevels } from '../../../../constants/kycLevels' import { serviceVisibilities } from '../../../../constants/serviceVisibility' import { verificationStatuses } from '../../../../constants/verificationStatus' @@ -415,6 +416,22 @@ const apiCalls = await Astro.locals.banners.try( class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]" /> + ({ + label: clarification.label, + value: clarification.value, + icon: clarification.icon, + description: clarification.description, + noTransitionPersist: true, + }))} + selectedValue={service.kycLevelClarification ?? 'NONE'} + iconSize="sm" + cardSize="sm" + error={serviceInputErrors.kycLevelClarification} + /> + { + console.error('Endpoint not found', { url: context.url.href, method: context.request.method }) + return new Response( + JSON.stringify({ + error: 'Endpoint not found', + }), + { + status: 404, + } + ) +} diff --git a/web/src/pages/internal-api/notifications/subscribe.ts b/web/src/pages/internal-api/notifications/subscribe.ts new file mode 100644 index 0000000..4dadbdf --- /dev/null +++ b/web/src/pages/internal-api/notifications/subscribe.ts @@ -0,0 +1,7 @@ +import { actions } from 'astro:actions' + +import { makeEndpointFromAction } from '../../../lib/endpoints' + +import type { APIRoute } from 'astro' + +export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.subscribe) diff --git a/web/src/pages/internal-api/notifications/unsubscribe.ts b/web/src/pages/internal-api/notifications/unsubscribe.ts new file mode 100644 index 0000000..33c42b7 --- /dev/null +++ b/web/src/pages/internal-api/notifications/unsubscribe.ts @@ -0,0 +1,7 @@ +import { actions } from 'astro:actions' + +import { makeEndpointFromAction } from '../../../lib/endpoints' + +import type { APIRoute } from 'astro' + +export const POST: APIRoute = makeEndpointFromAction(actions.notification.webPush.unsubscribe) diff --git a/web/src/pages/notifications.astro b/web/src/pages/notifications.astro index 73bef7a..e487345 100644 --- a/web/src/pages/notifications.astro +++ b/web/src/pages/notifications.astro @@ -4,6 +4,7 @@ import { Icon } from 'astro-icon/components' import { actions } from 'astro:actions' import Button from '../components/Button.astro' +import PushNotificationBanner from '../components/PushNotificationBanner.astro' import TimeFormatted from '../components/TimeFormatted.astro' import Tooltip from '../components/Tooltip.astro' import { getNotificationTypeInfo } from '../constants/notificationTypes' @@ -28,131 +29,146 @@ const { data: params } = zodParseQueryParamsStoringErrors( ) const skip = (params.page - 1) * PAGE_SIZE -const [dbNotifications, notificationPreferences, totalNotifications] = await Astro.locals.banners.tryMany([ - [ - 'Error while fetching notifications', - () => - prisma.notification.findMany({ - where: { - userId: user.id, - }, - orderBy: { - createdAt: 'desc', - }, - skip, - take: PAGE_SIZE, - select: { +const [dbNotifications, notificationPreferences, totalNotifications, pushSubscriptions] = + await Astro.locals.banners.tryMany([ + [ + 'Error while fetching notifications', + () => + prisma.notification.findMany({ + where: { + userId: user.id, + }, + orderBy: { + createdAt: 'desc', + }, + skip, + take: PAGE_SIZE, + select: { + id: true, + type: true, + createdAt: true, + read: true, + aboutAccountStatusChange: true, + aboutCommentStatusChange: true, + aboutServiceVerificationStatusChange: true, + aboutSuggestionStatusChange: true, + aboutComment: { + select: { + id: true, + author: { + select: { + id: true, + }, + }, + status: true, + content: true, + communityNote: true, + service: { + select: { + slug: true, + name: true, + }, + }, + parent: { + select: { + author: { + select: { + id: 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, + }, + }, + }, + }), + [], + ], + [ + 'Error while fetching notification preferences', + () => + getOrCreateNotificationPreferences(user.id, { id: true, - type: true, - createdAt: true, - read: true, - aboutAccountStatusChange: true, - aboutCommentStatusChange: true, - aboutServiceVerificationStatusChange: true, - aboutSuggestionStatusChange: true, - aboutComment: { - select: { - id: true, - author: { - select: { - id: true, - }, - }, - status: true, - content: true, - communityNote: true, - service: { - select: { - slug: true, - name: true, - }, - }, - parent: { - select: { - author: { - select: { - id: true, - }, - }, - }, - }, - }, + enableOnMyCommentStatusChange: true, + enableAutowatchMyComments: true, + enableNotifyPendingRepliesOnWatch: true, + karmaNotificationThreshold: true, + }), + null, + ], + [ + 'Error while fetching total notifications', + () => prisma.notification.count({ where: { userId: user.id } }), + 0, + ], + [ + 'Error while fetching push subscriptions', + () => + prisma.pushSubscription.findMany({ + where: { userId: user.id }, + select: { + endpoint: true, + userAgent: 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, - }, - }, - }, - }), - [], - ], - [ - 'Error while fetching notification preferences', - () => - getOrCreateNotificationPreferences(user.id, { - id: true, - enableOnMyCommentStatusChange: true, - enableAutowatchMyComments: true, - enableNotifyPendingRepliesOnWatch: true, - karmaNotificationThreshold: true, - }), - null, - ], - [ - 'Error while fetching total notifications', - () => prisma.notification.count({ where: { userId: user.id } }), - 0, - ], -]) + }), + [], + ], + ]) + +if (!notificationPreferences) console.error('No notification preferences found') const totalPages = Math.ceil(totalNotifications / PAGE_SIZE) @@ -199,6 +215,17 @@ const notifications = dbNotifications.map((notification) => ({ }} >
+ { + notifications.length >= 5 && ( + + ) + } +

@@ -306,57 +333,57 @@ const notifications = dbNotifications.map((notification) => ({ ) } - { - !!notificationPreferences && ( -
-

- - Notification Settings -

+

+ + Notification Settings +

-
- {notificationPreferenceFields.map((field) => ( - - ))} + -
- - - Notify me when my karma changes by at least - -
- - points -
-
+ + { + notificationPreferenceFields.map((field) => ( + + )) + } -
-
- +
+ + + Notify me when my karma changes by at least + +
+ + points
- ) - } +
+ +
+
+

diff --git a/web/src/pages/service-suggestion/new.astro b/web/src/pages/service-suggestion/new.astro index a92713c..cbb29a5 100644 --- a/web/src/pages/service-suggestion/new.astro +++ b/web/src/pages/service-suggestion/new.astro @@ -20,6 +20,7 @@ import InputTextArea from '../../components/InputTextArea.astro' import { getAttributeCategoryInfo } from '../../constants/attributeCategories' import { getAttributeTypeInfo } from '../../constants/attributeTypes' import { currencies } from '../../constants/currencies' +import { kycLevelClarifications } from '../../constants/kycLevelClarifications' import { kycLevels } from '../../constants/kycLevels' import BaseLayout from '../../layouts/BaseLayout.astro' import { prisma } from '../../lib/prisma' @@ -242,6 +243,21 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([ error={inputErrors.kycLevel} /> + ({ + label: clarification.label, + value: clarification.value, + icon: clarification.icon, + description: clarification.description, + }))} + selectedValue="NONE" + iconSize="sm" + cardSize="sm" + error={inputErrors.kycLevelClarification} + /> +
-
{kycLevelInfo.name}
+
+ {kycLevelInfo.name} + { + kycLevelClarificationInfo.value !== 'NONE' && ( + <> + + {kycLevelClarificationInfo.label} + + ) + } +
{kycLevelInfo.description} + + {kycLevelClarificationInfo.value !== 'NONE' && ` ${kycLevelClarificationInfo.description}`} +