Release 202506041641
This commit is contained in:
53
.clinerules
53
.clinerules
@@ -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;
|
||||
} = {}
|
||||
);
|
||||
```
|
||||
36
justfile
36
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
101
web/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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';
|
||||
@@ -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])
|
||||
|
||||
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal 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();
|
||||
@@ -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 }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
>
|
||||
|
||||
10
web/src/lib/assert.ts
Normal file
10
web/src/lib/assert.ts
Normal 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
|
||||
@@ -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
35
web/src/lib/json.ts
Normal 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
|
||||
}
|
||||
@@ -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>) => {
|
||||
|
||||
248
web/src/lib/postgresListenerIntegration.ts
Normal file
248
web/src/lib/postgresListenerIntegration.ts
Normal file
@@ -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)
|
||||
}
|
||||
14
web/src/lib/serverEnvVariables.ts
Normal file
14
web/src/lib/serverEnvVariables.ts
Normal 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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
@@ -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={``}
|
||||
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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user