Compare commits

...

25 Commits

Author SHA1 Message Date
pluja
dacf73a804 Release 202506041641 2025-06-04 16:41:32 +00:00
pluja
5812399e29 Release 202506031821 2025-06-03 18:21:55 +00:00
pluja
6a6908518d Release 202506020353 2025-06-02 03:53:03 +00:00
pluja
d065910ff3 Release 202506011533 2025-06-01 15:33:44 +00:00
pluja
490433b002 Release 202506011511 2025-06-01 15:11:37 +00:00
pluja
e17bc8a521 Release 202505312236 2025-05-31 22:36:39 +00:00
pluja
ec1215f2ae Release 202505311848 2025-05-31 18:48:33 +00:00
pluja
3afa824c18 Release 202505311149 2025-05-31 11:49:38 +00:00
pluja
9a68112e24 Release 202505311113 2025-05-31 11:13:24 +00:00
pluja
0c40d8eec5 Release 202505311002 2025-05-31 10:02:50 +00:00
pluja
e16c9b64ed Release 202505311001 2025-05-31 10:01:35 +00:00
pluja
22944fcdb3 Release 202505310921 2025-05-31 09:21:32 +00:00
pluja
f7f380c591 Release 202505302056 2025-05-30 20:56:04 +00:00
pluja
577c524ca2 Release 202505302029 2025-05-30 20:29:01 +00:00
pluja
da12e8de79 add basic API plus minor updates and fixes 2025-05-30 08:17:23 +00:00
pluja
ea40f17d3c Release 202505281348 2025-05-28 13:48:27 +00:00
pluja
7e0d41cc7a Release 202505280851 2025-05-28 08:51:59 +00:00
pluja
70a097054b Release 202505271800 2025-05-27 18:00:08 +00:00
pluja
e536ca6519 Release 202505261804 2025-05-26 18:04:45 +00:00
pluja
b361ed3aa8 Release 202505261604 2025-05-26 16:04:25 +00:00
pluja
50ede46d50 release-34 2025-05-26 14:49:37 +00:00
pluja
ba809840c6 Release 202505261445 2025-05-26 14:45:22 +00:00
pluja
f2021a3027 Release 202505261035 2025-05-26 10:35:43 +00:00
pluja
6b86a72d1e Release 2025-05-25-GgNU 2025-05-25 12:28:30 +00:00
pluja
8f2b2c34ff Release 2025-05-25-irZj 2025-05-25 11:21:35 +00:00
110 changed files with 4910 additions and 1326 deletions

View File

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

View File

@@ -5,6 +5,7 @@ alwaysApply: false
---
- We use Prisma as ORM.
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [seed.ts](mdc:web/prisma/seed.ts), you run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json). And never do the migrations manually.
- Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
```ts
type Props = {
@@ -51,4 +52,4 @@ const [user, services] = await Astro.locals.banners.tryMany([
],
])
```
- When editing the database, remember to edit the db seeding file [faker.ts](mdc:web/scripts/faker.ts) to generate data for the new schema.
- When editing the database, remember to edit the db seeding file [seed.ts](mdc:web/prisma/seed.ts) to generate data for the new schema.

0
.env.example Normal file
View File

View File

@@ -23,8 +23,7 @@ cd web
nvm install
npm i
cp -n .env.example .env
npm run db-push
npm run db-fill-clean
npm run db-reset
```
Now open the [.env](web/.env) file and fill in the missing values.

View File

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

View File

@@ -2,8 +2,12 @@ DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
REDIS_URL="redis://localhost:6379"
SOURCE_CODE_URL="https://github.com"
DATABASE_UI_URL="http://localhost:5555"
SITE_URL="https://localhost:4321"
SITE_URL="http://localhost:4321"
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
RELEASE_NUMBER=123
RELEASE_DATE="2025-05-23T19:00:00.000Z"
# Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
VAPID_SUBJECT="mailto:no-reply@kycnot.me"

View File

@@ -6,24 +6,23 @@
All commands are run from the root of the project, from a terminal:
| Command | Action |
| :------------------------ | :------------------------------------------------------------------- |
| `nvm install` | Installs and uses the correct version of node |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run db-admin` | Runs Prisma Studio (database admin) |
| `npm run db-gen` | Generates the Prisma client without running migrations |
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
| `npm run db-fill` | Fills the database with fake data (development mode) |
| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) |
| `npm run format` | Formats the code with Prettier |
| `npm run lint` | Lints the code with ESLint |
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
| Command | Action |
| :------------------------ | :------------------------------------------------------------------ |
| `nvm install` | Installs and uses the correct version of node |
| `npm install` | Installs dependencies |
| `npm run dev` | Starts local dev server at `localhost:4321` |
| `npm run build` | Build your production site to `./dist/` |
| `npm run preview` | Preview your build locally, before deploying |
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
| `npm run astro -- --help` | Get help using the Astro CLI |
| `npm run db-admin` | Runs Prisma Studio (database admin) |
| `npm run db-gen` | Generates the Prisma client without running migrations |
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
| `npm run db-seed` | Seeds the database with fake data (development mode) |
| `npm run format` | Formats the code with Prettier |
| `npm run lint` | Lints the code with ESLint |
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
> **Note**: `db-fill` and `db-fill-clean` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-fill -- --services=5` will add 5 fake services.
> **Note**: `db-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
> **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)

View File

@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite'
import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon'
import { loadEnv } from 'vite'
// @ts-expect-error process.env actually exists
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
const SITE_URL = getServerEnvVariable('SITE_URL')
export default defineConfig({
site: SITE_URL,
@@ -22,6 +22,7 @@ export default defineConfig({
plugins: [tailwindcss()],
},
integrations: [
postgresListener(),
icon(),
mdx(),
sitemap({
@@ -177,6 +178,12 @@ export default defineConfig({
url: true,
optional: false,
}),
LOGS_UI_URL: envField.string({
context: 'server',
access: 'secret',
url: true,
optional: true,
}),
RELEASE_NUMBER: envField.number({
context: 'server',
@@ -189,6 +196,27 @@ export default defineConfig({
access: 'public',
optional: true,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PUBLIC_KEY: envField.string({
context: 'server',
access: 'public',
min: 1,
optional: false,
}),
// Generated with `npx web-push generate-vapid-keys`
VAPID_PRIVATE_KEY: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
VAPID_SUBJECT: envField.string({
context: 'server',
access: 'secret',
min: 1,
optional: false,
}),
},
},
})

224
web/package-lock.json generated
View File

@@ -19,6 +19,7 @@
"@prisma/client": "6.8.2",
"@tailwindcss/vite": "4.1.7",
"@types/mime-types": "2.1.4",
"@types/pg": "8.15.4",
"@vercel/og": "0.6.8",
"astro": "5.7.13",
"astro-loading-indicator": "0.7.0",
@@ -32,6 +33,7 @@
"lodash-es": "4.17.21",
"mime-types": "3.0.1",
"object-to-formdata": "4.5.1",
"pg": "8.16.0",
"qrcode": "1.5.4",
"react": "19.1.0",
"redis": "5.0.1",
@@ -44,6 +46,7 @@
"tailwindcss": "4.1.7",
"typescript": "5.8.3",
"unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7"
},
"devDependencies": {
@@ -60,6 +63,7 @@
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4",
"@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",
@@ -3150,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": "*",
@@ -3215,6 +3217,16 @@
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@types/web-push": {
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/ws": {
"version": "8.5.13",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
@@ -3797,6 +3809,15 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
"license": "MIT",
"engines": {
"node": ">= 14"
}
},
"node_modules/ajv": {
"version": "6.12.6",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
@@ -4063,6 +4084,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"license": "MIT",
"dependencies": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"node_modules/ast-types-flow": {
"version": "0.0.8",
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
@@ -4822,6 +4855,12 @@
],
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
"license": "MIT"
},
"node_modules/boolbase": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
@@ -4916,6 +4955,12 @@
"node": "*"
}
},
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
"node_modules/bundle-name": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
@@ -6157,6 +6202,15 @@
"node": ">= 0.4"
}
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
"license": "Apache-2.0",
"dependencies": {
"safe-buffer": "^5.0.1"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
@@ -8181,6 +8235,15 @@
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
"license": "0BSD"
},
"node_modules/http_ece": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/http-cache-semantics": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
@@ -8203,6 +8266,19 @@
"node": ">= 0.8"
}
},
"node_modules/https-proxy-agent": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"license": "MIT",
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
@@ -8935,6 +9011,27 @@
"node": ">=4.0"
}
},
"node_modules/jwa": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
"license": "MIT",
"dependencies": {
"buffer-equal-constant-time": "^1.0.1",
"ecdsa-sig-formatter": "1.0.11",
"safe-buffer": "^5.0.1"
}
},
"node_modules/jws": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
"license": "MIT",
"dependencies": {
"jwa": "^2.0.0",
"safe-buffer": "^5.0.1"
}
},
"node_modules/keyv": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -10556,6 +10653,12 @@
"mini-svg-data-uri": "cli.js"
}
},
"node_modules/minimalistic-assert": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
"license": "ISC"
},
"node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
@@ -11310,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",
@@ -11347,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",
@@ -11456,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"
}
@@ -11467,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"
}
@@ -11478,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"
}
@@ -11489,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"
},
@@ -12696,7 +12843,6 @@
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT"
},
"node_modules/sass-formatter": {
@@ -13149,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",
@@ -14675,6 +14830,25 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/web-push": {
"version": "3.6.7",
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
"license": "MPL-2.0",
"dependencies": {
"asn1.js": "^5.3.0",
"http_ece": "1.2.0",
"https-proxy-agent": "^7.0.0",
"jws": "^4.0.0",
"minimist": "^1.2.5"
},
"bin": {
"web-push": "src/cli.js"
},
"engines": {
"node": ">= 16"
}
},
"node_modules/web-streams-polyfill": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -14916,8 +15090,6 @@
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.4"
}

View File

@@ -12,13 +12,15 @@
"db-push": "prisma migrate dev",
"db-triggers": "just import-triggers",
"db-update": "prisma migrate dev && just import-triggers",
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts",
"db-fill": "tsx scripts/faker.ts",
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
"db-reset": "prisma migrate reset -f && prisma migrate dev",
"db-seed": "prisma db seed",
"format": "prettier --write .",
"lint": "eslint .",
"lint-fix": "eslint . --fix && prettier --write ."
},
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"dependencies": {
"@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14",
@@ -31,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",
@@ -44,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",
@@ -56,6 +60,7 @@
"tailwindcss": "4.1.7",
"typescript": "5.8.3",
"unique-username-generator": "1.4.0",
"web-push": "3.6.7",
"zod-form-data": "2.0.7"
},
"devDependencies": {
@@ -72,6 +77,7 @@
"@types/qrcode": "1.5.5",
"@types/react": "19.1.4",
"@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1",
"astro-icon": "1.1.5",
"date-fns": "4.1.0",

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "InternalServiceNote" (
"id" SERIAL NOT NULL,
"content" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"serviceId" INTEGER NOT NULL,
"addedByUserId" INTEGER,
CONSTRAINT "InternalServiceNote_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "InternalServiceNote_serviceId_idx" ON "InternalServiceNote"("serviceId");
-- CreateIndex
CREATE INDEX "InternalServiceNote_addedByUserId_idx" ON "InternalServiceNote"("addedByUserId");
-- CreateIndex
CREATE INDEX "InternalServiceNote_createdAt_idx" ON "InternalServiceNote"("createdAt");
-- AddForeignKey
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_addedByUserId_fkey" FOREIGN KEY ("addedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,2 @@
-- Enable pg_trgm extension for similarity functions
CREATE EXTENSION IF NOT EXISTS pg_trgm;

View File

@@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
-- CreateIndex
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");

View File

@@ -0,0 +1,6 @@
-- CreateEnum
CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS');
-- AlterTable
ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification",
ADD COLUMN "kycLevelDetailsId" INTEGER;

View File

@@ -0,0 +1,25 @@
-- CreateTable
CREATE TABLE "PushSubscription" (
"id" SERIAL NOT NULL,
"userId" INTEGER NOT NULL,
"endpoint" TEXT NOT NULL,
"p256dh" TEXT NOT NULL,
"auth" TEXT NOT NULL,
"userAgent" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
-- CreateIndex
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
-- CreateIndex
CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint");
-- AddForeignKey
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

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

View File

@@ -87,6 +87,7 @@ enum ServiceVisibility {
PUBLIC
UNLISTED
HIDDEN
ARCHIVED
}
enum Currency {
@@ -296,6 +297,11 @@ enum ServiceSuggestionType {
EDIT_SERVICE
}
enum KycLevelClarification {
NONE
DEPENDS_ON_PARTNERS
}
model ServiceSuggestion {
id Int @id @default(autoincrement())
type ServiceSuggestionType
@@ -335,9 +341,11 @@ model Service {
id Int @id @default(autoincrement())
name String
slug String @unique
previousSlugs String[] @default([])
description String
categories Category[] @relation("ServiceToCategory")
kycLevel Int @default(4)
kycLevelClarification KycLevelClarification @default(NONE)
overallScore Int @default(0)
privacyScore Int @default(0)
trustScore Int @default(0)
@@ -376,6 +384,7 @@ model Service {
attributes ServiceAttribute[]
verificationSteps VerificationStep[]
suggestions ServiceSuggestion[]
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -394,6 +403,7 @@ model Service {
@@index([createdAt])
@@index([updatedAt])
@@index([slug])
@@index([previousSlugs])
}
model ServiceContactMethod {
@@ -441,6 +451,7 @@ model Attribute {
model InternalUserNote {
id Int @id @default(autoincrement())
/// Markdown
content String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -455,6 +466,23 @@ model InternalUserNote {
@@index([createdAt])
}
model InternalServiceNote {
id Int @id @default(autoincrement())
/// Markdown
content String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
serviceId Int
addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
addedByUserId Int?
@@index([serviceId])
@@index([addedByUserId])
@@index([createdAt])
}
model User {
id Int @id @default(autoincrement())
name String @unique
@@ -481,10 +509,12 @@ model User {
suggestionMessages ServiceSuggestionMessage[]
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
verificationRequests ServiceVerificationRequest[]
notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences?
serviceAffiliations ServiceUser[] @relation("UserServices")
pushSubscriptions PushSubscription[]
@@index([createdAt])
@@index([totalKarma])
@@ -633,3 +663,21 @@ model Announcement {
@@index([isActive, startDate, endDate])
}
model PushSubscription {
id Int @id @default(autoincrement())
userId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique
/// Public key for encryption
p256dh String
/// Authentication secret
auth String
/// To identify different devices
userAgent String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([userId])
@@index([endpoint])
}

View File

@@ -1,22 +1,26 @@
import crypto from 'crypto'
import { execSync } from 'node:child_process'
import { parseArgs } from 'node:util'
import { faker } from '@faker-js/faker'
import {
AnnouncementType,
AttributeCategory,
AttributeType,
CommentStatus,
Currency,
EventType,
PrismaClient,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceUserRole,
VerificationStatus,
type Prisma,
EventType,
type User,
ServiceUserRole,
AnnouncementType,
type ServiceVisibility,
ServiceSuggestionType,
KycLevelClarification,
} from '@prisma/client'
import { uniqBy } from 'lodash-es'
import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
import { kycLevels } from '../src/constants/kycLevels'
@@ -95,20 +99,6 @@ async function createAccount(preGeneratedToken?: string) {
return { token, user }
}
// Parse command line arguments
const args = process.argv.slice(2)
const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
// Parse number of services from --services or -s flag
const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
if (isNaN(numServices) || numServices < 1) {
console.error('❌ Invalid number of services specified. Must be a positive number.')
process.exit(1)
}
const prisma = new PrismaClient()
const generateFakeAttribute = () => {
@@ -611,18 +601,35 @@ const generateFakeEvent = (serviceId: number) => {
}
const generateFakeService = (users: User[]) => {
const status = faker.helpers.arrayElement(Object.values(VerificationStatus))
const status = faker.helpers.weightedArrayElement<VerificationStatus>([
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
{ weight: 30, value: 'APPROVED' },
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
{ weight: 10, value: 'VERIFICATION_FAILED' },
])
const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
return {
name,
slug,
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
description: faker.helpers.arrayElement(serviceDescriptions),
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
kycLevelClarification: faker.helpers.maybe(
() =>
faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])),
{ probability: 0.25 }
),
overallScore: 0,
privacyScore: 0,
trustScore: 0,
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
{ weight: 80, value: 'PUBLIC' },
{ weight: 10, value: 'UNLISTED' },
{ weight: 5, value: 'HIDDEN' },
{ weight: 5, value: 'ARCHIVED' },
]),
verificationStatus: status,
verificationSummary:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
@@ -636,25 +643,37 @@ const generateFakeService = (users: User[]) => {
},
verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
referral: `?ref=${faker.string.alphanumeric(6)}`,
referral: faker.helpers.arrayElement([
`?ref=${faker.string.alphanumeric(6)}`,
`/ref/${faker.string.alphanumeric(6)}`,
]),
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()),
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
onionUrls: Array.from(
{ length: faker.number.int({ min: 0, max: 2 }) },
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
onionUrls: faker.helpers.multiple(
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
{ count: { min: 0, max: 2 } }
),
i2pUrls: Array.from(
{ length: faker.number.int({ min: 0, max: 2 }) },
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
i2pUrls: faker.helpers.multiple(
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
{ count: { min: 0, max: 2 } }
),
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples),
tosReviewAt: faker.date.past(),
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(),
internalNotes: faker.helpers.maybe(
() => ({
create: {
content: faker.lorem.paragraph(),
addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
},
}),
{ probability: 0.33 }
),
} as const satisfies Prisma.ServiceCreateInput
}
@@ -904,7 +923,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
moderator: {
name: 'moderator_dev',
@@ -916,7 +935,7 @@ const specialUsersData = {
verifiedLink: 'https://kycnot.me',
totalKarma: 1001,
link: 'https://kycnot.me',
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
},
verified: {
name: 'verified_dev',
@@ -1003,359 +1022,378 @@ const generateFakeAnnouncement = () => {
} as const satisfies Prisma.AnnouncementCreateInput
}
async function runFaker() {
await prisma.$transaction(
async (tx) => {
// ---- Clean up existing data if requested ----
if (shouldCleanup || onlyCleanup) {
console.info('🧹 Cleaning up existing data...')
async function cleanup() {
console.info('🧹 Cleaning up existing data...')
try {
await tx.commentVote.deleteMany()
await tx.karmaTransaction.deleteMany()
await tx.comment.deleteMany()
await tx.serviceAttribute.deleteMany()
await tx.serviceContactMethod.deleteMany()
await tx.event.deleteMany()
await tx.verificationStep.deleteMany()
await tx.serviceSuggestionMessage.deleteMany()
await tx.serviceSuggestion.deleteMany()
await tx.serviceVerificationRequest.deleteMany()
await tx.service.deleteMany()
await tx.attribute.deleteMany()
await tx.category.deleteMany()
await tx.internalUserNote.deleteMany()
await tx.user.deleteMany()
await tx.announcement.deleteMany()
console.info('✅ Existing data cleaned up')
} catch (error) {
console.error('❌ Error cleaning up data:', error)
throw error
}
if (onlyCleanup) return
}
// ---- Get or create categories ----
const categories = await Promise.all(
categoriesToCreate.map(async (cat) => {
const existing = await tx.category.findUnique({
where: { name: cat.name },
})
if (existing) return existing
return await tx.category.create({
data: cat,
})
})
)
// ---- Create users ----
const specialUsersUntyped = Object.fromEntries(
await Promise.all(
Object.entries(specialUsersData).map(async ([key, userData]) => {
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
const secretTokenHash = hashUserSecretToken(secretToken)
const { envToken, defaultToken, ...userCreateData } = userData
const user = await tx.user.create({
data: {
notificationPreferences: { create: {} },
...userCreateData,
secretTokenHash,
},
})
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
return [key, user] as const
})
)
)
const specialUsers = specialUsersUntyped as {
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
}
let users = await Promise.all(
Array.from({ length: 10 }, async () => {
const { user } = await createAccount()
return user
})
)
// ---- Create attributes ----
const attributes = await Promise.all(
Array.from({ length: 16 }, async () => {
return await tx.attribute.create({
data: generateFakeAttribute(),
})
})
)
// ---- Create services ----
const services = await Promise.all(
Array.from({ length: numServices }, async () => {
const serviceData = generateFakeService(users)
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await tx.service.create({
data: {
...serviceData,
categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })),
},
},
})
// Create contact methods for each service
await Promise.all(
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
tx.serviceContactMethod.create({
data: generateFakeServiceContactMethod(service.id),
})
)
)
// Link random attributes to the service
await Promise.all(
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
tx.serviceAttribute.create({
data: {
serviceId: service.id,
attributeId: attr.id,
},
})
)
)
// Create events for the service
await Promise.all(
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
tx.event.create({
data: generateFakeEvent(service.id),
})
)
)
return service
})
)
// ---- Create service user affiliations for the service ----
await Promise.all(
users
.filter((user) => user.verified)
.map(async (user) => {
const servicesToAddAffiliations = uniqBy(
faker.helpers.arrayElements(services, {
min: 1,
max: 3,
}),
'id'
)
return tx.user.update({
where: { id: user.id },
data: {
serviceAffiliations: {
createMany: {
data: servicesToAddAffiliations.map((service) => ({
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
serviceId: service.id,
})),
},
},
},
})
})
)
users = await tx.user.findMany({
include: {
serviceAffiliations: true,
},
})
// ---- Create comments and replies ----
await Promise.all(
services.map(async (service) => {
// Create parent comments
const commentCount = faker.number.int({ min: 1, max: 10 })
const commentData = Array.from({ length: commentCount }, () =>
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
)
const indexesToUpdate = users.map((user) => {
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
})
commentData.forEach((comment, index) => {
if (indexesToUpdate.includes(index)) comment.ratingActive = true
})
await tx.comment.createMany({
data: commentData,
})
const comments = await tx.comment.findMany({
where: {
serviceId: service.id,
parentId: null,
},
orderBy: {
createdAt: 'desc',
},
take: commentCount,
})
const affiliatedUsers = undefinedIfEmpty(
users.filter((user) =>
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
)
)
// Create replies to comments
await Promise.all(
comments.map(async (comment) => {
const replyCount = faker.number.int({ min: 0, max: 3 })
return Promise.all(
Array.from({ length: replyCount }, () => {
const user = faker.helpers.arrayElement(
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
)
return tx.comment.create({
data: generateFakeComment(user.id, service.id, comment.id),
})
})
)
})
)
})
)
// ---- Create service suggestions for normal_dev user ----
// First create 3 CREATE_SERVICE suggestions with their services
for (let i = 0; i < 3; i++) {
const serviceData = generateFakeService(users)
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await tx.service.create({
data: {
...serviceData,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })),
},
},
})
const serviceSuggestion = await tx.serviceSuggestion.create({
data: generateFakeServiceSuggestion({
type: ServiceSuggestionType.CREATE_SERVICE,
userId: specialUsers.normal.id,
serviceId: service.id,
}),
})
// Create some messages for each suggestion
await Promise.all(
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
tx.serviceSuggestionMessage.create({
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
specialUsers.normal.id,
specialUsers.admin.id,
]),
})
)
)
}
// Then create 5 EDIT_SERVICE suggestions
await Promise.all(
services.slice(0, 5).map(async (service) => {
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
const suggestion = await tx.serviceSuggestion.create({
data: generateFakeServiceSuggestion({
type: ServiceSuggestionType.EDIT_SERVICE,
status,
userId: specialUsers.normal.id,
serviceId: service.id,
}),
})
// Create some messages for each suggestion
await Promise.all(
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
tx.serviceSuggestionMessage.create({
data: generateFakeServiceSuggestionMessage(suggestion.id, [
specialUsers.normal.id,
specialUsers.admin.id,
]),
})
)
)
})
)
// ---- Create internal notes for users ----
await Promise.all(
users.map(async (user) => {
// Create 1-3 notes for each user
const numNotes = faker.number.int({ min: 1, max: 3 })
return Promise.all(
Array.from({ length: numNotes }, () =>
tx.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
),
})
)
)
})
)
// Add some notes to special users as well
await Promise.all(
Object.values(specialUsers).map(async (user) => {
const numNotes = faker.number.int({ min: 1, max: 3 })
return Promise.all(
Array.from({ length: numNotes }, () =>
tx.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
),
})
)
)
})
)
// ---- Create announcement ----
await tx.announcement.create({
data: generateFakeAnnouncement(),
})
},
{
timeout: 1000 * 60 * 10, // 10 minutes
}
)
}
async function main() {
try {
await runFaker()
console.info('✅ Fake data generated successfully')
await prisma.commentVote.deleteMany()
await prisma.karmaTransaction.deleteMany()
await prisma.comment.deleteMany()
await prisma.serviceAttribute.deleteMany()
await prisma.serviceContactMethod.deleteMany()
await prisma.event.deleteMany()
await prisma.verificationStep.deleteMany()
await prisma.serviceSuggestionMessage.deleteMany()
await prisma.serviceSuggestion.deleteMany()
await prisma.serviceVerificationRequest.deleteMany()
await prisma.service.deleteMany()
await prisma.attribute.deleteMany()
await prisma.category.deleteMany()
await prisma.internalUserNote.deleteMany()
await prisma.user.deleteMany()
await prisma.announcement.deleteMany()
console.info('✅ Existing data cleaned up')
} catch (error) {
console.error('❌ Error generating fake data:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
console.error('❌ Error cleaning up data:', error)
throw error
}
}
main().catch((error: unknown) => {
console.error('❌ Fatal error:', error)
process.exit(1)
})
function importTriggers() {
console.info('🔄 Importing SQL triggers...')
try {
execSync('just import-triggers', { stdio: 'inherit' })
console.info('✅ Triggers imported')
} catch (error) {
console.error('❌ Error importing triggers:', error)
throw error
}
}
async function main() {
const { values: options } = parseArgs({
options: {
services: { type: 'string', short: 's', default: '100' },
cleanup: { type: 'boolean', short: 'c', default: true },
'only-cleanup': { type: 'boolean', short: 'o', default: false },
},
})
const numServices = parseIntWithFallback(options.services, 100)
if (isNaN(numServices) || numServices < 1) {
console.error('❌ Invalid number of services specified. Must be a positive number.')
process.exit(1)
}
importTriggers()
if (options.cleanup || options['only-cleanup']) {
await cleanup()
if (options['only-cleanup']) return
}
// ---- Get or create categories ----
const categories = await Promise.all(
categoriesToCreate.map(async (cat) => {
const existing = await prisma.category.findUnique({
where: { name: cat.name },
})
if (existing) return existing
return await prisma.category.create({
data: cat,
})
})
)
// ---- Create users ----
const specialUsersUntyped = Object.fromEntries(
await Promise.all(
Object.entries(specialUsersData).map(async ([key, userData]) => {
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
const secretTokenHash = hashUserSecretToken(secretToken)
const { envToken, defaultToken, ...userCreateData } = userData
const user = await prisma.user.create({
data: {
notificationPreferences: { create: {} },
...userCreateData,
secretTokenHash,
},
})
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
return [key, user] as const
})
)
)
const specialUsers = specialUsersUntyped as {
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
}
let users = await Promise.all(
Array.from({ length: 10 }, async () => {
const { user } = await createAccount()
return user
})
)
// ---- Create attributes ----
const attributes = await Promise.all(
Array.from({ length: 16 }, async () => {
return await prisma.attribute.create({
data: generateFakeAttribute(),
})
})
)
// ---- Create services ----
const services = await Promise.all(
Array.from({ length: numServices }, async () => {
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await prisma.service.create({
data: {
...serviceData,
categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })),
},
},
})
// Create contact methods for each service
await Promise.all(
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
prisma.serviceContactMethod.create({
data: generateFakeServiceContactMethod(service.id),
})
)
)
// Link random attributes to the service
await Promise.all(
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
prisma.serviceAttribute.create({
data: {
serviceId: service.id,
attributeId: attr.id,
},
})
)
)
// Create events for the service
await Promise.all(
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
prisma.event.create({
data: generateFakeEvent(service.id),
})
)
)
return service
})
)
// ---- Create service user affiliations for the service ----
await Promise.all(
users
.filter((user) => user.verified)
.map(async (user) => {
const servicesToAddAffiliations = uniqBy(
faker.helpers.arrayElements(services, {
min: 1,
max: 3,
}),
'id'
)
return prisma.user.update({
where: { id: user.id },
data: {
serviceAffiliations: {
createMany: {
data: servicesToAddAffiliations.map((service) => ({
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
serviceId: service.id,
})),
},
},
},
})
})
)
users = await prisma.user.findMany({
include: {
serviceAffiliations: true,
},
})
// ---- Create comments and replies ----
await Promise.all(
services.map(async (service) => {
// Create parent comments
const commentCount = faker.number.int({ min: 1, max: 10 })
const commentData = Array.from({ length: commentCount }, () =>
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
)
const indexesToUpdate = users.map((user) => {
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
})
commentData.forEach((comment, index) => {
if (indexesToUpdate.includes(index)) comment.ratingActive = true
})
await prisma.comment.createMany({
data: commentData,
})
const comments = await prisma.comment.findMany({
where: {
serviceId: service.id,
parentId: null,
},
orderBy: {
createdAt: 'desc',
},
take: commentCount,
})
const affiliatedUsers = undefinedIfEmpty(
users.filter((user) =>
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
)
)
// Create replies to comments
await Promise.all(
comments.map(async (comment) => {
const replyCount = faker.number.int({ min: 0, max: 3 })
return Promise.all(
Array.from({ length: replyCount }, () => {
const user = faker.helpers.arrayElement(
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
)
return prisma.comment.create({
data: generateFakeComment(user.id, service.id, comment.id),
})
})
)
})
)
})
)
// ---- Create service suggestions for normal_dev user ----
// First create 3 CREATE_SERVICE suggestions with their services
for (let i = 0; i < 3; i++) {
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await prisma.service.create({
data: {
...serviceData,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
categories: {
connect: randomCategories.map((cat) => ({ id: cat.id })),
},
},
})
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: generateFakeServiceSuggestion({
type: ServiceSuggestionType.CREATE_SERVICE,
userId: specialUsers.normal.id,
serviceId: service.id,
}),
})
// Create some messages for each suggestion
await Promise.all(
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
prisma.serviceSuggestionMessage.create({
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
specialUsers.normal.id,
specialUsers.admin.id,
]),
})
)
)
}
// Then create 5 EDIT_SERVICE suggestions
await Promise.all(
services.slice(0, 5).map(async (service) => {
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
const suggestion = await prisma.serviceSuggestion.create({
data: generateFakeServiceSuggestion({
type: ServiceSuggestionType.EDIT_SERVICE,
status,
userId: specialUsers.normal.id,
serviceId: service.id,
}),
})
// Create some messages for each suggestion
await Promise.all(
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
prisma.serviceSuggestionMessage.create({
data: generateFakeServiceSuggestionMessage(suggestion.id, [
specialUsers.normal.id,
specialUsers.admin.id,
]),
})
)
)
})
)
// ---- Create internal notes for users ----
await Promise.all(
users.map(async (user) => {
// Create 1-3 notes for each user
const numNotes = faker.number.int({ min: 1, max: 3 })
return Promise.all(
Array.from({ length: numNotes }, () =>
prisma.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
),
})
)
)
})
)
// Add some notes to special users as well
await Promise.all(
Object.values(specialUsers).map(async (user) => {
const numNotes = faker.number.int({ min: 1, max: 3 })
return Promise.all(
Array.from({ length: numNotes }, () =>
prisma.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
),
})
)
)
})
)
// ---- Create announcement ----
await prisma.announcement.create({
data: generateFakeAnnouncement(),
})
}
main()
.then(async () => {
console.info('✅ Fake data generated successfully')
await prisma.$disconnect()
})
.catch(async (error: unknown) => {
console.error(
'❌ Fatal error:',
typeof error === 'object' && error !== null && 'message' in error ? error.message : 'Unknown error'
)
console.error(error)
await prisma.$disconnect()
process.exit(1)
})

View File

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

83
web/public/sw.js Normal file
View File

@@ -0,0 +1,83 @@
// @ts-check
/// <reference lib="webworker" />
/** @type {ServiceWorkerGlobalScope} */
// @ts-expect-error
const typedSelf = self
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
typedSelf.addEventListener('install', (event) => {
console.log('Service Worker installing')
typedSelf.skipWaiting()
})
typedSelf.addEventListener('activate', (event) => {
console.log('Service Worker activating')
event.waitUntil(typedSelf.clients.claim())
})
typedSelf.addEventListener('push', (event) => {
console.log('Push event received:', event)
if (!event.data) {
console.log('Push event but no data')
return
}
let notificationData
try {
notificationData = event.data.json()
} catch (error) {
console.error('Error parsing push data:', error)
notificationData = {
title: 'New Notification',
options: {
body: event.data.text() || 'You have a new notification',
},
}
}
const { title, options } = notificationData
const notificationOptions = {
body: options.body || '',
icon: options.icon || '/favicon.svg',
badge: options.badge || '/favicon.svg',
data: options.data || {},
requireInteraction: false,
silent: false,
...options,
}
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
})
typedSelf.addEventListener('notificationclick', (event) => {
console.log('Notification clicked:', event)
event.notification.close()
const url = event.notification.data?.url || '/'
event.waitUntil(
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
// If a window is already open, focus it
for (const client of clientList) {
if (client.url === url && 'focus' in client) {
return client.focus()
}
}
// Otherwise, open a new window
if (typedSelf.clients.openWindow) {
return typedSelf.clients.openWindow(url)
}
})
)
})
typedSelf.addEventListener('notificationclose', (event) => {
console.log('Notification closed:', event)
})

View File

@@ -1,11 +1,9 @@
import { type Prisma, type PrismaClient } from '@prisma/client'
import { type Prisma } from '@prisma/client'
import { ActionError } from 'astro:actions'
import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma as prismaInstance } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
import { prisma } from '../../lib/prisma'
const selectAnnouncementReturnFields = {
id: true,

View File

@@ -1,15 +1,17 @@
import { adminAnnouncementActions } from './announcement'
import { adminAttributeActions } from './attribute'
import { adminEventActions } from './event'
import { adminNotificationActions } from './notification'
import { adminServiceActions } from './service'
import { adminServiceSuggestionActions } from './serviceSuggestion'
import { adminUserActions } from './user'
import { verificationStep } from './verificationStep'
export const adminActions = {
attribute: adminAttributeActions,
announcement: adminAnnouncementActions,
attribute: adminAttributeActions,
event: adminEventActions,
notification: adminNotificationActions,
service: adminServiceActions,
serviceSuggestions: adminServiceSuggestionActions,
user: adminUserActions,

View File

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

View File

@@ -1,42 +1,20 @@
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { uniq } from 'lodash-es'
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,
stringListOfUrlsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
zodContactMethod,
} from '../../lib/zodUtils'
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(20),
description: z.string().min(1),
serviceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
kycLevel: z.coerce.number().int().min(0).max(4),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z.string().optional().nullable().default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
})
const addSlugIfMissing = <
T extends {
slug?: string | null | undefined
@@ -56,12 +34,61 @@ const addSlugIfMissing = <
}),
})
const serviceSchemaBase = z.object({
id: z.number().int().positive(),
slug: z
.string()
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
name: z.string().min(1).max(40),
description: z.string().min(1),
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
kycLevel: z.coerce.number().int().min(0).max(4),
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
verificationStatus: z.nativeEnum(VerificationStatus),
verificationSummary: z.string().optional().nullable().default(null),
verificationProofMd: z.string().optional().nullable().default(null),
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
referral: z
.string()
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
.optional()
.nullable()
.default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
internalNote: z.string().optional(),
})
// Define schema for the create action input
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
// Define schema for the update action input
const updateServiceInputSchema = serviceSchemaBase
.extend({
removeImage: z.boolean().optional(),
})
.transform(addSlugIfMissing)
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',
permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
handler: async (input) => {
input: createServiceInputSchema,
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
@@ -75,12 +102,34 @@ export const adminServiceActions = {
})
}
const { imageFile, ...serviceData } = input
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
const imageUrl = input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.create({
data: {
...serviceData,
name: input.name,
description: input.description,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -92,6 +141,14 @@ export const adminServiceActions = {
})),
},
imageUrl,
internalNotes: input.internalNote
? {
create: {
content: input.internalNote,
addedByUserId: context.locals.user.id,
},
}
: undefined,
},
select: {
id: true,
@@ -106,34 +163,40 @@ export const adminServiceActions = {
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.transform(addSlugIfMissing),
handler: async (input) => {
const { id, categories, attributes, imageFile, ...data } = input
const existing = await prisma.service.findUnique({
input: updateServiceInputSchema,
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
const anotherServiceWithNewSlug = await prisma.service.findUnique({
where: {
slug: input.slug,
NOT: { id },
NOT: { id: input.id },
},
})
if (existing) {
if (anotherServiceWithNewSlug) {
throw new ActionError({
code: 'CONFLICT',
message: 'A service with this slug already exists',
})
}
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
// Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({
where: { id },
include: {
categories: true,
where: { id: input.id },
select: {
slug: true,
previousSlugs: true,
categories: {
select: {
id: true,
},
},
attributes: {
include: {
attribute: true,
select: {
attributeId: true,
attribute: {
select: {
id: true,
},
},
},
},
},
@@ -146,96 +209,259 @@ export const adminServiceActions = {
})
}
// Find categories to connect and disconnect
const existingCategoryIds = existingService.categories.map((c) => c.id)
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
// Find attributes to connect and disconnect
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
const imageUrl = input.removeImage
? null
: input.imageFile
? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const service = await prisma.service.update({
where: { id },
where: { id: input.id },
data: {
...data,
name: input.name,
description: input.description,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification ?? undefined,
verificationStatus: input.verificationStatus,
verificationSummary: input.verificationSummary,
verificationProofMd: input.verificationProofMd,
acceptedCurrencies: input.acceptedCurrencies,
referral: input.referral,
serviceVisibility: input.serviceVisibility,
slug: input.slug,
overallScore: input.overallScore,
previousSlugs:
existingService.slug !== input.slug
? {
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
(slug) => slug !== input.slug
),
}
: undefined,
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })),
},
attributes: {
// Connect new attributes
create: attributesToAdd.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
// Delete specific attributes that are no longer needed
deleteMany: attributesToRemove.map((attributeId) => ({
attributeId,
})),
},
},
})
return { service }
},
}),
createContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
serviceId: z.number().int().positive(),
contactMethod: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
label: z.string().min(1).max(50).nullable(),
value: zodContactMethod,
serviceId: z.number().int().positive(),
}),
handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.create({
data: {
label: input.label,
value: input.value,
serviceId: input.serviceId,
},
})
return { contactMethod }
},
}),
handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.create({
data: {
label: input.label,
value: input.value,
serviceId: input.serviceId,
},
})
return { contactMethod }
},
}),
updateContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
serviceId: z.number().int().positive(),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
label: z.string().min(1).max(50).nullable(),
value: z.string().url(),
serviceId: z.number().int().positive(),
}),
handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.update({
where: { id: input.id },
data: {
label: input.label,
value: input.value,
serviceId: input.serviceId,
},
})
return { contactMethod }
},
}),
handler: async (input) => {
const contactMethod = await prisma.serviceContactMethod.update({
where: { id: input.id },
data: {
label: input.label,
value: input.value,
serviceId: input.serviceId,
},
})
return { contactMethod }
},
}),
deleteContactMethod: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
id: z.number().int().positive(),
}),
handler: async (input) => {
await prisma.serviceContactMethod.delete({
where: { id: input.id },
})
return { success: true }
},
}),
handler: async (input) => {
await prisma.serviceContactMethod.delete({
where: { id: input.id },
})
return { success: true }
},
}),
},
internalNote: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
serviceId: z.number().int().positive(),
content: z.string().min(1),
}),
handler: async (input, { locals }) => {
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
await prisma.internalServiceNote.create({
data: {
content: input.content,
serviceId: input.serviceId,
addedByUserId: locals.user.id,
},
})
},
}),
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
noteId: z.number().int().positive(),
content: z.string().min(1),
}),
handler: async (input) => {
const note = await prisma.internalServiceNote.findUnique({
where: { id: input.noteId },
})
if (!note) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Note not found',
})
}
await prisma.internalServiceNote.update({
where: { id: input.noteId },
data: { content: input.content },
})
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
noteId: z.number().int().positive(),
}),
handler: async (input) => {
const note = await prisma.internalServiceNote.findUnique({
where: { id: input.noteId },
})
if (!note) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Note not found',
})
}
await prisma.internalServiceNote.delete({
where: { id: input.noteId },
})
},
}),
},
evidenceImage: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageAddSchema,
handler: async (input) => {
const service = await prisma.service.findUnique({
where: { id: input.serviceId },
select: { slug: true },
})
if (!service) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found to associate image with.',
})
}
if (!input.imageFile) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'Image file is required.',
})
}
const imageUrl = await saveFileLocally(
input.imageFile,
input.imageFile.name,
`evidence/${service.slug}`
)
return { imageUrl }
},
}),
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: evidenceImageDeleteSchema,
handler: async (input) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await deleteFileLocally(input.fileUrl)
return { success: true }
},
}),
},
}

View File

@@ -0,0 +1,5 @@
import { apiServiceActions } from './service'
export const apiActions = {
service: apiServiceActions,
}

View File

@@ -0,0 +1,151 @@
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'
import { prisma } from '../../lib/prisma'
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
import type { Prisma } from '@prisma/client'
export const apiServiceActions = {
get: defineProtectedAction({
accept: 'json',
permissions: 'guest',
input: z.object({
id: z.coerce.number().int().positive().optional(),
slug: z
.string()
.min(1)
.max(2048)
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
.optional(),
url: zodUrlOptionalProtocol.optional(),
}),
handler: async (input, context) => {
if (!input.id && !input.slug && !input.url) {
throw new ActionError({
code: 'BAD_REQUEST',
message: 'At least one of the following parameters is required: id, slug, url',
})
}
const urlVariants = input.url
? [input.url]
.flatMap((url) =>
[
url,
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
].filter((url) => url !== undefined)
)
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
: undefined
const select = {
id: true,
name: true,
slug: true,
description: true,
kycLevel: true,
kycLevelClarification: true,
verificationStatus: true,
categories: {
select: {
name: true,
slug: true,
},
},
serviceUrls: true,
onionUrls: true,
i2pUrls: true,
tosUrls: true,
referral: true,
listedAt: true,
verifiedAt: true,
serviceVisibility: true,
} as const satisfies Prisma.ServiceSelect
let service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
OR: [
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
...(urlVariants
? ([
{ serviceUrls: { hasSome: urlVariants } },
{ onionUrls: { hasSome: urlVariants } },
{ i2pUrls: { hasSome: urlVariants } },
] satisfies Prisma.ServiceWhereInput[])
: []),
],
},
select,
})
if (!service && input.slug) {
service = await prisma.service.findFirst({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
previousSlugs: { has: input.slug },
},
select,
})
}
if (
!service ||
(service.serviceVisibility !== 'PUBLIC' &&
service.serviceVisibility !== 'ARCHIVED' &&
service.serviceVisibility !== 'UNLISTED') ||
!service.listedAt ||
service.listedAt > new Date()
) {
throw new ActionError({
code: 'NOT_FOUND',
message: 'Service not found',
})
}
return {
id: service.id,
slug: service.slug,
name: service.name,
description: service.description,
serviceVisibility: service.serviceVisibility,
verificationStatus: service.verificationStatus,
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
'value',
'slug',
'label',
'labelShort',
'description',
]),
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(
(url) => url + (service.referral ?? '')
),
tosUrls: service.tosUrls,
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
}
},
}),
}

View File

@@ -1,5 +1,6 @@
import { accountActions } from './account'
import { adminActions } from './admin'
import { apiActions } from './api'
import { commentActions } from './comment'
import { notificationActions } from './notifications'
import { serviceActions } from './service'
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
export const server = {
account: accountActions,
admin: adminActions,
api: apiActions,
comment: commentActions,
notification: notificationActions,
service: serviceActions,

View File

@@ -23,6 +23,63 @@ export const notificationActions = {
})
},
}),
webPush: {
subscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string(),
p256dhKey: z.string(),
authKey: z.string(),
userAgent: z.string().optional(),
}),
handler: async (input, context) => {
await prisma.pushSubscription.upsert({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
update: {
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
create: {
userId: context.locals.user.id,
endpoint: input.endpoint,
p256dh: input.p256dhKey,
auth: input.authKey,
userAgent: input.userAgent,
},
})
},
}),
unsubscribe: defineProtectedAction({
accept: 'json',
permissions: 'user',
input: z.object({
endpoint: z.string().optional(),
}),
handler: async (input, context) => {
if (input.endpoint) {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
endpoint: input.endpoint,
},
})
} else {
await prisma.pushSubscription.deleteMany({
where: {
userId: context.locals.user.id,
},
})
}
},
}),
},
preferences: {
update: defineProtectedAction({
accept: 'form',
@@ -31,7 +88,7 @@ export const notificationActions = {
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
enableAutowatchMyComments: z.coerce.boolean().optional(),
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(),
karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
}),
handler: async (input, context) => {
await prisma.notificationPreferences.upsert({

View File

@@ -1,10 +1,4 @@
import {
Currency,
ServiceSuggestionStatus,
ServiceSuggestionType,
ServiceVisibility,
VerificationStatus,
} from '@prisma/client'
import { Currency, KycLevelClarification } from '@prisma/client'
import { z } from 'astro/zod'
import { ActionError } from 'astro:actions'
import { formatDistanceStrict } from 'date-fns'
@@ -12,11 +6,13 @@ import { formatDistanceStrict } from 'date-fns'
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
import { defineProtectedAction } from '../lib/defineProtectedAction'
import { saveFileLocally } from '../lib/fileStorage'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { handleHoneypotTrap } from '../lib/honeypot'
import { prisma } from '../lib/prisma'
import { separateServiceUrlsByType } from '../lib/urls'
import {
imageFileSchemaRequired,
stringListOfUrlsSchema,
stringListOfContactMethodsSchema,
stringListOfUrlsSchemaRequired,
zodCohercedNumber,
} from '../lib/zodUtils'
@@ -33,11 +29,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
const findPossibleDuplicates = async (input: { name: string }) => {
const possibleDuplicates = await prisma.service.findMany({
const matches = await findServicesBySimilarity(input.name, 0.3)
return await prisma.service.findMany({
where: {
name: {
contains: input.name,
mode: 'insensitive',
id: {
in: matches.map(({ id }) => id),
},
},
select: {
@@ -47,8 +44,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
description: true,
},
})
return possibleDuplicates
}
const serializeExtraNotes = <T extends Record<string, unknown>>(
@@ -122,9 +117,9 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: {
type: ServiceSuggestionType.EDIT_SERVICE,
type: 'EDIT_SERVICE',
notes: combinedNotes,
status: ServiceSuggestionStatus.PENDING,
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},
@@ -161,10 +156,11 @@ export const serviceSuggestionActions = {
{ message: 'Slug must be unique, try a different one' }
),
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
serviceUrls: stringListOfUrlsSchemaRequired,
allServiceUrls: stringListOfUrlsSchemaRequired,
tosUrls: stringListOfUrlsSchemaRequired,
onionUrls: stringListOfUrlsSchema,
contactMethods: stringListOfContactMethodsSchema,
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
kycLevelClarification: z.nativeEnum(KycLevelClarification),
attributes: z.array(z.coerce.number().int().positive()),
categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
@@ -210,6 +206,12 @@ export const serviceSuggestionActions = {
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
const {
web: serviceUrls,
onion: onionUrls,
i2p: i2pUrls,
} = separateServiceUrlsByType(input.allServiceUrls)
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
const serviceSelect = {
id: true,
@@ -221,18 +223,20 @@ export const serviceSuggestionActions = {
name: input.name,
slug: input.slug,
description: input.description,
serviceUrls: input.serviceUrls,
serviceUrls,
tosUrls: input.tosUrls,
onionUrls: input.onionUrls,
onionUrls,
i2pUrls,
kycLevel: input.kycLevel,
kycLevelClarification: input.kycLevelClarification,
acceptedCurrencies: input.acceptedCurrencies,
imageUrl,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
verificationStatus: 'COMMUNITY_CONTRIBUTED',
overallScore: 0,
privacyScore: 0,
trustScore: 0,
listedAt: new Date(),
serviceVisibility: ServiceVisibility.UNLISTED,
serviceVisibility: 'UNLISTED',
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -241,6 +245,11 @@ export const serviceSuggestionActions = {
attributeId: id,
})),
},
contactMethods: {
create: input.contactMethods.map((value) => ({
value,
})),
},
},
select: serviceSelect,
})
@@ -248,8 +257,8 @@ export const serviceSuggestionActions = {
const serviceSuggestion = await tx.serviceSuggestion.create({
data: {
notes: input.notes,
type: ServiceSuggestionType.CREATE_SERVICE,
status: ServiceSuggestionStatus.PENDING,
type: 'CREATE_SERVICE',
status: 'PENDING',
userId: context.locals.user.id,
serviceId: service.id,
},

View File

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

View File

@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
as: Tag
icon: string
endIcon?: string
text: string
inlineIcon?: boolean
}>
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
const { icon, text, class: className, inlineIcon, endIcon, as: Tag = 'div', ...divProps } = Astro.props
---
<Tag
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
>
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
<span>{text}</span>
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
</Tag>

View File

@@ -0,0 +1,41 @@
---
import { uniq } from 'lodash-es'
import { cn } from '../lib/cn'
import BadgeStandard from './BadgeStandard.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<
ComponentProps<typeof BadgeStandard>,
'as' | 'endIcon' | 'href' | 'icon' | 'text' | 'variant'
> & {
name: string
value: string
label: string
icon: string
}
const { name, value, label, icon, ...props } = Astro.props
const selectedValues = Astro.url.searchParams.getAll(name)
const isSelected = selectedValues.includes(value)
const url = new URL(Astro.url)
url.searchParams.delete(name)
const valuesToSet = uniq(isSelected ? selectedValues.filter((v) => v !== value) : [...selectedValues, value])
for (const value of valuesToSet) {
url.searchParams.set(name, value)
}
---
<BadgeStandard
as="a"
href={url.href}
class={cn(isSelected && 'bg-green-950 text-green-500')}
text={label}
icon={icon}
endIcon={isSelected ? 'ri:close-fill' : undefined}
{...props}
/>

View File

@@ -6,7 +6,7 @@ import { cn } from '../lib/cn'
import type { HTMLAttributes, Polymorphic } from 'astro/types'
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
VariantProps<typeof button> & {
as: Tag
@@ -249,7 +249,7 @@ const button = tv({
})
const {
as: Tag = 'button' as 'a' | 'button' | 'label',
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
label,
icon,
endIcon,
@@ -286,8 +286,7 @@ const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
<ActualTag
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
role={role ??
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
aria-disabled={disabled}
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
{...htmlProps}

View File

@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
/>
<Tooltip text="Send">
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
<Button
type="submit"
icon="ri:send-plane-fill"
size="lg"
color="success"
class="h-16"
label="Send"
iconOnly
/>
</Tooltip>
</form>
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}

View File

@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{...htmlProps}
id={`comment-${comment.id.toString()}`}
class={cn([
'group',
'group bg-night-700',
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
'bg-[#182a1f]',
@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
checked={comment.suspicious}
/>
<div class="comment-header flex items-center gap-2 text-sm">
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
<span class="collapse-symbol text-xs"></span>
<span class="sr-only">Toggle comment visibility</span>
@@ -243,13 +243,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
comment.author.serviceAffiliations.map((affiliation) => {
const roleInfo = getServiceUserRoleInfo(affiliation.role)
return (
<BadgeSmall
icon={roleInfo.icon}
color={roleInfo.color}
text={`${roleInfo.label} at ${affiliation.service.name}`}
variant="faded"
inlineIcon
/>
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
{roleInfo.label} at
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
</BadgeSmall>
)
})
}
@@ -270,12 +267,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
)
}
{
comment.rating !== null && !comment.parentId && (
<Tooltip
@@ -320,6 +311,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
color={commentStatusById.REJECTED.color}
text={commentStatusById.REJECTED.label}
inlineIcon
endIcon="ri:lock-line"
/>
)
}
{
comment.requiresAdminReview && isAuthorOrPrivileged && (
<BadgeSmall
icon="ri:alert-fill"
color="yellow"
text="Needs admin review"
inlineIcon
endIcon="ri:lock-line"
/>
)
}

View File

@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button>
<button

View File

@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
<div class="flex flex-wrap gap-4">
<InputRating name="rating" label="Rating" />
<InputWrapper label="Tags" name="tags">
<InputWrapper label="I experienced..." name="tags">
<label class="flex cursor-pointer items-center gap-2">
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
<span class="flex items-center gap-1 text-xs text-red-400">

View File

@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
if (averageUserRatingFromProps !== undefined) {
if (
averageUserRatingFromQuery !== averageUserRatingFromProps ||
(averageUserRatingFromQuery !== null &&
averageUserRatingFromProps !== null &&
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
) {
const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
if (a !== b) {
console.error(
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
)

View File

@@ -27,6 +27,12 @@ const links = [
icon: 'i2p',
external: true,
},
{
href: '/docs/api',
label: 'API',
icon: 'ri:plug-line',
external: false,
},
{
href: '/about',
label: 'About',

View File

@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
<a
href={makeUnimpersonateUrl(Astro.url)}
data-astro-reload
data-astro-prefetch="tap"
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
transition:name="header-unimpersonate-link"
aria-label="Unimpersonate"

View File

@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)}
>
<input
transition:persist={option.noTransitionPersist ? undefined : true}
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name}
value={option.value}

View File

@@ -43,7 +43,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
: Array.from({ length: icons.length }, () => option.iconClassName)
: []
return (
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
<input
transition:persist
type="checkbox"
@@ -54,9 +54,9 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
disabled={disabled}
/>
{icons.map((icon, index) => (
<Icon name={icon} class={cn('size-4', iconClassName[index])} />
<Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
))}
<span class="text-sm leading-none">{option.label}</span>
<span class="truncate text-sm leading-none">{option.label}</span>
</label>
)
})

View File

@@ -16,13 +16,20 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
}
}
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props
const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
---
<InputWrapper inputId={inputId} {...wrapperProps}>
<InputWrapper
inputId={inputId}
classNames={{
...classNames,
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
}}
{...wrapperProps}
>
{
!!removeCheckbox && (
<label

View File

@@ -2,16 +2,24 @@
import { cn } from '../lib/cn'
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
import Button from './Button.astro'
import InputFile from './InputFile.astro'
import Tooltip from './Tooltip.astro'
import type { ComponentProps } from 'astro/types'
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
square?: boolean
value?: string | null
downloadButton?: boolean
}
const { class: className, square, value, ...inputFileProps } = Astro.props
const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
function makeDownloadFilename(value: string) {
const url = new URL(value, Astro.url.origin)
return url.pathname.split('/').pop() ?? 'service-image'
}
---
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
)}
/>
{
downloadButton && value && (
<Tooltip
text="Download"
classNames={{
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
}}
>
<Button
as="a"
href={value}
download={makeDownloadFilename(value)}
icon="ri:download-line"
size="sm"
label="Download"
class={cn(
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
)}
classNames={{
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
}}
/>
</Tooltip>
)
}
</div>
<script>

View File

@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { HTMLAttributes } from 'astro/types'
import type { ComponentProps, HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
hideCancel?: boolean
icon?: string
label?: string
disabled?: boolean
color?: ComponentProps<typeof Button>['color']
}
const {
hideCancel = false,
icon = 'ri:send-plane-2-line',
label = 'Submit',
disabled = false,
class: className,
color = 'success',
...htmlProps
} = Astro.props
---
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
<Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
</div>

View File

@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
icon?: string
inputId?: string
hideLabel?: boolean
classNames?: {
description?: string
}
}
const {
@@ -32,13 +35,14 @@ const {
class: className,
inputId,
hideLabel,
classNames,
...htmlProps
} = Astro.props
const hasError = !!error && error.length > 0
---
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
<fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
{
!hideLabel && (
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
{
!!description && (
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty">
<div
class={cn(
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
classNames?.description
)}
>
<Markdown content={description} />
</div>
)

View File

@@ -0,0 +1,374 @@
---
import { Icon } from 'astro-icon/components'
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
import { cn } from '../lib/cn'
import Button from './Button.astro'
import type { Prisma } from '@prisma/client'
import type { HTMLAttributes } from 'astro/types'
type Props = HTMLAttributes<'div'> & {
dismissable?: boolean
hideIfEnabled?: boolean
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
select: {
endpoint: true
userAgent: true
}
}>[]
}
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
// TODO: Feature flag, enabled only for admins
if (!Astro.locals.user?.admin) {
return null
}
---
<div
data-push-notification-banner
data-dismissed={undefined /* Updated by client script */}
data-supports-push-notifications={undefined /* Updated by client script */}
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
data-is-enabled={undefined /* Updated by client script */}
class={cn(
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
'data-dismissed:hidden',
hideIfEnabled && 'data-is-enabled:hidden',
'not-data-supports-push-notifications:hidden',
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
className
)}
{...props}
>
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
<div
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
<div
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
>
</div>
</div>
<div class="flex items-center gap-x-3">
<div class="rounded-md bg-blue-800/30 p-2">
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
</div>
<div>
<h3 class="font-medium text-blue-100">
<span data-show-if-enabled>Push notifications enabled</span>
<span data-show-if-disabled>Turn on push notifications?</span>
</h3>
<p class="text-sm text-blue-200/80">
<span data-show-if-enabled>Turn notifications off for this device?</span>
<span data-show-if-disabled>Get notifications on this device.</span>
</p>
</div>
</div>
<div class="flex items-center gap-2">
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
<Button
as="span"
label="Yes, notify me"
color="white"
data-push-action="subscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-disabled
/>
<Button
as="span"
label="Stop notifications"
color="white"
variant="faded"
data-push-action="unsubscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-enabled
/>
</div>
</div>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification banner dismissal. //
/////////////////////////////////////////////////////////////
import { typedLocalStorage } from '../lib/localstorage'
document.addEventListener('astro:page-load', () => {
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
if (
pushNotificationsBannerDismissedAt &&
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
) {
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
pushNotificationsBannerDismissedAt = undefined
}
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
if (!skipButton) return
if (pushNotificationsBannerDismissedAt) {
banner.dataset.dismissed = ''
}
skipButton.addEventListener('click', (event) => {
event.preventDefault()
event.stopPropagation()
banner.dataset.dismissed = ''
const now = new Date()
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
pushNotificationsBannerDismissedAt = now
})
})
})
</script>
<script>
/////////////////////////////////////////////////////////
// Script to style when notifications enabled. //
////////////////////////////////////////////////////////
type ServerSubscription = {
endpoint: string
userAgent: string | null
}
/** Parse push subscriptions from string */
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
try {
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
const subscriptions = JSON.parse(subscriptionsAsString)
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
throw new Error('Push subscriptions must be an array of objects')
}
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
throw new Error('Push subscriptions must be an array of objects with endpoint property')
}
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
throw new Error('Push subscriptions must be an array of objects with userAgent property')
}
return subscriptions as ServerSubscription[]
} catch (error) {
console.error('Failed to parse push subscriptions:', error)
return []
}
}
/** Check if current device has an active push subscription */
async function getCurrentPushSubscription() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
return await registration.pushManager.getSubscription()
} catch (error) {
console.error('Error getting current push subscription:', error)
return null
}
}
/** Check if current subscription matches any server subscription */
function isCurrentDeviceSubscribed(
currentSubscription: PushSubscription | null,
serverSubscriptions: ServerSubscription[]
) {
if (!currentSubscription || serverSubscriptions.length === 0) return false
const currentEndpoint = currentSubscription.endpoint
const currentUserAgent = navigator.userAgent
return serverSubscriptions.some(
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
}
document.addEventListener('astro:page-load', async () => {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
const currentSubscription = await getCurrentPushSubscription()
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
if (isSubscribed) banner.dataset.isEnabled = ''
})
})
</script>
<script>
/////////////////////////////////////////////////////////////
// Script to handle push notification subscription. //
/////////////////////////////////////////////////////////////
import type { actions } from 'astro:actions'
import type { ActionInput } from '../lib/astroActions'
/** Utility function to convert VAPID key */
function urlB64ToUint8Array(base64String: string) {
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
const base64 = cleaned + padding
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
/** Check for browser support */
function checkSupport() {
const isSecure =
window.isSecureContext ||
window.location.hostname === 'localhost' ||
window.location.hostname === '127.0.0.1'
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
}
async function registerServiceWorker() {
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered:', registration)
const readyRegistration = await navigator.serviceWorker.ready
console.log('Service Worker is active and ready:', readyRegistration)
return readyRegistration
} catch (error) {
console.error('Service Worker registration failed:', error)
throw error
}
}
async function subscribeToPush(vapidPublicKey: string) {
try {
if (!checkSupport()) return
// Request notification permission
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
alert('Push notifications permission denied')
return
}
const registration = await registerServiceWorker()
// Subscribe to push manager
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
})
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
// Send subscription to server
const response = await fetch('/internal-api/notifications/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
userAgent: navigator.userAgent,
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push subscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push subscription failed:', error)
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
}
}
async function unsubscribeFromPush() {
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) {
console.log('No service worker registration found')
return
}
const subscription = await registration.pushManager.getSubscription()
if (!subscription) {
console.log('No push subscription found')
return
}
// Unsubscribe from browser
await subscription.unsubscribe()
// Remove from server
const response = await fetch('/internal-api/notifications/unsubscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
endpoint: subscription.endpoint,
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
})
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
console.log('Push unsubscription successful')
// Reload page to update UI
window.location.reload()
} catch (error) {
console.error('Push unsubscription failed:', error)
alert('Failed to unsubscribe from push notifications')
}
}
document.addEventListener('astro:page-load', () => {
const supportsPushNotifications = checkSupport()
if (supportsPushNotifications) {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
element.dataset.supportsPushNotifications = ''
})
}
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
const vapidPublicKey = button.dataset.vapidPublicKey
if (!vapidPublicKey) {
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
return
}
button.addEventListener('click', async (event) => {
event.preventDefault()
event.stopPropagation()
const action = button.dataset.pushAction
if (action === 'subscribe') {
await subscribeToPush(vapidPublicKey)
} else if (action === 'unsubscribe') {
await unsubscribeFromPush()
}
})
})
})
</script>

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components'
import { currencies } from '../constants/currencies'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { verificationStatusesByValue } from '../constants/verificationStatus'
import { cn } from '../lib/cn'
import { makeOverallScoreInfo } from '../lib/overallScore'
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
kycLevel: true
imageUrl: true
verificationStatus: true
serviceVisibility: true
acceptedCurrencies: true
categories: {
select: {
@@ -43,11 +45,11 @@ const {
slug,
description,
overallScore,
kycLevel,
imageUrl,
categories,
verificationStatus,
serviceVisibility,
acceptedCurrencies,
},
class: className,
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
href={Element === 'a' ? `/service/${slug}` : undefined}
{...aProps}
class={cn(
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
className
)}
>
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
src={imageUrl}
fallback="service"
alt={name || 'Service logo'}
class="size-12 shrink-0 rounded-sm object-contain text-white"
class={cn(
'size-12 shrink-0 rounded-sm object-contain text-white',
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
)}
width={48}
height={48}
/>
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
]}
</Tooltip>
)
}{
serviceVisibility === 'ARCHIVED' && (
<Tooltip
text={serviceVisibilitiesById.ARCHIVED.label}
position="right"
class="-my-2 shrink-0 whitespace-nowrap"
>
<Icon
is:inline={inlineIcons}
name={serviceVisibilitiesById.ARCHIVED.icon}
class={cn(
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
serviceVisibilitiesById.ARCHIVED.iconClass
)}
/>
</Tooltip>
)
}
</h3>
<div class="max-h-2 flex-1"></div>

View File

@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
}
}
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
if (bitcointalkMatch) {
return {
type: 'clearnet' as const,
url: urlWithReferral,
textBits: [
{
style: 'normal',
text: 'BitcoinTalk ',
},
{
style: 'irrelevant',
text: 'thread',
},
],
icon: networksBySlug.clearnet.icon,
}
}
return {
type: 'clearnet' as const,
url: urlWithReferral,

View File

@@ -49,6 +49,7 @@ const {
class={cn(
// Check the scam filter when there is a text quey and the user has checked verified and approved
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
className
)}
>
@@ -80,16 +81,20 @@ const {
))
}
</select>
<p class="text-day-500 mt-1.5 text-center text-sm">
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
Ties randomly sorted
</p>
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
Sorted by match first
</p>
</fieldset>
<!-- Text Search -->
<fieldset class="mb-6">
<legend class="font-title mb-3 leading-none text-green-500">
<label for="q">Text</label>
<label for="q">Name</label>
</legend>
<input
type="text"

View File

@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
pageSize: number
sortSeed?: string
filters: ServicesFiltersObject
includeScams: boolean
countCommunityOnly: number | null
inlineIcons?: boolean
}
@@ -35,15 +34,12 @@ const {
sortSeed,
class: className,
filters,
includeScams,
countCommunityOnly,
inlineIcons,
...divProps
} = Astro.props
const hasScams =
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
filters.verification.includes('VERIFICATION_FAILED') || includeScams
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
/>
{
countCommunityOnly && (
!!countCommunityOnly && (
<>
<Button
as="a"
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
inlineIcon={inlineIcons}
/>
)}
{countCommunityOnly && (
{!!countCommunityOnly && (
<Button
as="a"
href={urlIfIncludingCommunity}

View File

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

View File

@@ -3,13 +3,17 @@ 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
/** Notice that the first capture group is then used to format the value */
matcher: RegExp
formatter: (value: string) => string | null
formatter: (match: RegExpMatchArray) => string | null
icon: string
urlType: string
}
export const {
@@ -22,95 +26,132 @@ 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,
formatter: ([, value]) => value ?? String(value),
urlType: type ?? 'unknown',
}),
[
{
type: 'email',
label: 'Email',
matcher: /mailto:(.+)/,
formatter: (value) => value,
formatter: ([, value]) => value ?? 'Email',
icon: 'ri:mail-line',
urlType: 'email',
},
{
type: 'telephone',
label: 'Telephone',
matcher: /tel:(.+)/,
formatter: (value) => {
return parsePhoneNumberWithError(value).formatInternational()
formatter: ([, value]) => {
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 parsePhoneNumberWithError(value).formatInternational()
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
formatter: ([, value]) => {
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',
label: 'Telegram',
matcher: /https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: (value) => `t.me/${value}`,
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
icon: 'ri:telegram-line',
urlType: 'url',
},
{
type: 'linkedin',
label: 'LinkedIn',
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: (value) => `in/${value}`,
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
icon: 'ri:linkedin-box-line',
urlType: 'url',
},
{
type: 'x',
label: 'X',
matcher: /https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: (value) => `@${value}`,
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'X'),
icon: 'ri:twitter-x-line',
urlType: 'url',
},
{
type: 'instagram',
label: 'Instagram',
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: (value) => `@${value}`,
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
icon: 'ri:instagram-line',
urlType: 'url',
},
{
type: 'matrix',
label: 'Matrix',
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: (value) => value,
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
icon: 'ri:hashtag',
urlType: 'url',
},
{
type: 'bitcointalk',
label: 'BitcoinTalk',
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
formatter: () => 'BitcoinTalk',
icon: 'ri:btc-line',
urlType: 'url',
},
// Website must go last because it's a catch-all
{
type: 'simplex',
label: 'SimpleX Chat',
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
formatter: () => 'SimpleX Chat',
icon: 'simplex',
urlType: 'url',
},
{
type: 'nostr',
label: 'Nostr',
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
type: 'website',
label: 'Website',
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: (value) => value,
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
formatter: ([, value]) => value ?? 'Website',
icon: 'ri:global-line',
urlType: 'url',
},
] as const satisfies ContactMethodInfo[]
)
export function formatContactMethod(url: string) {
for (const contactMethod of contactMethods) {
const captureGroup = url.match(contactMethod.matcher)?.[1]
if (!captureGroup) continue
const match = url.match(contactMethod.matcher)
if (!match) continue
const formattedValue = contactMethod.formatter(captureGroup)
const formattedValue = contactMethod.formatter(match)
if (!formattedValue) continue
return {
@@ -121,3 +162,38 @@ export function formatContactMethod(url: string) {
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
}
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
value: T
labelPlural: string
}
export const {
dataArray: contactMethodUrlTypes,
dataObject: contactMethodUrlTypesById,
getFn: getContactMethodUrlTypeInfo,
} = makeHelpersForOptions(
'value',
(value): ContactMethodUrlTypeInfo<typeof value> => ({
value,
labelPlural: value ? transformCase(value, 'title') : String(value),
}),
[
{
value: 'email',
labelPlural: 'emails',
},
{
value: 'telephone',
labelPlural: 'phone numbers',
},
{
value: 'url',
labelPlural: 'URLs',
},
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
)
type _ExpectUrlTypesToHaveAllValues = Assert<
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
>

View File

@@ -15,6 +15,8 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
}
icon: string
color: ComponentProps<typeof BadgeSmall>['color']
isSolved: boolean
showBanner: boolean
}
export const {
@@ -36,6 +38,8 @@ export const {
},
icon: 'ri:question-fill',
color: 'gray',
isSolved: false,
showBanner: false,
}),
[
{
@@ -46,8 +50,10 @@ export const {
classNames: {
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
},
icon: 'ri:error-warning-fill',
icon: 'ri:alert-fill',
color: 'yellow',
isSolved: false,
showBanner: true,
},
{
id: 'WARNING_SOLVED',
@@ -55,10 +61,12 @@ export const {
label: 'Warning Solved',
description: 'A previously reported warning has been solved',
classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
},
icon: 'ri:check-fill',
icon: 'ri:alert-fill',
color: 'green',
isSolved: true,
showBanner: false,
},
{
id: 'ALERT',
@@ -68,8 +76,10 @@ export const {
classNames: {
dot: 'bg-red-900 text-red-300 ring-red-900/50',
},
icon: 'ri:alert-fill',
icon: 'ri:spam-fill',
color: 'red',
isSolved: false,
showBanner: true,
},
{
id: 'ALERT_SOLVED',
@@ -77,10 +87,12 @@ export const {
label: 'Alert Solved',
description: 'A previously reported alert has been solved',
classNames: {
dot: 'bg-green-900 text-green-300 ring-green-900/50',
dot: 'bg-red-900 text-red-300 ring-red-900/50',
},
icon: 'ri:check-fill',
icon: 'ri:spam-fill',
color: 'green',
isSolved: true,
showBanner: false,
},
{
id: 'INFO',
@@ -92,6 +104,8 @@ export const {
},
icon: 'ri:information-fill',
color: 'sky',
isSolved: false,
showBanner: false,
},
{
id: 'NORMAL',
@@ -103,6 +117,8 @@ export const {
},
icon: 'ri:notification-fill',
color: 'green',
isSolved: false,
showBanner: false,
},
{
id: 'UPDATE',
@@ -114,6 +130,8 @@ export const {
},
icon: 'ri:pencil-fill',
color: 'sky',
isSolved: false,
showBanner: false,
},
] as const satisfies EventTypeInfo<EventType>[]
)

View File

@@ -0,0 +1,39 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type { KycLevelClarification } from '@prisma/client'
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
value: T
label: string
description: string
icon: string
}
export const {
dataArray: kycLevelClarifications,
dataObject: kycLevelClarificationsById,
getFn: getKycLevelClarificationInfo,
} = makeHelpersForOptions(
'value',
(value): KycLevelClarificationInfo<typeof value> => ({
value,
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
description: '',
icon: 'ri:question-line',
}),
[
{
value: 'NONE',
label: 'None',
description: 'No clarification needed.',
icon: 'ri:file-copy-line',
},
{
value: 'DEPENDS_ON_PARTNERS',
label: 'Depends on partners',
description: 'May vary across partners.',
icon: 'ri:share-forward-line',
},
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
)

View File

@@ -1 +1 @@
export const SUPPORT_EMAIL = 'support@kycnot.me'
export const SUPPORT_EMAIL = 'contact@kycnot.me'

View File

@@ -1,14 +1,18 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro'
import type { ServiceSuggestionType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T
slug: string
label: string
icon: string
order: number
default: boolean
color: ComponentProps<typeof BadgeSmall>['color']
}
export const {
@@ -25,9 +29,11 @@ export const {
(value): ServiceSuggestionTypeInfo<typeof value> => ({
value,
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
icon: 'ri:question-line',
order: Infinity,
default: false,
color: 'zinc',
}),
[
{
@@ -35,14 +41,18 @@ export const {
slug: 'create',
label: 'Create',
icon: 'ri:add-line',
order: 1,
default: true,
color: 'green',
},
{
value: 'EDIT_SERVICE',
slug: 'edit',
label: 'Edit',
icon: 'ri:pencil-line',
order: 2,
default: false,
color: 'blue',
},
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
)

View File

@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
slug: string
label: string
description: string
longDescription: string
icon: string
iconClass: string
}
@@ -28,6 +29,7 @@ export const {
slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value),
description: '',
longDescription: '',
icon: 'ri:eye-line',
iconClass: 'text-current/60',
}),
@@ -37,6 +39,7 @@ export const {
slug: 'public',
label: 'Public',
description: 'Listed in search and browse.',
longDescription: 'Listed in search and browse.',
icon: 'ri:global-line',
iconClass: 'text-green-500',
},
@@ -45,6 +48,7 @@ export const {
slug: 'unlisted',
label: 'Unlisted',
description: 'Only accessible via direct link.',
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
icon: 'ri:link',
iconClass: 'text-yellow-500',
},
@@ -53,8 +57,19 @@ export const {
slug: 'hidden',
label: 'Hidden',
description: 'Only visible to moderators.',
longDescription: 'Hidden service, only visible to moderators.',
icon: 'ri:lock-line',
iconClass: 'text-red-500',
},
{
value: 'ARCHIVED',
slug: 'archived',
label: 'Archived',
description: 'No longer operational.',
longDescription:
'Archived service, no longer exists or ceased operations. Information may be outdated.',
icon: 'ri:archive-line',
iconClass: 'text-day-100',
},
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
)

4
web/src/icons/nostr.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 620 620">
<path
d="M620 270v328c0 12-10 22-22 22H332c-12 0-22-10-22-22v-61c1-75 9-147 26-179 9-20 26-30 44-36 36-11 98-4 124-5 0 0 80 3 80-42 0-37-36-34-36-34-39 1-69-1-88-9-33-13-34-36-34-44-1-91-134-102-252-79-128 24 2 209 2 456v33c0 12-10 22-22 22H22c-12 0-22-10-22-22V31C0 19 10 9 22 9h124c12 0 22 10 22 22 0 19 20 29 35 18C248 17 305 0 369 0c143 0 251 84 251 270Zm-238-66c0-27-21-48-47-48s-47 21-47 48c0 26 21 48 47 48s47-22 47-48Z" />
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
<path fill-rule="evenodd"
d="m2.14 6.31 3.95 3.79 3.97-3.81L6.11 2.5 8.13.57l3.94 3.79L16.1.5l1.98 1.9-4.03 3.85L18 10.03l4.03-3.85L24 8.07l-4.03 3.86 3.95 3.78-2.01 1.93-3.95-3.78-4.03 3.85 3.95 3.79-2 1.93-3.96-3.79L7.9 23.5l-1.98-1.9 4.06-3.89-3.95-3.78-4.06 3.89L0 15.92l4.06-3.88L.1 8.26 2.14 6.3Zm13.85 5.65L12 15.77 8.06 12l3.98-3.81 3.95 3.78Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 473 B

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

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

View File

@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
import { getAttributeTypeInfo } from '../constants/attributeTypes'
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
import { formatDateShort } from './timeAgo'
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
service: Prisma.ServiceGetPayload<{
select: {
verificationStatus: true
serviceVisibility: true
isRecentlyListed: true
listedAt: true
createdAt: true
@@ -134,6 +136,16 @@ export function makeNonDbAttributes(
},
],
},
{
title: serviceVisibilitiesById.ARCHIVED.label,
show: service.serviceVisibility === 'ARCHIVED',
type: 'WARNING',
category: 'TRUST',
description: serviceVisibilitiesById.ARCHIVED.longDescription,
privacyPoints: 0,
trustPoints: 0,
links: [],
},
{
title: 'Recently listed',
show: service.isRecentlyListed,

32
web/src/lib/endpoints.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type ActionClient } from 'astro:actions'
import type { APIRoute } from 'astro'
import type { z } from 'astro/zod'
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
action: Action
): APIRoute {
return async (context) => {
try {
const input = await context.request.json()
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const result = await context.callAction(action, input)
if (result.error) {
console.error('Error on endpoint', result.error)
return new Response(JSON.stringify({ error: result.error.message }), {
status: result.error.status,
})
}
return new Response(JSON.stringify(result.data), {
status: 200,
})
} catch (error) {
console.error('Error on endpoint', error)
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
})
}
}
}

View File

@@ -114,7 +114,13 @@ export class ErrorBanners {
return result
} catch (error) {
this.handler(uiMessage)(error)
return fallback as F
return fallback as F extends never[]
? T extends [infer _First, ...infer _Rest]
? []
: T extends unknown[]
? T[number][]
: F
: F
}
}

View File

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

View File

@@ -0,0 +1,16 @@
import { z } from 'astro/zod'
import { prisma } from './prisma'
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
const data = await prisma.$queryRaw`
SELECT id, similarity(name, ${value}) AS similarity_score
FROM "Service"
WHERE similarity(name, ${value}) >= ${similarityThreshold}
ORDER BY similarity(name, ${value}) desc`
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
const parsedData = schema.parse(data)
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
}

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

@@ -0,0 +1,35 @@
import type { z } from 'astro:content'
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface JSONObject {
[k: string]: JSONValue
}
type JSONList = JSONValue[]
type JSONPrimitive = boolean | number | string | null
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
export type ZodJSON = z.ZodType<JSONValue>
export function zodParseJSON<T extends z.ZodType<JSONValue>, D extends z.output<T> | undefined = undefined>(
schema: T,
stringValue: string | null | undefined,
defaultValue?: D
): D | z.output<T> {
if (!stringValue) return defaultValue as D
let jsonValue: D | z.output<typeof schema> = defaultValue as D
try {
jsonValue = JSON.parse(stringValue)
} catch (error) {
console.error(error)
return defaultValue as D
}
const parsedValue = schema.safeParse(jsonValue)
if (!parsedValue.success) {
console.error(parsedValue.error)
return defaultValue as D
}
return parsedValue.data
}

View File

@@ -0,0 +1,53 @@
import { z } from 'astro:schema'
import { zodParseJSON, type ZodJSON } from './json'
import { typedObjectEntries } from './objects'
function makeTypedLocalStorage<
Schemas extends Record<string, ZodJSON>,
T extends {
[K in keyof Schemas]: {
schema: Schemas[K]
default?: z.output<Schemas[K]> | undefined
key?: string
}
},
>(options: T) {
return Object.fromEntries(
typedObjectEntries(options).map(([originalKey, option]) => {
const key = option.key ?? originalKey
return [
key,
{
get: () => {
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
},
set: (value: z.input<typeof option.schema>) => {
localStorage.setItem(key, JSON.stringify(value))
},
remove: () => {
localStorage.removeItem(key)
},
default: option.default,
},
]
})
) as {
[K in keyof T]: {
get: () => z.output<T[K]['schema']> | (T[K] extends { default: infer D } ? D : undefined)
set: (value: z.input<T[K]['schema']>) => void
remove: () => void
default: z.output<T[K]['schema']> | undefined
}
}
}
export const typedLocalStorage = makeTypedLocalStorage({
pushNotificationsBannerDismissedAt: {
schema: z.coerce.date(),
},
})

View File

@@ -0,0 +1,50 @@
import type { Misc } from 'ts-toolbelt'
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
method,
path,
input,
baseUrl,
}: {
method: 'POST' | 'QUERY'
path: `/${string}`
input: T
baseUrl: URL | string
}) {
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
const fetchProsmise = fetch(fullPath, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
}).then((res) => {
try {
return res.json() as Promise<Misc.JSON.Value>
} catch (errJson: unknown) {
console.error(errJson)
try {
return res.text()
} catch (errText: unknown) {
console.error(errText)
return ''
}
}
})
let output: Misc.JSON.Value = ''
try {
output = await fetchProsmise
} catch (err: unknown) {
console.error(err)
output = err instanceof Error ? err.message : String(err)
}
return {
method,
path,
fullPath,
input,
output,
}
}

View File

@@ -162,3 +162,16 @@ export function areEqualObjectsWithoutOrder<T extends Record<string, unknown>>(
return undefined
})
}
/**
* Same as {@link Object.entries}, but with proper typing.
* @example
* typedObjectEntries({ a: 1, b: 2 }) // [['a', 1], ['b', 2]]
*/
export function typedObjectEntries<T extends Record<string, unknown>>(obj: T) {
return Object.entries(obj) as Prettify<
{
[K in Extract<keyof T, string>]: [K, T[K]]
}[Extract<keyof T, string>]
>[]
}

View 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)
}

View File

@@ -25,24 +25,23 @@ const findManyAndCount = {
},
}
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
// NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
type ModelsWithCustomMethods = {
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
findMany: (...args: any[]) => Promise<any>
}
? PrismaClient[Model] & {
findManyAndCount: FindManyAndCountType
}
: PrismaClient[Model]
}
// type ModelsWithCustomMethods = {
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
// findMany: (...args: any[]) => Promise<any>
// }
// ? PrismaClient[Model] & {
// findManyAndCount: FindManyAndCountType
// }
// : PrismaClient[Model]
// }
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
function prismaClientSingleton(): ExtendedPrismaClient {
const prisma = new PrismaClient().$extends(findManyAndCount)
return prisma as unknown as ExtendedPrismaClient
function prismaClientSingleton() {
return new PrismaClient().$extends(findManyAndCount)
}
declare global {

View File

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

View File

@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
}
return url.origin
}
export function separateServiceUrlsByType(allServiceUrls: string[]) {
const result: {
web: string[]
onion: string[]
i2p: string[]
} = {
web: [],
onion: [],
i2p: [],
}
for (const url of allServiceUrls) {
const parsedUrl = new URL(url)
if (parsedUrl.origin.endsWith('.onion')) {
result.onion.push(url)
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
result.i2p.push(url)
} else {
result.web.push(url)
}
}
return result
}

View File

@@ -50,7 +50,7 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
}[]
const env =
// This file can also be called from faker.ts, where import.meta.env is not available
// This file can also be called from seed.ts, where import.meta.env is not available
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
(import.meta.env
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))

59
web/src/lib/webPush.ts Normal file
View File

@@ -0,0 +1,59 @@
/* eslint-disable import/no-named-as-default-member */
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
keys: {
p256dh: string
auth: string
}
},
data: NotificationData
) {
try {
const result = await webpush.sendNotification(
subscription,
JSON.stringify({
title: data.title,
options: {
body: data.body,
icon: data.icon ?? '/favicon.svg',
badge: data.badge ?? '/favicon.svg',
data: {
url: data.url,
},
},
}),
{
TTL: 24 * 60 * 60, // 24 hours
}
)
return { success: true, result } as const
} catch (error) {
console.error('Error sending push notification:', error)
return {
success: false,
error: error instanceof WebPushError ? error : undefined,
} as const
}
}

View File

@@ -13,17 +13,44 @@ const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
const cleanUrl = (input: unknown) => {
if (typeof input !== 'string') return input
const cleanInput = input.trim().replace(/\/$/, '')
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
}
export const zodUrlOptionalProtocol = z.preprocess(
(input) => {
if (typeof input !== 'string') return input
const trimmedVal = input.trim()
return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal
},
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
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'
@@ -34,6 +61,11 @@ const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
.filter((item) => item !== '')
}
export const stringListOfSlugsSchemaRequired = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(z.string().regex(/^[a-z0-9-_A-Z]+$/)).min(1)
)
export const stringListOfUrlsSchema = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(zodUrlOptionalProtocol).default([])
@@ -44,6 +76,11 @@ export const stringListOfUrlsSchemaRequired = z.preprocess(
z.array(zodUrlOptionalProtocol).min(1)
)
export const stringListOfContactMethodsSchema = z.preprocess(
stringToArrayFactory(/[\s,\n]+/),
z.array(zodContactMethod).default([])
)
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
export const ACCEPTED_IMAGE_TYPES = [

View File

@@ -67,6 +67,17 @@ Once submitted, you get a unique tracking page where you can monitor its status
All new listings begin as **unlisted** — they're only accessible via direct link and won't appear in search results. After a brief admin review to confirm the request isn't spam or inappropriate, the listing will be marked as **Community Contributed**.
#### Requirements
To list a new service, it must fulfill these requirements:
- Offer a service.
- Publicly available website explaining what the service is about
- Terms of service or FAQ document
For examples:
- Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process
#### First Review
@@ -190,6 +201,12 @@ Some reviews may be spam or fake. Read comments carefully and **always do your o
To **see comments waiting for moderation**, toggle the switch in the comments section. These comments show up with a yellow background and a "pending" label.
## API
You can access basic service data via our public API.
See the [API page](/docs/api) for more details.
## Support
If you like this project, you can **support** it through these methods:

View File

@@ -50,6 +50,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
<a
href={makeLoginUrl(Astro.url, { redirect, logout: true, message: reason })}
data-astro-reload
data-astro-prefetch="tap"
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon
@@ -62,6 +63,7 @@ if (reasonType === 'admin-required' && Astro.locals.user?.admin) {
Astro.locals.actualUser && (
<a
href={makeUnimpersonateUrl(Astro.url, { redirect })}
data-astro-prefetch="tap"
class="focus-visible:outline-primary group flex items-center gap-2 px-3.5 py-2.5 text-white"
>
<Icon

View File

@@ -53,7 +53,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.displayName,
}}
description={!user.karmaUnlocks.displayName
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/karma)`
? `${makeKarmaUnlockMessage(karmaUnlocksById.displayName)} [Learn more](/docs/karma)`
: undefined}
/>
@@ -68,7 +68,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
disabled: !user.karmaUnlocks.websiteLink,
}}
description={!user.karmaUnlocks.websiteLink
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/karma)`
? `${makeKarmaUnlockMessage(karmaUnlocksById.websiteLink)} [Learn more](/docs/karma)`
: undefined}
/>
@@ -80,7 +80,7 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
square
disabled={!user.karmaUnlocks.profilePicture}
description={!user.karmaUnlocks.profilePicture
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/karma)`
? `${makeKarmaUnlockMessage(karmaUnlocksById.profilePicture)} [Learn more](/docs/karma)`
: undefined}
removeCheckbox={user.picture
? {

View File

@@ -529,8 +529,9 @@ if (!user) return Astro.rewrite('/404')
<div class="border-night-500 bg-night-800/70 mb-4 rounded-md border px-4 py-3">
<p class="text-day-300">
Earn karma to unlock features and privileges. <a href="/karma" class="text-day-200 hover:underline"
>Learn about karma</a
Earn karma to unlock features and privileges. <a
href="/docs/karma"
class="text-day-200 hover:underline">Learn about karma</a
>
</p>
</div>

View File

@@ -271,6 +271,7 @@ if (toggleResult?.error) {
label: type.label,
value: type.value,
icon: type.icon,
noTransitionPersist: false,
}))}
cardSize="sm"
required
@@ -305,8 +306,8 @@ if (toggleResult?.error) {
label="Status"
error={createInputErrors.isActive}
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
{ label: 'Active', value: 'true', noTransitionPersist: true },
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
]}
selectedValue={newAnnouncement.isActive ? 'true' : 'false'}
cardSize="sm"
@@ -627,6 +628,7 @@ if (toggleResult?.error) {
label: type.label,
value: type.value,
icon: type.icon,
noTransitionPersist: true,
}))}
cardSize="sm"
required
@@ -659,8 +661,8 @@ if (toggleResult?.error) {
name="isActive"
label="Status"
options={[
{ label: 'Active', value: 'true' },
{ label: 'Inactive', value: 'false' },
{ label: 'Active', value: 'true', noTransitionPersist: true },
{ label: 'Inactive', value: 'false', noTransitionPersist: true },
]}
selectedValue={announcement.isActive ? 'true' : 'false'}
cardSize="sm"

View File

@@ -1,6 +1,6 @@
---
import { Icon } from 'astro-icon/components'
import { DATABASE_UI_URL } from 'astro:env/server'
import { DATABASE_UI_URL, LOGS_UI_URL } from 'astro:env/server'
import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
@@ -65,6 +65,14 @@ const adminLinks: AdminLink[] = [
base: 'text-pink-300',
},
},
{
icon: 'ri:notification-3-line',
title: 'Notifications',
href: '/admin/notifications',
classNames: {
base: 'text-indigo-300',
},
},
{
icon: 'ri:rocket-2-line',
title: 'Releases',
@@ -81,6 +89,18 @@ const adminLinks: AdminLink[] = [
base: 'text-gray-300',
},
},
...(LOGS_UI_URL
? [
{
icon: 'ri:menu-search-line',
title: 'Logs',
href: LOGS_UI_URL,
classNames: {
base: 'text-cyan-300',
},
},
]
: []),
]
---
@@ -93,25 +113,27 @@ const adminLinks: AdminLink[] = [
<nav>
<ol class="grid grid-cols-[repeat(auto-fill,minmax(calc(var(--spacing)*40),1fr))] gap-4">
{
adminLinks.map((link) => (
<li
class={cn(
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
link.classNames.base
)}
>
<a
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
adminLinks
.filter((link) => link.href)
.map((link) => (
<li
class={cn(
'group ease-out-back transition-transform duration-250 hover:-translate-y-0.5 hover:scale-105',
link.classNames.base
)}
>
<Icon
name={link.icon}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
/>
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
<a
href={link.href}
class="flex min-h-24 flex-col items-center justify-around rounded-lg border border-current/4 bg-current/3 py-3 text-center transition-all duration-250 group-hover:border-current/10 group-hover:bg-current/10 group-hover:shadow-xl"
>
<Icon
name={link.icon}
class="size-8 text-current opacity-50 transition-opacity duration-250 group-hover:opacity-100"
/>
<span class="font-title text-xl leading-none font-semibold text-current">{link.title}</span>
</a>
</li>
))
}
</ol>
</nav>

View File

@@ -0,0 +1,155 @@
---
import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions'
import { groupBy, round, uniq } from 'lodash-es'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputText from '../../components/InputText.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { cn } from '../../lib/cn'
import { prisma } from '../../lib/prisma'
// Check if user is admin
if (!Astro.locals.user?.admin) {
return Astro.redirect('/access-denied')
}
const testResult = Astro.getActionResult(actions.admin.notification.webPush.test)
const testInputErrors = isInputError(testResult?.error) ? testResult.error.fields : {}
Astro.locals.banners.addIfSuccess(testResult, (data) => data.message)
const subscriptions = await Astro.locals.banners.try(
'Error while fetching subscriptions by user',
() =>
prisma.pushSubscription.findMany({
select: {
id: true,
user: {
select: {
id: true,
name: true,
},
},
},
}),
[] as []
)
const totalSubscriptions = subscriptions.length
const subscriptionsByUser = groupBy(subscriptions, 'user.id')
const totalUsers = Object.keys(subscriptionsByUser).length
const adminUsers = await prisma.user.findMany({
where: {
admin: true,
},
select: {
name: true,
},
})
const stats = [
{
icon: 'ri:notification-4-line',
iconClass: 'text-blue-400',
title: 'Total Subscriptions',
value: totalSubscriptions.toLocaleString(),
},
{
icon: 'ri:user-3-line',
iconClass: 'text-green-400',
title: 'Subscribed Users',
value: totalUsers.toLocaleString(),
},
{
icon: 'ri:smartphone-line',
iconClass: 'text-purple-400',
title: 'Avg Devices/User',
value: (totalUsers > 0 ? round(totalSubscriptions / totalUsers, 1) : 0).toLocaleString(),
},
] satisfies {
icon: string
iconClass: string
title: string
value: string
}[]
---
<MiniLayout
pageTitle="Push notifications"
description="Send test notifications"
layoutHeader={{
icon: 'ri:notification-3-line',
title: 'Push notifications',
subtitle: 'Send test notifications',
}}
>
<div class="mb-6 grid gap-4 sm:grid-cols-3">
{
stats.map((stat) => (
<div class="flex flex-col items-center gap-2 text-center">
<div class="flex items-end gap-1">
<span class="text-5xl leading-[0.8] font-bold text-white">{stat.value}</span>
<Icon name={stat.icon} class={cn('size-5 shrink-0', stat.iconClass)} />
</div>
<span class="text-day-200 flex grow flex-col justify-center text-sm leading-none font-medium text-balance">
{stat.title}
</span>
</div>
))
}
</div>
<h2 class="text-center text-lg font-semibold text-white">Send Test Notification</h2>
<form method="POST" action={actions.admin.notification.webPush.test} class="space-y-4">
<InputTextArea
label="Users"
name="userNames"
inputProps={{
placeholder: 'john-doe, jane-doe',
class: 'leading-tight min-h-24',
}}
value={uniq([Astro.locals.user, ...adminUsers].map((user) => user.name)).join('\n')}
description={[
'- Comma-separated list of user names.',
'- Minimum 1 user name.',
'- By default, all admin users are selected.',
].join('\n')}
error={testInputErrors.userNames}
/>
<InputText
label="Title"
name="title"
inputProps={{
value: 'Test Notification',
required: true,
}}
error={testInputErrors.title}
/>
<InputTextArea
label="Body"
name="body"
inputProps={{
value: 'This is a test push notification from KYCNot.me',
}}
error={testInputErrors.body}
/>
<InputText
label="Action URL"
name="url"
inputProps={{
placeholder: 'https://example.com/path',
}}
description="URL to open when the notification is clicked"
error={testInputErrors.url}
/>
<InputSubmitButton label="Send" icon="ri:send-plane-line" hideCancel color="danger" />
</form>
</MiniLayout>

View File

@@ -1,8 +1,8 @@
---
import { RELEASE_DATE, RELEASE_NUMBER } from 'astro:env/server'
import TimeFormatted from '../../components/TimeFormatted.astro'
import MiniLayout from '../../layouts/MiniLayout.astro'
import { timeAgo } from '../../lib/timeAgo'
const releaseDate =
RELEASE_DATE && !isNaN(new Date(RELEASE_DATE).getTime()) ? new Date(RELEASE_DATE) : undefined
@@ -37,7 +37,7 @@ const releaseDate =
{
!!releaseDate && (
<p class="text-day-500 mt-2">
(<TimeFormatted date={releaseDate} hourPrecision daysUntilDate={Infinity} />)
(<time datetime={releaseDate.toISOString()}>{timeAgo.format(releaseDate, 'round')}</time>)
</p>
)
}

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro'
import ServiceCard from '../../../components/ServiceCard.astro'
@@ -10,6 +11,7 @@ import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
} from '../../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers'
@@ -57,6 +59,7 @@ const serviceSuggestion = await Astro.locals.banners.try('Error fetching service
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
serviceVisibility: true,
categories: {
select: {
name: true,
@@ -92,6 +95,7 @@ if (!serviceSuggestion) {
}
const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
---
<BaseLayout
@@ -110,7 +114,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
label="Back"
/>
<h1 class="font-title text-xl text-green-500">Service Suggestion</h1>
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2">
@@ -118,12 +124,13 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div>
<div
class="rounded-lg border border-green-500/30 bg-black/40 p-4 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<h2 class="font-title mb-3 text-lg text-green-500">Suggestion Details</h2>
<div class="rounded-lg bg-black/40 p-4 backdrop-blur-xs">
<h2 class="font-title text-day-200 mb-3 text-lg">Suggestion Details</h2>
<div class="mb-3 grid grid-cols-[auto_1fr] gap-x-3 gap-y-2 text-sm">
<span class="font-title text-gray-400">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
<span class="font-title text-gray-400">Status:</span>
<span
class={cn(
@@ -142,7 +149,7 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
<span class="text-gray-300">{serviceSuggestion.createdAt.toLocaleString()}</span>
<span class="font-title text-gray-400">Service page:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="text-green-400 hover:text-green-500">
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
View Service <Icon
name="ri:external-link-line"
class="ml-0.5 inline-block size-3 align-[-0.05em]"
@@ -164,11 +171,9 @@ const statusInfo = getServiceSuggestionStatusInfo(serviceSuggestion.status)
</div>
</div>
<div
class="rounded-lg border border-green-500/30 bg-black/40 p-6 shadow-[0_0_15px_rgba(34,197,94,0.2)] backdrop-blur-xs"
>
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs">
<div class="flex items-center justify-between">
<h2 class="font-title text-lg text-green-500">Messages</h2>
<h2 class="font-title text-day-200 text-lg">Messages</h2>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2">
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} />

View File

@@ -4,6 +4,7 @@ import { actions } from 'astro:actions'
import { z } from 'astro:content'
import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import TimeFormatted from '../../../components/TimeFormatted.astro'
@@ -12,59 +13,67 @@ import UserBadge from '../../../components/UserBadge.astro'
import {
getServiceSuggestionStatusInfo,
serviceSuggestionStatuses,
serviceSuggestionStatusesZodEnumBySlug,
serviceSuggestionStatusSlugToId,
} from '../../../constants/serviceSuggestionStatus'
import {
getServiceSuggestionTypeInfo,
serviceSuggestionTypes,
serviceSuggestionTypeSlugToId,
serviceSuggestionTypesZodEnumBySlug,
} from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls'
import type { Prisma, ServiceSuggestionStatus } from '@prisma/client'
import type { Prisma } from '@prisma/client'
const user = Astro.locals.user
if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
}
const search = Astro.url.searchParams.get('search') ?? ''
const statusEnumValues = serviceSuggestionStatuses.map((s) => s.value) as [string, ...string[]]
const statusParam = Astro.url.searchParams.get('status')
const statusFilter = z
.enum(statusEnumValues)
.nullable()
.parse(statusParam === '' ? null : statusParam) as ServiceSuggestionStatus | null
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['service', 'status', 'user', 'createdAt', 'messageCount']).default('createdAt'),
search: z.string().optional(),
status: serviceSuggestionStatusesZodEnumBySlug
.transform((slug) => serviceSuggestionStatusSlugToId(slug))
.optional(),
type: serviceSuggestionTypesZodEnumBySlug
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
.optional(),
'sort-by': z
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
.default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
},
Astro
)
const sortBy = filters['sort-by']
const sortOrder = filters['sort-order']
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
if (sortBy === 'createdAt') {
prismaOrderBy = { createdAt: sortOrder }
if (filters['sort-by'] === 'createdAt') {
prismaOrderBy = { createdAt: filters['sort-order'] }
}
let suggestions = await prisma.serviceSuggestion.findMany({
where: {
...(search
...(filters.search
? {
OR: [
{ service: { name: { contains: search, mode: 'insensitive' } } },
{ user: { name: { contains: search, mode: 'insensitive' } } },
{ notes: { contains: search, mode: 'insensitive' } },
{ service: { name: { contains: filters.search, mode: 'insensitive' } } },
{ user: { name: { contains: filters.search, mode: 'insensitive' } } },
{ notes: { contains: filters.search, mode: 'insensitive' } },
],
}
: {}),
status: statusFilter ?? undefined,
status: filters.status,
type: filters.type,
},
orderBy: prismaOrderBy,
select: {
id: true,
type: true,
status: true,
notes: true,
createdAt: true,
@@ -119,18 +128,33 @@ let suggestions = await prisma.serviceSuggestion.findMany({
let suggestionsWithDetails = suggestions.map((s) => ({
...s,
statusInfo: getServiceSuggestionStatusInfo(s.status),
typeInfo: getServiceSuggestionTypeInfo(s.type),
messageCount: s._count.messages,
lastMessage: s.messages[0],
}))
if (sortBy === 'service') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.service.name.toLowerCase()], [sortOrder])
} else if (sortBy === 'status') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.statusInfo.label], [sortOrder])
} else if (sortBy === 'user') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.user.name.toLowerCase()], [sortOrder])
} else if (sortBy === 'messageCount') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [sortOrder])
if (filters['sort-by'] === 'service') {
suggestionsWithDetails = orderBy(
suggestionsWithDetails,
[(s) => s.service.name.toLowerCase()],
[filters['sort-order']]
)
} else if (filters['sort-by'] === 'status') {
suggestionsWithDetails = orderBy(
suggestionsWithDetails,
[(s) => s.statusInfo.label],
[filters['sort-order']]
)
} else if (filters['sort-by'] === 'type') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, [(s) => s.typeInfo.label], [filters['sort-order']])
} else if (filters['sort-by'] === 'user') {
suggestionsWithDetails = orderBy(
suggestionsWithDetails,
[(s) => s.user.name.toLowerCase()],
[filters['sort-order']]
)
} else if (filters['sort-by'] === 'messageCount') {
suggestionsWithDetails = orderBy(suggestionsWithDetails, ['messageCount'], [filters['sort-order']])
}
const suggestionCount = suggestionsWithDetails.length
@@ -162,7 +186,7 @@ const makeSortUrl = (slug: string) => {
type="text"
name="search"
id="search"
value={search}
value={filters.search}
placeholder="Search by service, user, notes..."
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 placeholder-zinc-500 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
/>
@@ -177,13 +201,30 @@ const makeSortUrl = (slug: string) => {
<option value="">All Statuses</option>
{
serviceSuggestionStatuses.map((status) => (
<option value={status.value} selected={statusFilter === status.value}>
<option value={status.slug} selected={filters.status === status.value}>
{status.label}
</option>
))
}
</select>
</div>
<div>
<label for="type-filter" class="block text-xs font-medium text-zinc-400">Type</label>
<select
name="type"
id="type-filter"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Types</option>
{
serviceSuggestionTypes.map((type) => (
<option value={type.slug} selected={filters.type === type.value}>
{type.label}
</option>
))
}
</select>
</div>
<div class="flex items-end">
<Button
as="button"
@@ -212,7 +253,7 @@ const makeSortUrl = (slug: string) => {
<thead class="bg-zinc-900/30">
<tr>
<th
class="w-[25%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
class="w-[20%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('service')} class="flex items-center hover:text-zinc-200">
Service <SortArrowIcon
@@ -222,7 +263,7 @@ const makeSortUrl = (slug: string) => {
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
class="w-[12%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('user')} class="flex items-center hover:text-zinc-200">
User <SortArrowIcon
@@ -232,7 +273,17 @@ const makeSortUrl = (slug: string) => {
</a>
</th>
<th
class="w-[15%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
class="w-[10%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('type')} class="flex items-center hover:text-zinc-200">
Type <SortArrowIcon
active={filters['sort-by'] === 'type'}
sortOrder={filters['sort-order']}
/>
</a>
</th>
<th
class="w-[13%] px-4 py-3 text-left text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a href={makeSortUrl('status')} class="flex items-center hover:text-zinc-200">
Status <SortArrowIcon
@@ -295,6 +346,13 @@ const makeSortUrl = (slug: string) => {
<td class="px-4 py-3">
<UserBadge user={suggestion.user} size="md" />
</td>
<td class="px-4 py-3">
<BadgeSmall
color={suggestion.typeInfo.color}
text={suggestion.typeInfo.label}
icon={suggestion.typeInfo.icon}
/>
</td>
<td class="px-4 py-3">
<form method="POST" action={actions.admin.serviceSuggestions.update}>
<input type="hidden" name="suggestionId" value={suggestion.id} />

View File

@@ -1,6 +1,8 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import { Code } from 'astro:components'
import { orderBy } from 'lodash-es'
import BadgeSmall from '../../../../components/BadgeSmall.astro'
@@ -21,9 +23,10 @@ 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'
import { kycLevels } from '../../../../constants/kycLevels'
import { serviceVisibilities } from '../../../../constants/serviceVisibility'
import { verificationStatuses } from '../../../../constants/verificationStatus'
@@ -32,6 +35,9 @@ import {
verificationStepStatuses,
} 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'
@@ -76,6 +82,42 @@ const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateRes
const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete)
Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully')
const internalNoteCreateResult = Astro.getActionResult(actions.admin.service.internalNote.add)
Astro.locals.banners.addIfSuccess(internalNoteCreateResult, 'Internal note added successfully')
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',
@@ -130,6 +172,20 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
label: 'asc',
},
},
internalNotes: {
include: {
addedByUser: {
select: {
name: true,
displayName: true,
picture: true,
},
},
},
orderBy: {
createdAt: 'desc',
},
},
_count: {
select: {
verificationRequests: true,
@@ -156,7 +212,41 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([
],
])
if (!service) return Astro.rewrite('/404')
if (!service) {
try {
const serviceWithOldSlug = await prisma.service.findFirst({
where: { previousSlugs: { has: slug } },
select: { slug: true },
})
if (serviceWithOldSlug) {
return Astro.redirect(`/admin/services/${serviceWithOldSlug.slug}/edit`, 301)
}
} catch (error) {
console.error(error)
}
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',
() =>
Promise.all([
makeAdminApiCallInfo({
method: 'QUERY',
path: '/service/get',
input: { slug: service.slug },
baseUrl: Astro.url,
}),
]),
[]
)
---
<BaseLayout pageTitle={`Edit Service: ${service.name}`}>
@@ -209,16 +299,32 @@ if (!service) return Astro.rewrite('/404')
enctype="multipart/form-data"
>
<input type="hidden" name="id" value={service.id} />
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<InputText
label="Name"
name="name"
inputProps={{
required: true,
value: service.name,
}}
error={serviceInputErrors.name}
/>
<InputText
label="Slug"
description={`Auto-generated if empty. ${
service.previousSlugs.length > 0 ? `Old slugs: ${service.previousSlugs.join(', ')}` : ''
}`}
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
</div>
<InputTextArea
label="Description"
name="description"
@@ -230,84 +336,64 @@ if (!service) return Astro.rewrite('/404')
error={serviceInputErrors.description}
/>
<InputText
label="Slug"
description="Auto-generated if empty"
name="slug"
inputProps={{
value: service.slug,
class: 'font-title',
}}
error={serviceInputErrors.slug}
class="font-title"
/>
<div class="grid grid-cols-1 gap-x-4 gap-y-6 md:grid-cols-2">
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
<InputTextArea
label="Service URLs"
description="One per line"
name="serviceUrls"
description="One per line. Accepts **Web**, **Onion**, and **I2P** URLs."
name="allServiceUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com\nhttps://example2.com',
placeholder: 'https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p',
class: 'grow min-h-24',
required: true,
}}
value={service.serviceUrls.join('\n')}
error={serviceInputErrors.serviceUrls}
class="row-span-2 flex flex-col self-stretch"
value={[...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].join('\n\n')}
error={serviceInputErrors.allServiceUrls}
/>
<InputTextArea
label="ToS URLs"
description="One per line"
name="tosUrls"
inputProps={{
rows: 3,
placeholder: 'https://example1.com/tos\nhttps://example2.com/tos',
required: true,
}}
value={service.tosUrls.join('\n')}
error={serviceInputErrors.tosUrls}
/>
<InputTextArea
label="Onion URLs"
description="One per line"
name="onionUrls"
<InputText
label="Referral link path"
name="referral"
inputProps={{
rows: 3,
placeholder: 'http://example1.onion\nhttp://example2.onion',
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
value={service.onionUrls.join('\n')}
error={serviceInputErrors.onionUrls}
/>
<InputTextArea
label="I2P URLs"
description="One per line"
name="i2pUrls"
inputProps={{
rows: 3,
placeholder: 'http://example1.b32.i2p\nhttp://example2.b32.i2p',
}}
value={service.i2pUrls.join('\n')}
error={serviceInputErrors.referral}
class="self-end"
description="Will be appended to the service URL"
/>
</div>
<InputText
label="Referral Code/Link"
name="referral"
inputProps={{
value: service.referral ?? undefined,
placeholder: 'e.g., REFCODE123 or https://example.com?ref=123',
}}
error={serviceInputErrors.referral}
/>
<div class="flex items-center justify-between gap-2">
<InputImageFile
label="Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
error={serviceInputErrors.imageFile}
square
value={service.imageUrl}
downloadButton
removeCheckbox={service.imageUrl
? {
name: 'removeImage',
label: 'Remove image',
}
: undefined}
class="grow"
/>
</div>
<InputImageFile
label="Image"
name="imageFile"
description="Square image. At least 192x192px. Transparency supported. Leave empty to keep current image."
error={serviceInputErrors.imageFile}
square
value={service.imageUrl}
/>
<div class="grid grid-cols-1 items-stretch gap-x-4 gap-y-6 sm:grid-cols-[1fr_2fr]">
<div class="xs:grid-cols-[1fr_2fr] grid grid-cols-1 items-stretch gap-x-4 gap-y-6">
<InputCheckboxGroup
name="categories"
label="Categories"
@@ -320,6 +406,7 @@ if (!service) return Astro.rewrite('/404')
}))}
selectedValues={service.categories.map((c) => c.id.toString())}
error={serviceInputErrors.categories}
class="min-w-auto"
/>
<InputCheckboxGroup
@@ -354,6 +441,7 @@ if (!service) return Astro.rewrite('/404')
value: kycLevel.id.toString(),
icon: kycLevel.icon,
description: kycLevel.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevel.toString()}
iconSize="md"
@@ -362,6 +450,22 @@ if (!service) return Astro.rewrite('/404')
class="[&>div]:grid-cols-2 [&>div]:[--card-min-size:16rem]"
/>
<InputCardGroup
name="kycLevelClarification"
label="KYC Level Clarification"
options={kycLevelClarifications.map((clarification) => ({
label: clarification.label,
value: clarification.value,
icon: clarification.icon,
description: clarification.description,
noTransitionPersist: true,
}))}
selectedValue={service.kycLevelClarification}
iconSize="sm"
cardSize="sm"
error={serviceInputErrors.kycLevelClarification}
/>
<InputCardGroup
name="verificationStatus"
label="Verification Status"
@@ -371,6 +475,7 @@ if (!service) return Astro.rewrite('/404')
icon: status.icon,
iconClass: status.classNames.icon,
description: status.description,
noTransitionPersist: true,
}))}
selectedValue={service.verificationStatus}
error={serviceInputErrors.verificationStatus}
@@ -386,6 +491,7 @@ if (!service) return Astro.rewrite('/404')
label: currency.name,
value: currency.id,
icon: currency.icon,
noTransitionPersist: true,
}))}
selectedValue={service.acceptedCurrencies}
error={serviceInputErrors.acceptedCurrencies}
@@ -426,6 +532,7 @@ if (!service) return Astro.rewrite('/404')
icon: visibility.icon,
iconClass: visibility.iconClass,
description: visibility.description,
noTransitionPersist: true,
}))}
selectedValue={service.serviceVisibility}
error={serviceInputErrors.serviceVisibility}
@@ -436,6 +543,108 @@ if (!service) return Astro.rewrite('/404')
</form>
</FormSection>
<FormSection title="Internal Notes" id="internal-notes">
<FormSubSection title="Existing Notes">
{
service.internalNotes.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No internal notes yet.
</p>
) : (
<div class="space-y-4">
{service.internalNotes.map((note) => (
<div class="border-night-600 bg-night-800 rounded-md border p-4">
<input
type="checkbox"
class="peer/edit-note sr-only"
data-edit-note-checkbox
id={`edit-note-${note.id}`}
/>
<div class="flex items-start justify-between">
<div class="flex-grow space-y-1">
<div
data-note-content
class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty"
>
<Markdown content={note.content} />
</div>
<div class="text-day-500 flex items-center gap-2 text-xs">
<TimeFormatted date={note.createdAt} hourPrecision />
{note.addedByUser && (
<span class="flex items-center gap-1">
by <UserBadge user={note.addedByUser} size="sm" />
</span>
)}
</div>
</div>
<div class="flex items-center gap-1">
<Button
as="label"
for={`edit-note-${note.id}`}
variant="faded"
size="sm"
icon="ri:edit-line"
iconOnly
label="Edit"
/>
<form method="POST" action={actions.admin.service.internalNote.delete} class="contents">
<input type="hidden" name="noteId" value={note.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</div>
</div>
<form
method="POST"
action={actions.admin.service.internalNote.update}
data-note-edit-form
data-astro-reload
class="mt-4 hidden space-y-4 peer-checked/edit-note:block"
>
<input type="hidden" name="noteId" value={note.id} />
<InputTextArea
label="Note Content"
name="content"
value={note.content}
inputProps={{ class: 'bg-night-700' }}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Note">
<form method="POST" action={actions.admin.service.internalNote.add} class="space-y-4">
<input type="hidden" name="serviceId" value={service.id} />
<InputTextArea
label="Note Content"
name="content"
inputProps={{
required: true,
rows: 4,
placeholder: 'Add internal note about this service...',
}}
error={internalNoteInputErrors.content}
/>
<InputSubmitButton label="Add Note" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
<FormSection title="Events">
<FormSubSection title="Existing Events">
{
@@ -478,28 +687,43 @@ if (!service) return Astro.rewrite('/404')
</div>
</div>
<div class="flex shrink-0 gap-1.5">
<form method="POST" action={actions.admin.event.toggle} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Tooltip text={event.visible ? 'Hide Event' : 'Show Event'}>
<Tooltip text={event.visible ? 'Hide' : 'Show'}>
<form method="POST" action={actions.admin.event.toggle} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Button
type="submit"
variant="faded"
size="sm"
icon={event.visible ? 'ri:eye-off-line' : 'ri:eye-line'}
iconOnly
label={event.visible ? 'Hide' : 'Show'}
/>
</Tooltip>
</form>
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
/>
<form method="POST" action={actions.admin.event.delete} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
</form>
</form>
</Tooltip>
<Tooltip text="Edit">
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-event-${event.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/>
</Tooltip>
<Tooltip text="Delete">
<form method="POST" action={actions.admin.event.delete} class="contents">
<input type="hidden" name="eventId" value={event.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</Tooltip>
</div>
</div>
{/* Edit Event Form - Hidden by default */}
@@ -531,9 +755,14 @@ if (!service) return Astro.rewrite('/404')
label="Started At"
name="startedAt"
inputProps={{
type: 'date',
type: 'datetime-local',
required: true,
value: new Date(event.startedAt).toISOString().split('T')[0],
value: new Date(
new Date(event.startedAt).getTime() -
new Date(event.startedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16),
}}
error={eventUpdateInputErrors.startedAt}
/>
@@ -542,7 +771,15 @@ if (!service) return Astro.rewrite('/404')
label="Ended At"
name="endedAt"
inputProps={{
value: event.endedAt ? new Date(event.endedAt).toISOString().split('T')[0] : '',
type: 'datetime-local',
value: event.endedAt
? new Date(
new Date(event.endedAt).getTime() -
new Date(event.endedAt).getTimezoneOffset() * 60000
)
.toISOString()
.slice(0, 16)
: '',
}}
error={eventUpdateInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -602,9 +839,11 @@ if (!service) return Astro.rewrite('/404')
label="Started At"
name="startedAt"
inputProps={{
type: 'date',
type: 'datetime-local',
required: true,
value: new Date().toISOString().split('T')[0],
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}}
error={eventInputErrors.startedAt}
/>
@@ -613,7 +852,10 @@ if (!service) return Astro.rewrite('/404')
label="Ended At"
name="endedAt"
inputProps={{
value: new Date().toISOString().split('T')[0],
type: 'datetime-local',
value: new Date(Date.now() - new Date().getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16),
}}
error={eventInputErrors.endedAt}
description="- Empty: Event is ongoing.\n- Filled: Event with specific end date.\n- Same as start date: One-time event."
@@ -673,17 +915,30 @@ if (!service) return Astro.rewrite('/404')
</p>
</div>
<div class="flex shrink-0 gap-1.5">
<Button
type="button"
size="sm"
variant="faded"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
/>
<form method="POST" action={actions.admin.verificationStep.delete} class="inline">
<input type="hidden" name="id" value={step.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
</form>
<Tooltip text="Edit">
<Button
type="button"
size="sm"
variant="faded"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-verification-step-${step.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/>
</Tooltip>
<Tooltip text="Delete">
<form method="POST" action={actions.admin.verificationStep.delete} class="inline">
<input type="hidden" name="id" value={step.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</Tooltip>
</div>
</div>
@@ -844,21 +1099,34 @@ if (!service) return Astro.rewrite('/404')
<p class="text-day-400 text-sm text-pretty">{method.value}</p>
</div>
<div class="flex shrink-0 gap-1.5">
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
/>
<form
method="POST"
action={actions.admin.service.deleteContactMethod}
class="contents"
>
<input type="hidden" name="id" value={method.id} />
<Button type="submit" size="sm" variant="faded" icon="ri:delete-bin-line" />
</form>
<Tooltip text="Edit">
<Button
type="button"
variant="faded"
size="sm"
icon="ri:pencil-line"
onclick={`document.getElementById('edit-contact-method-${method.id}')?.classList.toggle('hidden')`}
iconOnly
label="Edit"
/>
</Tooltip>
<Tooltip text="Delete">
<form
method="POST"
action={actions.admin.service.contactMethod.delete}
class="contents"
>
<input type="hidden" name="id" value={method.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</Tooltip>
</div>
</div>
@@ -866,7 +1134,7 @@ if (!service) return Astro.rewrite('/404')
<form
id={`edit-contact-method-${method.id}`}
method="POST"
action={actions.admin.service.updateContactMethod}
action={actions.admin.service.contactMethod.update}
class="border-night-500 bg-night-700 mt-3 hidden space-y-3 rounded-md border p-3"
>
<input type="hidden" name="id" value={method.id} />
@@ -879,6 +1147,7 @@ if (!service) return Astro.rewrite('/404')
value: method.label,
placeholder: contactMethodInfo.formattedValue,
}}
error={contactMethodUpdateInputErrors.label}
/>
<InputText
@@ -888,6 +1157,7 @@ if (!service) return Astro.rewrite('/404')
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 />
@@ -901,24 +1171,125 @@ if (!service) return Astro.rewrite('/404')
</FormSubSection>
<FormSubSection title="Add New Contact Method">
<form method="POST" action={actions.admin.service.createContactMethod} class="space-y-2">
<form method="POST" action={actions.admin.service.contactMethod.add} class="space-y-2">
<input type="hidden" name="serviceId" value={service.id} />
<InputText label="Override Label" name="label" />
<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 />
</form>
</FormSubSection>
</FormSection>
<FormSection title="API">
{
DEPLOYMENT_MODE === 'staging' && (
<p class="rounded-lg bg-red-900/30 p-4 text-sm text-red-200">
<Icon name="ri:alert-line" class="inline-block size-4 align-[-0.2em] text-red-400" />
This endpoints section doesn't work in PRE. Use curl commands instead.
</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}`}>
<p class="text-day-400 text-sm">Input:</p>
<Code code={JSON.stringify(call.input, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
<p class="text-day-400 text-sm">Output:</p>
<Code code={JSON.stringify(call.output, null, 2)} lang="json" class="rounded-lg p-4 text-xs" />
</FormSubSection>
))
}
</FormSection>
<FormSection title="Evidence Images" id="evidence-images">
<FormSubSection title="Existing Evidence Images">
{
evidenceImageUrls.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No evidence images yet.
</p>
) : (
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4">
{evidenceImageUrls.map((imageUrl: string) => (
<div class="border-night-600 bg-night-800 group relative rounded-md border p-2">
<MyPicture
src={imageUrl}
alt="Evidence image"
class="aspect-square w-full rounded object-cover"
width={200}
height={200}
/>
<form
method="POST"
action={actions.admin.service.evidenceImage.delete}
class="absolute top-1 right-1"
>
<input type="hidden" name="fileUrl" value={imageUrl} />
<Button
type="submit"
variant="faded"
color="danger"
size="sm"
icon="ri:delete-bin-line"
iconOnly
label="Delete Image"
class="opacity-0 transition-opacity group-hover:opacity-100"
/>
</form>
<input
type="text"
readonly
value={`![Evidence](${imageUrl})`}
class="bg-night-700 text-day-200 mt-2 w-full cursor-text rounded border p-2 font-mono text-xs select-all"
/>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Evidence Image">
<form
method="POST"
action={actions.admin.service.evidenceImage.add}
class="space-y-4"
enctype="multipart/form-data"
>
<input type="hidden" name="serviceId" value={service.id} />
<InputImageFile
label="Upload Image"
name="imageFile"
description="Upload an evidence image."
error={evidenceImageAddInputErrors.imageFile}
required
/>
<InputSubmitButton label="Add Image" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
</div>
</BaseLayout>

View File

@@ -8,7 +8,8 @@ import MyPicture from '../../../components/MyPicture.astro'
import SortArrowIcon from '../../../components/SortArrowIcon.astro'
import Tooltip from '../../../components/Tooltip.astro'
import { getKycLevelInfo } from '../../../constants/kycLevels'
import { getVerificationStatusInfo } from '../../../constants/verificationStatus'
import { serviceVisibilities } from '../../../constants/serviceVisibility'
import { getVerificationStatusInfo, verificationStatuses } from '../../../constants/verificationStatus'
import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { zodParseQueryParamsStoringErrors } from '../../../lib/parseUrlFilters'
@@ -110,6 +111,7 @@ const services = await Astro.locals.banners.try(
_count: {
select: {
verificationRequests: true,
internalNotes: true,
},
},
},
@@ -209,11 +211,11 @@ const truncate = (text: string, length: number) => {
id="visibility"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Visibilities</option>
<option value="">All</option>
{
Object.values(ServiceVisibility).map((status) => (
<option value={status} selected={filters.visibility === status}>
{status}
serviceVisibilities.map((visibility) => (
<option value={visibility.value} selected={filters.visibility === visibility.value}>
{visibility.label}
</option>
))
}
@@ -227,11 +229,11 @@ const truncate = (text: string, length: number) => {
id="verificationStatus"
class="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-200 focus:border-blue-500 focus:ring-blue-500 focus:outline-none"
>
<option value="">All Statuses</option>
<option value="">All</option>
{
Object.values(VerificationStatus).map((status) => (
<option value={status} selected={filters.verificationStatus === status}>
{status}
verificationStatuses.map((status) => (
<option value={status.value} selected={filters.verificationStatus === status.value}>
{status.label}
</option>
))
}
@@ -439,9 +441,18 @@ const truncate = (text: string, length: number) => {
</div>
</td>
<td class="px-4 py-3 text-center">
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
{service._count.verificationRequests}
</span>
<div class="flex flex-col items-center justify-center gap-1">
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
{service._count.verificationRequests}
</span>
{service._count.internalNotes > 0 && (
<span class="inline-flex items-center text-purple-400">
<Icon name="ri:sticky-note-line" class="mr-0.5 size-4" />
{service._count.internalNotes}
</span>
)}
</div>
</td>
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
<td class="px-4 py-3 text-right">

View File

@@ -74,19 +74,19 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div>
<div>
<label for="serviceUrls" class="font-title mb-2 block text-sm text-green-500">serviceUrls</label>
<label for="allServiceUrls" class="font-title mb-2 block text-sm text-green-500">Service URLs</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="serviceUrls"
id="serviceUrls"
name="allServiceUrls"
id="allServiceUrls"
rows={3}
placeholder="https://example1.com https://example2.com"
placeholder="https://example1.com\nhttps://example2.onion\nhttps://example3.b32.i2p"
set:text=""
/>
{
inputErrors.serviceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.serviceUrls.join(', ')}</p>
inputErrors.allServiceUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.allServiceUrls.join(', ')}</p>
)
}
</div>
@@ -109,24 +109,6 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
}
</div>
<div>
<label for="onionUrls" class="font-title mb-2 block text-sm text-green-500">onionUrls</label>
<textarea
transition:persist
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
name="onionUrls"
id="onionUrls"
rows={3}
placeholder="http://example.onion"
set:text=""
/>
{
inputErrors.onionUrls && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.onionUrls.join(', ')}</p>
)
}
</div>
<div>
<label for="imageFile" class="font-title mb-2 block text-sm text-green-500">serviceImage</label>
<div class="space-y-2">
@@ -350,13 +332,13 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral</label>
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label>
<input
transition:persist
type="text"
name="referral"
id="referral"
placeholder="Optional referral code/link"
placeholder="e.g. ?ref=123 or /ref/123"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
@@ -366,6 +348,26 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
}
</div>
<div>
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label>
<div class="space-y-2">
<textarea
transition:persist
name="internalNote"
id="internalNote"
rows={4}
placeholder="Markdown supported"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
</div>
{
inputErrors.internalNote && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
)
}
</div>
<button
type="submit"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"

View File

@@ -1,5 +1,6 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
@@ -225,9 +226,24 @@ if (!user) return Astro.rewrite('/404')
name="type"
label="Type"
options={[
{ label: 'Admin', value: 'admin', icon: 'ri:shield-star-fill' },
{ label: 'Moderator', value: 'moderator', icon: 'ri:graduation-cap-fill' },
{ label: 'Spammer', value: 'spammer', icon: 'ri:alert-fill' },
{
label: 'Admin',
value: 'admin',
icon: 'ri:shield-star-fill',
noTransitionPersist: true,
},
{
label: 'Moderator',
value: 'moderator',
icon: 'ri:graduation-cap-fill',
noTransitionPersist: true,
},
{
label: 'Spammer',
value: 'spammer',
icon: 'ri:alert-fill',
noTransitionPersist: true,
},
{
label: 'Verified',
value: 'verified',
@@ -304,8 +320,8 @@ if (!user) return Astro.rewrite('/404')
</div>
</div>
<div data-note-content>
<p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
<div data-note-content class="prose prose-sm text-day-200 prose-invert max-w-none text-pretty">
<Markdown content={note.content} />
</div>
<form
@@ -418,6 +434,7 @@ if (!user) return Astro.rewrite('/404')
label: role.label,
value: role.value,
icon: role.icon,
noTransitionPersist: true,
}))}
required
cardSize="sm"

View File

@@ -19,7 +19,7 @@ import type { Prisma } from '@prisma/client'
const { data: filters } = zodParseQueryParamsStoringErrors(
{
'sort-by': z.enum(['name', 'role', 'createdAt', 'karma']).default('createdAt'),
'sort-by': z.enum(['name', 'role', 'lastLoginAt', 'karma', 'createdAt']).default('createdAt'),
'sort-order': z.enum(['asc', 'desc']).default('desc'),
search: z.string().optional(),
role: z.enum(['user', 'admin', 'moderator', 'verified', 'spammer']).optional(),
@@ -29,7 +29,10 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
// Set up Prisma orderBy with correct typing
const prismaOrderBy =
filters['sort-by'] === 'name' || filters['sort-by'] === 'createdAt' || filters['sort-by'] === 'karma'
filters['sort-by'] === 'name' ||
filters['sort-by'] === 'createdAt' ||
filters['sort-by'] === 'lastLoginAt' ||
filters['sort-by'] === 'karma'
? {
[filters['sort-by'] === 'karma' ? 'totalKarma' : filters['sort-by']]:
filters['sort-order'] === 'asc' ? 'asc' : 'desc',
@@ -86,6 +89,7 @@ const dbUsers = await prisma.user.findMany({
totalKarma: true,
createdAt: true,
updatedAt: true,
lastLoginAt: true,
internalNotes: {
select: {
id: true,
@@ -218,16 +222,29 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
<div class="flex flex-wrap items-center justify-center gap-1">
<a
href={makeSortUrl('lastLoginAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Login <SortArrowIcon
active={filters['sort-by'] === 'lastLoginAt'}
sortOrder={filters['sort-order']}
/>
</a>
<span class="text-zinc-600">/</span>
<a
href={makeSortUrl('createdAt')}
class="flex items-center justify-center hover:text-zinc-200"
>
Joined <SortArrowIcon
active={filters['sort-by'] === 'createdAt'}
sortOrder={filters['sort-order']}
/>
</a>
</div>
</th>
<th
class="w-[15%] px-4 py-3 text-center text-xs font-medium tracking-wider text-zinc-400 uppercase"
>
@@ -305,8 +322,24 @@ const makeSortUrl = (sortBy: NonNullable<(typeof filters)['sort-by']>) => {
{user.totalKarma}
</span>
</td>
<td class="px-4 py-3 text-center text-sm text-zinc-400">
<TimeFormatted date={user.createdAt} hourPrecision hoursShort prefix={false} />
<td class="px-4 py-3 text-center text-sm">
<div class="flex flex-wrap items-center justify-center gap-1 text-center">
<TimeFormatted
class="text-zinc-300"
date={user.lastLoginAt}
hourPrecision
hoursShort
prefix={false}
/>
<span class="text-zinc-600">/</span>
<TimeFormatted
class="text-zinc-400"
date={user.createdAt}
hourPrecision
hoursShort
prefix={false}
/>
</div>
</td>
<td class="px-4 py-3">
<div class="flex justify-center gap-3">

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

View File

@@ -0,0 +1,7 @@
import { actions } from 'astro:actions'
import { makeEndpointFromAction } from '../../../../lib/endpoints'
import type { APIRoute } from 'astro'
export const QUERY: APIRoute = makeEndpointFromAction(actions.api.service.get)

196
web/src/pages/docs/api.mdx Normal file
View File

@@ -0,0 +1,196 @@
---
layout: ../../layouts/MarkdownLayout.astro
title: API
author: KYCnot.me
pubDate: 2025-05-31
description: 'Access basic service data via our public API.'
icon: 'ri:plug-line'
---
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.
All endpoints should be prefixed with `/api/v1/`.
The endpoints <a href={SOURCE_CODE_URL}>source code</a> is available on the `/web/src/actions/api/index.ts` file.
**Attribution:** Please credit **KYCnot.me** if you use data from this API.
## `QUERY` `/service/get`
Fetches details for a single service by various lookup criteria.
### Request Parameters
| Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ----------- |
| `id` | number | No* | Service ID |
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) |
| `serviceUrl` | string | No* | Service URL. May be web, onion, or i2p. May just be a domain or a full URL. |
\* At least one of the marked parameters is required.
### Response Format
```typescript
type ServiceResponse = {
id: number
slug: string
name: string
description: string
serviceVisibility: 'PUBLIC' | 'ARCHIVED' | 'UNLISTED'
verificationStatus: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
verificationStatusInfo: {
value: 'VERIFICATION_SUCCESS' | 'APPROVED' | 'COMMUNITY_CONTRIBUTED' | 'VERIFICATION_FAILED'
slug: string
label: string
labelShort: string
description: string
}
verifiedAt: Date | null
kycLevel: 0 | 1 | 2 | 3 | 4
kycLevelInfo: {
value: 0 | 1 | 2 | 3 | 4
name: string
description: string
}
kycLevelClarification: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
kycLevelClarificationInfo: {
value: 'NONE' | 'DEPENDS_ON_PARTNERS' | ...
name: string
description: string
}
categories: {
name: string
slug: string
}[]
listedAt: Date
serviceUrls: string[]
tosUrls: string[]
kycnotmeUrl: `https://kycnot.me/service/${service.slug}`
}
```
#### KYC Levels
<ul>
{kycLevels.map((level) => (
<li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description}
</li>
))}
</ul>
#### Verification Status
<ul>
{verificationStatuses.map((status) => (
<li key={status.value}>
<strong>{status.value}</strong>: {status.description}
</li>
))}
</ul>
#### Service Visibility
<ul>
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
</ul>
#### KYC Level Clarifications
<ul>
{kycLevelClarifications.map((clarification) => (
<li key={clarification.value}>
<strong>{clarification.value}</strong>: {clarification.description}
</li>
))}
</ul>
### Examples
#### Request
```zsh
curl -X QUERY https://kycnot.me/api/v1/service/get \
-H "Content-Type: application/json" \
-d '{"slug": "my-example-service"}'
```
#### Response
```json
{
"name": "My Example Service",
"description": "This is a description of my example service",
"serviceVisibility": "PUBLIC",
"verificationStatus": "VERIFICATION_SUCCESS",
"verificationStatusInfo": {
"value": "VERIFICATION_SUCCESS",
"slug": "verified",
"label": "Verified",
"labelShort": "Verified",
"description": "Thoroughly tested and verified by the team. But things might change, this is not a guarantee."
},
"verifiedAt": "2025-01-20T07:12:29.393Z",
"kycLevel": 0,
"kycLevelInfo": {
"value": 0,
"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",
"slug": "exchange"
}
],
"listedAt": "2025-05-31T19:09:18.043Z",
"serviceUrls": [
"https://example.com",
"http://c9ikae0fdidzh1ufrzp022e5uqfvz6ofxlkycz59cvo6fdxjgx7ekl9e.onion"
],
"tosUrls": ["https://example.com/terms-of-service"],
"kycnotmeUrl": "https://kycnot.me/service/bisq"
}
```
#### Error Responses
**404 Not Found**: Service not found
```json
{
"error": "Service not found"
}
```
**400 Bad Request**: Invalid input parameters
```json
{
"error": "Validation error message"
}
```
**500 Internal Server Error**: Server error
```json
{
"error": "Internal server error"
}
```

View File

@@ -1,5 +1,5 @@
---
layout: ../layouts/MarkdownLayout.astro
layout: ../../layouts/MarkdownLayout.astro
title: How does karma work?
description: "KYCnot.me has a user karma system, here's how it works"
icon: 'ri:hearts-line'
@@ -7,7 +7,7 @@ author: KYCnot.me
pubDate: 2025-05-15
---
import KarmaUnlocksTable from '../components/KarmaUnlocksTable.astro'
import KarmaUnlocksTable from '../../components/KarmaUnlocksTable.astro'
[KYCnot.me](https://kycnot.me) implements a karma system to encourage quality contributions and maintain community standards. Users can earn (or lose) karma points through various interactions on the platform, primarily through their comments on services.

View File

@@ -13,6 +13,7 @@ import {
getEventTypeInfo,
getEventTypeInfoBySlug,
} from '../constants/eventTypes'
import { getServiceVisibilityInfo } from '../constants/serviceVisibility'
import { getVerificationStatusInfo } from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn'
@@ -44,6 +45,8 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
async () =>
prisma.service.findMany({
where: {
listedAt: { lte: new Date() },
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
events: {
some: {
visible: true,
@@ -72,8 +75,15 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
createdAt: {
lte: params.now,
},
...(params.service ? { service: { slug: params.service } } : {}),
...(params.type ? { type: getEventTypeInfoBySlug(params.type).id } : {}),
service: {
slug: params.service ?? undefined,
listedAt: params.service ? undefined : { lte: new Date() },
serviceVisibility: {
in: params.service ? ['PUBLIC', 'ARCHIVED', 'UNLISTED'] : ['PUBLIC', 'ARCHIVED'],
},
},
type: params.type ? getEventTypeInfoBySlug(params.type).id : undefined,
...(params.from || params.to
? {
OR: [
@@ -105,6 +115,7 @@ const [services, [dbEvents, totalEvents]] = await Astro.locals.banners.tryMany([
name: true,
imageUrl: true,
verificationStatus: true,
serviceVisibility: true,
},
},
},
@@ -126,6 +137,7 @@ const events = orderBy(
service: {
...event.service,
verificationStatusInfo: getVerificationStatusInfo(event.service.verificationStatus),
serviceVisibilityInfo: getServiceVisibilityInfo(event.service.serviceVisibility),
},
})),
['actualEndedAt', 'startedAt'],
@@ -416,6 +428,16 @@ const createUrlWithoutFilter = (paramName: keyof typeof params) => {
)}
/>
)}
{event.service.serviceVisibility === 'ARCHIVED' && (
<Icon
name={event.service.serviceVisibilityInfo.icon}
class={cn(
'ms-1 inline-block size-3 shrink-0',
event.service.serviceVisibilityInfo.iconClass
)}
/>
)}
</a>
{event.source && (

View File

@@ -1,7 +1,6 @@
---
import { ServiceVisibility } from '@prisma/client'
import { z } from 'astro:schema'
import { groupBy, omit, orderBy, uniq } from 'lodash-es'
import { groupBy, omit, orderBy, sortBy, uniq } from 'lodash-es'
import seedrandom from 'seedrandom'
import Button from '../components/Button.astro'
@@ -27,6 +26,7 @@ import {
} from '../constants/verificationStatus'
import BaseLayout from '../layouts/BaseLayout.astro'
import { areEqualArraysWithoutOrder, zodEnumFromConstant } from '../lib/arrays'
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
import { parseIntWithFallback } from '../lib/numbers'
import { areEqualObjectsWithoutOrder } from '../lib/objects'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
@@ -182,6 +182,7 @@ const {
'min-score': { if: 'default' },
'user-rating': { if: 'default' },
'max-kyc': { if: 'default' },
'sort-seed': { if: 'another-is-unset', prop: 'page' },
},
},
}
@@ -214,15 +215,16 @@ const groupedAttributes = groupBy(
'value'
)
const servicesQMatch = filters.q ? await findServicesBySimilarity(filters.q) : null
const where = {
listedAt: {
lte: new Date(),
},
id: servicesQMatch ? { in: servicesQMatch.map(({ id }) => id) } : undefined,
listedAt: { lte: new Date() },
categories: filters.categories.length ? { some: { slug: { in: filters.categories } } } : undefined,
verificationStatus: {
in: includeScams ? uniq([...filters.verification, 'VERIFICATION_FAILED'] as const) : filters.verification,
},
serviceVisibility: ServiceVisibility.PUBLIC,
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
overallScore: { gte: filters['min-score'] },
acceptedCurrencies: filters.currencies.length
? filters['currency-mode'] === 'and'
@@ -242,16 +244,6 @@ const where = {
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.q
? [
{
OR: [
{ name: { contains: filters.q, mode: 'insensitive' as const } },
{ description: { contains: filters.q, mode: 'insensitive' as const } },
],
} satisfies Prisma.ServiceWhereInput,
]
: []),
...(filters.networks.length
? [
{
@@ -324,7 +316,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
select: {
services: {
where: {
serviceVisibility: 'PUBLIC',
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED'] },
listedAt: { lte: new Date() },
},
},
},
@@ -335,33 +328,46 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
[
'Unable to load services.',
async () => {
const [unsortedServices, totalServices] = await prisma.service.findManyAndCount({
where,
select: {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'],
true
>),
},
})
const [unsortedServicesMissingSimilarityScore, totalServices] = await prisma.service.findManyAndCount(
{
where,
select: {
id: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'],
true
>),
},
}
)
const unsortedServices = unsortedServicesMissingSimilarityScore.map((service) => ({
...service,
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
}))
const rng = seedrandom(filters['sort-seed'])
const selectedSort = sortOptions.find((sort) => sort.value === filters.sort) ?? defaultSortOption
const sortedServices = orderBy(
unsortedServices,
[selectedSort.orderBy.key, () => rng()],
[selectedSort.orderBy.direction, 'asc']
// NOTE: We do a first sort by id to make the seeded sort deterministic
sortBy(unsortedServices, 'id'),
[
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key,
() => rng(),
],
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
const unsortedServicesWithInfo = await prisma.service.findMany({
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
where: {
id: {
in: sortedServices.map((service) => service.id),
},
},
select: {
id: true,
name: true,
slug: true,
description: true,
@@ -372,6 +378,7 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
imageUrl: true,
verificationStatus: true,
acceptedCurrencies: true,
serviceVisibility: true,
attributes: {
select: {
attribute: {
@@ -394,14 +401,20 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
},
})
const unsortedServicesWithInfo = unsortedServicesWithInfoMissingSimilarityScore.map((service) => ({
...service,
similarityScore: servicesQMatch?.find((match) => match.id === service.id)?.similarityScore ?? 0,
}))
const sortedServicesWithInfo = orderBy(
unsortedServicesWithInfo,
[
...(filters.q ? (['similarityScore'] as const) : ([] as const)),
selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(),
],
[selectedSort.orderBy.direction, 'asc']
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc']
)
return [sortedServicesWithInfo, totalServices] as const
@@ -708,7 +721,6 @@ const showFiltersId = 'show-filters'
pageSize={PAGE_SIZE}
sortSeed={filters['sort-seed']}
filters={filters}
includeScams={includeScams}
countCommunityOnly={countCommunityOnly}
inlineIcons
/>

View File

@@ -0,0 +1,13 @@
import type { APIRoute } from 'astro'
export const ALL: APIRoute = (context) => {
console.error('Endpoint not found', { url: context.url.href, method: context.request.method })
return new Response(
JSON.stringify({
error: 'Endpoint not found',
}),
{
status: 404,
}
)
}

Some files were not shown because too many files have changed in this diff Show More