Compare commits

...

8 Commits

Author SHA1 Message Date
pluja
459d7c91f7 Release 202506091901 2025-06-09 19:01:08 +00:00
pluja
b8b2dee4a4 Release 202506091433 2025-06-09 14:33:57 +00:00
pluja
eb0af871e1 Release 202506091118 2025-06-09 11:18:06 +00:00
pluja
3ccd7fd395 Release 202506091053 2025-06-09 10:53:52 +00:00
pluja
87f0f36aa1 Release 202506091000 2025-06-09 10:00:55 +00:00
pluja
8b90b3eef6 Release 202506061009 2025-06-06 10:09:59 +00:00
pluja
2489e94b0e Release 202506042153 2025-06-04 21:53:07 +00:00
pluja
144af17a70 Release 202506042038 2025-06-04 20:38:49 +00:00
84 changed files with 6681 additions and 1922 deletions

View File

@@ -10,7 +10,11 @@ services:
env_file: env_file:
- .env - .env
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"] test:
[
'CMD-SHELL',
'pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}',
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -18,6 +22,7 @@ services:
pyworker: pyworker:
build: build:
context: ./pyworker context: ./pyworker
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
restart: always restart: always
env_file: env_file:
- .env - .env
@@ -25,7 +30,7 @@ services:
crawl4ai: crawl4ai:
image: unclecode/crawl4ai:basic-amd64 image: unclecode/crawl4ai:basic-amd64
expose: expose:
- "11235" - '11235'
env_file: env_file:
- .env - .env
volumes: volumes:
@@ -41,7 +46,7 @@ services:
image: redis:latest image: redis:latest
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ['CMD', 'redis-cli', 'ping']
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -61,7 +66,15 @@ services:
expose: expose:
- 4321 - 4321
healthcheck: healthcheck:
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321/health"] test:
[
'CMD',
'curl',
'-k',
'--silent',
'--fail',
'http://localhost:4321/internal-api/healthcheck',
]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5

View File

@@ -184,16 +184,16 @@ Be concise but thorough, and make sure your output is properly formatted JSON.
PROMPT_COMMENT_SENTIMENT_SUMMARY = """ PROMPT_COMMENT_SENTIMENT_SUMMARY = """
You will be given a list of user comments to a service. You will be given a list of user comments to a service.
Your task is to summarize the comments in a way that is easy to understand and to the point. Your task is to summarize the comments in a way that is easy to understand and to the point.
The summary should be concise and to the point, no more than 150 words. The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
Use markdown formatting to highlight in bold the most important information. Only bold is allowed. Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
You must format your response as a valid JSON object with the following structure: You must format your response as a valid JSON object with the following structure:
interface CommentSummary { interface CommentSummary {
summary: string; summary: string; // Concise, 100 words max
sentiment: 'positive'|'negative'|'neutral'; sentiment: 'positive'|'negative'|'neutral';
whatUsersLike: string[]; // Concise, 2-3 words, max 4 whatUsersLike: string[]; // Concise, 2-3 words max
whatUsersDislike: string[]; // Concise, 2-3 words, max 4 whatUsersDislike: string[]; // Concise, 2-3 words max
} }
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items. Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.

View File

@@ -16,7 +16,7 @@ ARG ASTRO_BUILD_MODE=production
RUN npx prisma generate RUN npx prisma generate
# Build the application # Build the application
RUN npm run build -- --mode ${ASTRO_BUILD_MODE} RUN npm run build:astro -- --mode ${ASTRO_BUILD_MODE} && npm run build:server-init
ENV HOST=0.0.0.0 ENV HOST=0.0.0.0
ENV PORT=4321 ENV PORT=4321
@@ -26,4 +26,4 @@ EXPOSE 4321
COPY web/migrate.sh /usr/local/bin/knm-migrate COPY web/migrate.sh /usr/local/bin/knm-migrate
RUN chmod +x /usr/local/bin/knm-migrate RUN chmod +x /usr/local/bin/knm-migrate
CMD ["node", "./dist/server/entry.mjs"] CMD ["sh", "-c", "node ./dist/server/server-init.js & node ./dist/server/entry.mjs"]

View File

@@ -4,8 +4,11 @@ import mdx from '@astrojs/mdx'
import node from '@astrojs/node' import node from '@astrojs/node'
import sitemap from '@astrojs/sitemap' import sitemap from '@astrojs/sitemap'
import tailwindcss from '@tailwindcss/vite' import tailwindcss from '@tailwindcss/vite'
import { minimal2023Preset } from '@vite-pwa/assets-generator/config'
import AstroPWA from '@vite-pwa/astro'
import { defineConfig, envField } from 'astro/config' import { defineConfig, envField } from 'astro/config'
import icon from 'astro-icon' import icon from 'astro-icon'
import devtoolsJson from 'vite-plugin-devtools-json'
import { postgresListener } from './src/lib/postgresListenerIntegration' import { postgresListener } from './src/lib/postgresListenerIntegration'
import { getServerEnvVariable } from './src/lib/serverEnvVariables' import { getServerEnvVariable } from './src/lib/serverEnvVariables'
@@ -16,15 +19,63 @@ export default defineConfig({
site: SITE_URL, site: SITE_URL,
vite: { vite: {
build: { build: {
sourcemap: true, sourcemap: true, // Enable sourcemaps on production, so users can inspect the code
}, },
plugins: [tailwindcss()], plugins: [devtoolsJson(), tailwindcss()],
}, },
integrations: [ integrations: [
postgresListener(), postgresListener(),
icon(), icon(),
mdx(), mdx(),
AstroPWA({
mode: 'development',
base: '/',
scope: '/',
registerType: 'autoUpdate',
manifest: {
name: 'KYCnot.me',
description: 'Find services that respect your privacy',
theme_color: '#040505',
background_color: '#171c1b',
},
pwaAssets: {
image: './public/favicon.svg',
preset: {
...minimal2023Preset,
maskable: {
...minimal2023Preset.maskable,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.maskable.resizeOptions,
background: '#3bdb78',
},
},
apple: {
...minimal2023Preset.apple,
padding: 0.1,
resizeOptions: {
...minimal2023Preset.apple.resizeOptions,
background: '#3bdb78',
},
},
},
},
workbox: {
navigateFallback: '/404',
globPatterns: ['**/*.{js,css,html,ico,jpg,jpeg,png,svg,webp,avif}'],
},
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.ts',
devOptions: {
enabled: true,
type: 'module',
},
experimental: {
directoryAndTrailingSlashHandler: true,
},
}),
sitemap({ sitemap({
filter: (page) => { filter: (page) => {
const url = new URL(page) const url = new URL(page)
@@ -95,35 +146,6 @@ export default defineConfig({
startsWith: 'redis://', startsWith: 'redis://',
default: 'redis://redis:6379', default: 'redis://redis:6379',
}), }),
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 60 * 24, // 24 hours in seconds
}),
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 60 * 24, // 24 hours in seconds
}),
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 5, // 5 minutes in seconds
}),
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
context: 'server',
access: 'secret',
int: true,
gt: 0,
default: 60 * 5, // 5 minutes in seconds
}),
// Development tokens // Development tokens
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({ DEV_ADMIN_USER_SECRET_TOKEN: envField.string({

View File

@@ -121,7 +121,7 @@ export default tseslint.config(
'import/first': 'error', 'import/first': 'error',
'import/newline-after-import': 'error', 'import/newline-after-import': 'error',
'import/no-duplicates': 'error', 'import/no-duplicates': 'error',
'import/no-unresolved': ['error', { ignore: ['^astro:'] }], 'import/no-unresolved': ['error', { ignore: ['^astro:', '^virtual:'] }],
'@typescript-eslint/no-explicit-any': 'warn', '@typescript-eslint/no-explicit-any': 'warn',
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }], 'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
'import/namespace': 'off', 'import/namespace': 'off',

5155
web/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,8 +4,10 @@
"version": "0.0.1", "version": "0.0.1",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build --remote", "build": "npm run build:astro && npm run build:server-init",
"preview": "astro preview", "build:astro": "astro build --remote",
"build:server-init": "esbuild src/server-init.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/server-init.js",
"preview": "node dist/server/server-init.js & astro preview",
"astro": "astro", "astro": "astro",
"db-admin": "prisma studio --browser=none", "db-admin": "prisma studio --browser=none",
"db-gen": "prisma generate", "db-gen": "prisma generate",
@@ -23,19 +25,19 @@
}, },
"dependencies": { "dependencies": {
"@astrojs/check": "0.9.4", "@astrojs/check": "0.9.4",
"@astrojs/db": "0.14.14", "@astrojs/db": "0.15.0",
"@astrojs/mdx": "4.2.6", "@astrojs/mdx": "4.3.0",
"@astrojs/node": "9.2.1", "@astrojs/node": "9.2.2",
"@astrojs/sitemap": "3.4.0", "@astrojs/sitemap": "3.4.1",
"@fontsource-variable/space-grotesk": "5.2.7", "@fontsource-variable/space-grotesk": "5.2.8",
"@fontsource/inter": "5.2.5", "@fontsource/inter": "5.2.5",
"@fontsource/space-grotesk": "5.2.7", "@fontsource/space-grotesk": "5.2.8",
"@prisma/client": "6.8.2", "@prisma/client": "6.9.0",
"@tailwindcss/vite": "4.1.7", "@tailwindcss/vite": "4.1.8",
"@types/mime-types": "2.1.4", "@types/mime-types": "3.0.0",
"@types/pg": "8.15.4", "@types/pg": "8.15.4",
"@vercel/og": "0.6.8", "@vercel/og": "0.6.8",
"astro": "5.7.13", "astro": "5.9.0",
"astro-loading-indicator": "0.7.0", "astro-loading-indicator": "0.7.0",
"astro-remote": "0.3.4", "astro-remote": "0.3.4",
"astro-seo-schema": "5.0.0", "astro-seo-schema": "5.0.0",
@@ -43,59 +45,65 @@
"clsx": "2.1.1", "clsx": "2.1.1",
"htmx.org": "1.9.12", "htmx.org": "1.9.12",
"javascript-time-ago": "2.5.11", "javascript-time-ago": "2.5.11",
"libphonenumber-js": "1.12.8", "libphonenumber-js": "1.12.9",
"lodash-es": "4.17.21", "lodash-es": "4.17.21",
"mime-types": "3.0.1", "mime-types": "3.0.1",
"object-to-formdata": "4.5.1", "object-to-formdata": "4.5.1",
"pg": "8.16.0", "pg": "8.16.0",
"qrcode": "1.5.4", "qrcode": "1.5.4",
"react": "19.1.0", "react": "19.1.0",
"redis": "5.0.1", "redis": "5.5.6",
"schema-dts": "1.1.5", "schema-dts": "1.1.5",
"seedrandom": "3.0.5", "seedrandom": "3.0.5",
"sharp": "0.34.1", "sharp": "0.34.2",
"slugify": "1.6.6", "slugify": "1.6.6",
"tailwind-merge": "3.3.0", "tailwind-merge": "3.3.0",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.7", "tailwindcss": "4.1.8",
"typescript": "5.8.3", "typescript": "5.8.3",
"unique-username-generator": "1.4.0", "unique-username-generator": "1.4.0",
"web-push": "3.6.7", "web-push": "3.6.7",
"zod-form-data": "2.0.7" "zod-form-data": "2.0.7"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.27.0", "@eslint/js": "9.28.0",
"@faker-js/faker": "9.8.0", "@faker-js/faker": "9.8.0",
"@iconify-json/material-symbols": "1.2.21", "@iconify-json/material-symbols": "1.2.24",
"@iconify-json/mdi": "1.2.3", "@iconify-json/mdi": "1.2.3",
"@iconify-json/ri": "1.2.5", "@iconify-json/ri": "1.2.5",
"@stylistic/eslint-plugin": "4.2.0", "@stylistic/eslint-plugin": "4.4.1",
"@tailwindcss/forms": "0.5.10", "@tailwindcss/forms": "0.5.10",
"@tailwindcss/typography": "0.5.16", "@tailwindcss/typography": "0.5.16",
"@types/eslint__js": "9.14.0", "@types/eslint__js": "9.14.0",
"@types/lodash-es": "4.17.12", "@types/lodash-es": "4.17.12",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.1.4", "@types/react": "19.1.6",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/web-push": "3.6.4", "@types/web-push": "3.6.4",
"@typescript-eslint/parser": "8.32.1", "@typescript-eslint/parser": "8.33.1",
"@vite-pwa/assets-generator": "1.0.0",
"@vite-pwa/astro": "1.1.0",
"astro-icon": "1.1.5", "astro-icon": "1.1.5",
"date-fns": "4.1.0", "date-fns": "4.1.0",
"eslint": "9.27.0", "esbuild": "0.25.5",
"eslint-import-resolver-typescript": "4.3.5", "eslint": "9.28.0",
"eslint-import-resolver-typescript": "4.4.3",
"eslint-plugin-astro": "1.3.1", "eslint-plugin-astro": "1.3.1",
"eslint-plugin-import": "2.31.0", "eslint-plugin-import": "2.31.0",
"eslint-plugin-jsx-a11y": "6.10.2", "eslint-plugin-jsx-a11y": "6.10.2",
"globals": "16.1.0", "globals": "16.2.0",
"prettier": "3.5.3", "prettier": "3.5.3",
"prettier-plugin-astro": "0.14.1", "prettier-plugin-astro": "0.14.1",
"prettier-plugin-tailwindcss": "0.6.11", "prettier-plugin-tailwindcss": "0.6.12",
"prisma": "6.8.2", "prisma": "6.9.0",
"prisma-json-types-generator": "3.4.1", "prisma-json-types-generator": "3.4.2",
"tailwind-htmx": "0.1.2", "tailwind-htmx": "0.1.2",
"ts-essentials": "10.0.4", "ts-essentials": "10.0.4",
"ts-toolbelt": "9.6.0", "ts-toolbelt": "9.6.0",
"tsx": "4.19.4", "tsx": "4.19.4",
"typescript-eslint": "8.32.1" "typescript-eslint": "8.33.1",
"vite-plugin-devtools-json": "0.1.1",
"workbox-core": "7.3.0",
"workbox-precaching": "7.3.0"
} }
} }

View File

@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';

View File

@@ -25,6 +25,7 @@ enum OrderIdStatus {
PENDING PENDING
APPROVED APPROVED
REJECTED REJECTED
WITHDRAWN
} }
model Comment { model Comment {

View File

@@ -19,6 +19,7 @@ import {
type ServiceVisibility, type ServiceVisibility,
ServiceSuggestionType, ServiceSuggestionType,
KycLevelClarification, KycLevelClarification,
VerificationStepStatus,
} from '@prisma/client' } from '@prisma/client'
import { omit, uniqBy } from 'lodash-es' import { omit, uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator' import { generateUsername } from 'unique-username-generator'
@@ -610,6 +611,10 @@ const generateFakeService = (users: User[]) => {
const name = faker.helpers.arrayElement(serviceNames) const name = faker.helpers.arrayElement(serviceNames)
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}` const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
const tosReview = faker.helpers.maybe(() => faker.helpers.arrayElement(tosReviewExamples), {
probability: 0.8,
})
return { return {
name, name,
slug, slug,
@@ -643,6 +648,19 @@ const generateFakeService = (users: User[]) => {
}, },
verificationProofMd: verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null, status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
verificationSteps:
(status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED') && faker.datatype.boolean(0.75)
? {
create: Array.from({ length: faker.number.int({ min: 1, max: 5 }) }, () => ({
title: faker.lorem.sentence(),
description: faker.lorem.paragraph(),
status: faker.helpers.arrayElement(Object.values(VerificationStepStatus)),
evidenceMd: faker.lorem.paragraph(),
createdAt: faker.date.recent(),
updatedAt: faker.date.recent(),
})),
}
: undefined,
referral: faker.helpers.arrayElement([ referral: faker.helpers.arrayElement([
`?ref=${faker.string.alphanumeric(6)}`, `?ref=${faker.string.alphanumeric(6)}`,
`/ref/${faker.string.alphanumeric(6)}`, `/ref/${faker.string.alphanumeric(6)}`,
@@ -661,8 +679,10 @@ const generateFakeService = (users: User[]) => {
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`, imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
listedAt: faker.date.past(), listedAt: faker.date.past(),
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null, verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
tosReview: faker.helpers.arrayElement(tosReviewExamples), tosReview,
tosReviewAt: faker.date.past(), tosReviewAt: tosReview
? faker.date.recent()
: faker.helpers.maybe(() => faker.date.recent(), { probability: 0.5 }),
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }), userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(), userSentimentAt: faker.date.recent(),
internalNotes: faker.helpers.maybe( internalNotes: faker.helpers.maybe(

View File

@@ -135,6 +135,17 @@ BEGIN
SET "isRecentlyListed" = FALSE SET "isRecentlyListed" = FALSE
WHERE id = service_id; WHERE id = service_id;
END IF; END IF;
-- Apply penalty if ToS cannot be analyzed
IF EXISTS (
SELECT 1
FROM "Service"
WHERE id = service_id
AND "tosReviewAt" IS NOT NULL
AND "tosReview" IS NULL
) THEN
trust_score := trust_score - 3;
END IF;
-- Calculate final trust score (base 100) -- Calculate final trust score (base 100)
trust_score := trust_score + verification_factor + attributes_score; trust_score := trust_score + verification_factor + attributes_score;

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#3BDB78" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 619 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#FF0040" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#FF0040" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 613 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" d="M4 4h24v24H4z" />
<path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#FF0000" viewBox="0 0 32 32" height="32" width="32"> <svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title> <title>KYCnot.me logo</title>
<path fill="#040505" d="M4 4h24v24H4z" />
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z" d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" /> clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#33BE00" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 616 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32"> <svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title> <title>KYCnot.me logo</title>
<path fill="#fff" d="M4 4h24v24H4z" />
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z" d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" /> clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 607 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#00ffff" class="a" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 626 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
<title>KYCnot.me logo with badge</title>
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
<path fill="#0080ff" class="a" fill-rule="evenodd"
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 623 B

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
<path fill="#0080ff" class="a" fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -1,13 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32"> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title> <title>KYCnot.me logo</title>
<style> <path fill="#040505" class="b" d="M4 4h24v24H4z" />
@media (prefers-color-scheme: light) { <path fill="#00ffff" class="a" fill-rule="evenodd"
path {
fill: #0080ff;
}
}
</style>
<path fill="#00ffff" fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z" d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" /> clip-rule="evenodd" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 692 B

After

Width:  |  Height:  |  Size: 642 B

View File

@@ -1,5 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32"> <svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title> <title>KYCnot.me logo</title>
<path fill="#040505" d="M4 4h24v24H4z" />
<path fill-rule="evenodd" <path fill-rule="evenodd"
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z" d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" /> clip-rule="evenodd" />

Before

Width:  |  Height:  |  Size: 566 B

After

Width:  |  Height:  |  Size: 610 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
<title>KYCnot.me logo</title>
<path fill-rule="evenodd"
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
clip-rule="evenodd" />
</svg>

After

Width:  |  Height:  |  Size: 566 B

View File

@@ -1,83 +0,0 @@
// @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

@@ -2,7 +2,6 @@ import { z } from 'astro/zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { sendNotifications } from '../../lib/sendNotifications'
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils' import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
export const adminNotificationActions = { export const adminNotificationActions = {
@@ -26,10 +25,8 @@ export const adminNotificationActions = {
select: { id: true }, select: { id: true },
}) })
const results = await sendNotifications(notifications.map((notification) => notification.id))
return { return {
message: `Sent to ${results.subscriptions.success.toLocaleString()} devices, ${results.subscriptions.failure.toLocaleString()} failed.`, message: `Created ${notifications.length.toString()} notifications.`,
} }
}, },
}), }),

View File

@@ -307,7 +307,7 @@ export const adminServiceActions = {
input: z.object({ input: z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
label: z.string().min(1).max(50).nullable(), label: z.string().min(1).max(50).nullable(),
value: z.string().url(), value: zodContactMethod,
serviceId: z.number().int().positive(), serviceId: z.number().int().positive(),
}), }),
handler: async (input) => { handler: async (input) => {
@@ -458,7 +458,6 @@ export const adminServiceActions = {
permissions: 'admin', permissions: 'admin',
input: evidenceImageDeleteSchema, input: evidenceImageDeleteSchema,
handler: async (input) => { handler: async (input) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
await deleteFileLocally(input.fileUrl) await deleteFileLocally(input.fileUrl)
return { success: true } return { success: true }
}, },

View File

@@ -1,12 +1,10 @@
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client' import { type Prisma, type ServiceUserRole } from '@prisma/client'
import { ActionError } from 'astro:actions' import { ActionError } from 'astro:actions'
import { z } from 'zod' import { z } from 'zod'
import { defineProtectedAction } from '../../lib/defineProtectedAction' import { defineProtectedAction } from '../../lib/defineProtectedAction'
import { saveFileLocally } from '../../lib/fileStorage' import { saveFileLocally } from '../../lib/fileStorage'
import { prisma as prismaInstance } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
const prisma = prismaInstance as PrismaClient
const selectUserReturnFields = { const selectUserReturnFields = {
id: true, id: true,

View File

@@ -345,10 +345,11 @@ export const commentActions = {
'order-id-status', 'order-id-status',
'kyc-requested', 'kyc-requested',
'funds-blocked', 'funds-blocked',
'toggle-rating-active',
]), ]),
value: z.union([ value: z.union([
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']), z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
z.enum(['PENDING', 'APPROVED', 'REJECTED']), z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
z.boolean(), z.boolean(),
z.string(), z.string(),
]), ]),
@@ -411,7 +412,7 @@ export const commentActions = {
updateData.privateContext = input.value as string updateData.privateContext = input.value as string
break break
case 'order-id-status': case 'order-id-status':
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
break break
case 'kyc-requested': case 'kyc-requested':
updateData.kycRequested = !!input.value updateData.kycRequested = !!input.value
@@ -419,6 +420,9 @@ export const commentActions = {
case 'funds-blocked': case 'funds-blocked':
updateData.fundsBlocked = !!input.value updateData.fundsBlocked = !!input.value
break break
case 'toggle-rating-active':
updateData.ratingActive = !!input.value
break
} }
// Update the comment // Update the comment

View File

@@ -165,6 +165,11 @@ export const serviceSuggestionActions = {
categories: z.array(z.coerce.number().int().positive()).min(1), categories: z.array(z.coerce.number().int().positive()).min(1),
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1), acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
imageFile: imageFileSchemaRequired, imageFile: imageFileSchemaRequired,
rulesConfirm: z.literal('on', {
errorMap: () => ({
message: 'You must accept the suggestion rules and process to continue',
}),
}),
/** @deprecated Honey pot field, do not use */ /** @deprecated Honey pot field, do not use */
message: z.unknown().optional(), message: z.unknown().optional(),
skipDuplicateCheck: z skipDuplicateCheck: z
@@ -197,6 +202,7 @@ export const serviceSuggestionActions = {
'imageFile', 'imageFile',
'captcha-value', 'captcha-value',
'captcha-solution-hash', 'captcha-solution-hash',
'rulesConfirm',
]), ]),
serviceSuggestion: undefined, serviceSuggestion: undefined,
service: undefined, service: undefined,

View File

@@ -2,12 +2,17 @@
import LoadingIndicator from 'astro-loading-indicator/component' import LoadingIndicator from 'astro-loading-indicator/component'
import { Schema } from 'astro-seo-schema' import { Schema } from 'astro-seo-schema'
import { ClientRouter } from 'astro:transitions' import { ClientRouter } from 'astro:transitions'
import { pwaAssetsHead } from 'virtual:pwa-assets/head'
import { pwaInfo } from 'virtual:pwa-info'
import { isNotArray } from '../lib/arrays' import { isNotArray } from '../lib/arrays'
import { DEPLOYMENT_MODE } from '../lib/envVariables' import { DEPLOYMENT_MODE } from '../lib/envVariables'
import DynamicFavicon from './DynamicFavicon.astro'
import HtmxScript from './HtmxScript.astro' import HtmxScript from './HtmxScript.astro'
import NotificationEventsScript from './NotificationEventsScript.astro'
import { makeOgImageUrl } from './OgImage' import { makeOgImageUrl } from './OgImage'
import ServerEventsScript from './ServerEventsScript.astro'
import TailwindJsPluggin from './TailwindJsPluggin.astro' import TailwindJsPluggin from './TailwindJsPluggin.astro'
import type { ComponentProps } from 'astro/types' import type { ComponentProps } from 'astro/types'
@@ -70,12 +75,6 @@ const fullTitle = `${pageTitle} | KYCnot.me ${modeName}`
const ogImageUrl = makeOgImageUrl(ogImage, Astro.url) const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
--- ---
<!-- Favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/svg+xml" href="/favicon-lightmode.svg" media="(prefers-color-scheme: light)" />
{DEPLOYMENT_MODE === 'development' && <link rel="icon" type="image/svg+xml" href="/favicon-dev.svg" />}
{DEPLOYMENT_MODE === 'staging' && <link rel="icon" type="image/svg+xml" href="/favicon-stage.svg" />}
<!-- Primary Meta Tags --> <!-- Primary Meta Tags -->
<meta name="generator" content={Astro.generator} /> <meta name="generator" content={Astro.generator} />
<meta name="description" content={description} /> <meta name="description" content={description} />
@@ -98,7 +97,13 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
<!-- Other --> <!-- Other -->
<link rel="sitemap" href="/sitemap-index.xml" /> <link rel="sitemap" href="/sitemap-index.xml" />
<meta name="theme-color" content="#040505" />
<!-- PWA -->
{pwaAssetsHead.themeColor && <meta name="theme-color" content={pwaAssetsHead.themeColor.content} />}
{pwaAssetsHead.links.filter((link) => link.rel !== 'icon').map((link) => <link {...link} />)}
{pwaInfo && <Fragment set:html={pwaInfo.webManifest.linkTag} />}
<DynamicFavicon />
<!-- Components --> <!-- Components -->
<ClientRouter /> <ClientRouter />
@@ -131,3 +136,11 @@ const ogImageUrl = makeOgImageUrl(ogImage, Astro.url)
/> />
)) ))
} }
<!-- Server events -->
<ServerEventsScript />
<!-- Push Notifications -->
<script src="/src/pwa.ts"></script>
<NotificationEventsScript />

View File

@@ -20,6 +20,8 @@ type Props = HTMLAttributes<'div'> & {
privateContext: true privateContext: true
orderId: true orderId: true
orderIdStatus: true orderIdStatus: true
rating: true
ratingActive: true
} }
}> }>
} }
@@ -46,10 +48,10 @@ if (!user || !user.admin || !user.moderator) return null
<div <div
class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2" class="bg-night-600 border-night-500 mt-2 hidden overflow-hidden rounded-md border peer-checked:block peer-checked:p-2"
> >
<div class="border-night-500 flex flex-wrap gap-1 border-b pb-2"> <div class="border-night-500 flex flex-wrap items-center gap-1 border-b pb-2">
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'REJECTED' comment.status === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400' : 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -59,42 +61,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'} <Icon name="ri:close-circle-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'REJECTED' ? 'Unreject' : 'Reject'}</span>
</button> </button>
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.suspicious ? 'Not Spam' : 'Spam'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
</button>
<button
class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'VERIFIED' comment.status === 'VERIFIED'
? 'border border-green-500/30 bg-green-500/20 text-green-400' ? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400' : 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -104,12 +77,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'} <Icon name="ri:verified-badge-line" class="h-3.5 w-3.5" />
<span>{comment.status === 'VERIFIED' ? 'Unverify' : 'Verify'}</span>
</button> </button>
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400' : 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -121,12 +95,49 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'} <Icon name="ri:checkbox-circle-line" class="h-3.5 w-3.5" />
<span>
{comment.status === 'PENDING' || comment.status === 'HUMAN_PENDING' ? 'Approve' : 'Pending'}
</span>
</button>
<div class="bg-night-500 h-5 w-px"></div>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.suspicious
? 'border border-yellow-500/30 bg-yellow-500/20 text-yellow-400'
: 'bg-night-700 hover:bg-yellow-500/20 hover:text-yellow-400'
)}
data-action="suspicious"
data-value={!comment.suspicious}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:spam-2-line" class="h-3.5 w-3.5" />
<span>{comment.suspicious ? 'Not Spam' : 'Spam'}</span>
</button> </button>
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.requiresAdminReview
? 'border border-purple-500/30 bg-purple-500/20 text-purple-400'
: 'bg-night-700 hover:bg-purple-500/20 hover:text-purple-400'
)}
data-action="requires-admin-review"
data-value={!comment.requiresAdminReview}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:shield-user-line" class="h-3.5 w-3.5" />
<span>{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.kycRequested comment.kycRequested
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400' : 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -136,12 +147,13 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'} <Icon name="ri:bank-card-line" class="h-3.5 w-3.5" />
<span>{comment.kycRequested ? 'No KYC Issue' : 'KYC Issue'}</span>
</button> </button>
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.fundsBlocked comment.fundsBlocked
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400' : 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -151,8 +163,31 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'} <Icon name="ri:lock-line" class="h-3.5 w-3.5" />
<span>{comment.fundsBlocked ? 'No Funds Issue' : 'Funds Issue'}</span>
</button> </button>
<div class="bg-night-500 h-5 w-px"></div>
{
comment.rating && (
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.ratingActive
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
)}
data-action="toggle-rating-active"
data-value={!comment.ratingActive}
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:star-line" class="h-3.5 w-3.5" />
<span>{comment.ratingActive ? 'Disable Rating' : 'Enable Rating'}</span>
</button>
)
}
</div> </div>
<div class="mt-2 space-y-1.5"> <div class="mt-2 space-y-1.5">
@@ -208,7 +243,7 @@ if (!user || !user.admin || !user.moderator) return null
<div class="flex gap-1"> <div class="flex gap-1">
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'APPROVED' comment.orderIdStatus === 'APPROVED'
? 'border border-green-500/30 bg-green-500/20 text-green-400' ? 'border border-green-500/30 bg-green-500/20 text-green-400'
: 'bg-night-700 hover:bg-green-500/20 hover:text-green-400' : 'bg-night-700 hover:bg-green-500/20 hover:text-green-400'
@@ -218,11 +253,12 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
Approve <Icon name="ri:check-line" class="h-3.5 w-3.5" />
<span>Approve</span>
</button> </button>
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'REJECTED' comment.orderIdStatus === 'REJECTED'
? 'border border-red-500/30 bg-red-500/20 text-red-400' ? 'border border-red-500/30 bg-red-500/20 text-red-400'
: 'bg-night-700 hover:bg-red-500/20 hover:text-red-400' : 'bg-night-700 hover:bg-red-500/20 hover:text-red-400'
@@ -232,11 +268,12 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
Reject <Icon name="ri:close-line" class="h-3.5 w-3.5" />
<span>Reject</span>
</button> </button>
<button <button
class={cn( class={cn(
'rounded-sm px-1.5 py-0.5 text-xs transition-colors', 'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'PENDING' comment.orderIdStatus === 'PENDING'
? 'border border-blue-500/30 bg-blue-500/20 text-blue-400' ? 'border border-blue-500/30 bg-blue-500/20 text-blue-400'
: 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400' : 'bg-night-700 hover:bg-blue-500/20 hover:text-blue-400'
@@ -246,7 +283,23 @@ if (!user || !user.admin || !user.moderator) return null
data-comment-id={comment.id} data-comment-id={comment.id}
data-user-id={user.id} data-user-id={user.id}
> >
Pending <Icon name="ri:time-line" class="h-3.5 w-3.5" />
<span>Pending</span>
</button>
<button
class={cn(
'inline-flex items-center gap-1 rounded-sm px-1.5 py-0.5 text-xs transition-colors',
comment.orderIdStatus === 'WITHDRAWN'
? 'border-night-400 bg-night-500/50 text-night-300 border'
: 'bg-night-700 hover:bg-night-500/50 hover:text-night-300'
)}
data-action="order-id-status"
data-value="WITHDRAWN"
data-comment-id={comment.id}
data-user-id={user.id}
>
<Icon name="ri:arrow-go-back-line" class="h-3.5 w-3.5" />
<span>Withdrawn</span>
</button> </button>
</div> </div>
</div> </div>
@@ -280,7 +333,8 @@ if (!user || !user.admin || !user.moderator) return null
action === 'suspicious' || action === 'suspicious' ||
action === 'requires-admin-review' || action === 'requires-admin-review' ||
action === 'kyc-requested' || action === 'kyc-requested' ||
action === 'funds-blocked' action === 'funds-blocked' ||
action === 'toggle-rating-active'
? value === 'true' ? value === 'true'
: value, : value,
}) })
@@ -290,7 +344,8 @@ if (!user || !user.admin || !user.moderator) return null
if (action === 'status') { if (action === 'status') {
window.location.reload() window.location.reload()
} else if (action === 'suspicious') { } else if (action === 'suspicious') {
btn.textContent = value === 'true' ? 'Not Sus' : 'Sus' const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Not Spam' : 'Spam'
btn.classList.toggle('bg-yellow-500/20') btn.classList.toggle('bg-yellow-500/20')
btn.classList.toggle('text-yellow-400') btn.classList.toggle('text-yellow-400')
btn.classList.toggle('border-yellow-500/30') btn.classList.toggle('border-yellow-500/30')
@@ -298,7 +353,8 @@ if (!user || !user.admin || !user.moderator) return null
btn.classList.toggle('bg-night-700') btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true') btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'requires-admin-review') { } else if (action === 'requires-admin-review') {
btn.textContent = value === 'true' ? 'No Review' : 'Review' const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Admin Review' : 'Needs Admin Review'
btn.classList.toggle('bg-purple-500/20') btn.classList.toggle('bg-purple-500/20')
btn.classList.toggle('text-purple-400') btn.classList.toggle('text-purple-400')
btn.classList.toggle('border-purple-500/30') btn.classList.toggle('border-purple-500/30')
@@ -309,7 +365,8 @@ if (!user || !user.admin || !user.moderator) return null
// Refresh to show updated order ID status // Refresh to show updated order ID status
window.location.reload() window.location.reload()
} else if (action === 'kyc-requested') { } else if (action === 'kyc-requested') {
btn.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue' const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No KYC Issue' : 'KYC Issue'
btn.classList.toggle('bg-red-500/20') btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400') btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30') btn.classList.toggle('border-red-500/30')
@@ -317,13 +374,23 @@ if (!user || !user.admin || !user.moderator) return null
btn.classList.toggle('bg-night-700') btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true') btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'funds-blocked') { } else if (action === 'funds-blocked') {
btn.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue' const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'No Funds Issue' : 'Funds Issue'
btn.classList.toggle('bg-red-500/20') btn.classList.toggle('bg-red-500/20')
btn.classList.toggle('text-red-400') btn.classList.toggle('text-red-400')
btn.classList.toggle('border-red-500/30') btn.classList.toggle('border-red-500/30')
btn.classList.toggle('border') btn.classList.toggle('border')
btn.classList.toggle('bg-night-700') btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true') btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} else if (action === 'toggle-rating-active') {
const span = btn.querySelector('span')
if (span) span.textContent = value === 'true' ? 'Disable Rating' : 'Enable Rating'
btn.classList.toggle('bg-blue-500/20')
btn.classList.toggle('text-blue-400')
btn.classList.toggle('border-blue-500/30')
btn.classList.toggle('border')
btn.classList.toggle('bg-night-700')
btn.setAttribute('data-value', value === 'true' ? 'false' : 'true')
} }
} else { } else {
console.error('Error moderating comment:', error) console.error('Error moderating comment:', error)

View File

@@ -0,0 +1,79 @@
---
import { DEPLOYMENT_MODE } from '../lib/envVariables'
import { prisma } from '../lib/prisma'
const user = Astro.locals.user
const hasUnreadNotifications = await Astro.locals.banners.try(
'Error getting unread notification count',
async () =>
user
? !!(await prisma.notification.findFirst({
where: { userId: user.id, read: false },
select: { id: true },
}))
: false,
false
)
function addBadgeIfUnread(href: string) {
if (hasUnreadNotifications) return href.replace('.svg', '-badge.svg')
return href
}
---
{
DEPLOYMENT_MODE === 'production' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'development' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-dev.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-dev-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
{
DEPLOYMENT_MODE === 'staging' && (
<>
<link rel="icon" type="image/svg+xml" sizes="any" href={addBadgeIfUnread('/favicon-stage.svg')} />
<link
rel="icon"
type="image/svg+xml"
sizes="any"
href={addBadgeIfUnread('/favicon-stage-lightmode.svg')}
media="(prefers-color-scheme: light)"
/>
</>
)
}
<script>
document.addEventListener('sse-new-notification', () => {
const links = document.querySelectorAll('link[rel="icon"]')
links.forEach((link) => {
const href = link.getAttribute('href')
if (href && href.includes('favicon') && !href.endsWith('-badge.svg')) {
const newHref = href.replace('.svg', '-badge.svg')
link.setAttribute('href', newHref)
}
})
})
</script>

View File

@@ -27,6 +27,8 @@ const count =
user && ( user && (
<a <a
href="/notifications" href="/notifications"
data-notification-count-link
data-current-count={count}
class={cn( class={cn(
'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white', 'group relative flex cursor-pointer items-center justify-center text-gray-400 transition-colors duration-100 hover:text-white',
className className
@@ -35,11 +37,32 @@ const count =
{...htmlProps} {...htmlProps}
> >
<Icon name="material-symbols:notifications-outline" class="size-5" /> <Icon name="material-symbols:notifications-outline" class="size-5" />
{count > 0 && ( <span
<span class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500"> data-notification-count-badge
{count > 99 ? '★' : count.toLocaleString()} class="absolute top-[calc(50%-var(--spacing)*3.5)] right-[calc(50%-var(--spacing)*3.5)] flex size-3.5 items-center justify-center rounded-full bg-blue-600 text-[10px] font-bold tracking-tighter text-white group-hover:bg-blue-500 empty:hidden"
</span> >
)} {count > 0 ? (count > 99 ? '★' : count.toLocaleString()) : ''}
</span>
</a> </a>
) )
} }
<script>
document.addEventListener('sse-new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-notification-count-link]').forEach((link) => {
const currentCount = Number(link.getAttribute('data-current-count') || 0)
const newCount = currentCount + 1
link.querySelectorAll<HTMLElement>('[data-notification-count-badge]').forEach((badge) => {
badge.textContent = newCount > 0 ? (newCount > 99 ? '★' : newCount.toLocaleString()) : ''
})
link.setAttribute(
'aria-label',
`Go to notifications${newCount > 0 ? ` (${newCount.toLocaleString()} unread)` : ''}`
)
link.setAttribute('data-current-count', String(newCount))
})
})
</script>

View File

@@ -48,7 +48,9 @@ const hasError = !!error && error.length > 0
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}> <div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
<legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}> <legend class={cn('font-title block text-sm font-medium', hasError && 'text-red-500')}>
{icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />} {icon && <Icon name={icon} class="inline-block size-4 align-[-0.2em]" />}
<label for={inputId}>{label}</label> <label for={inputId} transition:persist>
{label}
</label>
{required && '*'} {required && '*'}
</legend> </legend>
{!!descriptionLabel && ( {!!descriptionLabel && (

View File

@@ -0,0 +1,29 @@
---
---
<script>
import { isBrowserNotificationsEnabled, showBrowserNotification } from '../lib/client/browserNotifications'
import { makeNotificationOptions } from '../lib/notificationOptions'
document.addEventListener('sse-new-notification', (event) => {
if (isBrowserNotificationsEnabled()) {
const payload = event.detail
const notification = showBrowserNotification(
payload.title,
makeNotificationOptions(payload, { removeActions: true })
)
// Handle notification click
if (notification) {
notification.onclick = () => {
const defaultAction = payload.actions.find((a) => a.url) ?? payload.actions[0]
if (defaultAction?.url) {
window.open(defaultAction.url, '_blank')
}
notification.close()
}
}
}
})
</script>

View File

@@ -2,6 +2,7 @@
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { VAPID_PUBLIC_KEY } from 'astro:env/server' import { VAPID_PUBLIC_KEY } from 'astro:env/server'
import { SUPPORT_EMAIL } from '../constants/project'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import Button from './Button.astro' import Button from './Button.astro'
@@ -21,24 +22,24 @@ type Props = HTMLAttributes<'div'> & {
} }
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props 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 <div
data-push-notification-banner data-push-notification-banner
data-dismissed={undefined /* Updated by client script */} data-dismissed={undefined as true | undefined /* Updated by client script */}
data-supports-push-notifications={undefined /* Updated by client script */} data-notification-supported={undefined as true | undefined /* Updated by client script */}
data-push-subscriptions={JSON.stringify(pushSubscriptions)} data-push-subscriptions={JSON.stringify(pushSubscriptions)}
data-is-enabled={undefined /* Updated by client script */} data-is-enabled={undefined as true | undefined /* Updated by client script */}
data-loaded={undefined as true | undefined /* Updated by client script */}
data-notification-permissions={undefined as
| NotificationPermission
| undefined /* Updated by client script */}
data-vapid-public-key={VAPID_PUBLIC_KEY}
class={cn( 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', 'no-js:hidden xs:grid-cols-[auto_auto] xs:justify-between relative isolate grid items-center justify-stretch gap-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4 not-data-loaded:hidden',
'data-dismissed:hidden', 'data-dismissed:hidden',
hideIfEnabled && 'data-is-enabled:hidden', hideIfEnabled && 'data-is-enabled:hidden',
'not-data-supports-push-notifications:hidden', 'not-data-notification-supported:hidden',
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden', 'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
className className
)} )}
@@ -60,25 +61,18 @@ if (!Astro.locals.user?.admin) {
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" /> <Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
</div> </div>
<div> <div>
<h3 class="font-medium text-blue-100"> <h3 class="font-medium text-blue-100" data-banner-title>Turn on push notifications?</h3>
<span data-show-if-enabled>Push notifications enabled</span> <p class="text-sm text-blue-200/80" data-banner-description>Get notifications on this device.</p>
<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> </div>
<div class="flex items-center gap-2"> <div class="xs:justify-end flex shrink flex-wrap items-center justify-center gap-2">
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />} {dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
<Button <Button
as="span" as="span"
label="Yes, notify me" label="Yes, notify me"
color="white" color="white"
data-push-action="subscribe" data-push-action="subscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-disabled data-show-if-disabled
/> />
<Button <Button
@@ -87,10 +81,21 @@ if (!Astro.locals.user?.admin) {
color="white" color="white"
variant="faded" variant="faded"
data-push-action="unsubscribe" data-push-action="unsubscribe"
data-vapid-public-key={VAPID_PUBLIC_KEY}
data-show-if-enabled data-show-if-enabled
/> />
</div> </div>
<div
class="col-span-full flex items-center justify-center gap-2 leading-tight text-red-500 has-[[data-error-message]:empty]:hidden"
>
<Icon name="ri:error-warning-line" class="size-5 shrink-0" />
<span>
<span data-error-message></span>
<a href={`mailto:${SUPPORT_EMAIL}`} class="text-red-300 underline hover:text-red-200">
Contact support
</a>
</span>
</div>
</div> </div>
<script> <script>
@@ -134,240 +139,128 @@ if (!Astro.locals.user?.admin) {
</script> </script>
<script> <script>
///////////////////////////////////////////////////////// ///////////////////////////////////////////
// Script to style when notifications enabled. // // Script to handle push notifications. //
//////////////////////////////////////////////////////// ///////////////////////////////////////////
type ServerSubscription = { import {
endpoint: string subscribeToPushNotifications,
userAgent: string | null unsubscribeFromPushNotifications,
} parsePushSubscriptions,
isCurrentDeviceSubscribed,
supportsPushNotifications,
type SafeResult,
} from '../lib/client/clientPushNotifications'
/** Parse push subscriptions from string */ import {
function parsePushSubscriptions(subscriptionsAsString: string | undefined) { enableBrowserNotifications,
try { disableBrowserNotifications,
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string') isBrowserNotificationsEnabled,
supportsBrowserNotifications,
} from '../lib/client/browserNotifications'
const subscriptions = JSON.parse(subscriptionsAsString) async function setDataAttributes(banner: HTMLElement) {
if (!supportsPushNotifications() && !supportsBrowserNotifications()) {
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array') return
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 */ banner.dataset.notificationSupported = ''
async function getCurrentPushSubscription() { banner.dataset.notificationPermissions = Notification.permission
try {
const registration = await navigator.serviceWorker.getRegistration()
if (!registration) return null
return await registration.pushManager.getSubscription() const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
} catch (error) { const titleElement = banner.querySelector<HTMLElement>('[data-banner-title]')
console.error('Error getting current push subscription:', error) const descriptionElement = banner.querySelector<HTMLElement>('[data-banner-description]')
return null
if (await isCurrentDeviceSubscribed(serverSubscriptions)) {
if (titleElement) titleElement.textContent = 'Push notifications enabled'
if (descriptionElement) descriptionElement.textContent = 'Turn push notifications off for this device?'
banner.dataset.isEnabled = ''
banner.dataset.loaded = ''
return
} }
}
/** Check if current subscription matches any server subscription */ if (isBrowserNotificationsEnabled()) {
function isCurrentDeviceSubscribed( if (titleElement) titleElement.textContent = 'Browser notifications enabled'
currentSubscription: PushSubscription | null, if (descriptionElement)
serverSubscriptions: ServerSubscription[] descriptionElement.textContent = 'Turn off notifications? (They only work while the tab is open.)'
) { banner.dataset.isEnabled = ''
if (!currentSubscription || serverSubscriptions.length === 0) return false banner.dataset.loaded = ''
return
}
const currentEndpoint = currentSubscription.endpoint // Default state (disabled)
const currentUserAgent = navigator.userAgent if (titleElement) titleElement.textContent = 'Turn on push notifications?'
if (descriptionElement) descriptionElement.textContent = 'Get notifications on this device.'
return serverSubscriptions.some( banner.dataset.loaded = ''
(sub) =>
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
)
} }
document.addEventListener('astro:page-load', async () => { document.addEventListener('astro:page-load', async () => {
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => { document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions) await setDataAttributes(banner)
const currentSubscription = await getCurrentPushSubscription()
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
if (isSubscribed) banner.dataset.isEnabled = '' const vapidPublicKey = banner.dataset.vapidPublicKey
})
})
</script>
<script> banner.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
///////////////////////////////////////////////////////////// button.addEventListener('click', async (event) => {
// Script to handle push notification subscription. // event.preventDefault()
///////////////////////////////////////////////////////////// event.stopPropagation()
import type { actions } from 'astro:actions' const action = button.dataset.pushAction
import type { ActionInput } from '../lib/astroActions' if (action !== 'subscribe' && action !== 'unsubscribe') {
console.error('Invalid push action:', action)
return
}
/** Utility function to convert VAPID key */ let result: SafeResult
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) if (action === 'subscribe') {
const outputArray = new Uint8Array(rawData.length) const pushResult = vapidPublicKey
? await subscribeToPushNotifications(vapidPublicKey)
: { success: false as const, error: 'VAPID public key not found' }
for (let i = 0; i < rawData.length; ++i) { if (pushResult.success) {
outputArray[i] = rawData.charCodeAt(i) result = pushResult
} } else {
return outputArray console.error(
} "Can't enable push notifications, trying browser notifications.",
pushResult.error
)
const browserResult = await enableBrowserNotifications()
/** Check for browser support */ if (browserResult.success) {
function checkSupport() { result = browserResult
const isSecure = } else {
window.isSecureContext || console.error("Can't enable browser notifications:", browserResult.error)
window.location.hostname === 'localhost' || result = {
window.location.hostname === '127.0.0.1' success: false,
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window error: `Can't enable either push or browser notifications. Push: ${pushResult.error}. Browser: ${browserResult.error}`,
} }
}
}
} else {
const pushResult = await unsubscribeFromPushNotifications()
const browserResult = disableBrowserNotifications()
async function registerServiceWorker() { const success = pushResult.success || browserResult.success
try {
const registration = await navigator.serviceWorker.register('/sw.js')
console.log('Service Worker registered:', registration)
const readyRegistration = await navigator.serviceWorker.ready result = success
console.log('Service Worker is active and ready:', readyRegistration) ? { success: true }
: { success: false, error: `${pushResult.error} | ${browserResult.error}` }
}
return readyRegistration if (!result.success) {
} catch (error) { console.error(result.error)
console.error('Service Worker registration failed:', error)
throw error
}
}
async function subscribeToPush(vapidPublicKey: string) { const errorMessageElement = banner.querySelector<HTMLElement>('[data-error-message]')
try { if (errorMessageElement) {
if (!checkSupport()) return errorMessageElement.textContent = `Failed to ${action === 'subscribe' ? 'enable' : 'disable'} notifications.`
}
// Request notification permission return
const permission = await Notification.requestPermission() }
if (permission !== 'granted') {
alert('Push notifications permission denied')
return
}
const registration = await registerServiceWorker() window.location.reload()
})
// 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()
}
}) })
}) })
}) })

View File

@@ -0,0 +1,108 @@
---
if (!Astro.locals.user) return
---
<script>
import type { ServerEventsEvent, SSEEventMap } from '../lib/serverEventsTypes'
const MAX_RECONNECT_ATTEMPTS = 5
const RECONNECT_DELAY = 2_000
let eventSource: EventSource | null = null
let reconnectTimeout: number | null = null
let reconnectAttempts = 0
startServerEventsListener()
// ------------------------------------------------------------
export function startServerEventsListener() {
stopServerEventsListener()
try {
eventSource = new EventSource('/internal-api/server-events')
eventSource.onopen = () => {
reconnectAttempts = 0
}
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data as string)
if (isServerEventsEvent(data)) {
const eventType = `sse-${data.type}` as const
document.dispatchEvent(
new CustomEvent(eventType, { detail: data.data }) as SSEEventMap[typeof eventType]
)
} else {
console.error('Invalid server events event:', data)
}
} catch (error) {
console.error('Error parsing server events data:', error)
}
}
eventSource.onerror = (error) => {
console.error('Server events error:', error)
if (eventSource?.readyState === EventSource.CLOSED) {
scheduleReconnect()
}
}
} catch (error) {
console.error('Failed to start server events listener:', error)
scheduleReconnect()
}
}
export function stopServerEventsListener() {
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
reconnectTimeout = null
}
if (eventSource) {
eventSource.close()
eventSource = null
console.info('Disconnected from server events listener')
}
reconnectAttempts = 0
}
function scheduleReconnect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
console.error('Max reconnection attempts reached, giving up')
return
}
if (reconnectTimeout) {
clearTimeout(reconnectTimeout)
}
const delay = RECONNECT_DELAY * Math.pow(2, reconnectAttempts)
reconnectAttempts++
const delayStr = String(delay)
const attemptsStr = String(reconnectAttempts)
const maxAttemptsStr = String(MAX_RECONNECT_ATTEMPTS)
console.info(`Attempting to reconnect in ${delayStr}ms (attempt ${attemptsStr}/${maxAttemptsStr})`)
reconnectTimeout = window.setTimeout(() => {
startServerEventsListener()
}, delay)
}
function isServerEventsEvent(event: unknown): event is ServerEventsEvent {
if (typeof event !== 'object' || event === null) return false
const e = event as Record<string, unknown>
return (
'type' in e &&
typeof e.type === 'string' &&
'data' in e &&
typeof e.data === 'object' &&
e.data !== null
)
}
</script>

View File

@@ -1,15 +1,14 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro' import type { TailwindColor } from '../lib/colors'
import type { CommentStatus } from '@prisma/client' import type { CommentStatus } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type CommentStatusInfo<T extends string | null | undefined = string> = { type CommentStatusInfo<T extends string | null | undefined = string> = {
id: T id: T
icon: string icon: string
label: string label: string
color: ComponentProps<typeof BadgeSmall>['color'] color: TailwindColor
creativeWorkStatus: string | undefined creativeWorkStatus: string | undefined
} }

View File

@@ -3,14 +3,13 @@ import { transformCase } from '../lib/strings'
import { commentStatusById } from './commentStatus' import { commentStatusById } from './commentStatus'
import type BadgeSmall from '../components/BadgeSmall.astro' import type { TailwindColor } from '../lib/colors'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type CommentStatusFilterInfo<T extends string | null | undefined = string> = { type CommentStatusFilterInfo<T extends string | null | undefined = string> = {
value: T value: T
label: string label: string
color: ComponentProps<typeof BadgeSmall>['color'] color: TailwindColor
icon: string icon: string
whereClause: Prisma.CommentWhereInput whereClause: Prisma.CommentWhereInput
classNames: { classNames: {

View File

@@ -1,9 +1,8 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro' import type { TailwindColor } from '../lib/colors'
import type { EventType } from '@prisma/client' import type { EventType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type EventTypeInfo<T extends string | null | undefined = string> = { type EventTypeInfo<T extends string | null | undefined = string> = {
id: T id: T
@@ -14,7 +13,7 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
dot: string dot: string
} }
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color'] color: TailwindColor
isSolved: boolean isSolved: boolean
showBanner: boolean showBanner: boolean
} }

View File

@@ -8,7 +8,7 @@ type ServiceSuggestionStatusInfo<T extends string | null | undefined = string> =
slug: string slug: string
label: string label: string
icon: string icon: string
iconClass: string color: string
default: boolean default: boolean
} }
@@ -28,7 +28,7 @@ export const {
slug: value ? value.toLowerCase() : '', slug: value ? value.toLowerCase() : '',
label: value ? transformCase(value, 'title') : String(value), label: value ? transformCase(value, 'title') : String(value),
icon: 'ri:question-line', icon: 'ri:question-line',
iconClass: 'text-current/60', color: 'gray',
default: false, default: false,
}), }),
[ [
@@ -37,7 +37,7 @@ export const {
slug: 'pending', slug: 'pending',
label: 'Pending', label: 'Pending',
icon: 'ri:time-line', icon: 'ri:time-line',
iconClass: 'text-yellow-400', color: 'yellow',
default: true, default: true,
}, },
{ {
@@ -45,7 +45,7 @@ export const {
slug: 'approved', slug: 'approved',
label: 'Approved', label: 'Approved',
icon: 'ri:check-line', icon: 'ri:check-line',
iconClass: 'text-green-400', color: 'green',
default: false, default: false,
}, },
{ {
@@ -53,7 +53,7 @@ export const {
slug: 'rejected', slug: 'rejected',
label: 'Rejected', label: 'Rejected',
icon: 'ri:close-line', icon: 'ri:close-line',
iconClass: 'text-red-400', color: 'red',
default: false, default: false,
}, },
{ {
@@ -61,7 +61,7 @@ export const {
slug: 'withdrawn', slug: 'withdrawn',
label: 'Withdrawn', label: 'Withdrawn',
icon: 'ri:arrow-left-line', icon: 'ri:arrow-left-line',
iconClass: 'text-gray-400', color: 'gray',
default: false, default: false,
}, },
] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[] ] as const satisfies ServiceSuggestionStatusInfo<ServiceSuggestionStatus>[]

View File

@@ -1,9 +1,8 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro' import type { TailwindColor } from '../lib/colors'
import type { ServiceSuggestionType } from '@prisma/client' import type { ServiceSuggestionType } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = { type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
value: T value: T
@@ -12,7 +11,7 @@ type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
icon: string icon: string
order: number order: number
default: boolean default: boolean
color: ComponentProps<typeof BadgeSmall>['color'] color: TailwindColor
} }
export const { export const {

View File

@@ -1,9 +1,8 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro' import type { TailwindColor } from '../lib/colors'
import type { ServiceUserRole } from '@prisma/client' import type { ServiceUserRole } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type ServiceUserRoleInfo<T extends string | null | undefined = string> = { type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
value: T value: T
@@ -11,7 +10,7 @@ type ServiceUserRoleInfo<T extends string | null | undefined = string> = {
label: string label: string
icon: string icon: string
order: number order: number
color: NonNullable<ComponentProps<typeof BadgeSmall>['color']> color: NonNullable<TailwindColor>
} }
export const { export const {

View File

@@ -1,15 +1,14 @@
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions' import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
import { transformCase } from '../lib/strings' import { transformCase } from '../lib/strings'
import type BadgeSmall from '../components/BadgeSmall.astro' import type { TailwindColor } from '../lib/colors'
import type { VerificationStepStatus } from '@prisma/client' import type { VerificationStepStatus } from '@prisma/client'
import type { ComponentProps } from 'astro/types'
type VerificationStepStatusInfo<T extends string | null | undefined = string> = { type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
value: T value: T
label: string label: string
icon: string icon: string
color: ComponentProps<typeof BadgeSmall>['color'] color: TailwindColor
} }
export const { export const {

3
web/src/env.d.ts vendored
View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import type { ErrorBanners } from './lib/errorBanners' import type { ErrorBanners } from './lib/errorBanners'
import type { KarmaUnlocks } from './lib/karmaUnlocks' import type { KarmaUnlocks } from './lib/karmaUnlocks'
/* eslint-disable @typescript-eslint/consistent-type-definitions */
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
import type * as htmx from 'htmx.org' import type * as htmx from 'htmx.org'

View File

@@ -41,6 +41,8 @@ export function makeNonDbAttributes(
isRecentlyListed: true isRecentlyListed: true
listedAt: true listedAt: true
createdAt: true createdAt: true
tosReviewAt: true
tosReview: true
} }
}>, }>,
{ filter = false }: { filter?: boolean } = {} { filter = false }: { filter?: boolean } = {}
@@ -156,6 +158,17 @@ export function makeNonDbAttributes(
trustPoints: -5, trustPoints: -5,
links: [], links: [],
}, },
{
title: "Can't analyse ToS",
show: service.tosReviewAt !== null && service.tosReview === null,
type: 'WARNING',
category: 'TRUST',
description:
'The Terms of Service page is not analyable by our AI. Possible reasons may be: captchas, client side rendering, DDoS protections, or non-text format.',
privacyPoints: 0,
trustPoints: -3,
links: [],
},
] ]
if (filter) return nonDbAttributes.filter(({ show }) => show) if (filter) return nonDbAttributes.filter(({ show }) => show)

View File

@@ -0,0 +1,60 @@
import { typedLocalStorage } from '../localstorage'
type SafeResult = { success: false; error: string } | { success: true; error?: undefined }
export function supportsBrowserNotifications() {
return 'Notification' in window
}
export function isBrowserNotificationsEnabled() {
return (
supportsBrowserNotifications() &&
Notification.permission === 'granted' &&
typedLocalStorage.browserNotificationsEnabled.get()
)
}
export async function enableBrowserNotifications(): Promise<SafeResult> {
try {
if (!supportsBrowserNotifications()) {
return { success: false, error: 'Browser notifications are not supported' }
}
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
return { success: false, error: 'Notification permission denied' }
}
typedLocalStorage.browserNotificationsEnabled.set(true)
return { success: true }
} catch (error) {
console.error('Browser notification setup failed:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: `Browser notification setup failed: ${errorMessage}` }
}
}
export function disableBrowserNotifications(): SafeResult {
try {
typedLocalStorage.browserNotificationsEnabled.set(false)
return { success: true }
} catch (error) {
console.error('Browser notification disable failed:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: `Browser notification disable failed: ${errorMessage}` }
}
}
export function showBrowserNotification(title: string, options?: NotificationOptions) {
if (!isBrowserNotificationsEnabled()) {
console.warn('Browser notifications are not enabled')
return null
}
try {
return new Notification(title, options)
} catch (error) {
console.error('Failed to show browser notification:', error)
return null
}
}

View File

@@ -0,0 +1,187 @@
/// <reference types="vite/client" />
import type { ActionInput } from '../astroActions'
import type { actions } from 'astro:actions'
type ServerSubscription = {
endpoint: string
userAgent: string | null
}
export type SafeResult =
| {
success: false
error: string
}
| {
success: true
error?: undefined
}
export async function subscribeToPushNotifications(vapidPublicKey: string): Promise<SafeResult> {
try {
if (!supportsPushNotifications()) {
return { success: false, error: 'Push notifications are not supported in this browser' }
}
const registration = await getServiceWorkerRegistration()
if (!registration) return { success: false, error: 'Service worker not available' }
const permission = await Notification.requestPermission()
if (permission !== 'granted') {
return { success: false, error: 'Notification permission denied' }
}
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
})
const p256dh = subscription.getKey('p256dh')
const auth = subscription.getKey('auth')
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) {
const errorText = await response.text()
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
}
return { success: true }
} catch (error) {
console.error('Push subscription failed:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: `Subscription failed: ${errorMessage}` }
}
}
export async function unsubscribeFromPushNotifications(): Promise<SafeResult> {
try {
const registration = await getServiceWorkerRegistration()
if (!registration) return { success: false, error: 'Service worker not available' }
const subscription = await registration.pushManager.getSubscription()
if (!subscription) return { success: false, error: 'No push subscription found' }
const unsubscribed = await subscription.unsubscribe()
if (!unsubscribed) return { success: false, error: 'Failed to unsubscribe from browser' }
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) {
const errorText = await response.text()
return { success: false, error: `Server error: ${response.statusText} ${errorText}` }
}
return { success: true }
} catch (error) {
console.error('Push unsubscription failed:', error)
const errorMessage = error instanceof Error ? error.message : String(error)
return { success: false, error: `Unsubscription failed: ${errorMessage}` }
}
}
export function supportsPushNotifications() {
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 getServiceWorkerRegistration() {
try {
if (window.__SW_REGISTRATION__) return window.__SW_REGISTRATION__
return (await navigator.serviceWorker.getRegistration()) ?? null
} catch (error) {
console.error('Error getting service worker registration:', error)
return null
}
}
async function getCurrentSubscription() {
try {
const registration = await getServiceWorkerRegistration()
if (!registration) return null
return await registration.pushManager.getSubscription()
} catch (error) {
console.error('Error getting current push subscription:', error)
return null
}
}
export async function isCurrentDeviceSubscribed(serverSubscriptions: ServerSubscription[]) {
const currentSubscription = await getCurrentSubscription()
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)
)
}
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
}
export function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
try {
if (typeof subscriptionsAsString !== 'string') {
console.error('Push subscriptions are not a string')
return []
}
const subscriptions = JSON.parse(subscriptionsAsString)
if (!Array.isArray(subscriptions)) {
console.error('Push subscriptions are not an array')
return []
}
if (!subscriptions.every(isServerSubscription)) {
console.error('Push subscriptions are not valid')
return subscriptions.filter(isServerSubscription)
}
return subscriptions
} catch (error) {
console.error('Failed to parse push subscriptions:', error)
return []
}
}
function isServerSubscription(subscription: unknown): subscription is ServerSubscription {
if (typeof subscription !== 'object' || subscription === null) return false
const s = subscription as Record<string, unknown>
return typeof s.endpoint === 'string' && (typeof s.userAgent === 'string' || s.userAgent === null)
}

25
web/src/lib/colors.ts Normal file
View File

@@ -0,0 +1,25 @@
export type TailwindColor =
| 'amber'
| 'black'
| 'blue'
| 'cyan'
| 'emerald'
| 'fuchsia'
| 'gray'
| 'green'
| 'indigo'
| 'lime'
| 'neutral'
| 'orange'
| 'pink'
| 'purple'
| 'red'
| 'rose'
| 'sky'
| 'slate'
| 'stone'
| 'teal'
| 'violet'
| 'white'
| 'yellow'
| 'zinc'

View File

@@ -1,4 +1,4 @@
import type { z } from 'astro:content' import type { z } from 'astro/zod'
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions // eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface JSONObject { interface JSONObject {

View File

@@ -50,4 +50,8 @@ export const typedLocalStorage = makeTypedLocalStorage({
pushNotificationsBannerDismissedAt: { pushNotificationsBannerDismissedAt: {
schema: z.coerce.date(), schema: z.coerce.date(),
}, },
browserNotificationsEnabled: {
schema: z.boolean(),
default: false,
},
}) })

View File

@@ -0,0 +1,56 @@
import type { NotificationData, NotificationPayload } from './serverEventsTypes'
export type CustomNotificationOptions = NotificationOptions & {
actions?: { action: string; title: string; icon?: string }[]
timestamp: number
data: NotificationData
}
export function makeNotificationOptions(
payload: NotificationPayload | null,
options: { removeActions?: boolean } = {}
) {
const defaultOptions: CustomNotificationOptions = {
body: 'You have a new notification',
lang: 'en-US',
icon: '/favicon.svg',
badge: '/notification-icon.svg',
requireInteraction: false,
silent: false,
actions: options.removeActions
? undefined
: [
{
action: 'view',
title: 'View',
icon: 'https://api.iconify.design/ri/arrow-right-line.svg',
},
],
timestamp: Date.now(),
data: {
defaultActionUrl: '/notifications',
payload: null,
},
}
if (!payload) {
return defaultOptions
}
return {
...defaultOptions,
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
body: payload.body || undefined,
actions: options.removeActions
? undefined
: payload.actions.map((action) => ({
action: action.action,
title: action.title,
icon: action.icon,
})),
data: {
...defaultOptions.data,
payload,
},
} as CustomNotificationOptions
}

View File

@@ -1,11 +1,14 @@
import { prisma } from './prisma' import { prisma } from './prisma'
import type { Prisma } from '@prisma/client' import type { Prisma, PrismaClient } from '@prisma/client'
export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>( export async function getOrCreateNotificationPreferences<T extends Prisma.NotificationPreferencesSelect>(
userId: number, userId: number,
select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never }, select: { [K in keyof T]: K extends keyof Prisma.NotificationPreferencesSelect ? T[K] : never },
tx: Prisma.TransactionClient = prisma tx:
| Parameters<Parameters<(typeof prisma)['$transaction']>[0]>[0]
| Prisma.TransactionClient
| PrismaClient = prisma
) { ) {
return ( return (
(await tx.notificationPreferences.findUnique({ where: { userId }, select })) ?? (await tx.notificationPreferences.findUnique({ where: { userId }, select })) ??

View File

@@ -7,6 +7,7 @@ import { serviceSuggestionStatusChangesById } from '../constants/suggestionStatu
import { makeCommentUrl } from './commentsWithReplies' import { makeCommentUrl } from './commentsWithReplies'
import type { NotificationAction } from './serverEventsTypes'
import type { Prisma } from '@prisma/client' import type { Prisma } from '@prisma/client'
export function makeNotificationTitle( export function makeNotificationTitle(
@@ -244,7 +245,7 @@ export function makeNotificationContent(
} }
} }
export function makeNotificationLink( export function makeNotificationActions(
notification: Prisma.NotificationGetPayload<{ notification: Prisma.NotificationGetPayload<{
select: { select: {
type: true type: true
@@ -286,47 +287,120 @@ export function makeNotificationLink(
} }
}>, }>,
origin: string origin: string
): string | null { ): NotificationAction[] {
switch (notification.type) { switch (notification.type) {
case 'TEST': { case 'TEST': {
return `${origin}/notifications` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/notifications`,
},
{
action: 'profile',
title: 'Profile',
...iconNameAndUrl('ri:user-line'),
url: `${origin}/account`,
},
]
} }
case 'COMMENT_STATUS_CHANGE': case 'COMMENT_STATUS_CHANGE':
case 'REPLY_COMMENT_CREATED': case 'REPLY_COMMENT_CREATED':
case 'COMMUNITY_NOTE_ADDED': case 'COMMUNITY_NOTE_ADDED':
case 'ROOT_COMMENT_CREATED': { case 'ROOT_COMMENT_CREATED': {
if (!notification.aboutComment) return null if (!notification.aboutComment) return []
return makeCommentUrl({ return [
serviceSlug: notification.aboutComment.service.slug, {
commentId: notification.aboutComment.id, action: 'view',
origin, title: 'View',
}) ...iconNameAndUrl('ri:arrow-right-line'),
url: makeCommentUrl({
serviceSlug: notification.aboutComment.service.slug,
commentId: notification.aboutComment.id,
origin,
}),
},
]
} }
case 'SUGGESTION_MESSAGE': { case 'SUGGESTION_MESSAGE': {
if (!notification.aboutServiceSuggestionMessage) return null if (!notification.aboutServiceSuggestionMessage) return []
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionMessage.suggestion.id)}#message-${String(notification.aboutServiceSuggestionMessage.id)}`,
},
]
} }
case 'SUGGESTION_STATUS_CHANGE': { case 'SUGGESTION_STATUS_CHANGE': {
if (!notification.aboutServiceSuggestionId) return null if (!notification.aboutServiceSuggestionId) return []
return `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service-suggestion/${String(notification.aboutServiceSuggestionId)}`,
},
]
} }
// TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code. // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
// case 'KARMA_UNLOCK': { // case 'KARMA_UNLOCK': {
// return `${origin}/account#karma-unlocks` // return [{ action: 'view', title: 'View', url: `${origin}/account#karma-unlocks` }]
// } // }
case 'KARMA_CHANGE': { case 'KARMA_CHANGE': {
return `${origin}/account#karma-transactions` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/account#karma-transactions`,
},
]
} }
case 'ACCOUNT_STATUS_CHANGE': { case 'ACCOUNT_STATUS_CHANGE': {
return `${origin}/account#account-status` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/account#account-status`,
},
]
} }
case 'EVENT_CREATED': { case 'EVENT_CREATED': {
if (!notification.aboutEvent) return null if (!notification.aboutEvent) return []
return `${origin}/service/${notification.aboutEvent.service.slug}#events` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutEvent.service.slug}#events`,
},
]
} }
case 'SERVICE_VERIFICATION_STATUS_CHANGE': { case 'SERVICE_VERIFICATION_STATUS_CHANGE': {
if (!notification.aboutService) return null if (!notification.aboutService) return []
return `${origin}/service/${notification.aboutService.slug}#verification` return [
{
action: 'view',
title: 'View',
...iconNameAndUrl('ri:arrow-right-line'),
url: `${origin}/service/${notification.aboutService.slug}#verification`,
},
]
} }
} }
} }
function iconUrl<T extends `${string}:${string}`>(iconName: T) {
return `https://api.iconify.design/${iconName.replace(':', '/') as T extends `${infer Prefix}:${infer Suffix}` ? `${Prefix}/${Suffix}` : never}.svg` as const
}
function iconNameAndUrl<T extends `${string}:${string}`>(iconName: T) {
return {
iconName,
icon: iconUrl(iconName),
} as const
}

View File

@@ -1,100 +1,21 @@
import { z } from 'astro/zod' import { startListener, stopListener } from './postgresListeners'
import { Client } from 'pg'
import { zodParseJSON } from './json' import type { AstroIntegration } from 'astro'
import { sendNotifications } from './sendNotifications'
import { getServerEnvVariable } from './serverEnvVariables'
import type { AstroIntegration, HookParameters } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
let pgClient: Client | null = null
const INTEGRATION_NAME = 'postgres-listener' const INTEGRATION_NAME = 'postgres-listener'
async function handleNotificationCreated(
notificationId: number,
options: HookParameters<'astro:server:start'>
) {
const logger = options.logger.fork(INTEGRATION_NAME)
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const results = await sendNotifications([notificationId], logger)
logger.info(
`Sent push notifications for notification ${String(notificationId)} to ${String(results.subscriptions.success)} devices, ${String(results.subscriptions.failure)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
}
}
export function postgresListener(): AstroIntegration { export function postgresListener(): AstroIntegration {
return { return {
name: 'postgres-listener', name: 'postgres-listener',
hooks: { hooks: {
'astro:server:start': async (options) => { 'astro:server:start': (options) => {
const logger = options.logger.fork(INTEGRATION_NAME) const logger = options.logger.fork(INTEGRATION_NAME)
void startListener(logger)
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': (options) => {
'astro:server:done': async ({ logger: originalLogger }) => { const logger = options.logger.fork(INTEGRATION_NAME)
const logger = originalLogger.fork(INTEGRATION_NAME) void stopListener(logger)
if (pgClient) {
try {
logger.info('Stopping PostgreSQL notification listener...')
await pgClient.end()
pgClient = null
logger.info('PostgreSQL listener stopped')
} catch (error) {
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
}
}
}, },
}, },
} }
} }
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
}

View File

@@ -0,0 +1,112 @@
import { Client } from 'pg'
import { getRedisServerEvents, type RedisServerEvents } from './redis/redisServerEvents'
import { sendNotification } from './sendNotifications'
import { getServerEnvVariable } from './serverEnvVariables'
import type { AstroIntegrationLogger } from 'astro'
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
let pgClient: Client | null = null
export async function startListener(
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>
) {
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')
const redisServerEvents = await getRedisServerEvents()
pgClient.on('notification', (msg) => {
if (msg.channel === 'notification_created') {
const payload = parseJSON(msg.payload)
if (
!payload ||
typeof payload !== 'object' ||
!('id' in payload) ||
typeof payload.id !== 'number' ||
payload.id <= 0 ||
!Number.isFinite(payload.id) ||
!Number.isInteger(payload.id)
) {
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
return
}
// NOTE: Don't await to avoid blocking
void handleNotificationCreated(payload.id, logger, redisServerEvents)
}
})
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)}`)
}
}
async function handleNotificationCreated(
notificationId: number,
logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>,
redisServerEvents: RedisServerEvents
) {
try {
logger.info(`Processing notification with ID: ${String(notificationId)}`)
const results = await sendNotification(notificationId, logger, redisServerEvents)
logger.info(
`Sent push notifications for notification ${String(notificationId)} to ${String(results.success)} devices, ${String(results.failure)} failed`
)
} catch (error) {
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
}
}
export async function stopListener(logger: AstroIntegrationLogger) {
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 parseJSON<T = unknown, D extends T | undefined = undefined>(
stringValue: string | null | undefined,
defaultValue?: D
): D | T {
if (!stringValue) return defaultValue as D
try {
return JSON.parse(stringValue) as T
} catch (error) {
console.error(error)
return defaultValue as D
}
}
function getErrorMessage(error: unknown) {
if (error instanceof Error) {
return error.message
}
return String(error)
}

View File

@@ -2,7 +2,6 @@ import { randomUUID } from 'node:crypto'
import { deserializeActionResult } from 'astro:actions' import { deserializeActionResult } from 'astro:actions'
import { z } from 'astro:content' import { z } from 'astro:content'
import { REDIS_ACTIONS_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager' import { RedisGenericManager } from './redisGenericManager'
@@ -29,11 +28,13 @@ const dataSchema = z.object({
}) })
class RedisActionsSessions extends RedisGenericManager { class RedisActionsSessions extends RedisGenericManager {
private readonly prefix = 'actions_session:'
async store(data: z.input<typeof dataSchema>) { async store(data: z.input<typeof dataSchema>) {
const sessionId = randomUUID() const sessionId = randomUUID()
const parsedData = dataSchema.parse(data) const parsedData = dataSchema.parse(data)
await this.redisClient.set(`actions-session:${sessionId}`, JSON.stringify(parsedData), { await this.redisClient.set(`${this.prefix}${sessionId}`, JSON.stringify(parsedData), {
EX: this.expirationTime, EX: this.expirationTime,
}) })
@@ -43,7 +44,7 @@ class RedisActionsSessions extends RedisGenericManager {
async get(sessionId: string | null | undefined) { async get(sessionId: string | null | undefined) {
if (!sessionId) return null if (!sessionId) return null
const key = `actions-session:${sessionId}` const key = `${this.prefix}${sessionId}`
const rawData = await this.redisClient.get(key) const rawData = await this.redisClient.get(key)
if (!rawData) return null if (!rawData) return null
@@ -60,10 +61,10 @@ class RedisActionsSessions extends RedisGenericManager {
async delete(sessionId: string | null | undefined) { async delete(sessionId: string | null | undefined) {
if (!sessionId) return if (!sessionId) return
await this.redisClient.del(`actions-session:${sessionId}`) await this.redisClient.del(`${this.prefix}${sessionId}`)
} }
} }
export const redisActionsSessions = await RedisActionsSessions.createAndConnect({ export const redisActionsSessions = await RedisActionsSessions.createAndConnect({
expirationTime: REDIS_ACTIONS_SESSION_EXPIRY_SECONDS, expirationTime: 60 * 5, // 5 minutes in seconds
}) })

View File

@@ -1,6 +1,9 @@
import { REDIS_URL } from 'astro:env/server'
import { createClient } from 'redis' import { createClient } from 'redis'
import { getServerEnvVariable } from '../serverEnvVariables'
const REDIS_URL = getServerEnvVariable('REDIS_URL')
type RedisGenericManagerOptions = { type RedisGenericManagerOptions = {
expirationTime: number expirationTime: number
} }

View File

@@ -1,7 +1,6 @@
import { randomUUID } from 'node:crypto' import { randomUUID } from 'node:crypto'
import { z } from 'astro:content' import { z } from 'astro:content'
import { REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager' import { RedisGenericManager } from './redisGenericManager'
@@ -11,11 +10,13 @@ const dataSchema = z.object({
}) })
class RedisImpersonationSessions extends RedisGenericManager { class RedisImpersonationSessions extends RedisGenericManager {
private readonly prefix = 'impersonation_session:'
async store(data: z.input<typeof dataSchema>) { async store(data: z.input<typeof dataSchema>) {
const sessionId = randomUUID() const sessionId = randomUUID()
const parsedData = dataSchema.parse(data) const parsedData = dataSchema.parse(data)
await this.redisClient.set(`impersonation-session:${sessionId}`, JSON.stringify(parsedData), { await this.redisClient.set(`${this.prefix}${sessionId}`, JSON.stringify(parsedData), {
EX: this.expirationTime, EX: this.expirationTime,
}) })
@@ -25,7 +26,7 @@ class RedisImpersonationSessions extends RedisGenericManager {
async get(sessionId: string | null | undefined) { async get(sessionId: string | null | undefined) {
if (!sessionId) return null if (!sessionId) return null
const key = `impersonation-session:${sessionId}` const key = `${this.prefix}${sessionId}`
const rawData = await this.redisClient.get(key) const rawData = await this.redisClient.get(key)
if (!rawData) return null if (!rawData) return null
@@ -36,10 +37,10 @@ class RedisImpersonationSessions extends RedisGenericManager {
async delete(sessionId: string | null | undefined) { async delete(sessionId: string | null | undefined) {
if (!sessionId) return if (!sessionId) return
await this.redisClient.del(`impersonation-session:${sessionId}`) await this.redisClient.del(`${this.prefix}${sessionId}`)
} }
} }
export const redisImpersonationSessions = await RedisImpersonationSessions.createAndConnect({ export const redisImpersonationSessions = await RedisImpersonationSessions.createAndConnect({
expirationTime: REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS, expirationTime: 60 * 60 * 24, // 24 hours in seconds
}) })

View File

@@ -1,14 +1,14 @@
import { REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager' import { RedisGenericManager } from './redisGenericManager'
class RedisPreGeneratedSecretTokens extends RedisGenericManager { class RedisPreGeneratedSecretTokens extends RedisGenericManager {
private readonly prefix = 'pregenerated_user_secret_token:'
/** /**
* Stores a pre-generated token with expiration * Stores a pre-generated token with expiration
* @param token The pre-generated token * @param token The pre-generated token
*/ */
async storePreGeneratedToken(token: string): Promise<void> { async storePreGeneratedToken(token: string): Promise<void> {
await this.redisClient.set(`pregenerated-user-secret-token:${token}`, '1', { await this.redisClient.set(`${this.prefix}${token}`, '1', {
EX: this.expirationTime, EX: this.expirationTime,
}) })
} }
@@ -19,7 +19,7 @@ class RedisPreGeneratedSecretTokens extends RedisGenericManager {
* @returns true if token was valid and consumed, false otherwise * @returns true if token was valid and consumed, false otherwise
*/ */
async validateAndConsumePreGeneratedToken(token: string): Promise<boolean> { async validateAndConsumePreGeneratedToken(token: string): Promise<boolean> {
const key = `pregenerated-user-secret-token:${token}` const key = `${this.prefix}${token}`
const exists = await this.redisClient.exists(key) const exists = await this.redisClient.exists(key)
if (exists) { if (exists) {
await this.redisClient.del(key) await this.redisClient.del(key)
@@ -30,5 +30,5 @@ class RedisPreGeneratedSecretTokens extends RedisGenericManager {
} }
export const redisPreGeneratedSecretTokens = await RedisPreGeneratedSecretTokens.createAndConnect({ export const redisPreGeneratedSecretTokens = await RedisPreGeneratedSecretTokens.createAndConnect({
expirationTime: REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS, expirationTime: 60 * 5, // 5 minutes in seconds
}) })

View File

@@ -0,0 +1,72 @@
import { allServerEventsData, type ServerEventsData, type ServerEventsEvent } from '../serverEventsTypes'
import { RedisGenericManager } from './redisGenericManager'
export class RedisServerEvents extends RedisGenericManager {
private readonly prefix = 'server_events:'
/**
* Broadcast an event to a user's server events listener.
*
* @param eventName - The event name to broadcast.
* @param userId - The user ID to broadcast to.
* @param data - The event to broadcast.
*/
async send<T extends keyof ServerEventsData>(
userId: number,
eventName: T,
data: ServerEventsData[T]
): Promise<void> {
const channel = `${this.prefix}${String(userId)}:${eventName}` as const
await this.redisClient.publish(channel, JSON.stringify(data))
}
/**
* Subscribe to server events for a user.
*
* @param eventName - The event name to subscribe to.
* @param userId - The user ID to subscribe to.
* @param callback - The callback to call when the event is received.
* @returns A cleanup function to unsubscribe.
*/
async addListener<T extends keyof ServerEventsData | 'all'>(
eventName: T,
userId: number,
callback: (event: T extends 'all' ? ServerEventsEvent : Extract<ServerEventsEvent, { type: T }>) => void
): Promise<() => Promise<void>> {
const subscriber = this.redisClient.duplicate()
await subscriber.connect()
const channel =
eventName === 'all'
? allServerEventsData.map((eventName) => `${this.prefix}${String(userId)}:${eventName}` as const)
: (`${this.prefix}${String(userId)}:${eventName}` as const)
await subscriber.subscribe(channel, (message, channelKey) => {
try {
const data = JSON.parse(message) as ServerEventsData[T extends 'all' ? keyof ServerEventsData : T]
const type = channelKey.split(':')[2] as T extends 'all' ? keyof ServerEventsData : T
const event = { type, data } as T extends 'all'
? ServerEventsEvent
: Extract<ServerEventsEvent, { type: T }>
callback(event)
} catch (error) {
console.error('Failed to parse notification stream event:', error)
}
})
return async () => {
await subscriber.unsubscribe(channel)
subscriber.destroy()
}
}
}
let redisServerEvents: RedisServerEvents | null = null
export async function getRedisServerEvents() {
redisServerEvents ??= await RedisServerEvents.createAndConnect({
expirationTime: 60 * 60 * 24, // 24 hours in seconds
})
return redisServerEvents
}

View File

@@ -1,7 +1,5 @@
import { randomBytes } from 'crypto' import { randomBytes } from 'crypto'
import { REDIS_USER_SESSION_EXPIRY_SECONDS } from 'astro:env/server'
import { RedisGenericManager } from './redisGenericManager' import { RedisGenericManager } from './redisGenericManager'
class RedisSessions extends RedisGenericManager { class RedisSessions extends RedisGenericManager {
@@ -74,5 +72,5 @@ class RedisSessions extends RedisGenericManager {
} }
export const redisSessions = await RedisSessions.createAndConnect({ export const redisSessions = await RedisSessions.createAndConnect({
expirationTime: REDIS_USER_SESSION_EXPIRY_SECONDS, expirationTime: 60 * 60 * 24, // 24 hours in seconds
}) })

View File

@@ -1,20 +1,21 @@
import { sum } from 'lodash-es' import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from './notifications'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
import { prisma } from './prisma' import { prisma } from './prisma'
import { getServerEnvVariable } from './serverEnvVariables' import { getServerEnvVariable } from './serverEnvVariables'
import { type NotificationData, sendPushNotification } from './webPush' import { sendPushNotification } from './webPush'
import type { RedisServerEvents } from './redis/redisServerEvents'
import type { NotificationPayload } from './serverEventsTypes'
import type { AstroIntegrationLogger } from 'astro' import type { AstroIntegrationLogger } from 'astro'
const SITE_URL = getServerEnvVariable('SITE_URL') const SITE_URL = getServerEnvVariable('SITE_URL')
export async function sendNotifications( export async function sendNotification(
notificationIds: number[], notificationId: number,
logger: AstroIntegrationLogger | Console = console logger: Pick<AstroIntegrationLogger, 'debug' | 'error' | 'info' | 'warn'>,
redisServerEvents: RedisServerEvents
) { ) {
const notifications = await prisma.notification.findMany({ const notification = await prisma.notification.findUnique({
where: { id: { in: notificationIds } }, where: { id: notificationId },
select: { select: {
id: true, id: true,
type: true, type: true,
@@ -110,76 +111,55 @@ export async function sendNotifications(
}, },
}) })
if (notifications.length < notificationIds.length) { if (!notification) {
const missingNotificationIds = notificationIds.filter( logger.error(`Notification with ID ${notificationId.toString()} not found`)
(id) => !notifications.some((notification) => notification.id === id) return { success: 0, failure: 0, total: 0 }
)
logger.error(`Notifications with IDs ${missingNotificationIds.join(', ')} not found`)
} }
const results = await Promise.allSettled( const subscriptions = await prisma.pushSubscription.findMany({
notifications.map(async (notification) => { where: { userId: notification.userId },
const subscriptions = await prisma.pushSubscription.findMany({ select: {
where: { userId: notification.userId }, id: true,
select: { endpoint: true,
id: true, p256dh: true,
endpoint: true, auth: true,
p256dh: true, },
auth: true, })
const notificationPayload = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification),
actions: makeNotificationActions(notification, SITE_URL),
} satisfies NotificationPayload
await redisServerEvents.send(notification.userId, 'new-notification', notificationPayload)
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
}, },
}) notificationPayload
if (subscriptions.length === 0) {
logger.info(`No push subscriptions found for user ${notification.user.name}`)
return
}
const notificationData = {
title: makeNotificationTitle(notification, notification.user),
body: makeNotificationContent(notification) ?? undefined,
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
} satisfies NotificationData
const subscriptionResults = await Promise.allSettled(
subscriptions.map(async (subscription) => {
const result = await sendPushNotification(
{
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.p256dh,
auth: subscription.auth,
},
},
notificationData
)
// Remove invalid subscriptions
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
}
return result.success
})
) )
return { // Remove invalid subscriptions
success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length, if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length, await prisma.pushSubscription.delete({ where: { id: subscription.id } })
total: subscriptionResults.length, logger.info(`Removed invalid subscription for user ${notification.user.name}`)
} }
return result.success
}) })
) )
return { return {
subscriptions: { success: subscriptionResults.filter((r) => r.status === 'fulfilled' && r.value).length,
success: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.success) ?? 0)), failure: subscriptionResults.filter((r) => !(r.status === 'fulfilled' && r.value)).length,
failure: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.failure) ?? 0)), total: subscriptionResults.length,
total: sum(results.map((r) => (r.status === 'fulfilled' && r.value?.total) ?? 0)),
},
notifications: {
success: results.filter((r) => r.status === 'fulfilled').length,
failure: results.filter((r) => r.status !== 'fulfilled').length,
total: results.length,
},
} }
} }

View File

@@ -0,0 +1,60 @@
import type { Assert } from './assert'
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
export type NotificationAction = {
action: string
title: string
icon?: string
url: string | null
iconName?: string
}
export type NotificationPayload = {
title: string
body: string | null
actions: NotificationAction[]
}
export type NotificationData = {
defaultActionUrl: string
payload: NotificationPayload | null
}
export type ServerEventsData = {
'new-notification': NotificationPayload
'new-connection': {
timestamp: string
}
}
export const allServerEventsData = [
'new-notification',
'new-connection',
] as const satisfies (keyof ServerEventsData)[]
type _ExpectServerEventsDataToHaveAllValues = Assert<
Equals<(typeof allServerEventsData)[number], keyof ServerEventsData>
>
export type ServerEventsEvent = {
[K in keyof ServerEventsData]: {
type: K
data: ServerEventsData[K]
}
}[keyof ServerEventsData]
export type SSEEventMap = {
[K in keyof ServerEventsData as `sse-${K}`]: CustomEvent<ServerEventsData[K]>
}
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Document {
addEventListener<K extends keyof SSEEventMap>(
type: K,
listener: (this: Document, ev: SSEEventMap[K]) => void
): void
dispatchEvent<K extends keyof SSEEventMap>(ev: SSEEventMap[K]): void
}
}

View File

@@ -3,23 +3,17 @@ import webpush, { WebPushError } from 'web-push'
import { getServerEnvVariable } from './serverEnvVariables' import { getServerEnvVariable } from './serverEnvVariables'
import type { NotificationPayload } from './serverEventsTypes'
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY') const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY') const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
// Configure VAPID keys // Configure VAPID keys
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY) webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
export { webpush } export { webpush }
export type NotificationData = {
title: string
body?: string
icon?: string
badge?: string
url?: string
}
export async function sendPushNotification( export async function sendPushNotification(
subscription: { subscription: {
endpoint: string endpoint: string
@@ -28,26 +22,13 @@ export async function sendPushNotification(
auth: string auth: string
} }
}, },
data: NotificationData payload: NotificationPayload
) { ) {
try { try {
const result = await webpush.sendNotification( // NOTE: View sw.js to see how the notification is handled
subscription, const result = await webpush.sendNotification(subscription, JSON.stringify(payload), {
JSON.stringify({ TTL: 24 * 60 * 60, // 24 hours
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 return { success: true, result } as const
} catch (error) { } catch (error) {
console.error('Error sending push notification:', error) console.error('Error sending push notification:', error)

View File

@@ -21,7 +21,7 @@ const cleanUrl = (input: unknown) => {
export const zodUrlOptionalProtocol = z.preprocess( export const zodUrlOptionalProtocol = z.preprocess(
cleanUrl, cleanUrl,
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), { z.string().refine((value) => /^(https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*$/i.test(value), {
message: 'Invalid URL', message: 'Invalid URL',
}) })
) )
@@ -42,7 +42,7 @@ export const zodContactMethod = z.preprocess(
.trim() .trim()
.refine( .refine(
(value) => (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( /^((https?:\/\/)?[a-z0-9]+(\.[a-z0-9])*(\.[a-z0-9]{2,}).*|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
value value
), ),
{ {

View File

@@ -61,57 +61,72 @@ Users can also be **affiliated** with a service if they're related to it, such a
### Suggesting a new listing ### Suggesting a new listing
To suggest a new listing, visit the [service suggestion form](/service-suggestion/new) and provide the most accurate information possible for higher chances to get approved. Suggest a new listing by visiting the [service suggestion form](/service-suggestion/new). Provide the most accurate information for higher chances of getting approved.
Once submitted, you get a unique tracking page where you can monitor its status and communicate directly with moderators. Once submitted, you will get a **unique tracking page** where you can monitor the suggestion status and communicate directly with moderators.
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**. #### Listing Requirements
#### Requirements
To list a new service, it must fulfill these requirements: To list a new service, it must fulfill these requirements:
- Offer a service. - Offer a service
- Publicly available website explaining what the service is about - Publicly available website explaining what the service is about
- Terms of service or FAQ document - Terms of service or FAQ document
For examples: Examples of non-valid services:
- Just a Telegram link or a criptocurrency itself is not a valid service.
### Suggestion Review Process - Just a Telegram link
- A cryptocurrency project
- A cryptocurrency wallet
#### First Review #### Suggestion Review Process
When you submit a new service, it gets the `COMMUNITY_CONTRIBUTED` status and the `unlisted` visibility level. The service **will not appear in search results**, but it can be viewed with a direct link.
##### First Review (Unlisted -> Public)
- A member of the **KYCnot.me team** reviews the submission to ensure it isn't spam or inappropriate. - A member of the **KYCnot.me team** reviews the submission to ensure it isn't spam or inappropriate.
- If the listing passes this initial check, it becomes **publicly visible** to all users. - If the listing passes this initial check, it becomes **publicly visible** to all users.
- At this stage, the listing is **Community Contributed** and will show a disclaimer. - At this stage, the listing is **Community Contributed** and will show a disclaimer.
#### Second Review (APPROVED) > Allow up to a week for the initial review. As a small team of two volunteers, we have to handle a large number of suggestions alongside our other life commitments.
- The service is tested and investigated again. ##### Second Review (Community Contributed -> Approved)
- If it proves to be reliable, it is granted the `APPROVED` status, which means:
- The information is accurate.
- The service works as described (at the time of the testing).
- Basic functionality has been tested.
- Communication with the service's support was successful.
- A brief investigation found no obvious red flags.
#### Final Review (VERIFIED) The service will be tested and investigated more in depth. If:
- After a period of no reported issues, the service will undergo a **third, comprehensive review**. - Service information is accurate.
- The service is tested across different dates and under various conditions. - Works as described (at the time of the testing).
- The service administrators and support teams will be contacted for additional verification. - Basic functionality is tested successfully.
- If the service meets all requirements, it is granted the **`VERIFIED`** status. - Communication with service's support was successful.
- No obvious red flags.
- Platform user reviews will be taken into account.
- Terms of Service and other documents are briefly reviewed.
- Other platforms are checked for the service reputation (Trustpilot, Reddit, Bitcointalk, etc.)
#### Failed Verifications The service will be granted the `APPROVED` status.
##### Final Review (Approved -> Verified)
After a period of no reported issues, we will do a **third review**.
- The service is tested across different dates and under various conditions.
- The service administrators and support teams will be contacted for additional verification.
- User reviews will be checked for any issues and taken into account.
- Terms of Service and other documents will be reviewed again.
- Other platforms are checked for the service reputation (Trustpilot, Reddit, Bitcointalk, etc.)
If the service meets all requirements, it is granted the **`VERIFIED`** status.
##### Failed Verifications (Scams)
If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer. If the data is not accurate, the service is a scam, or any other checks fail, the service will be rejected and will appear with a disclaimer.
### Verification Steps #### Verification Steps
Services will usually show the verification steps that the admins took to reach the verified (or not) status. Each step will have a description and some evidence attached. Services will usually show the verification steps that the admins took to reach the verified (or not) status. Each step will have a description and some evidence attached.
### Service Attributes #### Service Attributes
An attribute is a feature of a service, categorized as: An attribute is a feature of a service, categorized as:
@@ -125,50 +140,51 @@ You can view all available attributes on the [Attributes page](/attributes).
Attributes are classified into two main types: Attributes are classified into two main types:
1. **Privacy Attributes** Related to data protection and anonymity. 1. **Privacy Attributes** Related to data protection and anonymity.
2. **Trust Attributes** Related to reliability and security. 1. **Trust Attributes** Related to reliability and security.
These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**. These categories **directly influence** a service's Privacy and Trust scores, which contribute to its **overall rating**.
### Service Scores #### Service Scores
Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements. Scores are calculated **automatically** using clear, fixed rules. We do not change or adjust scores by hand. The scoring system is **open-source** and anyone can review or suggest improvements.
#### Privacy Score ##### Privacy Score
The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach: The privacy score measures how well a service protects user privacy, using a transparent, rules-based approach:
1. **Base Score:** Every service starts with a neutral score of 50 points. 1. **Base Score:** Every service starts with a neutral score of 50 points.
2. **KYC Level:** Adjusts the score based on the level of identity verification required: 1. **KYC Level:** Adjusts the score based on the level of identity verification required:
- KYC Level 0 (No KYC): **+25 points** - KYC Level 0 (No KYC): **+25 points**
- KYC Level 1 (Minimal KYC): **+10 points** - KYC Level 1 (Minimal KYC): **+10 points**
- KYC Level 2 (Moderate KYC): **-5 points** - KYC Level 2 (Moderate KYC): **-5 points**
- KYC Level 3 (More KYC): **-15 points** - KYC Level 3 (More KYC): **-15 points**
- KYC Level 4 (Full mandatory KYC): **-25 points** - KYC Level 4 (Full mandatory KYC): **-25 points**
3. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL. 1. **Onion URL:** **+5 points** if the service offers at least one Onion (Tor) URL.
4. **I2P URL:** **+5 points** if the service offers at least one I2P URL. 1. **I2P URL:** **+5 points** if the service offers at least one I2P URL.
5. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method. 1. **Monero Acceptance:** **+5 points** if the service accepts Monero as a payment method.
6. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. 1. **Privacy Attributes:** The sum of all privacy points from attributes categorized as 'PRIVACY' is added to the score. [See all attributes](/attributes).
7. **Final Score Range:** The final score is always kept between 0 and 100. 1. **Final Score Range:** The final score is always kept between 0 and 100.
#### Trust Score ##### Trust Score
The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria. The trust score represents how reliable and trustworthy a service is, based on objective, transparent criteria.
1. **Base Score:** Every service begins with a neutral score of 50 points. 1. **Base Score:** Every service begins with a neutral score of 50 points.
2. **Verification Status Adjustment:** 1. **Verification Status:**
- **Verification Success:** +10 points - **Verification Success:** +10 points
- **Approved:** +5 points - **Approved:** +5 points
- **Community Contributed:** 0 points - **Community Contributed:** 0 points
- **Verification Failed (SCAM):** -50 points - **Verification Failed (SCAM):** -50 points
3. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. 1. **Recently Listed:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed.
4. **Recently Listed Penalty & Flag:** If a service was listed within the last 15 days and its status is `APPROVED`, a penalty of -10 points is applied to the trust score, and the service is flagged as recently listed. 1. **Can't Analyze ToS:** If a service's Terms of Service cannot be analyzed by our AI (usually due to captchas, client-side rendering, DDoS protections, or non-text format), a penalty of -3 points is applied to the trust score.
5. **Final Score Range:** The final score is always kept between 0 and 100. 1. **Trust Attributes:** The total trust points from all attributes categorized as 'TRUST' are added to the score. [See all attributes](/attributes).
1. **Final Score Range:** The final score is always kept between 0 and 100.
#### Overall Score ##### Overall Score
The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust. The overall score is calculated as `(privacy * 0.6) + (trust * 0.4)` and provides a combined measure of privacy and trust.
### Terms of Service Reviews #### Terms of Service Reviews
KYCnot.me automatically reviews and summarizes the Terms of Service (ToS) for every service monthly using AI. You get simple, clear summaries that highlight the most important points, so you can quickly see what matters. KYCnot.me automatically reviews and summarizes the Terms of Service (ToS) for every service monthly using AI. You get simple, clear summaries that highlight the most important points, so you can quickly see what matters.
@@ -176,7 +192,7 @@ We hash each ToS document and only review it again if it changes. Some services
We aim for accuracy, but the AI may sometimes miss details or highlight less relevant information. If you see any error, contact us. We aim for accuracy, but the AI may sometimes miss details or highlight less relevant information. If you see any error, contact us.
### Events #### Events
There are two types of events: There are two types of events:
@@ -185,7 +201,7 @@ There are two types of events:
You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date. You can also take a look at the [global timeline](/events) where you will find all the service's events sorted by date.
### Reviews and Comments ## Reviews and Comments
Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one. Reviews are comments with a one to five star rating for the service. Each user can leave only one review per service; new reviews replace the old one.
@@ -195,7 +211,7 @@ If you've used the service, you can add an **order ID** or proof—this is only
Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**. Some reviews may be spam or fake. Read comments carefully and **always do your own research before making decisions**.
#### Note on moderation ### Note on moderation
**All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations. **All comments are moderated.** First, an AI checks each comment. If nothing is flagged, the comment is published right away. If something seems off, the comment is held for a human to review. We only remove comments that are spam, nonsense, unsupported accusations, doxxing, or clear rule violations.

View File

@@ -46,8 +46,6 @@ const createInputErrors = isInputError(createResult?.error) ? createResult.error
const updateResult = Astro.getActionResult(actions.admin.attribute.update) const updateResult = Astro.getActionResult(actions.admin.attribute.update)
Astro.locals.banners.addIfSuccess(updateResult, 'Attribute updated successfully') Astro.locals.banners.addIfSuccess(updateResult, 'Attribute updated successfully')
const updatedAttributeId = updateResult?.data?.attribute.id ?? null
const sortBy = filters['sort-by'] const sortBy = filters['sort-by']
const sortOrder = filters['sort-order'] const sortOrder = filters['sort-order']
@@ -724,8 +722,6 @@ const makeSortUrl = (slug: NonNullable<(typeof filters)['sort-by']>) => {
</div> </div>
</BaseLayout> </BaseLayout>
<script define:vars={{ updatedAttributeId }}></script>
<style> <style>
@keyframes highlight { @keyframes highlight {
0% { 0% {

View File

@@ -1,10 +1,11 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions } from 'astro:actions' import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro' import BadgeSmall from '../../../components/BadgeSmall.astro'
import Button from '../../../components/Button.astro' import Button from '../../../components/Button.astro'
import Chat from '../../../components/Chat.astro' import Chat from '../../../components/Chat.astro'
import InputSelect from '../../../components/InputSelect.astro'
import ServiceCard from '../../../components/ServiceCard.astro' import ServiceCard from '../../../components/ServiceCard.astro'
import UserBadge from '../../../components/UserBadge.astro' import UserBadge from '../../../components/UserBadge.astro'
import { import {
@@ -13,16 +14,22 @@ import {
} from '../../../constants/serviceSuggestionStatus' } from '../../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType' import { getServiceSuggestionTypeInfo } from '../../../constants/serviceSuggestionType'
import BaseLayout from '../../../layouts/BaseLayout.astro' import BaseLayout from '../../../layouts/BaseLayout.astro'
import { cn } from '../../../lib/cn'
import { parseIntWithFallback } from '../../../lib/numbers' import { parseIntWithFallback } from '../../../lib/numbers'
import { prisma } from '../../../lib/prisma' import { prisma } from '../../../lib/prisma'
import { makeLoginUrl } from '../../../lib/redirectUrls' import { makeLoginUrl } from '../../../lib/redirectUrls'
import { formatDateShort } from '../../../lib/timeAgo'
const user = Astro.locals.user const user = Astro.locals.user
if (!user?.admin) { if (!user?.admin) {
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' })) return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Admin access required' }))
} }
const serviceSuggestionUpdateResult = Astro.getActionResult(actions.admin.serviceSuggestions.update)
Astro.locals.banners.addIfSuccess(serviceSuggestionUpdateResult, 'Service suggestion updated successfully')
const serviceSuggestionUpdateInputErrors = isInputError(serviceSuggestionUpdateResult?.error)
? serviceSuggestionUpdateResult.error.fields
: {}
const { id: serviceSuggestionIdRaw } = Astro.params const { id: serviceSuggestionIdRaw } = Astro.params
const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw) const serviceSuggestionId = parseIntWithFallback(serviceSuggestionIdRaw)
if (!serviceSuggestionId) { if (!serviceSuggestionId) {
@@ -100,114 +107,88 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
<BaseLayout <BaseLayout
pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`} pageTitle={`${serviceSuggestion.service.name} | Admin Service Suggestion`}
htmx description="View and manage service suggestion"
widthClassName="max-w-screen-md" widthClassName="max-w-screen-md"
htmx
> >
<div class="mb-4 flex items-center gap-4"> <h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1>
<Button
as="a"
href="/admin/service-suggestions"
color="success"
variant="faded"
size="md"
icon="ri:arrow-left-s-line"
label="Back"
/>
<h1 class="font-title text-day-200 text-xl">Service suggestion</h1> <ServiceCard service={serviceSuggestion.service} class="mb-6" />
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} /> <section class="border-night-400 bg-night-600 rounded-lg border p-6">
</div> <div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm sm:grid-cols-3">
<div class="flex flex-wrap items-center gap-2">
<div class="mb-6 grid grid-cols-1 gap-6 md:grid-cols-2"> <span class="font-title font-bold">Status:</span>
<div> <BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
<ServiceCard service={serviceSuggestion.service} class="mx-auto max-w-full" />
</div>
<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(
'inline-flex w-fit items-center rounded-full px-2.5 py-0.5 text-xs font-medium',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="mr-1 size-3" />
{statusInfo.label}
</span>
<span class="font-title text-gray-400">Submitted by:</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
<span class="font-title text-gray-400">Submitted at:</span>
<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="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]"
/>
</a>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Author:</span>
<UserBadge class="text-gray-300" user={serviceSuggestion.user} size="md" />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Submitted:</span>
<span>
{
formatDateShort(serviceSuggestion.createdAt, {
prefix: false,
hourPrecision: true,
caseType: 'sentence',
})
}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Service:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
</a>
</div>
</div>
<div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<span class="font-title block font-bold">Notes for moderators:</span>
{ {
serviceSuggestion.notes && ( serviceSuggestion.notes ? (
<div class="mb-4"> <div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
<h3 class="font-title mb-1 text-sm text-gray-400">Notes from user:</h3> ) : (
<div <div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
class="rounded-md border border-gray-700 bg-black/50 p-3 text-sm wrap-anywhere whitespace-pre-wrap text-gray-300"
set:text={serviceSuggestion.notes}
/>
</div>
) )
} }
</div> </div>
</div>
<div class="rounded-lg bg-black/40 p-6 backdrop-blur-xs"> <form method="POST" action={actions.admin.serviceSuggestions.update} class="mt-6 flex items-end gap-2">
<div class="flex items-center justify-between"> <input type="hidden" name="suggestionId" value={serviceSuggestion.id} />
<h2 class="font-title text-day-200 text-lg">Messages</h2> <InputSelect
name="status"
label="Update status"
options={serviceSuggestionStatuses.map((status) => ({
label: status.label,
value: status.value,
}))}
selectProps={{ value: serviceSuggestion.status }}
class="flex-1"
error={serviceSuggestionUpdateInputErrors.status}
/>
<Button as="button" type="submit" color="success" size="md" icon="ri:save-line" label="Update" />
</form>
</section>
<form method="POST" action={actions.admin.serviceSuggestions.update} class="flex gap-2"> <Chat
<input type="hidden" name="suggestionId" value={serviceSuggestion.id} /> messages={serviceSuggestion.messages}
<select title="Chat with moderators"
name="status" userId={user.id}
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-sm text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500 disabled:opacity-50" action={actions.admin.serviceSuggestions.message}
> formData={{
{ suggestionId: serviceSuggestion.id,
serviceSuggestionStatuses.map((status) => ( }}
<option value={status.value} selected={serviceSuggestion.status === status.value}> class="mt-12"
{status.label} />
</option>
))
}
</select>
<Button
as="button"
type="submit"
color="success"
variant="faded"
size="md"
icon="ri:save-line"
label="Update"
/>
</form>
</div>
<Chat
messages={serviceSuggestion.messages}
userId={user.id}
action={actions.admin.serviceSuggestions.message}
formData={{
suggestionId: serviceSuggestion.id,
}}
/>
</div>
</BaseLayout> </BaseLayout>

View File

@@ -37,12 +37,8 @@ if (!user?.admin) {
const { data: filters } = zodParseQueryParamsStoringErrors( const { data: filters } = zodParseQueryParamsStoringErrors(
{ {
search: z.string().optional(), search: z.string().optional(),
status: serviceSuggestionStatusesZodEnumBySlug status: serviceSuggestionStatusesZodEnumBySlug.or(z.literal('all')).default('pending'),
.transform((slug) => serviceSuggestionStatusSlugToId(slug)) type: serviceSuggestionTypesZodEnumBySlug.or(z.literal('all')).optional(),
.optional(),
type: serviceSuggestionTypesZodEnumBySlug
.transform((slug) => serviceSuggestionTypeSlugToId(slug))
.optional(),
'sort-by': z 'sort-by': z
.enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount']) .enum(['service', 'status', 'type', 'user', 'createdAt', 'messageCount'])
.default('createdAt'), .default('createdAt'),
@@ -51,6 +47,11 @@ const { data: filters } = zodParseQueryParamsStoringErrors(
Astro Astro
) )
const statusFilter = filters.status === 'all' ? undefined : serviceSuggestionStatusSlugToId(filters.status)
const typeFilter =
!filters.type || filters.type === 'all' ? undefined : serviceSuggestionTypeSlugToId(filters.type)
let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' } let prismaOrderBy: Prisma.ServiceSuggestionOrderByWithRelationInput = { createdAt: 'desc' }
if (filters['sort-by'] === 'createdAt') { if (filters['sort-by'] === 'createdAt') {
prismaOrderBy = { createdAt: filters['sort-order'] } prismaOrderBy = { createdAt: filters['sort-order'] }
@@ -67,8 +68,8 @@ let suggestions = await prisma.serviceSuggestion.findMany({
], ],
} }
: {}), : {}),
status: filters.status, status: statusFilter,
type: filters.type, type: typeFilter,
}, },
orderBy: prismaOrderBy, orderBy: prismaOrderBy,
select: { select: {
@@ -198,10 +199,10 @@ const makeSortUrl = (slug: string) => {
id="status-filter" id="status-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" 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" selected={filters.status === 'all'}>All Statuses</option>
{ {
serviceSuggestionStatuses.map((status) => ( serviceSuggestionStatuses.map((status) => (
<option value={status.slug} selected={filters.status === status.value}> <option value={status.slug} selected={filters.status === status.slug}>
{status.label} {status.label}
</option> </option>
)) ))
@@ -215,10 +216,10 @@ const makeSortUrl = (slug: string) => {
id="type-filter" 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" 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> <option value="all" selected={!filters.type || filters.type === 'all'}>All Types</option>
{ {
serviceSuggestionTypes.map((type) => ( serviceSuggestionTypes.map((type) => (
<option value={type.slug} selected={filters.type === type.value}> <option value={type.slug} selected={filters.type === type.slug}>
{type.label} {type.label}
</option> </option>
)) ))

View File

@@ -1141,8 +1141,21 @@ const apiCalls = await Astro.locals.banners.try(
<input type="hidden" name="serviceId" value={service.id} /> <input type="hidden" name="serviceId" value={service.id} />
<InputText <InputText
label="Override Label (Optional)" label="Value"
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
name="value"
inputProps={{
required: true,
value: method.value,
placeholder: 'contact@example.com',
}}
error={contactMethodUpdateInputErrors.value}
/>
<InputText
label="Label"
name="label" name="label"
description="Leave empty to auto-generate"
inputProps={{ inputProps={{
value: method.label, value: method.label,
placeholder: contactMethodInfo.formattedValue, placeholder: contactMethodInfo.formattedValue,
@@ -1150,16 +1163,6 @@ const apiCalls = await Astro.locals.banners.try(
error={contactMethodUpdateInputErrors.label} error={contactMethodUpdateInputErrors.label}
/> />
<InputText
label="Value (with protocol)"
name="value"
inputProps={{
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 /> <InputSubmitButton label="Update" icon="ri:save-line" hideCancel />
</form> </form>
</div> </div>
@@ -1174,8 +1177,6 @@ const apiCalls = await Astro.locals.banners.try(
<form method="POST" action={actions.admin.service.contactMethod.add} 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} /> <input type="hidden" name="serviceId" value={service.id} />
<InputText label="Override Label" name="label" />
<InputText <InputText
label="Value" label="Value"
description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`} description={`Accepts: ${contactMethodUrlTypes.map((type) => type.labelPlural).join(', ')}`}
@@ -1187,6 +1188,13 @@ const apiCalls = await Astro.locals.banners.try(
error={contactMethodAddInputErrors.value} error={contactMethodAddInputErrors.value}
/> />
<InputText
label="Label"
description="Leave empty to auto-generate"
name="label"
inputProps={{ placeholder: 'Auto-generated' }}
/>
<InputSubmitButton label="Add" icon="ri:add-line" hideCancel /> <InputSubmitButton label="Add" icon="ri:add-line" hideCancel />
</form> </form>
</FormSubSection> </FormSubSection>

View File

@@ -27,11 +27,11 @@ Fetches details for a single service by various lookup criteria.
### Request Parameters ### Request Parameters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| ------------ | ------ | -------- | ----------- | | ------------ | ------ | -------- | --------------------------------------------------------------------------- |
| `id` | number | No* | Service ID | | `id` | number | No\* | Service ID |
| `slug` | string | No* | Service URL slug (lowercase letters, numbers, and hyphens only) | | `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. | | `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. \* At least one of the marked parameters is required.
@@ -79,41 +79,46 @@ type ServiceResponse = {
#### KYC Levels #### KYC Levels
<ul> <ul>
{kycLevels.map((level) => ( {kycLevels.map((level) => (
<li key={level.id}> <li key={level.id}>
<strong>{level.id}</strong>: {level.name} - {level.description} <strong>{level.id}</strong>: {level.name} - {level.description}
</li> </li>
))} ))}
</ul> </ul>
#### Verification Status #### Verification Status
<ul> <ul>
{verificationStatuses.map((status) => ( {verificationStatuses.map((status) => (
<li key={status.value}> <li key={status.value}>
<strong>{status.value}</strong>: {status.description} <strong>{status.value}</strong>: {status.description}
</li> </li>
))} ))}
</ul> </ul>
#### Service Visibility #### Service Visibility
<ul> <ul>
{serviceVisibilities.filter((visibility) => visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED').map((visibility) => ( {serviceVisibilities
<li key={visibility.value}> .filter(
<strong>{visibility.value}</strong>: {visibility.longDescription} (visibility) =>
</li> visibility.value === 'PUBLIC' || visibility.value === 'ARCHIVED' || visibility.value === 'UNLISTED'
))} )
.map((visibility) => (
<li key={visibility.value}>
<strong>{visibility.value}</strong>: {visibility.longDescription}
</li>
))}
</ul> </ul>
#### KYC Level Clarifications #### KYC Level Clarifications
<ul> <ul>
{kycLevelClarifications.map((clarification) => ( {kycLevelClarifications.map((clarification) => (
<li key={clarification.value}> <li key={clarification.value}>
<strong>{clarification.value}</strong>: {clarification.description} <strong>{clarification.value}</strong>: {clarification.description}
</li> </li>
))} ))}
</ul> </ul>
### Examples ### Examples

View File

@@ -333,6 +333,8 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
where, where,
select: { select: {
id: true, id: true,
serviceVisibility: true,
verificationStatus: true,
...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record< ...(Object.fromEntries(sortOptions.map((option) => [option.orderBy.key, true])) as Record<
(typeof sortOptions)[number]['orderBy']['key'], (typeof sortOptions)[number]['orderBy']['key'],
true true
@@ -354,10 +356,18 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
sortBy(unsortedServices, 'id'), sortBy(unsortedServices, 'id'),
[ [
...(filters.q ? (['similarityScore'] as const) : ([] as const)), ...(filters.q ? (['similarityScore'] as const) : ([] as const)),
(service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0),
(service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0),
selectedSort.orderBy.key, selectedSort.orderBy.key,
() => rng(), () => rng(),
], ],
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc'] [
...(filters.q ? (['desc'] as const) : ([] as const)),
'asc',
'asc',
selectedSort.orderBy.direction,
'asc',
]
).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE) ).slice((filters.page - 1) * PAGE_SIZE, filters.page * PAGE_SIZE)
const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({ const unsortedServicesWithInfoMissingSimilarityScore = await prisma.service.findMany({
@@ -410,11 +420,19 @@ const [categories, [services, totalServices], countCommunityOnly, attributes] =
unsortedServicesWithInfo, unsortedServicesWithInfo,
[ [
...(filters.q ? (['similarityScore'] as const) : ([] as const)), ...(filters.q ? (['similarityScore'] as const) : ([] as const)),
(service) => (service.verificationStatus === 'VERIFICATION_FAILED' ? 1 : 0),
(service) => (service.serviceVisibility === 'ARCHIVED' ? 1 : 0),
selectedSort.orderBy.key, selectedSort.orderBy.key,
// Now we can shuffle indeternimistically, because the pagination was already applied // Now we can shuffle indeternimistically, because the pagination was already applied
() => Math.random(), () => Math.random(),
], ],
[...(filters.q ? (['desc'] as const) : ([] as const)), selectedSort.orderBy.direction, 'asc'] [
...(filters.q ? (['desc'] as const) : ([] as const)),
'asc',
'asc',
selectedSort.orderBy.direction,
'asc',
]
) )
return [sortedServicesWithInfo, totalServices] as const return [sortedServicesWithInfo, totalServices] as const

View File

@@ -9,6 +9,7 @@ export const GET: APIRoute = () => {
{ {
status: 200, status: 200,
headers: { headers: {
'Access-Control-Allow-Origin': '*',
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
} }

View File

@@ -0,0 +1,74 @@
import { SITE_URL } from 'astro:env/client'
import { getRedisServerEvents } from '../../lib/redis/redisServerEvents'
import type { ServerEventsEvent } from '../../lib/serverEventsTypes'
import type { APIRoute } from 'astro'
const redisServerEvents = await getRedisServerEvents()
export const GET: APIRoute = ({ request, locals }) => {
const user = locals.user
let cleanup: (() => Promise<void>) | null = null
const stream = new ReadableStream({
async start(controller) {
function sendEvent(event: ServerEventsEvent) {
try {
controller.enqueue(encodeSSE(event))
} catch (error) {
console.error('Failed to send SSE event:', event.type, error)
}
}
try {
sendEvent({ type: 'new-connection', data: { timestamp: new Date().toISOString() } })
cleanup = user
? await redisServerEvents.addListener('all', user.id, (event) => {
sendEvent(event)
})
: null
async function abort() {
try {
await cleanup?.()
controller.close()
} catch (error) {
console.error('Failed to cleanup SSE connection:', error)
}
}
request.signal.addEventListener('abort', () => {
void abort()
})
} catch (error) {
console.error('Failed to start SSE stream:', error)
controller.error(error)
}
},
async cancel() {
try {
await cleanup?.()
} catch (error) {
console.error('Failed to cleanup on cancel:', error)
}
},
})
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
'Access-Control-Allow-Origin': new URL(SITE_URL).origin,
'Access-Control-Allow-Headers': 'Cache-Control',
},
})
}
function encodeSSE(event: ServerEventsEvent) {
return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`)
}

View File

@@ -11,7 +11,7 @@ import { getNotificationTypeInfo } from '../constants/notificationTypes'
import BaseLayout from '../layouts/BaseLayout.astro' import BaseLayout from '../layouts/BaseLayout.astro'
import { cn } from '../lib/cn' import { cn } from '../lib/cn'
import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences' import { getOrCreateNotificationPreferences } from '../lib/notificationPreferences'
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from '../lib/notifications' import { makeNotificationActions, makeNotificationContent, makeNotificationTitle } from '../lib/notifications'
import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters' import { zodParseQueryParamsStoringErrors } from '../lib/parseUrlFilters'
import { prisma } from '../lib/prisma' import { prisma } from '../lib/prisma'
import { makeLoginUrl } from '../lib/redirectUrls' import { makeLoginUrl } from '../lib/redirectUrls'
@@ -199,7 +199,7 @@ const notifications = dbNotifications.map((notification) => ({
typeInfo: getNotificationTypeInfo(notification.type), typeInfo: getNotificationTypeInfo(notification.type),
title: makeNotificationTitle(notification, user), title: makeNotificationTitle(notification, user),
content: makeNotificationContent(notification), content: makeNotificationContent(notification),
link: makeNotificationLink(notification, Astro.url.origin), actions: makeNotificationActions(notification, Astro.url.origin),
})) }))
--- ---
@@ -245,6 +245,22 @@ const notifications = dbNotifications.map((notification) => ({
</form> </form>
</div> </div>
<div
class="mb-4 flex items-center gap-2 rounded-lg border border-blue-500/20 bg-blue-500/10 p-3 text-blue-200"
data-new-notification-banner
style={{ display: 'none' }}
>
<span>You received a new notification</span>
<Button
type="button"
label="Reload"
icon="ri:refresh-line"
color="white"
class="ml-auto"
onclick="window.location.reload()"
/>
</div>
{ {
notifications.length === 0 ? ( notifications.length === 0 ? (
<div class="flex flex-col items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900 p-10 text-center shadow-sm"> <div class="flex flex-col items-center justify-center rounded-lg border border-zinc-800 bg-zinc-900 p-10 text-center shadow-sm">
@@ -285,18 +301,21 @@ const notifications = dbNotifications.map((notification) => ({
type="submit" type="submit"
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200" class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
> >
<Icon name={notification.read ? 'ri:eye-close-line' : 'ri:eye-line'} class="size-4" /> <Icon name={notification.read ? 'ri:close-line' : 'ri:check-line'} class="size-4" />
</Tooltip> </Tooltip>
</form> </form>
{notification.link && ( {notification.actions.map((action) => (
<a <Tooltip
href={notification.link} as="a"
href={action.url}
class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200" class="flex size-8 items-center justify-center rounded-full border border-zinc-700 bg-zinc-800 text-zinc-400 transition-colors duration-200 hover:bg-zinc-700 hover:text-zinc-200"
text={action.title}
position="left"
> >
<Icon name="ri:arrow-right-line" class="size-4" /> <Icon name={action.iconName ?? 'ri:arrow-right-line'} class="size-4" />
<span class="sr-only">View details</span> <span class="sr-only">{action.title}</span>
</a> </Tooltip>
)} ))}
</div> </div>
</div> </div>
</div> </div>
@@ -387,3 +406,11 @@ const notifications = dbNotifications.map((notification) => ({
</form> </form>
</section> </section>
</BaseLayout> </BaseLayout>
<script>
document.addEventListener('sse-new-notification', () => {
document.querySelectorAll<HTMLElement>('[data-new-notification-banner]').forEach((banner) => {
banner.style.display = ''
})
})
</script>

View File

@@ -10,7 +10,6 @@ import ServiceCard from '../../components/ServiceCard.astro'
import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus' import { getServiceSuggestionStatusInfo } from '../../constants/serviceSuggestionStatus'
import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType' import { getServiceSuggestionTypeInfo } from '../../constants/serviceSuggestionType'
import BaseLayout from '../../layouts/BaseLayout.astro' import BaseLayout from '../../layouts/BaseLayout.astro'
import { cn } from '../../lib/cn'
import { parseIntWithFallback } from '../../lib/numbers' import { parseIntWithFallback } from '../../lib/numbers'
import { prisma } from '../../lib/prisma' import { prisma } from '../../lib/prisma'
import { makeLoginUrl } from '../../lib/redirectUrls' import { makeLoginUrl } from '../../lib/redirectUrls'
@@ -111,9 +110,9 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
}, },
]} ]}
> >
<div class="mt-12 mb-6 flex flex-col items-center justify-center gap-3"> <div class="mt-12 mb-6 text-center">
<div class="flex items-center gap-2"> <h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} /> <div class="flex items-center justify-center gap-2">
<AdminOnly> <AdminOnly>
<Button <Button
as="a" as="a"
@@ -124,29 +123,24 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
/> />
</AdminOnly> </AdminOnly>
</div> </div>
<h1 class="font-title text-center text-3xl font-bold">Service suggestion</h1>
</div> </div>
<ServiceCard service={serviceSuggestion.service} class="mb-6" /> <ServiceCard service={serviceSuggestion.service} class="mb-6" />
<section class="border-night-400 bg-night-600 rounded-lg border p-6"> <section class="border-night-400 bg-night-600 rounded-lg border p-6">
<div class="text-day-200 grid grid-cols-2 gap-6 text-sm"> <div class="text-day-200 xs:grid-cols-2 grid gap-2 text-sm">
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span>Status:</span> <span class="font-title font-bold">Status:</span>
<span <BadgeSmall color={statusInfo.color} text={statusInfo.label} icon={statusInfo.icon} />
class={cn(
'border-night-500 bg-night-800 box-content inline-flex h-8 items-center justify-center gap-1 rounded-full border px-2',
statusInfo.iconClass
)}
>
<Icon name={statusInfo.icon} class="size-4" />
{statusInfo.label}
</span>
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<span>Submitted:</span> <span class="font-title font-bold">Type:</span>
<BadgeSmall color={typeInfo.color} text={typeInfo.label} icon={typeInfo.icon} />
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Submitted:</span>
<span> <span>
{ {
formatDateShort(serviceSuggestion.createdAt, { formatDateShort(serviceSuggestion.createdAt, {
@@ -157,15 +151,22 @@ const typeInfo = getServiceSuggestionTypeInfo(serviceSuggestion.type)
} }
</span> </span>
</div> </div>
<div class="flex flex-wrap items-center gap-2">
<span class="font-title font-bold">Service:</span>
<a href={`/service/${serviceSuggestion.service.slug}`} class="hover:text-day-200 text-green-400">
Open <Icon name="ri:external-link-line" class="ml-0.5 inline-block size-3 align-[-0.05em]" />
</a>
</div>
</div> </div>
<div class="mt-6"> <div class="bg-night-700 -mx-2 mt-4 rounded-lg p-2 text-sm">
<div class="text-day-200 mb-2 text-sm">Notes for moderators:</div> <span class="font-title block font-bold">Notes for moderators:</span>
{ {
serviceSuggestion.notes ? ( serviceSuggestion.notes ? (
<div class="text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} /> <div class="mt-1 text-sm wrap-anywhere whitespace-pre-wrap" set:text={serviceSuggestion.notes} />
) : ( ) : (
<span class="text-sm italic">Empty</span> <div class="text-day-400 my-4 text-center text-sm italic">Empty</div>
) )
} }
</div> </div>

View File

@@ -1,6 +1,6 @@
--- ---
import { Icon } from 'astro-icon/components' import { Icon } from 'astro-icon/components'
import { actions, isInputError } from 'astro:actions' import { actions } from 'astro:actions'
import { orderBy } from 'lodash-es' import { orderBy } from 'lodash-es'
import { import {
@@ -36,7 +36,7 @@ const result = Astro.getActionResult(actions.serviceSuggestion.createService)
if (result && !result.error && !result.data.hasDuplicates) { if (result && !result.error && !result.data.hasDuplicates) {
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`) return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
} }
const inputErrors = isInputError(result?.error) ? result.error.fields : {} const inputErrors = result?.error?.code === 'VALIDATION_ERROR' ? result.error.fields : {}
const [categories, attributes] = await Astro.locals.banners.tryMany([ const [categories, attributes] = await Astro.locals.banners.tryMany([
[ [
@@ -89,7 +89,11 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
}, },
]} ]}
> >
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Service suggestion</h1> <h1 class="font-title mt-12 text-center text-3xl font-bold">Service suggestion</h1>
<p class="text-day-400 mb-6 text-center text-sm">
Suggestions are reviewed by moderators before being public.
</p>
<form <form
method="POST" method="POST"
@@ -345,6 +349,17 @@ const [categories, attributes] = await Astro.locals.banners.tryMany([
<Captcha action={actions.serviceSuggestion.createService} /> <Captcha action={actions.serviceSuggestion.createService} />
<div>
<div class="flex items-center gap-2 text-lg">
<input type="checkbox" name="rulesConfirm" id="rules-confirm" required />
<label for="rules-confirm">
I understand the
<a class="underline" target="_blank" href="/about#listings">suggestion rules and process</a>
</label>
</div>
{inputErrors.rulesConfirm && <p class="mt-1 text-sm text-red-500">{inputErrors.rulesConfirm?.[0]}</p>}
</div>
<InputHoneypotTrap name="message" /> <InputHoneypotTrap name="message" />
<InputSubmitButton label={result?.data?.hasDuplicates ? 'Submit anyway' : 'Submit'} /> <InputSubmitButton label={result?.data?.hasDuplicates ? 'Submit anyway' : 'Submit'} />

View File

@@ -9,7 +9,6 @@ import { head, orderBy, pick, shuffle, sortBy, tail } from 'lodash-es'
import AdminOnly from '../../components/AdminOnly.astro' import AdminOnly from '../../components/AdminOnly.astro'
import BadgeSmall from '../../components/BadgeSmall.astro' import BadgeSmall from '../../components/BadgeSmall.astro'
import BadgeStandard from '../../components/BadgeStandard.astro' import BadgeStandard from '../../components/BadgeStandard.astro'
import Button from '../../components/Button.astro'
import CommentSection from '../../components/CommentSection.astro' import CommentSection from '../../components/CommentSection.astro'
import CommentSummary from '../../components/CommentSummary.astro' import CommentSummary from '../../components/CommentSummary.astro'
import DropdownButton from '../../components/DropdownButton.astro' import DropdownButton from '../../components/DropdownButton.astro'
@@ -411,6 +410,8 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
const activeAlertOrWarningEvents = service.events.filter( const activeAlertOrWarningEvents = service.events.filter(
(event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now) (event) => getEventTypeInfo(event.type).showBanner && (event.endedAt === null || event.endedAt >= now)
) )
const activeEventToShow =
activeAlertOrWarningEvents.find((event) => event.type === EventType.ALERT) ?? activeAlertOrWarningEvents[0]
--- ---
<BaseLayout <BaseLayout
@@ -512,27 +513,23 @@ const activeAlertOrWarningEvents = service.events.filter(
]} ]}
> >
{ {
activeAlertOrWarningEvents.length > 0 && ( !!activeEventToShow && (
<a <a
href="#events" href="#events"
class={cn( class={cn(
'mb-4 block rounded-md px-3 py-2 text-sm font-medium', 'group mb-4 block rounded-md px-3 py-2 text-sm transition-colors duration-200',
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT) activeEventToShow.type === EventType.ALERT
? 'bg-red-900/50 text-red-300 hover:bg-red-800/60' ? 'bg-red-900/50 text-red-300 hover:bg-red-800/60 focus-visible:bg-red-800/60'
: 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60' : 'bg-yellow-900/50 text-yellow-300 hover:bg-yellow-800/60 focus-visible:bg-yellow-800/60'
)} )}
> >
<Icon <Icon
name={ name={activeEventToShow.type === EventType.ALERT ? 'ri:alert-fill' : 'ri:alarm-warning-fill'}
activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT)
? 'ri:alert-fill'
: 'ri:alarm-warning-fill'
}
class="me-1.5 inline-block size-4 align-[-0.15em]" class="me-1.5 inline-block size-4 align-[-0.15em]"
/> />
{activeAlertOrWarningEvents.some((e) => e.type === EventType.ALERT) <span class="font-bold">{activeEventToShow.title}</span> — {activeEventToShow.content}
? 'There is an active alert for this service. Click to see details.' {activeAlertOrWarningEvents.length >= 2 && <>+{activeAlertOrWarningEvents.length - 1} more events.</>}
: 'There is an active warning for this service. Click to see details.'} <span class="underline">Go to events</span>
</a> </a>
) )
} }
@@ -1081,105 +1078,119 @@ const activeAlertOrWarningEvents = service.events.filter(
</h2> </h2>
{ {
service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? ( service.verificationStatus === 'VERIFICATION_SUCCESS' || service.verificationStatus === 'APPROVED' ? (
service.tosReview ? ( service.tosReviewAt !== null ? (
<> service.tosReview ? (
<Schema <>
item={ <Schema
{ item={
'@context': 'https://schema.org', {
'@type': 'Review', '@context': 'https://schema.org',
itemReviewed: { '@id': itemReviewedId }, '@type': 'Review',
reviewAspect: 'Terms of Service', itemReviewed: { '@id': itemReviewedId },
name: 'Terms of Service Review', reviewAspect: 'Terms of Service',
reviewBody: service.tosReview.summary, name: 'Terms of Service Review',
datePublished: service.tosReviewAt?.toISOString(), reviewBody: service.tosReview.summary,
author: KYCNOTME_SCHEMA_MINI, datePublished: service.tosReviewAt.toISOString(),
positiveNotes: service.tosReview.highlights author: KYCNOTME_SCHEMA_MINI,
.filter((h) => h.rating === 'positive') positiveNotes: service.tosReview.highlights
.map( .filter((h) => h.rating === 'positive')
(h, i) => .map(
({ (h, i) =>
'@type': 'ListItem', ({
position: i + 1, '@type': 'ListItem',
name: h.title, position: i + 1,
description: h.content, name: h.title,
}) satisfies ListItem description: h.content,
), }) satisfies ListItem
negativeNotes: service.tosReview.highlights ),
.filter((h) => h.rating === 'negative') negativeNotes: service.tosReview.highlights
.map( .filter((h) => h.rating === 'negative')
(h, i) => .map(
({ (h, i) =>
'@type': 'ListItem', ({
position: i + 1, '@type': 'ListItem',
name: h.title, position: i + 1,
description: h.content, name: h.title,
}) satisfies ListItem description: h.content,
), }) satisfies ListItem
} satisfies WithContext<Review> ),
} } satisfies WithContext<Review>
/> }
/>
<div class="flex flex-col sm:flex-row sm:gap-6"> <div class="flex flex-col sm:flex-row sm:gap-6">
<div class="prose prose-invert prose-sm bg-night-800 max-w-none flex-1 rounded-lg px-4 py-2 text-pretty sm:px-6 sm:py-4"> <div class="prose prose-invert prose-sm bg-night-800 max-w-none flex-1 rounded-lg px-4 py-2 text-pretty sm:px-6 sm:py-4">
{service.tosReview.summary ? ( {service.tosReview.summary ? (
<Markdown content={service.tosReview.summary} /> <Markdown content={service.tosReview.summary} />
) : ( ) : (
<p>No summary available</p> <p>No summary available</p>
)}
</div>
</div>
<div class="mt-6 grid gap-4 sm:grid-cols-2 sm:gap-6">
{sortBy(
service.tosReview.highlights.map((highlight) => ({
...highlight,
ratingInfo: getTosHighlightRatingInfo(highlight.rating),
})),
'ratingInfo.order'
).map((highlight) => (
<div
class={cn(
'border-l-2 border-l-transparent pl-2 text-pretty',
highlight.ratingInfo.classNames.borderColor
)}
>
<h3 class="font-title font-bold">
<Icon
name={highlight.ratingInfo.icon}
class={cn(
highlight.ratingInfo.classNames.icon,
'me-0.5 inline-block size-4 align-[-0.1em]'
)}
/>
<span>{highlight.title}</span>
</h3>
{!!highlight.content && (
<div class="prose prose-invert prose-sm">
<Markdown content={highlight.content} />
</div>
)} )}
</div> </div>
))} </div>
</div>
<p class="text-day-400 mt-4 text-center text-xs"> <div class="mt-6 grid gap-4 sm:grid-cols-2 sm:gap-6">
{!!service.tosReviewAt && ( {sortBy(
<> service.tosReview.highlights.map((highlight) => ({
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision /> ...highlight,
</> ratingInfo: getTosHighlightRatingInfo(highlight.rating),
})),
'ratingInfo.order'
).map((highlight) => (
<div
class={cn(
'border-l-2 border-l-transparent pl-2 text-pretty',
highlight.ratingInfo.classNames.borderColor
)}
>
<h3 class="font-title font-bold">
<Icon
name={highlight.ratingInfo.icon}
class={cn(
highlight.ratingInfo.classNames.icon,
'me-0.5 inline-block size-4 align-[-0.1em]'
)}
/>
<span>{highlight.title}</span>
</h3>
{!!highlight.content && (
<div class="prose prose-invert prose-sm">
<Markdown content={highlight.content} />
</div>
)}
</div>
))}
</div>
<p class="text-day-400 mt-4 text-center text-xs">
Reviewed <TimeFormatted date={service.tosReviewAt} hourPrecision />
{service.tosUrls.length > 0 && 'from'}
{service.tosUrls.map((url) => (
<a href={url} class="cursor-pointer hover:underline">
{urlDomain(url)}
</a>
))}
</p>
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
ToS reviews are AI-generated and should be used as a reference only.
</p>
</>
) : (
<div class="text-day-400 my-12 text-center">
<p>Can't analyze ToS with AI</p>
<p class="text-day-500 mt-1 text-sm text-balance">
Maybe due to captchas, client side rendering, DDoS protections, or non-text format.
</p>
{service.tosUrls.length > 0 && (
<p class="mt-2 text-xs">
{service.tosUrls.map((url) => (
<a href={url} class="hover:underline">
{urlDomain(url)}
</a>
))}
</p>
)} )}
{!!service.tosReviewAt && !!service.tosUrls.length && 'from'} </div>
{service.tosUrls.map((url) => ( )
<a href={url} class="cursor-pointer hover:underline">
{urlDomain(url)}
</a>
))}
</p>
<p class="text-day-600 hover:text-day-400 mb-2 text-center text-xs duration-400">
ToS reviews are AI-generated and should be used as a reference only.
</p>
</>
) : ( ) : (
<div class="text-day-400 my-12 text-center"> <div class="text-day-400 my-12 text-center">
<p>Not reviewed yet</p> <p>Not reviewed yet</p>
@@ -1332,77 +1343,6 @@ const activeAlertOrWarningEvents = service.events.filter(
</div> </div>
) )
} }
{
service.verificationSteps.length > 0 && (
<div class="border-night-500 mt-2 border-t pt-3">
<h3 class="font-title mb-2 flex items-center gap-1 text-sm font-semibold">
<Icon name="ri:checkbox-multiple-line" class="size-4" />
Verification Steps
</h3>
<ul class="grid gap-2 text-xs">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li>
<details class="group/step">
<summary class="border-night-600 hover:bg-night-700/50 relative flex cursor-pointer items-center gap-2 rounded p-1.5 select-none">
<div
class={cn('h-1.5 w-1.5 rounded-full', {
'bg-gray-400 shadow-[0_0_3px_1px_rgba(156,163,175,0.7)]':
step.status === VerificationStepStatus.PENDING,
'bg-blue-400 shadow-[0_0_4px_1px_rgba(96,165,250,0.8)]':
step.status === VerificationStepStatus.IN_PROGRESS,
'bg-green-400 shadow-[0_0_4px_1px_rgba(34,197,94,0.8)]':
step.status === VerificationStepStatus.PASSED,
'bg-red-400 shadow-[0_0_4px_1px_rgba(248,113,113,0.8)]':
step.status === VerificationStepStatus.FAILED,
})}
/>
<div class="flex flex-1 items-center">
<strong class="mr-1 font-medium text-white">{step.title}</strong>
<span class="text-day-400 mr-1">•</span>
<span
class={cn('mr-2 font-bold', {
'text-gray-400': step.status === VerificationStepStatus.PENDING,
'text-blue-400': step.status === VerificationStepStatus.IN_PROGRESS,
'text-green-400': step.status === VerificationStepStatus.PASSED,
'text-red-400': step.status === VerificationStepStatus.FAILED,
})}
>
{statusInfo.text}
</span>
<Icon
name="ri:arrow-down-s-line"
class="text-day-400 size-3.5 transition-transform group-open/step:rotate-180"
/>
</div>
</summary>
<div class="border-night-600 mt-1 ml-3.5 border-l pb-1 pl-3">
<p class="text-day-400 text-2xs mb-1 font-medium">
Added
<TimeFormatted date={step.createdAt} hourPrecision />
</p>
{step.description && (
<div class="text-day-300 my-1 text-xs">{step.description}</div>
)}
{step.evidenceMd && (
<div class="bg-night-900 mt-2 rounded p-2">
<div class="text-day-400 text-2xs mb-1 font-medium">Evidence:</div>
<div class="prose prose-invert prose-xs prose-img:max-w-full text-[11px] leading-relaxed">
<Markdown content={step.evidenceMd} />
</div>
</div>
)}
</div>
</details>
</li>
)
})}
</ul>
</div>
)
}
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
@@ -1456,89 +1396,64 @@ const activeAlertOrWarningEvents = service.events.filter(
</form> </form>
) )
} }
{
service.verificationProofMd && (
<Button
as="label"
for="show-verification-proof"
icon="ri:file-text-line"
label="Verification audit"
color="white"
/>
)
}
</div> </div>
</div> </div>
{
service.verificationProofMd && (
<>
<input type="checkbox" id="show-verification-proof" class="peer sr-only" />
<div class="bg-night-800 mt-4 hidden rounded-md p-4 peer-checked:block" id="verification-proof">
<div class="flex items-center justify-between">
<h3 class="font-title text-lg font-semibold">Verification proof</h3>
<Button as="label" for="show-verification-proof" label="Close" icon="ri:close-line" size="sm" />
</div>
<div class="prose prose-invert prose-sm mt-4 max-w-none">
<Markdown content={service.verificationProofMd} />
</div>
{service.verificationSteps.length > 0 && (
<div class="mt-6">
<h4 class="font-title text-md mb-3 font-semibold">Verification Steps</h4>
<ul class="-mb-4">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li class="flex pb-4">
<div class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col">
<div class="flex items-center gap-1.5">
<BadgeSmall
text={statusInfo.text}
icon={statusInfo.icon}
color={statusInfo.color}
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
class={cn(
{ 'animate-spin': statusInfo.icon === 'ri:loader-4-line' },
'shrink-0'
)}
/>
<strong class="text-sm leading-tight font-semibold text-white">
{step.title}
</strong>
</div>
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
</div>
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
<TimeFormatted date={step.updatedAt} prefix={false} />
</p>
</div>
{step.evidenceMd && (
<details class="group/evidence mt-2 text-xs">
<summary class="text-day-400 hover:text-day-200 cursor-pointer py-0.5 text-[11px] font-medium">
Show Evidence
</summary>
<div class="prose prose-invert prose-xs bg-night-900 mt-1.5 rounded p-2 text-[11px] leading-relaxed group-open/evidence:block">
<Markdown content={step.evidenceMd} />
</div>
</details>
)}
</div>
</li>
)
})}
</ul>
</div>
)}
</div>
</>
)
}
</div> </div>
{
service.verificationProofMd && (
<>
<h3 class="font-title mt-4 text-lg font-semibold">Verification proof</h3>
<div class="prose prose-invert prose-sm max-w-none">
<Markdown content={service.verificationProofMd} />
</div>
</>
)
}
{
service.verificationSteps.length > 0 && (
<>
<h3 class="font-title text-md mt-6 mb-2 font-semibold">Verification Steps</h3>
<ul class="mb-8 space-y-2">
{service.verificationSteps.map((step) => {
const statusInfo = getVerificationStepStatusInfo(step.status)
return (
<li class="border-night-500 bg-night-700/50 flex-grow rounded-md border p-2.5 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="flex flex-col">
<div class="text-sm leading-tight font-semibold text-white">
<BadgeSmall
text={statusInfo.text}
icon={statusInfo.icon}
color={statusInfo.color}
inlineIcon={statusInfo.icon === 'ri:loader-4-line'}
classNames={{
icon: cn(statusInfo.icon === 'ri:loader-4-line' && 'animate-spin'),
}}
class="me-1"
/>
{step.title}
</div>
<p class="text-day-300 mt-1.5 text-xs">{step.description}</p>
</div>
<p class="text-2xs text-day-400 shrink-0 pt-0.5 whitespace-nowrap">
<TimeFormatted date={step.updatedAt} prefix={false} />
</p>
</div>
{step.evidenceMd && (
<div class="prose prose-invert prose-xs mt-2 block max-w-none text-xs leading-relaxed">
<Markdown content={step.evidenceMd} />
</div>
)}
</li>
)
})}
</ul>
</>
)
}
<h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments"> <h2 class="font-title border-day-500 text-day-200 mt-2 mb-3 border-b text-lg font-bold" id="comments">
Comments Comments

21
web/src/pwa.ts Normal file
View File

@@ -0,0 +1,21 @@
import { registerSW } from 'virtual:pwa-register'
declare global {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
interface Window {
__SW_REGISTRATION__?: ServiceWorkerRegistration
}
}
export const updateSW = registerSW({
immediate: true,
onRegisteredSW: (_swScriptUrl, registration) => {
if (registration) window.__SW_REGISTRATION__ = registration
},
onNeedRefresh: () => {
void updateSW(true)
},
onRegisterError: (error) => {
console.error('Service Worker registration error', error)
},
})

9
web/src/server-init.ts Normal file
View File

@@ -0,0 +1,9 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import { startListener } from './lib/postgresListeners'
await startListener({
error: console.error,
warn: console.warn,
info: () => {},
debug: () => {},
})

95
web/src/sw.ts Normal file
View File

@@ -0,0 +1,95 @@
/* eslint-disable @typescript-eslint/prefer-nullish-coalescing */
/// <reference types="vite/client" />
/// <reference lib="webworker" />
import { clientsClaim } from 'workbox-core'
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
import { makeNotificationOptions } from './lib/notificationOptions'
import type { NotificationData, NotificationPayload } from './lib/serverEventsTypes'
declare const self: ServiceWorkerGlobalScope
void self.skipWaiting()
clientsClaim()
cleanupOutdatedCaches()
precacheAndRoute(self.__WB_MANIFEST)
self.addEventListener('message', (event) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
if (event.data?.type === 'SKIP_WAITING') {
void self.skipWaiting()
}
})
self.addEventListener('push', (event) => {
if (!event.data) {
console.error('Push event received but no data')
return
}
const payload = parseNotificationPayload(event.data)
event.waitUntil(showPushNotification(payload))
})
self.addEventListener('notificationclick', (event) => {
event.notification.close()
const data: NotificationData = event.notification.data
const url =
data.payload?.actions.find((action) => action.action === event.action)?.url || data.defaultActionUrl
event.waitUntil(handleNotificationClick(url))
})
async function handleNotificationClick(url: string) {
const clients = await self.clients.matchAll({ type: 'window' })
for (const client of clients) {
if (client.url === url && 'focus' in client) {
await client.focus()
return
}
}
await self.clients.openWindow(url)
}
async function showPushNotification(payload: NotificationPayload | null) {
await self.registration.showNotification(
payload?.title ?? 'New Notification',
makeNotificationOptions(payload)
)
}
function parseNotificationPayload(data: PushMessageData) {
try {
const payload = data.json()
if (isValidNotificationPayload(payload)) return payload
console.error('Invalid push notification payload:', payload)
return null
} catch (error) {
console.error('Error parsing push notification data:', error)
return null
}
}
function isValidNotificationPayload(payload: unknown): payload is NotificationPayload {
if (typeof payload !== 'object' || payload === null) return false
const p = payload as Record<string, unknown>
return (
typeof p.title === 'string' &&
(typeof p.body === 'string' || p.body === null) &&
Array.isArray(p.actions) &&
p.actions.every((action) => {
if (typeof action !== 'object' || action === null) return false
const a = action as Record<string, unknown>
return typeof a.action === 'string' && typeof a.title === 'string'
})
)
}

View File

@@ -5,6 +5,12 @@
"compilerOptions": { "compilerOptions": {
"noUncheckedIndexedAccess": true, "noUncheckedIndexedAccess": true,
"jsx": "react-jsx", "jsx": "react-jsx",
"jsxImportSource": "react" "jsxImportSource": "react",
"types": [
"astro/client",
"vite-plugin-pwa/vanillajs",
"vite-plugin-pwa/info",
"vite-plugin-pwa/pwa-assets"
]
} }
} }