Compare commits

...

3 Commits

Author SHA1 Message Date
pluja
02e52d7351 Release 202506041937 2025-06-04 19:37:33 +00:00
pluja
dacf73a804 Release 202506041641 2025-06-04 16:41:32 +00:00
pluja
5812399e29 Release 202506031821 2025-06-03 18:21:55 +00:00
37 changed files with 1037 additions and 330 deletions

View File

@@ -1,53 +0,0 @@
# Cursor Rules
- When merging tailwind classes, use the `cn` function.
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
- Instead of using the syntax`Array<T>`, use `T[]`.
- Use TypeScript `type` over `interface`.
- You are forbiddent o add comments unless explicitly stated by the user.
- Avoid sending JavaScript to the client. The JS send should be optional.
- In prisma preffer `select` over `include` when making queries.
- Import the types from prisma instead of hardcoding duplicates.
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
- Remember to check the prisma schema when doing things related to the database.
- Avoid hardcoding enums from the database, import them from prisma.
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
- The admin pages can use client-side JavaScript.
- Keep README.md in sync with new capabilities.
- The package manager is npm.
- For icons use the `Icon` component from `astro-icon/components`.
- For icons use the Remix Icon library preferably.
- Use the `Image` component from `astro:assets` for images.
- Use the `zod` library for schema validation.
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
- Don't forget that the astro files have thre dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
- The admin actions go into a separate folder.
- In Actro actions when throwing errors use ActionError.
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
```ts
import { actions } from "astro:actions"; /* CORRECT */
import { server } from "~/actions"; /* WRONG!!!! DON'T DO THIS */
import { adminAttributeActions } from "~/actions/admin/attribute.ts"; /* WRONG!!!! DON'T DO THIS */
const result = Astro.getActionResult(actions.admin.attribute.create);
```
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
- When adding clientside js do it with HTMX.
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts
```ts
function makeLoginUrl(
currentUrl: URL,
options: {
redirect?: URL | string | null;
error?: string | null;
logout?: boolean;
message?: string | null;
} = {}
);
```

View File

@@ -0,0 +1,46 @@
# Database
POSTGRES_USER=your_db_user
POSTGRES_PASSWORD=your_db_password
POSTGRES_DATABASE=your_db_name
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
DATABASE_UI_URL="https://db.example.com"
# Generic Config
UPLOAD_DIR=/app/uploads
SITE_URL="https://your-site.example.com"
SOURCE_CODE_URL="https://your-source-code.example.com"
TIME_TRAP_SECRET=your_time_trap_secret
LOGS_UI_URL="https://logs.example.com"
# Release Info
RELEASE_NUMBER=
RELEASE_DATE=
# Redis
REDIS_URL="redis://redis:6379"
# Crawl4AI
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
CRAWL4AI_API_TOKEN=your_crawl4ai_token
# Tor and I2P
ONION_ADDRESS="http://youronionaddress.onion"
I2P_ADDRESS="http://youri2paddress.b32.i2p"
I2P_PASS=your_i2p_password
# Push Notifications
VAPID_PUBLIC_KEY=your_vapid_public_key
VAPID_PRIVATE_KEY=your_vapid_private_key
VAPID_SUBJECT="mailto:your-email@example.com"
# OpenAI
OPENAI_API_KEY=your_openai_api_key
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
OPENAI_MODEL=your_openai_model
OPENAI_RETRY=3
# Pyworker Crons
CRON_TOSREVIEW_TASK="0 0 1 * *" # Every month
CRON_USER_SENTIMENT_TASK="0 0 * * *" # Every day
CRON_COMMENT_MODERATION_TASK="0 * * * *" # Every hour
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # Every day

View File

@@ -7,10 +7,8 @@ services:
volumes:
- database:/var/lib/postgresql/data:z
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
env_file:
- .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
interval: 10s
@@ -21,18 +19,15 @@ services:
build:
context: ./pyworker
restart: always
environment:
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
env_file:
- .env
crawl4ai:
image: unclecode/crawl4ai:basic-amd64
expose:
- "11235"
environment:
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
MAX_CONCURRENT_TASKS: 10
env_file:
- .env
volumes:
- /dev/shm:/dev/shm
deploy:
@@ -53,15 +48,9 @@ services:
astro:
build:
context: ./web
dockerfile: web/Dockerfile
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
REDIS_URL: "redis://redis:6379"
env_file:
- .env
depends_on:

View File

@@ -51,42 +51,6 @@ import-db file="":
fi
fi
echo "Restoring database from $BACKUP_FILE..."
# First drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
# Drop and recreate database
echo "Dropping and recreating the database..."
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
# Restore the database
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
echo "Database restored successfully!"
# Import triggers
echo "Importing triggers..."
just import-triggers
echo "Database import completed!"
# Check if migrations need to be run
cd web && npx prisma migrate status
#!/bin/bash
if [ -z "{{file}}" ]; then
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
if [ -z "$BACKUP_FILE" ]; then
echo "Error: No backup files found in the backups directory"
exit 1
fi
else
BACKUP_FILE="{{file}}"
if [ ! -f "$BACKUP_FILE" ]; then
echo "Error: Backup file '$BACKUP_FILE' not found"
exit 1
fi
fi
echo "=== STEP 1: PREPARING DATABASE ==="
# Drop all connections to the database
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres

View File

@@ -2,12 +2,12 @@ DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="https://localhost:4321"
SITE_URL="http://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
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_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
VAPID_SUBJECT="mailto:no-reply@kycnot.me"

View File

@@ -1,17 +1,20 @@
FROM node:lts AS runtime
WORKDIR /app
COPY package.json package-lock.json ./
COPY .env .env
COPY web/package.json web/package-lock.json ./
COPY .npmrc .npmrc
COPY web/.npmrc .npmrc
RUN npm ci
COPY . .
COPY web/ .
ARG ASTRO_BUILD_MODE=production
# Generate Prisma client
RUN npx prisma generate
# Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
@@ -20,7 +23,7 @@ ENV PORT=4321
EXPOSE 4321
# Add knm-migrate command script and make it executable
COPY migrate.sh /usr/local/bin/knm-migrate
COPY web/migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"]

View File

@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon'
import { loadEnv } from 'vite'
// @ts-expect-error process.env actually exists
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL')
export default defineConfig({
site: SITE_URL,
@@ -22,6 +22,7 @@ export default defineConfig({
plugins: [tailwindcss()],
},
integrations: [
postgresListener(),
icon(),
mdx(),
sitemap({

101
web/package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro-loading-indicator": "0.7.0",
@@ -32,6 +33,7 @@
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",
@@ -3152,12 +3154,10 @@
}
},
"node_modules/@types/pg": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
"integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==",
"version": "8.15.4",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
@@ -11413,32 +11413,75 @@
"dev": true,
"license": "MIT"
},
"node_modules/pg": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.9.0",
"pg-pool": "^3.10.0",
"pg-protocol": "^1.10.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 8.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.2.5"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz",
"integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz",
"integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"optional": true,
"peer": true,
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-protocol": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz",
"integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==",
"node_modules/pg-pool": {
"version": "3.10.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz",
"integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==",
"license": "MIT",
"optional": true,
"peer": true
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz",
"integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
@@ -11450,6 +11493,15 @@
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -11559,8 +11611,6 @@
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=4"
}
@@ -11570,8 +11620,6 @@
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11581,8 +11629,6 @@
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -11592,8 +11638,6 @@
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"xtend": "^4.0.0"
},
@@ -13251,6 +13295,15 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/stable-hash": {
"version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -15037,8 +15090,6 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.4"
}

View File

@@ -33,6 +33,7 @@
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro-loading-indicator": "0.7.0",
@@ -46,6 +47,7 @@
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",

View File

@@ -0,0 +1,11 @@
/*
Warnings:
- You are about to drop the column `kycLevelDetailsId` on the `Service` table. All the data in the column will be lost.
- Made the column `kycLevelClarification` on table `Service` required. This step will fail if there are existing NULL values in that column.
*/
-- AlterTable
ALTER TABLE "Service" DROP COLUMN "kycLevelDetailsId",
ALTER COLUMN "kycLevelClarification" SET NOT NULL,
ALTER COLUMN "kycLevelClarification" SET DEFAULT 'NONE';

View File

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

View File

@@ -128,6 +128,7 @@ enum AccountStatusChange {
}
enum NotificationType {
TEST
COMMENT_STATUS_CHANGE
REPLY_COMMENT_CREATED
COMMUNITY_NOTE_ADDED
@@ -345,7 +346,7 @@ model Service {
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification?
kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
@@ -391,7 +392,6 @@ model Service {
onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices")
Notification Notification[]
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
kycLevelDetailsId Int?
@@index([listedAt])
@@index([overallScore])

View File

@@ -0,0 +1,16 @@
CREATE OR REPLACE FUNCTION trigger_notification_push()
RETURNS TRIGGER AS $$
BEGIN
PERFORM pg_notify('notification_created', json_build_object('id', NEW.id)::text);
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Drop the trigger if it exists to ensure a clean setup
DROP TRIGGER IF EXISTS notification_push_trigger ON "Notification";
-- Create the trigger to fire after inserts
CREATE TRIGGER notification_push_trigger
AFTER INSERT ON "Notification"
FOR EACH ROW
EXECUTE FUNCTION trigger_notification_push();

View File

@@ -1,80 +1,36 @@
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 { sendNotifications } from '../../lib/sendNotifications'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = {
webPush: {
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
title: z.string().min(1).nullable(),
body: z.string().nullable(),
url: z.string().url().optional(),
}),
handler: async (input) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { user: { name: { in: input.userNames } } },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
userAgent: true,
user: {
select: {
id: true,
name: true,
},
},
},
})
const results = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
{
title: input.title ?? 'Test Notification',
body: input.body ?? 'This is a test push notification from KYCNot.me',
url: input.url ?? '/',
}
)
// If subscription is invalid, remove it from database
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({
where: { id: subscription.id },
})
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
}
return result.success
})
)
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
const now = new Date()
return {
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
totalSubscriptions: subscriptions.length,
successCount,
failureCount,
sentAt: now,
}
},
test: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
userNames: stringListOfSlugsSchemaRequired,
}),
},
handler: async (input) => {
const users = await prisma.user.findMany({
where: { name: { in: input.userNames } },
select: { id: true },
})
const notifications = await prisma.notification.createManyAndReturn({
data: users.map((user) => ({
type: 'TEST',
userId: user.id,
})),
select: { id: true },
})
const results = await sendNotifications(notifications.map((notification) => notification.id))
return {
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`,
}
},
}),
}

View File

@@ -5,10 +5,15 @@ import { uniq } from 'lodash-es'
import slugify from 'slugify'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage'
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
import { prisma } from '../../lib/prisma'
import { separateServiceUrlsByType } from '../../lib/urls'
import { imageFileSchema, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../../lib/zodUtils'
import {
imageFileSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils'
const addSlugIfMissing = <
T extends {
@@ -69,6 +74,15 @@ const updateServiceInputSchema = serviceSchemaBase
})
.transform(addSlugIfMissing)
const evidenceImageAddSchema = z.object({
serviceId: z.number().int().positive(),
imageFile: imageFileSchema,
})
const evidenceImageDeleteSchema = z.object({
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
})
export const adminServiceActions = {
create: defineProtectedAction({
accept: 'form',
@@ -107,7 +121,7 @@ export const adminServiceActions = {
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
@@ -225,7 +239,7 @@ export const adminServiceActions = {
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
@@ -272,7 +286,7 @@ export const adminServiceActions = {
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
value: zodContactMethod,
serviceId: z.number().int().positive(),
}),
handler: async (input) => {
@@ -404,4 +418,50 @@ export const adminServiceActions = {
},
}),
},
evidenceImage: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageAddSchema,
handler: async (input) => {
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { slug: true },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found to associate image with.',
})
}
if (!input.imageFile) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Image file is required.',
})
}
const imageUrl = await saveFileLocally(
input.imageFile,
input.imageFile.name,
`evidence/${service.slug}`
)
return { imageUrl }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageDeleteSchema,
handler: async (input) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
}

View File

@@ -2,6 +2,7 @@ import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { pick } from 'lodash-es'
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
import { getKycLevelInfo } from '../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
@@ -50,6 +51,7 @@ export const apiServiceActions = {
slug: true,
description: true,
kycLevel: true,
kycLevelClarification: true,
verificationStatus: true,
categories: {
select: {
@@ -130,6 +132,12 @@ export const apiServiceActions = {
verifiedAt: service.verifiedAt,
kycLevel: service.kycLevel,
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
kycLevelClarification: service.kycLevelClarification,
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
'value',
'name',
'description',
]),
categories: service.categories,
listedAt: service.listedAt,
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(

View File

@@ -10,7 +10,12 @@ import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls'
import { imageFileSchemaRequired, stringListOfUrlsSchemaRequired, zodCohercedNumber } from '../lib/zodUtils'
import {
imageFileSchemaRequired,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
import type { Prisma } from '@prisma/client'
@@ -153,6 +158,7 @@ export const serviceSuggestionActions = {
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()),
@@ -239,6 +245,11 @@ export const serviceSuggestionActions = {
attributeId: id,
})),
},
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
},
select: serviceSelect,
})

View File

@@ -2,13 +2,14 @@
import { Icon } from 'astro-icon/components'
import { tv, type VariantProps } from 'tailwind-variants'
import type { AstroChildren } from '../lib/astro'
import type { Polymorphic } from 'astro/types'
const badge = tv({
slots: {
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
icon: 'size-3 shrink-0',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap [&>a]:hover:underline [&>a]:focus-visible:underline',
},
variants: {
color: {
@@ -122,18 +123,22 @@ const badge = tv({
},
})
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
type Props<
Tag extends 'a' | 'div' | 'li' = 'div',
Text extends string | undefined = string | undefined,
> = Polymorphic<
VariantProps<typeof badge> & {
as: Tag
icon?: string
endIcon?: string
text: string
text?: Text
inlineIcon?: boolean
classNames?: {
icon?: string
text?: string
endIcon?: string
}
children?: Text extends string ? never : AstroChildren
}
>
@@ -161,7 +166,7 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
)
}
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
<span class={textSlot({ class: classNames?.text })}>{textContent ?? <slot />}</span>
{
!!endIconName && (
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />

View File

@@ -243,13 +243,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall
icon={roleInfo.icon}
color={roleInfo.color}
text={`${roleInfo.label} at ${affiliation.service.name}`}
variant="faded"
inlineIcon
/>
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
}

View File

@@ -56,7 +56,15 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
<span>Community-contributed. Information not reviewed.</span>
<span>
Community-contributed. Information not reviewed.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div>
) : wasRecentlyAdded ? (
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
@@ -64,11 +72,25 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
caution.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</div>
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
<Icon name="ri:information-line" class="size-5 text-blue-400" />
<span>Basic checks passed, but not fully verified.</span>
<span>
Basic checks passed, but not fully verified.
<a
href="/about#suggestion-review-process"
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
>
Learn more
</a>
</span>
</div>
) : null
}

View File

@@ -3,6 +3,9 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { Assert } from '../lib/assert'
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
type ContactMethodInfo<T extends string | null | undefined = string> = {
type: T
label: string
@@ -10,6 +13,7 @@ type ContactMethodInfo<T extends string | null | undefined = string> = {
matcher: RegExp
formatter: (match: RegExpMatchArray) => string | null
icon: string
urlType: string
}
export const {
@@ -22,9 +26,10 @@ export const {
(type): ContactMethodInfo<typeof type> => ({
type,
label: type ? transformCase(type, 'title') : String(type),
icon: 'ri:shield-fill',
icon: 'ri:link',
matcher: /(.*)/,
formatter: ([, value]) => value ?? String(value),
urlType: type ?? 'unknown',
}),
[
{
@@ -33,24 +38,37 @@ export const {
matcher: /mailto:(.+)/,
formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line',
urlType: 'email',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.+)/,
formatter: ([, value]) => {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
} catch (_error) {
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
return value ?? 'Telephone'
}
},
icon: 'ri:phone-line',
urlType: 'telephone',
},
{
type: 'whatsapp',
label: 'WhatsApp',
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: ([, value]) => {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
try {
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
} catch (_error) {
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
return value ?? 'WhatsApp'
}
},
icon: 'ri:whatsapp-line',
urlType: 'url',
},
{
type: 'telegram',
@@ -58,6 +76,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line',
urlType: 'url',
},
{
type: 'linkedin',
@@ -65,6 +84,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line',
urlType: 'url',
},
{
type: 'x',
@@ -72,6 +92,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line',
urlType: 'url',
},
{
type: 'instagram',
@@ -79,6 +100,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line',
urlType: 'url',
},
{
type: 'matrix',
@@ -86,6 +108,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
icon: 'ri:hashtag',
urlType: 'url',
},
{
type: 'bitcointalk',
@@ -93,6 +116,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
urlType: 'url',
},
{
type: 'simplex',
@@ -100,6 +124,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',
},
{
type: 'nostr',
@@ -107,6 +132,7 @@ export const {
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
formatter: () => 'Nostr',
icon: 'nostr',
urlType: 'url',
},
{
// Website must go last because it's a catch-all
@@ -115,6 +141,7 @@ export const {
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line',
urlType: 'url',
},
] as const satisfies ContactMethodInfo[]
)
@@ -135,3 +162,38 @@ export function formatContactMethod(url: string) {
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
value: T
labelPlural: string
}
export const {
dataArray: contactMethodUrlTypes,
dataObject: contactMethodUrlTypesById,
getFn: getContactMethodUrlTypeInfo,
} = makeHelpersForOptions(
'value',
(value): ContactMethodUrlTypeInfo<typeof value> => ({
value,
labelPlural: value ? transformCase(value, 'title') : String(value),
}),
[
{
value: 'email',
labelPlural: 'emails',
},
{
value: 'telephone',
labelPlural: 'phone numbers',
},
{
value: 'url',
labelPlural: 'URLs',
},
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
)
type _ExpectUrlTypesToHaveAllValues = Assert<
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
>

View File

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

10
web/src/lib/assert.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Gives an error if the type is not equal to 1.
*
* @example
* ```ts
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'a'>> // Gives an error
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'b' | 'a'>> // No error
* ```
*/
export type Assert<T extends 1> = T

View File

@@ -69,6 +69,53 @@ export async function saveFileLocally(
return url
}
/**
* List all files in a specific subdirectory of the upload directory.
* Returns an array of web-accessible URLs.
*/
export async function listFiles(subDir: string): Promise<string[]> {
const { fsPath: uploadDir, webPath: webUploadPath } = getUploadDir(subDir)
try {
const files = await fs.readdir(uploadDir)
return files.map((file) => sanitizePath(`${webUploadPath}/${file}`))
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
return []
}
console.error(`Error listing files in ${uploadDir}:`, error)
throw error
}
}
/**
* Delete a file locally given its web-accessible URL path
*/
export async function deleteFileLocally(fileUrl: string): Promise<void> {
// Extract the subpath and filename from the webPath
// Example: /files/evidence/service-slug/image.jpg -> evidence/service-slug/image.jpg
const basePath = '/files'
if (!fileUrl.startsWith(basePath)) {
throw new Error('Invalid file URL for deletion. Must start with /files')
}
const subPathAndFile = fileUrl.substring(basePath.length).replace(/^\/+/, '') // Remove leading /files/ and any extra leading slashes
const { fsPath: uploadDirWithoutSubDir } = getUploadDir() // Get base upload directory
const filePath = path.join(uploadDirWithoutSubDir, subPathAndFile)
try {
await fs.unlink(filePath)
} catch (error: unknown) {
const err = error as NodeJS.ErrnoException
if (err.code === 'ENOENT') {
console.warn(`File not found for deletion, but treating as success: ${filePath}`)
return
}
console.error(`Error deleting file ${filePath}:`, error)
throw error
}
}
function sanitizePath(inputPath: string): string {
let sanitized = inputPath.replace(/\\+/g, '/')
// Collapse multiple slashes, but preserve protocol (e.g., http://)

35
web/src/lib/json.ts Normal file
View File

@@ -0,0 +1,35 @@
import type { z } from 'astro:content'
// 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
export type ZodJSON = z.ZodType<JSONValue>
export function zodParseJSON<T extends z.ZodType<JSONValue>, D extends z.output<T> | undefined = undefined>(
schema: T,
stringValue: string | null | undefined,
defaultValue?: D
): D | z.output<T> {
if (!stringValue) return defaultValue as D
let jsonValue: D | z.output<typeof schema> = defaultValue as D
try {
jsonValue = JSON.parse(stringValue)
} catch (error) {
console.error(error)
return defaultValue as D
}
const parsedValue = schema.safeParse(jsonValue)
if (!parsedValue.success) {
console.error(parsedValue.error)
return defaultValue as D
}
return parsedValue.data
}

View File

@@ -1,17 +1,10 @@
import { z } from 'astro:schema'
import { zodParseJSON, type ZodJSON } from './json'
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>>,
Schemas extends Record<string, ZodJSON>,
T extends {
[K in keyof Schemas]: {
schema: Schemas[K]
@@ -28,24 +21,7 @@ function makeTypedLocalStorage<
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
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
},
set: (value: z.input<typeof option.schema>) => {

View File

@@ -12,6 +12,7 @@ import type { Prisma } from '@prisma/client'
export function makeNotificationTitle(
notification: Prisma.NotificationGetPayload<{
select: {
id: true
type: true
aboutAccountStatusChange: true
aboutCommentStatusChange: true
@@ -87,6 +88,9 @@ export function makeNotificationTitle(
user: Prisma.UserGetPayload<{ select: { id: true } }> | null
): string {
switch (notification.type) {
case 'TEST': {
return `Test notification #${notification.id.toString()}`
}
case 'COMMENT_STATUS_CHANGE': {
if (!notification.aboutComment) return 'A comment you are watching had a status change'
@@ -178,6 +182,7 @@ export function makeNotificationTitle(
export function makeNotificationContent(
notification: Prisma.NotificationGetPayload<{
select: {
createdAt: true
type: true
aboutKarmaTransaction: {
select: {
@@ -204,6 +209,9 @@ export function makeNotificationContent(
}>
): string | null {
switch (notification.type) {
case 'TEST': {
return `Created on ${notification.createdAt.toLocaleString()}`
}
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK':
case 'KARMA_CHANGE': {
@@ -280,6 +288,9 @@ export function makeNotificationLink(
origin: string
): string | null {
switch (notification.type) {
case 'TEST': {
return `${origin}/notifications`
}
case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED':

View File

@@ -0,0 +1,100 @@
import { z } from 'astro/zod'
import { Client } from 'pg'
import { zodParseJSON } from './json'
import { sendNotifications } from './sendNotifications'
import { getServerEnvVariable } from './serverEnvVariables'
import type { AstroIntegration, HookParameters } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
let pgClient: Client | null = null
const INTEGRATION_NAME = 'postgres-listener'
async function handleNotificationCreated(
notificationId: number,
options: HookParameters<'astro:server:start'>
) {
const logger = options.logger.fork(INTEGRATION_NAME)
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const results = await sendNotifications([notificationId], logger)
logger.info(
`Sent push notifications for notification ${String(notificationId)} to ${String(results.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
}
}
export function postgresListener(): AstroIntegration {
return {
name: 'postgres-listener',
hooks: {
'astro:server:start': async (options) => {
const logger = options.logger.fork(INTEGRATION_NAME)
try {
logger.info('Starting PostgreSQL notification listener...')
pgClient = new Client({ connectionString: DATABASE_URL })
await pgClient.connect()
logger.info('Connected to PostgreSQL for notifications')
await pgClient.query('LISTEN notification_created')
logger.info('Listening for notification_created events')
pgClient.on('notification', (msg) => {
if (msg.channel === 'notification_created') {
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
if (!payload) {
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
return
}
// NOTE: Don't await to avoid blocking
void handleNotificationCreated(payload.id, options)
}
})
pgClient.on('error', (error) => {
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
})
pgClient.on('end', () => {
logger.info('PostgreSQL client connection ended')
})
} catch (error) {
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
}
},
'astro:server:done': async ({ logger: originalLogger }) => {
const logger = originalLogger.fork(INTEGRATION_NAME)
if (pgClient) {
try {
logger.info('Stopping PostgreSQL notification listener...')
await pgClient.end()
pgClient = null
logger.info('PostgreSQL listener stopped')
} catch (error) {
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
}
}
},
},
}
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
}

View File

@@ -0,0 +1,185 @@
import { sum } from 'lodash-es'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables'
import { type NotificationData, sendPushNotification } from './webPush'
import type { AstroIntegrationLogger } from 'astro'
const SITE_URL = getServerEnvVariable('SITE_URL')
export async function sendNotifications(
notificationIds: number[],
logger: AstroIntegrationLogger | Console = console
) {
const notifications = await prisma.notification.findMany({
where: { id: { in: notificationIds } },
select: {
id: true,
type: true,
userId: true,
createdAt: true,
aboutAccountStatusChange: true,
aboutCommentStatusChange: true,
aboutServiceVerificationStatusChange: true,
aboutSuggestionStatusChange: true,
aboutComment: {
select: {
id: true,
author: { select: { id: true } },
status: true,
content: true,
communityNote: true,
parent: {
select: {
author: {
select: {
id: true,
},
},
},
},
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutServiceSuggestionId: true,
aboutServiceSuggestion: {
select: {
status: true,
service: {
select: {
name: true,
},
},
},
},
aboutServiceSuggestionMessage: {
select: {
id: true,
content: true,
suggestion: {
select: {
id: true,
service: {
select: {
name: true,
},
},
},
},
},
},
aboutEvent: {
select: {
title: true,
type: true,
service: {
select: {
slug: true,
name: true,
},
},
},
},
aboutService: {
select: {
slug: true,
name: true,
verificationStatus: true,
},
},
aboutKarmaTransaction: {
select: {
points: true,
action: true,
description: true,
},
},
user: {
select: {
id: true,
name: true,
},
},
},
})
if (notifications.length < notificationIds.length) {
const missingNotificationIds = notificationIds.filter(
(id) => !notifications.some((notification) => notification.id === id)
)
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
}
const results = await Promise.allSettled(
notifications.map(async (notification) => {
const subscriptions = await prisma.pushSubscription.findMany({
where: { userId: notification.userId },
select: {
id: true,
endpoint: true,
p256dh: true,
auth: true,
},
})
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
)
return {
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
total: subscriptionResults.length,
}
})
)
return {
subscriptions: {
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)),
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)),
total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)),
},
notifications: {
success: results.filter((r) => r.status === 'fulfilled').length,
failure: results.filter((r) => r.status !== 'fulfilled').length,
total: results.length,
},
}
}

View File

@@ -0,0 +1,14 @@
import { loadEnv } from 'vite'
/** Only use when you can't import the variables from `astro:env/server` */
// @ts-expect-error process.env actually exists
const untypedServerEnvVariables = loadEnv(process.env.NODE_ENV, process.cwd(), '')
/** Only use when you can't import the variables from `astro:env/server` */
export function getServerEnvVariable<T extends keyof typeof untypedServerEnvVariables>(
name: T
): NonNullable<(typeof untypedServerEnvVariables)[T]> {
const value = untypedServerEnvVariables[name]
if (!value) throw new Error(`${name} environment variable is not set`)
return value
}

View File

@@ -1,12 +1,25 @@
/* 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'
import { getServerEnvVariable } from './serverEnvVariables'
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
// Configure VAPID keys
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
export { webpush }
export type NotificationData = {
title: string
body?: string
icon?: string
badge?: string
url?: string
}
export async function sendPushNotification(
subscription: {
endpoint: string
@@ -15,13 +28,7 @@ export async function sendPushNotification(
auth: string
}
},
data: {
title: string
body?: string
icon?: string
badge?: string
url?: string
}
data: NotificationData
) {
try {
const result = await webpush.sendNotification(

View File

@@ -13,17 +13,44 @@ const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
const cleanUrl = (input: unknown) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
}
export const zodUrlOptionalProtocol = z.preprocess(
(input) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
},
cleanUrl,
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
message: 'Invalid URL',
})
)
export const zodContactMethod = z.preprocess(
(input) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim()
if (/^([\d\s+\-_/()[\]*#.,]|ext|x){7,}$/i.test(cleanInput)) return `tel:${cleanInput}`
if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(cleanInput)) return `mailto:${cleanInput}`
return cleanUrl(cleanInput)
},
z
.string()
.trim()
.refine(
(value) =>
/^((https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
value
),
{
message: 'Invalid contact method',
}
)
)
const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
return <T>(input: T) =>
typeof input !== 'string'
@@ -49,6 +76,11 @@ export const stringListOfUrlsSchemaRequired = z.preprocess(
z.array(zodUrlOptionalProtocol).min(1)
)
export const stringListOfContactMethodsSchema = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(zodContactMethod).default([])
)
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
export const ACCEPTED_IMAGE_TYPES = [

View File

@@ -3,8 +3,7 @@ import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import Button from '../../components/Button.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn'
@@ -15,7 +14,7 @@ if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied')
}
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
const testResult = Astro.getActionResult(actions.admin.notification.test)
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
@@ -104,7 +103,7 @@ const stats = [
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
<form method="POST" action={actions.admin.notification.test} class="space-y-4">
<InputTextArea
label="Users"
name="userNames"
@@ -121,35 +120,6 @@ const stats = [
error={testInputErrors.userNames}
/>
<InputText
label="Title"
name="title"
inputProps={{
value: 'Test Notification',
required: true,
}}
error={testInputErrors.title}
/>
<InputTextArea
label="Body"
name="body"
inputProps={{
value: 'This is a test push notification from KYCNot.me',
}}
error={testInputErrors.body}
/>
<InputText
label="Action URL"
name="url"
inputProps={{
placeholder: 'https://example.com/path',
}}
description="URL to open when the notification is clicked"
error={testInputErrors.url}
/>
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
<Button type="submit" label="Send" icon="ri:send-plane-line" color="danger" class="w-full" />
</form>
</MiniLayout>

View File

@@ -23,7 +23,7 @@ import Tooltip from '../../../../components/Tooltip.astro'
import UserBadge from '../../../../components/UserBadge.astro'
import { getAttributeCategoryInfo } from '../../../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../../../constants/attributeTypes'
import { formatContactMethod } from '../../../../constants/contactMethods'
import { contactMethodUrlTypes, formatContactMethod } from '../../../../constants/contactMethods'
import { currencies } from '../../../../constants/currencies'
import { eventTypes, getEventTypeInfo } from '../../../../constants/eventTypes'
import { kycLevelClarifications } from '../../../../constants/kycLevelClarifications'
@@ -36,6 +36,7 @@ import {
} from '../../../../constants/verificationStepStatus'
import BaseLayout from '../../../../layouts/BaseLayout.astro'
import { DEPLOYMENT_MODE } from '../../../../lib/envVariables'
import { listFiles } from '../../../../lib/fileStorage'
import { makeAdminApiCallInfo } from '../../../../lib/makeAdminApiCallInfo'
import { pluralize } from '../../../../lib/pluralize'
import { prisma } from '../../../../lib/prisma'
@@ -87,9 +88,36 @@ const internalNoteInputErrors = isInputError(internalNoteCreateResult?.error)
? internalNoteCreateResult.error.fields
: {}
const contactMethodUpdateResult = Astro.getActionResult(actions.admin.service.contactMethod.update)
Astro.locals.banners.addIfSuccess(contactMethodUpdateResult, 'Contact method updated successfully')
const contactMethodUpdateInputErrors = isInputError(contactMethodUpdateResult?.error)
? contactMethodUpdateResult.error.fields
: {}
const contactMethodAddResult = Astro.getActionResult(actions.admin.service.contactMethod.add)
Astro.locals.banners.addIfSuccess(contactMethodAddResult, 'Contact method added successfully')
const contactMethodAddInputErrors = isInputError(contactMethodAddResult?.error)
? contactMethodAddResult.error.fields
: {}
const internalNoteDeleteResult = Astro.getActionResult(actions.admin.service.internalNote.delete)
Astro.locals.banners.addIfSuccess(internalNoteDeleteResult, 'Internal note deleted successfully')
const evidenceImageAddResult = Astro.getActionResult(actions.admin.service.evidenceImage.add)
if (evidenceImageAddResult?.data?.imageUrl) {
Astro.locals.banners.add({
uiMessage: 'Evidence image added successfully',
type: 'success',
origin: 'action',
})
}
const evidenceImageAddInputErrors = isInputError(evidenceImageAddResult?.error)
? evidenceImageAddResult.error.fields
: {}
const evidenceImageDeleteResult = Astro.getActionResult(actions.admin.service.evidenceImage.delete)
Astro.locals.banners.addIfSuccess(evidenceImageDeleteResult, 'Evidence image deleted successfully')
const [service, categories, attributes] = await Astro.locals.banners.tryMany([
[
'Error fetching service',
@@ -200,6 +228,12 @@ if (!service) {
return Astro.rewrite('/404')
}
const evidenceImageUrls = await Astro.locals.banners.try(
'Error listing evidence files',
() => listFiles(`evidence/${service.slug}`),
[] as string[]
)
const apiCalls = await Astro.locals.banners.try(
'Error fetching api calls',
() =>
@@ -426,7 +460,7 @@ const apiCalls = await Astro.locals.banners.try(
description: clarification.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevelClarification ?? 'NONE'}
selectedValue={service.kycLevelClarification}
iconSize="sm"
cardSize="sm"
error={serviceInputErrors.kycLevelClarification}
@@ -1113,6 +1147,7 @@ const apiCalls = await Astro.locals.banners.try(
value: method.label,
placeholder: contactMethodInfo.formattedValue,
}}
error={contactMethodUpdateInputErrors.label}
/>
<InputText
@@ -1122,6 +1157,7 @@ const apiCalls = await Astro.locals.banners.try(
value: method.value,
placeholder: 'e.g., mailto:contact@example.com or https://t.me/example',
}}
error={contactMethodUpdateInputErrors.value}
/>
<InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
@@ -1142,12 +1178,13 @@ const apiCalls = await Astro.locals.banners.try(
<InputText
label="Value"
description="With protocol (e.g., `mailto:contact@example.com` or `https://t.me/example`)"
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
name="value"
inputProps={{
required: true,
placeholder: 'mailto:contact@example.com',
placeholder: 'contact@example.com',
}}
error={contactMethodAddInputErrors.value}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
@@ -1164,6 +1201,16 @@ const apiCalls = await Astro.locals.banners.try(
</p>
)
}
<div class="mb-6 flex justify-center">
<Button
as="a"
href="/docs/api"
icon="ri:book-open-line"
color="gray"
size="sm"
label=" Documentation"
/>
</div>
{
apiCalls.map((call) => (
<FormSubSection title={`${call.method} ${call.path}`}>
@@ -1176,5 +1223,73 @@ const apiCalls = await Astro.locals.banners.try(
))
}
</FormSection>
<FormSection title="Evidence Images" id="evidence-images">
<FormSubSection title="Existing Evidence Images">
{
evidenceImageUrls.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No evidence images yet.
</p>
) : (
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{evidenceImageUrls.map((imageUrl: string) => (
<div class="border-night-600 bg-night-800 group relative rounded-md border p-2">
<MyPicture
src={imageUrl}
alt="Evidence image"
class="aspect-square w-full rounded object-cover"
width={200}
height={200}
/>
<form
method="POST"
action={actions.admin.service.evidenceImage.delete}
class="absolute top-1 right-1"
>
<input type="hidden" name="fileUrl" value={imageUrl} />
<Button
type="submit"
variant="faded"
color="danger"
size="sm"
icon="ri:delete-bin-line"
iconOnly
label="Delete Image"
class="opacity-0 transition-opacity group-hover:opacity-100"
/>
</form>
<input
type="text"
readonly
value={`![Evidence](${imageUrl})`}
class="bg-night-700 text-day-200 mt-2 w-full cursor-text rounded border p-2 font-mono text-xs select-all"
/>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Evidence Image">
<form
method="POST"
action={actions.admin.service.evidenceImage.add}
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="serviceId" value={service.id} />
<InputImageFile
label="Upload Image"
name="imageFile"
description="Upload an evidence image."
error={evidenceImageAddInputErrors.imageFile}
required
/>
<InputSubmitButton label="Add Image" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
</div>
</BaseLayout>

View File

@@ -11,6 +11,7 @@ import { SOURCE_CODE_URL } from 'astro:env/server'
import { kycLevels } from '../../constants/kycLevels'
import { verificationStatuses } from '../../constants/verificationStatus'
import { serviceVisibilities } from '../../constants/serviceVisibility'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
Access basic service data via our public API.
@@ -58,6 +59,12 @@ type ServiceResponse = {
name: string
description: string
}
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
kycLevelClarificationInfo: {
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
name: string
description: string
}
categories: {
name: string
slug: string
@@ -99,6 +106,16 @@ type ServiceResponse = {
))}
</ul>
#### KYC Level Clarifications
<ul>
{kycLevelClarifications.map((clarification) => (
<li key={clarification.value}>
<strong>{clarification.value}</strong>: {clarification.description}
</li>
))}
</ul>
### Examples
#### Request
@@ -131,6 +148,11 @@ curl -X QUERY https://kycnot.me/api/v1/service/get \
"name": "Guaranteed no KYC",
"description": "Terms explicitly state KYC will never be requested."
},
"kycLevelClarification": "NONE",
"kycLevelClarificationInfo": {
"value": "NONE",
"description": "No clarification needed."
},
"categories": [
{
"name": "Exchange",

View File

@@ -19,6 +19,7 @@ import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
import { contactMethodUrlTypes } from '../../constants/contactMethods'
import { currencies } from '../../constants/currencies'
import { kycLevelClarifications } from '../../constants/kycLevelClarifications'
import { kycLevels } from '../../constants/kycLevels'
@@ -208,26 +209,42 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'min-h-24',
placeholder: 'example1.com\nexample2.onion\nexample3.b32.i2p',
class: 'md:min-h-20 min-h-24 h-full',
required: true,
}}
class="row-span-2 flex flex-col self-stretch"
class="flex flex-col self-stretch"
error={inputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
label="Contact Methods"
description={[
'One per line.',
`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`,
].join('\n')}
name="contactMethods"
inputProps={{
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
class: 'md:min-h-24',
required: true,
placeholder: 'contact@example.com\nt.me/example\n+123 456 7890',
class: 'h-full',
}}
error={inputErrors.tosUrls}
class="flex flex-col self-stretch"
error={inputErrors.contactMethods}
/>
</div>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
placeholder: 'example.com/tos',
required: true,
class: 'min-h-10',
}}
error={inputErrors.tosUrls}
/>
<InputCardGroup
name="kycLevel"
label="KYC Level"

View File

@@ -1,5 +1,5 @@
---
import { VerificationStepStatus, EventType, KycLevelClarification } from '@prisma/client'
import { VerificationStepStatus, EventType } from '@prisma/client'
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { Schema } from 'astro-seo-schema'
@@ -1295,9 +1295,17 @@ const activeAlertOrWarningEvents = service.events.filter(
</a>
</div>
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="verification">
Verification
</h2>
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b" id="verification">
<h2 class="font-title text-day-100 text-lg font-bold">Verification</h2>
<a
href="/about#suggestion-review-process"
class="text-day-500 hover:text-day-200 inline-flex items-center gap-1 text-xs leading-none transition-colors hover:underline"
>
Learn more
<Icon name="ri:arrow-right-s-line" class="size-4" />
</a>
</div>
<div class={cn('mb-6 rounded-lg p-4', statusInfo.classNames.containerBg)}>
<div class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div class="flex-1">