Release 202506020353
This commit is contained in:
@@ -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-public-key-placeholder>"
|
||||
VAPID_PRIVATE_KEY="<vapid-private-key-placeholder>"
|
||||
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
123
web/package-lock.json
generated
123
web/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
83
web/public/sw.js
Normal file
83
web/public/sw.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// @ts-check
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/** @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)
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
80
web/src/actions/admin/notification.ts
Normal file
80
web/src/actions/admin/notification.ts
Normal file
@@ -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,
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -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<typeof createServiceInputSchema>, 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<typeof updateServiceInputSchema>) => {
|
||||
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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -6,7 +6,7 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
||||
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
|
||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||
VariantProps<typeof button> & {
|
||||
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
|
||||
|
||||
<ActualTag
|
||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||
role={role ??
|
||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
|
||||
@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hideCancel?: boolean
|
||||
icon?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
color?: ComponentProps<typeof Button>['color']
|
||||
}
|
||||
|
||||
const {
|
||||
hideCancel = false,
|
||||
icon = 'ri:send-plane-2-line',
|
||||
label = 'Submit',
|
||||
disabled = false,
|
||||
class: className,
|
||||
color = 'success',
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
|
||||
</div>
|
||||
|
||||
374
web/src/components/PushNotificationBanner.astro
Normal file
374
web/src/components/PushNotificationBanner.astro
Normal file
@@ -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
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
data-push-notification-banner
|
||||
data-dismissed={undefined /* Updated by client script */}
|
||||
data-supports-push-notifications={undefined /* Updated by client script */}
|
||||
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
||||
data-is-enabled={undefined /* Updated by client script */}
|
||||
class={cn(
|
||||
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
|
||||
'data-dismissed:hidden',
|
||||
hideIfEnabled && 'data-is-enabled:hidden',
|
||||
'not-data-supports-push-notifications:hidden',
|
||||
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="rounded-md bg-blue-800/30 p-2">
|
||||
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-blue-100">
|
||||
<span data-show-if-enabled>Push notifications enabled</span>
|
||||
<span data-show-if-disabled>Turn on push notifications?</span>
|
||||
</h3>
|
||||
<p class="text-sm text-blue-200/80">
|
||||
<span data-show-if-enabled>Turn notifications off for this device?</span>
|
||||
<span data-show-if-disabled>Get notifications on this device.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
||||
<Button
|
||||
as="span"
|
||||
label="Yes, notify me"
|
||||
color="white"
|
||||
data-push-action="subscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-disabled
|
||||
/>
|
||||
<Button
|
||||
as="span"
|
||||
label="Stop notifications"
|
||||
color="white"
|
||||
variant="faded"
|
||||
data-push-action="unsubscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-enabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification banner dismissal. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
|
||||
import { typedLocalStorage } from '../lib/localstorage'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
|
||||
|
||||
if (
|
||||
pushNotificationsBannerDismissedAt &&
|
||||
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
|
||||
) {
|
||||
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
|
||||
pushNotificationsBannerDismissedAt = undefined
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
|
||||
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
|
||||
if (!skipButton) return
|
||||
|
||||
if (pushNotificationsBannerDismissedAt) {
|
||||
banner.dataset.dismissed = ''
|
||||
}
|
||||
|
||||
skipButton.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
banner.dataset.dismissed = ''
|
||||
|
||||
const now = new Date()
|
||||
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
|
||||
pushNotificationsBannerDismissedAt = now
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////
|
||||
// Script to style when notifications enabled. //
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
/** Parse push subscriptions from string */
|
||||
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||
try {
|
||||
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
||||
|
||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||
|
||||
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
||||
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
|
||||
throw new Error('Push subscriptions must be an array of objects with endpoint property')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects with userAgent property')
|
||||
}
|
||||
|
||||
return subscriptions as ServerSubscription[]
|
||||
} catch (error) {
|
||||
console.error('Failed to parse push subscriptions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current device has an active push subscription */
|
||||
async function getCurrentPushSubscription() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return null
|
||||
|
||||
return await registration.pushManager.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('Error getting current push subscription:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current subscription matches any server subscription */
|
||||
function isCurrentDeviceSubscribed(
|
||||
currentSubscription: PushSubscription | null,
|
||||
serverSubscriptions: ServerSubscription[]
|
||||
) {
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', async () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
||||
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||
const currentSubscription = await getCurrentPushSubscription()
|
||||
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
||||
|
||||
if (isSubscribed) banner.dataset.isEnabled = ''
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification subscription. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
|
||||
import type { actions } from 'astro:actions'
|
||||
import type { ActionInput } from '../lib/astroActions'
|
||||
|
||||
/** Utility function to convert VAPID key */
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||
const base64 = cleaned + padding
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
/** Check for browser support */
|
||||
function checkSupport() {
|
||||
const isSecure =
|
||||
window.isSecureContext ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js')
|
||||
console.log('Service Worker registered:', registration)
|
||||
|
||||
const readyRegistration = await navigator.serviceWorker.ready
|
||||
console.log('Service Worker is active and ready:', readyRegistration)
|
||||
|
||||
return readyRegistration
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPush(vapidPublicKey: string) {
|
||||
try {
|
||||
if (!checkSupport()) return
|
||||
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
alert('Push notifications permission denied')
|
||||
return
|
||||
}
|
||||
|
||||
const registration = await registerServiceWorker()
|
||||
|
||||
// Subscribe to push manager
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push subscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error)
|
||||
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeFromPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) {
|
||||
console.log('No service worker registration found')
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
console.log('No push subscription found')
|
||||
return
|
||||
}
|
||||
|
||||
// Unsubscribe from browser
|
||||
await subscription.unsubscribe()
|
||||
|
||||
// Remove from server
|
||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push unsubscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push unsubscription failed:', error)
|
||||
alert('Failed to unsubscribe from push notifications')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const supportsPushNotifications = checkSupport()
|
||||
if (supportsPushNotifications) {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
|
||||
element.dataset.supportsPushNotifications = ''
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||
const vapidPublicKey = button.dataset.vapidPublicKey
|
||||
if (!vapidPublicKey) {
|
||||
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
|
||||
return
|
||||
}
|
||||
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const action = button.dataset.pushAction
|
||||
if (action === 'subscribe') {
|
||||
await subscribeToPush(vapidPublicKey)
|
||||
} else if (action === 'unsubscribe') {
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
39
web/src/constants/kycLevelClarifications.ts
Normal file
39
web/src/constants/kycLevelClarifications.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { KycLevelClarification } from '@prisma/client'
|
||||
|
||||
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: kycLevelClarifications,
|
||||
dataObject: kycLevelClarificationsById,
|
||||
getFn: getKycLevelClarificationInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): KycLevelClarificationInfo<typeof value> => ({
|
||||
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<KycLevelClarification>[]
|
||||
)
|
||||
77
web/src/lib/localstorage.ts
Normal file
77
web/src/lib/localstorage.ts
Normal file
@@ -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<string, z.ZodType<JSONValue>>,
|
||||
T extends {
|
||||
[K in keyof Schemas]: {
|
||||
schema: Schemas[K]
|
||||
default?: z.output<Schemas[K]> | 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<typeof option.schema> | 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<typeof option.schema>) => {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
|
||||
default: option.default,
|
||||
},
|
||||
]
|
||||
})
|
||||
) as {
|
||||
[K in keyof T]: {
|
||||
get: () => z.output<T[K]['schema']> | (T[K] extends { default: infer D } ? D : undefined)
|
||||
set: (value: z.input<T[K]['schema']>) => void
|
||||
remove: () => void
|
||||
default: z.output<T[K]['schema']> | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const typedLocalStorage = makeTypedLocalStorage({
|
||||
pushNotificationsBannerDismissedAt: {
|
||||
schema: z.coerce.date(),
|
||||
},
|
||||
})
|
||||
@@ -162,3 +162,16 @@ export function areEqualObjectsWithoutOrder<T extends Record<string, unknown>>(
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link Object.entries}, but with proper typing.
|
||||
* @example
|
||||
* typedObjectEntries({ a: 1, b: 2 }) // [['a', 1], ['b', 2]]
|
||||
*/
|
||||
export function typedObjectEntries<T extends Record<string, unknown>>(obj: T) {
|
||||
return Object.entries(obj) as Prettify<
|
||||
{
|
||||
[K in Extract<keyof T, string>]: [K, T[K]]
|
||||
}[Extract<keyof T, string>]
|
||||
>[]
|
||||
}
|
||||
|
||||
52
web/src/lib/webPush.ts
Normal file
52
web/src/lib/webPush.ts
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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([])
|
||||
|
||||
@@ -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',
|
||||
|
||||
155
web/src/pages/admin/notifications.astro
Normal file
155
web/src/pages/admin/notifications.astro
Normal file
@@ -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
|
||||
}[]
|
||||
---
|
||||
|
||||
<MiniLayout
|
||||
pageTitle="Push notifications"
|
||||
description="Send test notifications"
|
||||
layoutHeader={{
|
||||
icon: 'ri:notification-3-line',
|
||||
title: 'Push notifications',
|
||||
subtitle: 'Send test notifications',
|
||||
}}
|
||||
>
|
||||
<div class="mb-6 grid gap-4 sm:grid-cols-3">
|
||||
{
|
||||
stats.map((stat) => (
|
||||
<div class="flex flex-col items-center gap-2 text-center">
|
||||
<div class="flex items-end gap-1">
|
||||
<span class="text-5xl leading-[0.8] font-bold text-white">{stat.value}</span>
|
||||
<Icon name={stat.icon} class={cn('size-5 shrink-0', stat.iconClass)} />
|
||||
</div>
|
||||
<span class="text-day-200 flex grow flex-col justify-center text-sm leading-none font-medium text-balance">
|
||||
{stat.title}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<InputTextArea
|
||||
label="Users"
|
||||
name="userNames"
|
||||
inputProps={{
|
||||
placeholder: 'john-doe, jane-doe',
|
||||
class: 'leading-tight min-h-24',
|
||||
}}
|
||||
value={uniq([Astro.locals.user, ...adminUsers].map((user) => 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}
|
||||
/>
|
||||
|
||||
<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" />
|
||||
</form>
|
||||
</MiniLayout>
|
||||
@@ -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]"
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevelClarification"
|
||||
label="KYC Level Clarification"
|
||||
options={kycLevelClarifications.map((clarification) => ({
|
||||
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}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="verificationStatus"
|
||||
label="Verification Status"
|
||||
|
||||
13
web/src/pages/internal-api/[...catchAll].ts
Normal file
13
web/src/pages/internal-api/[...catchAll].ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import type { APIRoute } from 'astro'
|
||||
|
||||
export const ALL: APIRoute = (context) => {
|
||||
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: 'Endpoint not found',
|
||||
}),
|
||||
{
|
||||
status: 404,
|
||||
}
|
||||
)
|
||||
}
|
||||
7
web/src/pages/internal-api/notifications/subscribe.ts
Normal file
7
web/src/pages/internal-api/notifications/subscribe.ts
Normal file
@@ -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)
|
||||
7
web/src/pages/internal-api/notifications/unsubscribe.ts
Normal file
7
web/src/pages/internal-api/notifications/unsubscribe.ts
Normal file
@@ -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)
|
||||
@@ -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) => ({
|
||||
}}
|
||||
>
|
||||
<section class="mx-auto w-full">
|
||||
{
|
||||
notifications.length >= 5 && (
|
||||
<PushNotificationBanner
|
||||
class="mb-4"
|
||||
dismissable
|
||||
pushSubscriptions={pushSubscriptions}
|
||||
hideIfEnabled
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<h1 class="font-title flex items-center text-2xl leading-tight font-bold tracking-wider">
|
||||
<Icon name="ri:notification-line" class="mr-2 size-6 text-zinc-400" />
|
||||
@@ -306,57 +333,57 @@ const notifications = dbNotifications.map((notification) => ({
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
!!notificationPreferences && (
|
||||
<div class="mt-8">
|
||||
<h2 class="font-title mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
|
||||
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
|
||||
Notification Settings
|
||||
</h2>
|
||||
<h2 class="font-title mt-8 mb-3 flex items-center border-b border-zinc-800 text-lg font-bold">
|
||||
<Icon name="ri:settings-3-line" class="mr-2 size-5 text-zinc-400" />
|
||||
Notification Settings
|
||||
</h2>
|
||||
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.notification.preferences.update}
|
||||
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
|
||||
>
|
||||
{notificationPreferenceFields.map((field) => (
|
||||
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
||||
<span class="flex items-center text-zinc-300">
|
||||
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
|
||||
{field.label}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={field.id}
|
||||
checked={notificationPreferences[field.id]}
|
||||
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
|
||||
/>
|
||||
</label>
|
||||
))}
|
||||
<PushNotificationBanner class="mb-3" pushSubscriptions={pushSubscriptions} />
|
||||
|
||||
<div class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
||||
<span class="flex items-center text-zinc-300">
|
||||
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
|
||||
Notify me when my karma changes by at least
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="karmaNotificationThreshold"
|
||||
value={notificationPreferences.karmaNotificationThreshold}
|
||||
min="1"
|
||||
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
|
||||
/>
|
||||
<span class="text-zinc-400">points</span>
|
||||
</div>
|
||||
</div>
|
||||
<form
|
||||
method="POST"
|
||||
action={actions.notification.preferences.update}
|
||||
class="rounded-lg border border-zinc-800 bg-zinc-900 p-4 shadow-sm"
|
||||
>
|
||||
{
|
||||
notificationPreferenceFields.map((field) => (
|
||||
<label class="flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800">
|
||||
<span class="flex items-center text-zinc-300">
|
||||
<Icon name={field.icon} class="mr-2 size-5 text-zinc-400" />
|
||||
{field.label}
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={field.id}
|
||||
checked={notificationPreferences?.[field.id]}
|
||||
class="size-4 rounded border-zinc-700 bg-zinc-800 text-blue-600 focus:ring-blue-600"
|
||||
/>
|
||||
</label>
|
||||
))
|
||||
}
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
||||
</div>
|
||||
</form>
|
||||
<div
|
||||
class="mt-4 flex items-center justify-between rounded-md p-2 transition-colors duration-200 hover:bg-zinc-800"
|
||||
>
|
||||
<span class="flex items-center text-zinc-300">
|
||||
<Icon name="ri:award-line" class="mr-2 size-5 text-zinc-400" />
|
||||
Notify me when my karma changes by at least
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<input
|
||||
type="number"
|
||||
name="karmaNotificationThreshold"
|
||||
value={notificationPreferences?.karmaNotificationThreshold}
|
||||
min="1"
|
||||
class="w-20 rounded border border-zinc-700 bg-zinc-800 px-2 py-1 text-zinc-200 focus:border-blue-600 focus:ring-1 focus:ring-blue-600 focus:outline-none"
|
||||
/>
|
||||
<span class="text-zinc-400">points</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex justify-end">
|
||||
<Button type="submit" label="Save" icon="ri:save-line" color="success" />
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
</BaseLayout>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevelClarification"
|
||||
label="KYC Level Clarification"
|
||||
options={kycLevelClarifications.map((clarification) => ({
|
||||
label: clarification.label,
|
||||
value: clarification.value,
|
||||
icon: clarification.icon,
|
||||
description: clarification.description,
|
||||
}))}
|
||||
selectedValue="NONE"
|
||||
iconSize="sm"
|
||||
cardSize="sm"
|
||||
error={inputErrors.kycLevelClarification}
|
||||
/>
|
||||
|
||||
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
import { VerificationStepStatus, EventType } from '@prisma/client'
|
||||
import { VerificationStepStatus, EventType, KycLevelClarification } from '@prisma/client'
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { Markdown } from 'astro-remote'
|
||||
import { Schema } from 'astro-seo-schema'
|
||||
@@ -32,6 +32,7 @@ import { getAttributeTypeInfo } from '../../constants/attributeTypes'
|
||||
import { formatContactMethod } from '../../constants/contactMethods'
|
||||
import { currencies, getCurrencyInfo } from '../../constants/currencies'
|
||||
import { getEventTypeInfo } from '../../constants/eventTypes'
|
||||
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
|
||||
import { getKycLevelInfo, kycLevels } from '../../constants/kycLevels'
|
||||
import { getServiceVisibilityInfo } from '../../constants/serviceVisibility'
|
||||
import { getTosHighlightRatingInfo } from '../../constants/tosHighlightRating'
|
||||
@@ -78,6 +79,7 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
|
||||
name: true,
|
||||
description: true,
|
||||
kycLevel: true,
|
||||
kycLevelClarification: true,
|
||||
overallScore: true,
|
||||
privacyScore: true,
|
||||
trustScore: true,
|
||||
@@ -308,6 +310,9 @@ const hiddenLinks = [
|
||||
]
|
||||
|
||||
const kycLevelInfo = getKycLevelInfo(`${service.kycLevel}`)
|
||||
|
||||
const kycLevelClarificationInfo = getKycLevelClarificationInfo(service.kycLevelClarification)
|
||||
|
||||
const userSentiment = service.userSentiment
|
||||
? { ...service.userSentiment, info: getUserSentimentInfo(service.userSentiment.sentiment) }
|
||||
: null
|
||||
@@ -896,8 +901,12 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
'@type': 'Review',
|
||||
itemReviewed: { '@id': itemReviewedId },
|
||||
reviewAspect: 'KYC Level',
|
||||
name: kycLevelInfo.name,
|
||||
reviewBody: kycLevelInfo.description,
|
||||
name:
|
||||
kycLevelInfo.name +
|
||||
(kycLevelClarificationInfo.value !== 'NONE' ? ` (${kycLevelClarificationInfo.label})` : ''),
|
||||
reviewBody:
|
||||
kycLevelInfo.description +
|
||||
(kycLevelClarificationInfo.value !== 'NONE' ? ` ${kycLevelClarificationInfo.description}` : ''),
|
||||
reviewRating: {
|
||||
'@type': 'Rating',
|
||||
ratingValue: kycLevelInfo.value,
|
||||
@@ -918,9 +927,22 @@ const activeAlertOrWarningEvents = service.events.filter(
|
||||
</div>
|
||||
|
||||
<dl class="flex-grow-5 basis-0">
|
||||
<dt class="text-base font-bold text-pretty">{kycLevelInfo.name}</dt>
|
||||
<dt class="text-base font-bold text-pretty">
|
||||
{kycLevelInfo.name}
|
||||
{
|
||||
kycLevelClarificationInfo.value !== 'NONE' && (
|
||||
<>
|
||||
<span class="text-day-400 mx-1">•</span>
|
||||
<span class="text-blue-500">{kycLevelClarificationInfo.label}</span>
|
||||
</>
|
||||
)
|
||||
}
|
||||
</dt>
|
||||
<dd class="text-day-700 mt-1 font-sans text-sm text-pretty">
|
||||
{kycLevelInfo.description}
|
||||
<span class="font-medium text-blue-600">
|
||||
{kycLevelClarificationInfo.value !== 'NONE' && ` ${kycLevelClarificationInfo.description}`}
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user