diff --git a/.clinerules b/.clinerules deleted file mode 100644 index 1063fcd..0000000 --- a/.clinerules +++ /dev/null @@ -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`, 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; - } = {} - ); - ``` diff --git a/justfile b/justfile index a94a3ad..9f39b9f 100644 --- a/justfile +++ b/justfile @@ -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 diff --git a/web/.env.example b/web/.env.example index 8638f8b..19bf19d 100644 --- a/web/.env.example +++ b/web/.env.example @@ -2,7 +2,7 @@ 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 diff --git a/web/astro.config.mjs b/web/astro.config.mjs index 1caf909..f037a9e 100644 --- a/web/astro.config.mjs +++ b/web/astro.config.mjs @@ -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({ diff --git a/web/package-lock.json b/web/package-lock.json index 270f22a..075d664 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -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" } diff --git a/web/package.json b/web/package.json index 35600d9..18137a7 100644 --- a/web/package.json +++ b/web/package.json @@ -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", diff --git a/web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql b/web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql new file mode 100644 index 0000000..e5d2c48 --- /dev/null +++ b/web/prisma/migrations/20250603051814_default_clarification_to_none/migration.sql @@ -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'; diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma index e9be338..0e52ad8 100644 --- a/web/prisma/schema.prisma +++ b/web/prisma/schema.prisma @@ -345,7 +345,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 +391,6 @@ model Service { onVerificationChangeForServices NotificationPreferences[] @relation("onVerificationChangeForServices") Notification Notification[] affiliatedUsers ServiceUser[] @relation("ServiceUsers") - kycLevelDetailsId Int? @@index([listedAt]) @@index([overallScore]) diff --git a/web/prisma/triggers/12_notification_push_trigger.sql b/web/prisma/triggers/12_notification_push_trigger.sql new file mode 100644 index 0000000..73d16cd --- /dev/null +++ b/web/prisma/triggers/12_notification_push_trigger.sql @@ -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(); \ No newline at end of file diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts index 38bbde4..38ec6ae 100644 --- a/web/src/actions/admin/service.ts +++ b/web/src/actions/admin/service.ts @@ -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 } + }, + }), + }, } diff --git a/web/src/actions/api/service.ts b/web/src/actions/api/service.ts index 5a21dd7..7942197 100644 --- a/web/src/actions/api/service.ts +++ b/web/src/actions/api/service.ts @@ -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( diff --git a/web/src/actions/serviceSuggestion.ts b/web/src/actions/serviceSuggestion.ts index 3a0dd2f..5452159 100644 --- a/web/src/actions/serviceSuggestion.ts +++ b/web/src/actions/serviceSuggestion.ts @@ -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, }) diff --git a/web/src/constants/contactMethods.ts b/web/src/constants/contactMethods.ts index 9ab1e83..0f9eeda 100644 --- a/web/src/constants/contactMethods.ts +++ b/web/src/constants/contactMethods.ts @@ -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 = { type: T label: string @@ -10,6 +13,7 @@ type ContactMethodInfo = { matcher: RegExp formatter: (match: RegExpMatchArray) => string | null icon: string + urlType: string } export const { @@ -22,9 +26,10 @@ export const { (type): ContactMethodInfo => ({ 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 = { + value: T + labelPlural: string +} + +export const { + dataArray: contactMethodUrlTypes, + dataObject: contactMethodUrlTypesById, + getFn: getContactMethodUrlTypeInfo, +} = makeHelpersForOptions( + 'value', + (value): ContactMethodUrlTypeInfo => ({ + 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> +> diff --git a/web/src/lib/assert.ts b/web/src/lib/assert.ts new file mode 100644 index 0000000..1c494d7 --- /dev/null +++ b/web/src/lib/assert.ts @@ -0,0 +1,10 @@ +/** + * Gives an error if the type is not equal to 1. + * + * @example + * ```ts + * type _ExpectEquals = Assert> // Gives an error + * type _ExpectEquals = Assert> // No error + * ``` + */ +export type Assert = T diff --git a/web/src/lib/fileStorage.ts b/web/src/lib/fileStorage.ts index 4b6e90e..8140a12 100644 --- a/web/src/lib/fileStorage.ts +++ b/web/src/lib/fileStorage.ts @@ -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 { + 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 { + // 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://) diff --git a/web/src/lib/json.ts b/web/src/lib/json.ts new file mode 100644 index 0000000..6cc7bad --- /dev/null +++ b/web/src/lib/json.ts @@ -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 + +export function zodParseJSON, D extends z.output | undefined = undefined>( + schema: T, + stringValue: string | null | undefined, + defaultValue?: D +): D | z.output { + if (!stringValue) return defaultValue as D + + let jsonValue: D | z.output = 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 +} diff --git a/web/src/lib/localstorage.ts b/web/src/lib/localstorage.ts index b73ab44..198b086 100644 --- a/web/src/lib/localstorage.ts +++ b/web/src/lib/localstorage.ts @@ -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>, + Schemas extends Record, 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 | 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) => { diff --git a/web/src/lib/postgresListenerIntegration.ts b/web/src/lib/postgresListenerIntegration.ts new file mode 100644 index 0000000..2e39a09 --- /dev/null +++ b/web/src/lib/postgresListenerIntegration.ts @@ -0,0 +1,248 @@ +import { z } from 'astro/zod' +import { Client } from 'pg' + +import { zodParseJSON } from './json' +import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications' +import { prisma } from './prisma' +import { getServerEnvVariable } from './serverEnvVariables' +import { sendPushNotification, type NotificationData } from './webPush' + +import type { AstroIntegration, HookParameters } from 'astro' + +const DATABASE_URL = getServerEnvVariable('DATABASE_URL') +const SITE_URL = getServerEnvVariable('SITE_URL') + +let pgClient: Client | null = null + +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 notification = await prisma.notification.findUnique({ + where: { id: notificationId }, + select: { + id: true, + type: true, + userId: true, + aboutAccountStatusChange: true, + aboutCommentStatusChange: true, + aboutServiceVerificationStatusChange: true, + aboutSuggestionStatusChange: true, + aboutComment: { + select: { + id: true, + author: { select: { id: true } }, + status: true, + content: true, + communityNote: true, + parent: { + select: { + author: { + select: { + id: true, + }, + }, + }, + }, + service: { + select: { + slug: true, + name: true, + }, + }, + }, + }, + aboutServiceSuggestionId: true, + aboutServiceSuggestion: { + select: { + status: true, + service: { + select: { + name: true, + }, + }, + }, + }, + aboutServiceSuggestionMessage: { + select: { + id: true, + content: true, + suggestion: { + select: { + id: true, + service: { + select: { + name: true, + }, + }, + }, + }, + }, + }, + aboutEvent: { + select: { + title: true, + type: true, + service: { + select: { + slug: true, + name: true, + }, + }, + }, + }, + aboutService: { + select: { + slug: true, + name: true, + verificationStatus: true, + }, + }, + aboutKarmaTransaction: { + select: { + points: true, + action: true, + description: true, + }, + }, + user: { + select: { + id: true, + name: true, + }, + }, + }, + }) + + if (!notification) { + logger.warn(`Notification with ID ${String(notificationId)} not found`) + return + } + + const subscriptions = await prisma.pushSubscription.findMany({ + where: { userId: notification.userId }, + select: { + id: true, + endpoint: true, + p256dh: true, + auth: true, + }, + }) + + if (subscriptions.length === 0) { + logger.info(`No push subscriptions found for user ${notification.user.name}`) + return + } + const notificationData = { + title: makeNotificationTitle(notification, notification.user), + body: makeNotificationContent(notification) ?? undefined, + url: makeNotificationLink(notification, SITE_URL) ?? undefined, + } satisfies NotificationData + + const results = await Promise.allSettled( + subscriptions.map(async (subscription) => { + const result = await sendPushNotification( + { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.p256dh, + auth: subscription.auth, + }, + }, + notificationData + ) + + // Remove invalid subscriptions + if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) { + await prisma.pushSubscription.delete({ where: { id: subscription.id } }) + logger.info(`Removed invalid subscription for user ${notification.user.name}`) + } + + return result.success + }) + ) + + const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length + const failureCount = results.filter((r) => !(r.status === 'fulfilled' && r.value)).length + + logger.info( + `Push notification sent for notification ${String(notificationId)} to user ${notification.user.name}: ${String(successCount)} successful, ${String(failureCount)} 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) +} diff --git a/web/src/lib/serverEnvVariables.ts b/web/src/lib/serverEnvVariables.ts new file mode 100644 index 0000000..4e4355a --- /dev/null +++ b/web/src/lib/serverEnvVariables.ts @@ -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( + name: T +): NonNullable<(typeof untypedServerEnvVariables)[T]> { + const value = untypedServerEnvVariables[name] + if (!value) throw new Error(`${name} environment variable is not set`) + return value +} diff --git a/web/src/lib/webPush.ts b/web/src/lib/webPush.ts index 50f51ff..9ccade2 100644 --- a/web/src/lib/webPush.ts +++ b/web/src/lib/webPush.ts @@ -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( diff --git a/web/src/lib/zodUtils.ts b/web/src/lib/zodUtils.ts index a199022..fdf322d 100644 --- a/web/src/lib/zodUtils.ts +++ b/web/src/lib/zodUtils.ts @@ -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 (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 = [ diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index 00a60f7..a29fac2 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -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} /> @@ -1142,12 +1178,13 @@ const apiCalls = await Astro.locals.banners.try( type.labelPlural).join(', ')}`} name="value" inputProps={{ required: true, - placeholder: 'mailto:contact@example.com', + placeholder: 'contact@example.com', }} + error={contactMethodAddInputErrors.value} /> @@ -1164,6 +1201,16 @@ const apiCalls = await Astro.locals.banners.try(

) } +
+
{ apiCalls.map((call) => ( @@ -1176,5 +1223,73 @@ const apiCalls = await Astro.locals.banners.try( )) } + + + + { + evidenceImageUrls.length === 0 ? ( +

+ No evidence images yet. +

+ ) : ( +
+ {evidenceImageUrls.map((imageUrl: string) => ( +
+ +
+ +
+ ))} +
+ ) + } +
+ +
+ + + + +
+
diff --git a/web/src/pages/docs/api.mdx b/web/src/pages/docs/api.mdx index a9208d9..5149dea 100644 --- a/web/src/pages/docs/api.mdx +++ b/web/src/pages/docs/api.mdx @@ -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 = { ))} +#### KYC Level Clarifications + +
    +{kycLevelClarifications.map((clarification) => ( +
  • + {clarification.value}: {clarification.description} +
  • +))} +
+ ### 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", diff --git a/web/src/pages/service-suggestion/new.astro b/web/src/pages/service-suggestion/new.astro index cbb29a5..a8ebce5 100644 --- a/web/src/pages/service-suggestion/new.astro +++ b/web/src/pages/service-suggestion/new.astro @@ -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} /> + 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} /> + +