Compare commits
32 Commits
release-20
...
release-55
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dacf73a804 | ||
|
|
5812399e29 | ||
|
|
6a6908518d | ||
|
|
d065910ff3 | ||
|
|
490433b002 | ||
|
|
e17bc8a521 | ||
|
|
ec1215f2ae | ||
|
|
3afa824c18 | ||
|
|
9a68112e24 | ||
|
|
0c40d8eec5 | ||
|
|
e16c9b64ed | ||
|
|
22944fcdb3 | ||
|
|
f7f380c591 | ||
|
|
577c524ca2 | ||
|
|
da12e8de79 | ||
|
|
ea40f17d3c | ||
|
|
7e0d41cc7a | ||
|
|
70a097054b | ||
|
|
e536ca6519 | ||
|
|
b361ed3aa8 | ||
|
|
50ede46d50 | ||
|
|
ba809840c6 | ||
|
|
f2021a3027 | ||
|
|
6b86a72d1e | ||
|
|
8f2b2c34ff | ||
|
|
ac9a2f428a | ||
|
|
970622d061 | ||
|
|
4806a7fd4e | ||
|
|
85605de8aa | ||
|
|
7a22629c55 | ||
|
|
8deb9acb93 | ||
|
|
61a5448ff5 |
53
.clinerules
53
.clinerules
@@ -1,53 +0,0 @@
|
||||
# Cursor Rules
|
||||
|
||||
- When merging tailwind classes, use the `cn` function.
|
||||
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You are forbiddent o add comments unless explicitly stated by the user.
|
||||
- Avoid sending JavaScript to the client. The JS send should be optional.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Import the types from prisma instead of hardcoding duplicates.
|
||||
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
|
||||
- Remember to check the prisma schema when doing things related to the database.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- Keep README.md in sync with new capabilities.
|
||||
- The package manager is npm.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `Image` component from `astro:assets` for images.
|
||||
- Use the `zod` library for schema validation.
|
||||
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- Don't forget that the astro files have thre dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- The admin actions go into a separate folder.
|
||||
- In Actro actions when throwing errors use ActionError.
|
||||
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from "astro:actions"; /* CORRECT */
|
||||
import { server } from "~/actions"; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from "~/actions/admin/attribute.ts"; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
|
||||
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
|
||||
- When adding clientside js do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts
|
||||
|
||||
```ts
|
||||
function makeLoginUrl(
|
||||
currentUrl: URL,
|
||||
options: {
|
||||
redirect?: URL | string | null;
|
||||
error?: string | null;
|
||||
logout?: boolean;
|
||||
message?: string | null;
|
||||
} = {}
|
||||
);
|
||||
```
|
||||
20
.cursor/rules/astro-actions-api.mdc
Normal file
20
.cursor/rules/astro-actions-api.mdc
Normal file
@@ -0,0 +1,20 @@
|
||||
---
|
||||
description:
|
||||
globs: web/src/actions,web/src/pages
|
||||
alwaysApply: false
|
||||
---
|
||||
- In the astro actions return, generaly don't return anythig unless the caller doesn't needs it. Specially don't `return { success: true }`, or similar. If needed, just return an object with the newly created/edited objects (Like: `return { newService: service }` or don't return anything if not needed).
|
||||
- When importing actions, use `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from 'astro:actions'; /* CORRECT */
|
||||
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- When throwing errors in Astro actions use ActionError.
|
||||
- Always use Astro actions instead of with API routes and instead of `if (Astro.request.method === "POST")`.
|
||||
- Generally call the actions using html forms. But if you need to, you can call them from the server-side code with Astro.callAction(), or [callActionWithUrlParams.ts](mdc:web/src/lib/callActionWithUrlParams.ts).
|
||||
- The admin actions go into a separate folder.
|
||||
26
.cursor/rules/client-side-javascript.mdc
Normal file
26
.cursor/rules/client-side-javascript.mdc
Normal file
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description:
|
||||
globs: web/src/pages,web/src/components
|
||||
alwaysApply: false
|
||||
---
|
||||
- Avoid sending JavaScript to the client. The JS send should always be optional.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- To avoid using JavaScript, you can use HTML and CSS features such as hidden checkboxes, deltails tag, etc.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- When adding clientside JS do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout [BaseLayout.astro](mdc:web/src/layouts/BaseLayout.astro) [BaseHead.astro](mdc:web/src/components/BaseHead.astro) accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
|
||||
|
||||
```tsx
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for __________. //
|
||||
// Desctiption goes here... //
|
||||
////////////////////////////////////////////////////////////
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
|
||||
// Do something
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
8
.cursor/rules/code-style.mdc
Normal file
8
.cursor/rules/code-style.mdc
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You should never add unnecessary or unuseful comments, if you add a comment it must provide some value to the code.
|
||||
55
.cursor/rules/database.mdc
Normal file
55
.cursor/rules/database.mdc
Normal file
@@ -0,0 +1,55 @@
|
||||
---
|
||||
description: Querying the database, editing the database, needing to import types from the database, or anything related to the database or Prisma.
|
||||
globs:
|
||||
alwaysApply: false
|
||||
---
|
||||
- We use Prisma as ORM.
|
||||
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
|
||||
- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [seed.ts](mdc:web/prisma/seed.ts), you run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json). And never do the migrations manually.
|
||||
- Import the types from prisma instead of hardcoding duplicates. Specially use the Prisma.___GetPayload type and the enums. Like this:
|
||||
```ts
|
||||
type Props = {
|
||||
user: Prisma.UserGetPayload<{
|
||||
select: {
|
||||
name: true
|
||||
displayName: true
|
||||
picture: true
|
||||
}
|
||||
}>
|
||||
}
|
||||
```
|
||||
|
||||
- Only `select` the necessary fields, no more.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- To query the database from Astro pages, use Astro.locals.try() or Astro.locals.tryMany([]) [errorBanners.ts](mdc:web/src/lib/errorBanners.ts) [middleware.ts](mdc:web/src/middleware.ts) , like so:
|
||||
```ts
|
||||
const [user, services] = await Astro.locals.banners.tryMany([
|
||||
[
|
||||
'Error fetching user',
|
||||
() =>
|
||||
prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: {
|
||||
name: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
[
|
||||
'Error fetching services',
|
||||
() =>
|
||||
prisma.service.findMany({
|
||||
where: { categories: { some: { id: categoryId } } },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
}),
|
||||
[] as [],
|
||||
],
|
||||
])
|
||||
```
|
||||
- When editing the database, remember to edit the db seeding file [seed.ts](mdc:web/prisma/seed.ts) to generate data for the new schema.
|
||||
98
.cursor/rules/design-patterns.mdc
Normal file
98
.cursor/rules/design-patterns.mdc
Normal file
@@ -0,0 +1,98 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
- The main libraries used are: Astro, TypeScript, Tailwind 4, HTMX, Prisma, npm, zod, lodash-es, date-fns, ts-toolbelt. Full list in: [package.json](mdc:web/package.json)
|
||||
- When creating constants or enums, use the `makeHelpersForOptions` function [makeHelpersForOptions.ts](mdc:web/src/lib/makeHelpersForOptions.ts) like in this example. Save the file in the `web/src/constants` folder (like [attributeTypes.ts](mdc:web/src/constants/attributeTypes.ts)). Note that it's not necessary to use all the options or export all the variables that the example has, just the ones you need.
|
||||
|
||||
```ts
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
|
||||
import { transformCase } from '../lib/strings';
|
||||
|
||||
import type { AttributeType } from '@prisma/client';
|
||||
|
||||
type AttributeTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T;
|
||||
slug: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
classNames: {
|
||||
text: string;
|
||||
icon: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const {
|
||||
dataArray: attributeTypes,
|
||||
dataObject: attributeTypesById,
|
||||
getFn: getAttributeTypeInfo,
|
||||
getFnSlug: getAttributeTypeInfoBySlug,
|
||||
zodEnumBySlug: attributeTypesZodEnumBySlug,
|
||||
zodEnumById: attributeTypesZodEnumById,
|
||||
keyToSlug: attributeTypeIdToSlug,
|
||||
slugToKey: attributeTypeSlugToId,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): AttributeTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value
|
||||
? transformCase(value.replace('_', ' '), 'title')
|
||||
: String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
classNames: {
|
||||
text: 'text-current/60',
|
||||
icon: 'text-current/60',
|
||||
},
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'BAD',
|
||||
slug: 'bad',
|
||||
label: 'Bad',
|
||||
icon: 'ri:close-line',
|
||||
order: 1,
|
||||
classNames: {
|
||||
text: 'text-red-200',
|
||||
icon: 'text-red-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'WARNING',
|
||||
slug: 'warning',
|
||||
label: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
order: 2,
|
||||
classNames: {
|
||||
text: 'text-yellow-200',
|
||||
icon: 'text-yellow-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'GOOD',
|
||||
slug: 'good',
|
||||
label: 'Good',
|
||||
icon: 'ri:check-line',
|
||||
order: 3,
|
||||
classNames: {
|
||||
text: 'text-green-200',
|
||||
icon: 'text-green-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'INFO',
|
||||
slug: 'info',
|
||||
label: 'Info',
|
||||
icon: 'ri:information-line',
|
||||
order: 4,
|
||||
classNames: {
|
||||
text: 'text-blue-200',
|
||||
icon: 'text-blue-400',
|
||||
},
|
||||
},
|
||||
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
||||
);
|
||||
```
|
||||
161
.cursor/rules/pages-and-components.mdc
Normal file
161
.cursor/rules/pages-and-components.mdc
Normal file
@@ -0,0 +1,161 @@
|
||||
---
|
||||
description:
|
||||
globs: web/src/pages,web/src/components
|
||||
alwaysApply: false
|
||||
---
|
||||
- On .astro files, don't forget to include the three dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `MyPicture` component from `src/components/MyPicture.astro` for images.
|
||||
- When redirecting to login use the `makeLoginUrl` function from [redirectUrls.ts](mdc:web/src/lib/redirectUrls.ts) and if the link is for an `<a>` tag, use the `data-astro-reload` attribute. Similar for the logout and impersonate.
|
||||
- Don't use the `web/src/pages/admin` pages as example unless explicitly stated or you're creating/editing an admin page.
|
||||
- Checkout the @errorBanners.ts @middleware.ts @env.d.ts to see the avilable Astro.locals values.
|
||||
- Avoid duplicating similar html code. You can use jsx for loops, create variables in the constants folder, or create separate components.
|
||||
- When redirecting to the 404 not found page, use `Astro.rewrite` (Like this example: `if (!user) return Astro.rewrite('/404')`)
|
||||
- Include schema markup in the pages when it makes sense. Examples: [[slug].astro](mdc:web/src/pages/service/[slug].astro)
|
||||
- When creating forms, we already have utilities, components and established design patterns. Follow this example. (Note that this example may come slightly outdaded, but the overall philosophy doesn't change)
|
||||
```astro
|
||||
---
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import Captcha from '../../components/Captcha.astro'
|
||||
import InputCardGroup from '../../components/InputCardGroup.astro'
|
||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||
import InputImageFile from '../../components/InputImageFile.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
|
||||
}
|
||||
|
||||
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
|
||||
if (result && !result.error) {
|
||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
||||
}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
notes: z.string().default(''),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
if (!params.serviceId) return Astro.rewrite('/404')
|
||||
|
||||
const service = await Astro.locals.banners.try(
|
||||
'Failed to fetch service',
|
||||
async () =>
|
||||
prisma.service.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
overallScore: true,
|
||||
kycLevel: true,
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { id: params.serviceId },
|
||||
}),
|
||||
null
|
||||
)
|
||||
|
||||
if (!service) return Astro.rewrite('/404')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
pageTitle="Edit service"
|
||||
description="Suggest an edit to service"
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'Edit service',
|
||||
description: 'Suggest an edit to service',
|
||||
icon: 'ri:edit-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
|
||||
|
||||
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
|
||||
<input type="hidden" name="serviceId" value={params.serviceId} />
|
||||
|
||||
<InputText
|
||||
label="Service name"
|
||||
name="name"
|
||||
value={service.name}
|
||||
error={inputErrors.name}
|
||||
inputProps={{ 'data-custom-value': true, required: true }}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
label="KYC Level"
|
||||
options={kycLevels.map((kycLevel) => ({
|
||||
label: kycLevel.name,
|
||||
value: kycLevel.id.toString(),
|
||||
icon: kycLevel.icon,
|
||||
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/5_`,
|
||||
}))}
|
||||
iconSize="md"
|
||||
cardSize="md"
|
||||
required
|
||||
error={inputErrors.kycLevel}
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
error={inputErrors.categories}
|
||||
/>
|
||||
|
||||
<InputImageFile
|
||||
label="Service Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported."
|
||||
error={inputErrors.imageFile}
|
||||
square
|
||||
required
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Note for Moderators"
|
||||
name="notes"
|
||||
value={params.notes}
|
||||
inputProps={{ rows: 10 }}
|
||||
error={inputErrors.notes}
|
||||
/>
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<InputSubmitButton hideCancel />
|
||||
</form>
|
||||
</BaseLayout>
|
||||
```
|
||||
10
.cursor/rules/styles.mdc
Normal file
10
.cursor/rules/styles.mdc
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
description:
|
||||
globs: /web/src/pages,/web/src/components,/web/src/constants
|
||||
alwaysApply: false
|
||||
---
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes from Tailwind 3.
|
||||
- Checkout the custom tailwind theme [global.css](mdc:web/src/styles/global.css).
|
||||
- When adding conditional styles or merging tailwind classes, use the `cn` function. Never use template strings. [cn.ts](mdc:web/src/lib/cn.ts)
|
||||
- For the grayscale colors, try to use the custom color `day` for the light/foreground colors (50-500) and `night` for the dark/bakground (500-950).
|
||||
- Generally avoid using opacity modifiers (In `text-red-500/50` the `/50`), but it's fine to also use it.
|
||||
312
.cursorrules
312
.cursorrules
@@ -1,312 +0,0 @@
|
||||
# Cursor Rules
|
||||
|
||||
- When merging tailwind classes, use the `cn` function.
|
||||
- When using Tailwind and you need to merge classes use the `cn` function if avilable.
|
||||
- We use Tailwind 4 (the latest version), make sure to not use outdated classes.
|
||||
- Instead of using the syntax`Array<T>`, use `T[]`.
|
||||
- Use TypeScript `type` over `interface`.
|
||||
- You are forbiddent o add comments unless explicitly stated by the user.
|
||||
- Avoid sending JavaScript to the client. The JS send should be optional.
|
||||
- In prisma preffer `select` over `include` when making queries.
|
||||
- Import the types from prisma instead of hardcoding duplicates.
|
||||
- Avoid duplicating similar html code, and parametrize it when possible or create separate components.
|
||||
- Remember to check the prisma schema when doing things related to the database.
|
||||
- Avoid hardcoding enums from the database, import them from prisma.
|
||||
- Avoid using client-side JavaScript as much as possible. And if it has to be done, make it optional.
|
||||
- The admin pages can use client-side JavaScript.
|
||||
- Keep README.md in sync with new capabilities.
|
||||
- The package manager is npm.
|
||||
- For icons use the `Icon` component from `astro-icon/components`.
|
||||
- For icons use the Remix Icon library preferably.
|
||||
- Use the `Image` component from `astro:assets` for images.
|
||||
- Use the `zod` library for schema validation.
|
||||
- In the astro actions return, don't return success: true, or similar, just return an object with the newly created/edited objects or nothing.
|
||||
- When adding actions, don't create and export a new variable called actions. Notice that Astro already provides that variable from `import { actions } from 'astro:actions'`. So just add the new actions to the `server` variable in `web/src/actions/index.ts` and that's it.
|
||||
- Don't forget that the astro files have three dashes (`---`) at the begining of the file and where the server js ends. I noticed that sometimes you forget them.
|
||||
- The admin actions go into a separate folder.
|
||||
- In Actro actions when throwing errors use ActionError.
|
||||
- @deprecated Don't import this object, use {@link actions} instead, like: `import { actions } from 'astro:actions'`. Example:
|
||||
|
||||
```ts
|
||||
import { actions } from 'astro:actions'; /* CORRECT */
|
||||
import { server } from '~/actions'; /* WRONG!!!! DON'T DO THIS */
|
||||
import { adminAttributeActions } from '~/actions/admin/attribute.ts'; /* WRONG!!!! DON'T DO THIS */
|
||||
|
||||
const result = Astro.getActionResult(actions.admin.attribute.create);
|
||||
```
|
||||
|
||||
- Always use Astro actions instead of with API routes or `if (Astro.request.method === "POST")`.
|
||||
- When adding clientside js do it with HTMX.
|
||||
- When adding HTMX, the layout component BaseLayout accepts a prop htmx to load it in that page. No need to use a cdn.
|
||||
- When redirecting to login use the `makeLoginUrl` function from web/src/lib/redirectUrls.ts and if the link is for an `<a>` tag, use the `data-astro-reload` attribute.
|
||||
|
||||
```ts
|
||||
function makeLoginUrl(
|
||||
currentUrl: URL,
|
||||
options: {
|
||||
redirect?: URL | string | null;
|
||||
error?: string | null;
|
||||
logout?: boolean;
|
||||
message?: string | null;
|
||||
} = {}
|
||||
);
|
||||
```
|
||||
|
||||
- When adding client scripts remember to use the event `astro:page-load`, `querySelectorAll<Type>` and add an explanation comment, like so:
|
||||
|
||||
```tsx
|
||||
<script>
|
||||
////////////////////////////////////////////////////////////
|
||||
// Optional script for __________. //
|
||||
// Desctiption goes here... //
|
||||
////////////////////////////////////////////////////////////
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
document.querySelectorAll<HTMLDivElement>('[data-my-div]').forEach((myDiv) => {
|
||||
// Do something
|
||||
})
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
- When creating forms, we already have utilities, components and established design patterns. Follow this example:
|
||||
|
||||
```astro
|
||||
---
|
||||
import { actions, isInputError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
|
||||
import Captcha from '../../components/Captcha.astro'
|
||||
import InputCardGroup from '../../components/InputCardGroup.astro'
|
||||
import InputCheckboxGroup from '../../components/InputCheckboxGroup.astro'
|
||||
import InputHoneypotTrap from '../../components/InputHoneypotTrap.astro'
|
||||
import InputImageFile from '../../components/InputImageFile.astro'
|
||||
import InputSubmitButton from '../../components/InputSubmitButton.astro'
|
||||
import InputText from '../../components/InputText.astro'
|
||||
import InputTextArea from '../../components/InputTextArea.astro'
|
||||
import { kycLevels } from '../../constants/kycLevels'
|
||||
import BaseLayout from '../../layouts/BaseLayout.astro'
|
||||
import { zodParseQueryParamsStoringErrors } from '../../lib/parseUrlFilters'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { makeLoginUrl } from '../../lib/redirectUrls'
|
||||
|
||||
const user = Astro.locals.user
|
||||
if (!user) {
|
||||
return Astro.redirect(makeLoginUrl(Astro.url, { message: 'Login to suggest a new service' }))
|
||||
}
|
||||
|
||||
const result = Astro.getActionResult(actions.serviceSuggestion.editService)
|
||||
if (result && !result.error) {
|
||||
return Astro.redirect(`/service-suggestion/${result.data.serviceSuggestion.id}`)
|
||||
}
|
||||
const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
|
||||
const { data: params } = zodParseQueryParamsStoringErrors(
|
||||
{
|
||||
serviceId: z.coerce.number().int().positive(),
|
||||
notes: z.string().default(''),
|
||||
},
|
||||
Astro
|
||||
)
|
||||
|
||||
if (!params.serviceId) return Astro.rewrite('/404')
|
||||
|
||||
const service = await Astro.locals.banners.try(
|
||||
'Failed to fetch service',
|
||||
async () =>
|
||||
prisma.service.findUnique({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
overallScore: true,
|
||||
kycLevel: true,
|
||||
imageUrl: true,
|
||||
verificationStatus: true,
|
||||
acceptedCurrencies: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
icon: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
where: { id: params.serviceId },
|
||||
}),
|
||||
null
|
||||
)
|
||||
|
||||
if (!service) return Astro.rewrite('/404')
|
||||
---
|
||||
|
||||
<BaseLayout
|
||||
pageTitle="Edit service"
|
||||
description="Suggest an edit to service"
|
||||
ogImage={{
|
||||
template: 'generic',
|
||||
title: 'Edit service',
|
||||
description: 'Suggest an edit to service',
|
||||
icon: 'ri:edit-line',
|
||||
}}
|
||||
widthClassName="max-w-screen-md"
|
||||
>
|
||||
<h1 class="font-title mt-12 mb-6 text-center text-3xl font-bold">Edit service</h1>
|
||||
|
||||
<form method="POST" action={actions.serviceSuggestion.editService} class="space-y-6">
|
||||
<input type="hidden" name="serviceId" value={params.serviceId} />
|
||||
|
||||
<InputText
|
||||
label="Service name"
|
||||
name="name"
|
||||
value={service.name}
|
||||
error={inputErrors.name}
|
||||
inputProps={{ 'data-custom-value': true, required: true }}
|
||||
/>
|
||||
|
||||
<InputCardGroup
|
||||
name="kycLevel"
|
||||
label="KYC Level"
|
||||
options={kycLevels.map((kycLevel) => ({
|
||||
label: kycLevel.name,
|
||||
value: kycLevel.id.toString(),
|
||||
icon: kycLevel.icon,
|
||||
description: `${kycLevel.description}\n\n_KYC Level ${kycLevel.value}/5_`,
|
||||
}))}
|
||||
iconSize="md"
|
||||
cardSize="md"
|
||||
required
|
||||
error={inputErrors.kycLevel}
|
||||
/>
|
||||
|
||||
<InputCheckboxGroup
|
||||
name="categories"
|
||||
label="Categories"
|
||||
required
|
||||
options={categories.map((category) => ({
|
||||
label: category.name,
|
||||
value: category.id.toString(),
|
||||
icon: category.icon,
|
||||
}))}
|
||||
error={inputErrors.categories}
|
||||
/>
|
||||
|
||||
<InputImageFile
|
||||
label="Service Image"
|
||||
name="imageFile"
|
||||
description="Square image. At least 192x192px. Transparency supported."
|
||||
error={inputErrors.imageFile}
|
||||
square
|
||||
required
|
||||
/>
|
||||
|
||||
<InputTextArea
|
||||
label="Note for Moderators"
|
||||
name="notes"
|
||||
value={params.notes}
|
||||
inputProps={{ rows: 10 }}
|
||||
error={inputErrors.notes}
|
||||
/>
|
||||
|
||||
<Captcha action={actions.serviceSuggestion.createService} />
|
||||
|
||||
<InputHoneypotTrap name="message" />
|
||||
|
||||
<InputSubmitButton hideCancel />
|
||||
</form>
|
||||
</BaseLayout>
|
||||
```
|
||||
|
||||
- Don't use the `web/src/pages/admin` pages as example unless explicitly stated or you're creating/editing an admin page.
|
||||
- When creating constants or enums, use the `makeHelpersForOptions` function like in this example. Save the file in the `web/src/constants` folder. Note that it's not necessary to use all the options the example has, just the ones you need.
|
||||
|
||||
```ts
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions';
|
||||
import { transformCase } from '../lib/strings';
|
||||
|
||||
import type { AttributeType } from '@prisma/client';
|
||||
|
||||
type AttributeTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T;
|
||||
slug: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
order: number;
|
||||
classNames: {
|
||||
text: string;
|
||||
icon: string;
|
||||
};
|
||||
};
|
||||
|
||||
export const {
|
||||
dataArray: attributeTypes,
|
||||
dataObject: attributeTypesById,
|
||||
getFn: getAttributeTypeInfo,
|
||||
getFnSlug: getAttributeTypeInfoBySlug,
|
||||
zodEnumBySlug: attributeTypesZodEnumBySlug,
|
||||
zodEnumById: attributeTypesZodEnumById,
|
||||
keyToSlug: attributeTypeIdToSlug,
|
||||
slugToKey: attributeTypeSlugToId,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): AttributeTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value
|
||||
? transformCase(value.replace('_', ' '), 'title')
|
||||
: String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
classNames: {
|
||||
text: 'text-current/60',
|
||||
icon: 'text-current/60',
|
||||
},
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'BAD',
|
||||
slug: 'bad',
|
||||
label: 'Bad',
|
||||
icon: 'ri:close-line',
|
||||
order: 1,
|
||||
classNames: {
|
||||
text: 'text-red-200',
|
||||
icon: 'text-red-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'WARNING',
|
||||
slug: 'warning',
|
||||
label: 'Warning',
|
||||
icon: 'ri:alert-line',
|
||||
order: 2,
|
||||
classNames: {
|
||||
text: 'text-yellow-200',
|
||||
icon: 'text-yellow-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'GOOD',
|
||||
slug: 'good',
|
||||
label: 'Good',
|
||||
icon: 'ri:check-line',
|
||||
order: 3,
|
||||
classNames: {
|
||||
text: 'text-green-200',
|
||||
icon: 'text-green-400',
|
||||
},
|
||||
},
|
||||
{
|
||||
value: 'INFO',
|
||||
slug: 'info',
|
||||
label: 'Info',
|
||||
icon: 'ri:information-line',
|
||||
order: 4,
|
||||
classNames: {
|
||||
text: 'text-blue-200',
|
||||
icon: 'text-blue-400',
|
||||
},
|
||||
},
|
||||
] as const satisfies AttributeTypeInfo<AttributeType>[]
|
||||
);
|
||||
```
|
||||
0
.env.example
Normal file
0
.env.example
Normal file
3
.gitignore
vendored
3
.gitignore
vendored
@@ -13,4 +13,5 @@ dump*.sql
|
||||
*.log
|
||||
*.bak
|
||||
migrate.py
|
||||
sync-all.sh
|
||||
sync-all.sh
|
||||
.DS_Store
|
||||
|
||||
@@ -23,13 +23,12 @@ cd web
|
||||
nvm install
|
||||
npm i
|
||||
cp -n .env.example .env
|
||||
npm run db-push
|
||||
npm run db-fill-clean
|
||||
npm run db-reset
|
||||
```
|
||||
|
||||
Now open the [.env](web/.env) file and fill in the missing values.
|
||||
|
||||
> Default users are created with tokens: `admin`, `verifier`, `verified`, `normal` (configurable via env vars)
|
||||
> Default users are created with tokens: `admin`, `moderator`, `verified`, `normal` (configurable via env vars)
|
||||
|
||||
### Running the project
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ services:
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||
REDIS_URL: "redis://redis:6379"
|
||||
env_file:
|
||||
- .env
|
||||
depends_on:
|
||||
database:
|
||||
condition: service_healthy
|
||||
|
||||
36
justfile
36
justfile
@@ -51,42 +51,6 @@ import-db file="":
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Restoring database from $BACKUP_FILE..."
|
||||
# First drop all connections to the database
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
|
||||
|
||||
# Drop and recreate database
|
||||
echo "Dropping and recreating the database..."
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "DROP DATABASE IF EXISTS ${POSTGRES_DATABASE:-kycnot};" postgres
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "CREATE DATABASE ${POSTGRES_DATABASE:-kycnot};" postgres
|
||||
|
||||
# Restore the database
|
||||
cat "$BACKUP_FILE" | docker compose exec -T database pg_restore -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot} --no-owner
|
||||
echo "Database restored successfully!"
|
||||
|
||||
# Import triggers
|
||||
echo "Importing triggers..."
|
||||
just import-triggers
|
||||
|
||||
echo "Database import completed!"
|
||||
# Check if migrations need to be run
|
||||
cd web && npx prisma migrate status
|
||||
|
||||
#!/bin/bash
|
||||
if [ -z "{{file}}" ]; then
|
||||
BACKUP_FILE=$(find backups/ -name 'db_backup_*.dump' | sort -r | head -n 1)
|
||||
if [ -z "$BACKUP_FILE" ]; then
|
||||
echo "Error: No backup files found in the backups directory"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
BACKUP_FILE="{{file}}"
|
||||
if [ ! -f "$BACKUP_FILE" ]; then
|
||||
echo "Error: Backup file '$BACKUP_FILE' not found"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=== STEP 1: PREPARING DATABASE ==="
|
||||
# Drop all connections to the database
|
||||
docker compose exec -T database psql -U ${POSTGRES_USER:-kycnot} -c "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE pg_stat_activity.datname = '${POSTGRES_DATABASE:-kycnot}' AND pid <> pg_backend_pid();" postgres
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
DATABASE_URL="postgresql://kycnot:kycnot@localhost:3399/kycnot?schema=public"
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
SOURCE_CODE_URL="https://github.com"
|
||||
SITE_URL="https://localhost:4321"
|
||||
DATABASE_UI_URL="http://localhost:5555"
|
||||
SITE_URL="http://localhost:4321"
|
||||
ONION_ADDRESS="http://kycnotmezdiftahfmc34pqbpicxlnx3jbf5p7jypge7gdvduu7i6qjqd.onion"
|
||||
I2P_ADDRESS="http://nti3rj4j4disjcm2kvp4eno7otcejbbxv3ggxwr5tpfk4jucah7q.b32.i2p"
|
||||
RELEASE_NUMBER=123
|
||||
RELEASE_DATE="2025-05-23T19:00:00.000Z"
|
||||
# Generated with `npx web-push generate-vapid-keys`
|
||||
VAPID_PUBLIC_KEY="BPmJbRXzG9zT181vyg1GlpyV8qu7rjVjfg6vkkOgtqeTZECyt6lR4MuzmlarEHSBF6gPpc77ZA0_tTVtmYh65iM"
|
||||
VAPID_PRIVATE_KEY="eN_S2SMXDB2hpwVXbgDkDrPIPMqirllZaJcUgYTt9w0"
|
||||
VAPID_SUBJECT="mailto:no-reply@kycnot.me"
|
||||
|
||||
@@ -6,24 +6,23 @@
|
||||
|
||||
All commands are run from the root of the project, from a terminal:
|
||||
|
||||
| Command | Action |
|
||||
| :------------------------ | :------------------------------------------------------------------- |
|
||||
| `nvm install` | Installs and uses the correct version of node |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
||||
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
||||
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
||||
| `npm run db-fill` | Fills the database with fake data (development mode) |
|
||||
| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) |
|
||||
| `npm run format` | Formats the code with Prettier |
|
||||
| `npm run lint` | Lints the code with ESLint |
|
||||
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
||||
| Command | Action |
|
||||
| :------------------------ | :------------------------------------------------------------------ |
|
||||
| `nvm install` | Installs and uses the correct version of node |
|
||||
| `npm install` | Installs dependencies |
|
||||
| `npm run dev` | Starts local dev server at `localhost:4321` |
|
||||
| `npm run build` | Build your production site to `./dist/` |
|
||||
| `npm run preview` | Preview your build locally, before deploying |
|
||||
| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
|
||||
| `npm run astro -- --help` | Get help using the Astro CLI |
|
||||
| `npm run db-admin` | Runs Prisma Studio (database admin) |
|
||||
| `npm run db-gen` | Generates the Prisma client without running migrations |
|
||||
| `npm run db-push` | Updates the database schema with latest changes (development mode). |
|
||||
| `npm run db-seed` | Seeds the database with fake data (development mode) |
|
||||
| `npm run format` | Formats the code with Prettier |
|
||||
| `npm run lint` | Lints the code with ESLint |
|
||||
| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
|
||||
|
||||
> **Note**: `db-fill` and `db-fill-clean` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-fill -- --services=5` will add 5 fake services.
|
||||
> **Note**: `db-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
|
||||
|
||||
> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `verifier`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||
> **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
|
||||
|
||||
@@ -6,11 +6,11 @@ import sitemap from '@astrojs/sitemap'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { defineConfig, envField } from 'astro/config'
|
||||
import icon from 'astro-icon'
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
// @ts-expect-error process.env actually exists
|
||||
const { SITE_URL } = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
||||
if (!SITE_URL) throw new Error('SITE_URL environment variable is not set')
|
||||
import { postgresListener } from './src/lib/postgresListenerIntegration'
|
||||
import { getServerEnvVariable } from './src/lib/serverEnvVariables'
|
||||
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
@@ -22,6 +22,7 @@ export default defineConfig({
|
||||
plugins: [tailwindcss()],
|
||||
},
|
||||
integrations: [
|
||||
postgresListener(),
|
||||
icon(),
|
||||
mdx(),
|
||||
sitemap({
|
||||
@@ -131,11 +132,11 @@ export default defineConfig({
|
||||
min: 1,
|
||||
default: 'admin',
|
||||
}),
|
||||
DEV_VERIFIER_USER_SECRET_TOKEN: envField.string({
|
||||
DEV_MODERATOR_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
default: 'verifier',
|
||||
default: 'moderator',
|
||||
}),
|
||||
DEV_VERIFIED_USER_SECRET_TOKEN: envField.string({
|
||||
context: 'server',
|
||||
@@ -170,6 +171,52 @@ export default defineConfig({
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
|
||||
DATABASE_UI_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
optional: false,
|
||||
}),
|
||||
LOGS_UI_URL: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
url: true,
|
||||
optional: true,
|
||||
}),
|
||||
|
||||
RELEASE_NUMBER: envField.number({
|
||||
context: 'server',
|
||||
access: 'public',
|
||||
int: true,
|
||||
optional: true,
|
||||
}),
|
||||
RELEASE_DATE: envField.string({
|
||||
context: 'server',
|
||||
access: 'public',
|
||||
optional: true,
|
||||
}),
|
||||
|
||||
// Generated with `npx web-push generate-vapid-keys`
|
||||
VAPID_PUBLIC_KEY: envField.string({
|
||||
context: 'server',
|
||||
access: 'public',
|
||||
min: 1,
|
||||
optional: false,
|
||||
}),
|
||||
// Generated with `npx web-push generate-vapid-keys`
|
||||
VAPID_PRIVATE_KEY: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
optional: false,
|
||||
}),
|
||||
VAPID_SUBJECT: envField.string({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
min: 1,
|
||||
optional: false,
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
224
web/package-lock.json
generated
224
web/package-lock.json
generated
@@ -19,6 +19,7 @@
|
||||
"@prisma/client": "6.8.2",
|
||||
"@tailwindcss/vite": "4.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.7.13",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
@@ -32,6 +33,7 @@
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.0",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.0.1",
|
||||
@@ -44,6 +46,7 @@
|
||||
"tailwindcss": "4.1.7",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod-form-data": "2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -60,6 +63,7 @@
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
@@ -3150,12 +3154,10 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/pg": {
|
||||
"version": "8.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.6.1.tgz",
|
||||
"integrity": "sha512-1Kc4oAGzAl7uqUStZCDvaLFqZrW9qWSjXOmBfdgyBP5La7Us6Mg4GBvRlSoaZMhQF/zSj1C8CtKMBkoiT8eL8w==",
|
||||
"version": "8.15.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.15.4.tgz",
|
||||
"integrity": "sha512-I6UNVBAoYbvuWkkU3oosC8yxqH21f4/Jc4DK71JLG3dT2mdlGe1z+ep/LQGXaKaOgcvUrsQoPRqfgtMcvZiJhg==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
"pg-protocol": "*",
|
||||
@@ -3215,6 +3217,16 @@
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/web-push": {
|
||||
"version": "3.6.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/web-push/-/web-push-3.6.4.tgz",
|
||||
"integrity": "sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/ws": {
|
||||
"version": "8.5.13",
|
||||
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.13.tgz",
|
||||
@@ -3797,6 +3809,15 @@
|
||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz",
|
||||
"integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "6.12.6",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz",
|
||||
@@ -4063,6 +4084,18 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/asn1.js": {
|
||||
"version": "5.4.1",
|
||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bn.js": "^4.0.0",
|
||||
"inherits": "^2.0.1",
|
||||
"minimalistic-assert": "^1.0.0",
|
||||
"safer-buffer": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types-flow": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz",
|
||||
@@ -4822,6 +4855,12 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/bn.js": {
|
||||
"version": "4.12.2",
|
||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/boolbase": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz",
|
||||
@@ -4916,6 +4955,12 @@
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/buffer-equal-constant-time": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/bundle-name": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz",
|
||||
@@ -6157,6 +6202,15 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
|
||||
"integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
@@ -8181,6 +8235,15 @@
|
||||
"integrity": "sha512-VZAohXyF7xPGS52IM8d1T1283y+X4D+Owf3qY1NZ9RuBypyu9l8cGsxUMAG5fEAb/DhT7rDoJ9Hpu5/HxFD3cw==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/http_ece": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz",
|
||||
"integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
}
|
||||
},
|
||||
"node_modules/http-cache-semantics": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz",
|
||||
@@ -8203,6 +8266,19 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz",
|
||||
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"agent-base": "^7.1.2",
|
||||
"debug": "4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
@@ -8935,6 +9011,27 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/jwa": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
|
||||
"integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-equal-constant-time": "^1.0.1",
|
||||
"ecdsa-sig-formatter": "1.0.11",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/jws": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz",
|
||||
"integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"jwa": "^2.0.0",
|
||||
"safe-buffer": "^5.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/keyv": {
|
||||
"version": "4.5.4",
|
||||
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
|
||||
@@ -10556,6 +10653,12 @@
|
||||
"mini-svg-data-uri": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/minimatch": {
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
|
||||
@@ -11310,32 +11413,75 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.0.tgz",
|
||||
"integrity": "sha512-7SKfdvP8CTNXjMUzfcVTaI+TDzBEeaUnVwiVGZQD1Hh33Kpev7liQba9uLd4CfN8r9mCVsD0JIpq03+Unpz+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"pg-connection-string": "^2.9.0",
|
||||
"pg-pool": "^3.10.0",
|
||||
"pg-protocol": "^1.10.0",
|
||||
"pg-types": "2.2.0",
|
||||
"pgpass": "1.0.5"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"pg-cloudflare": "^1.2.5"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"pg-native": ">=3.0.1"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"pg-native": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/pg-cloudflare": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.5.tgz",
|
||||
"integrity": "sha512-OOX22Vt0vOSRrdoUPKJ8Wi2OpE/o/h9T8X1s4qSkCedbNah9ei2W2765be8iMVxQUsvgT7zIAT2eIa9fs5+vtg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/pg-connection-string": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.0.tgz",
|
||||
"integrity": "sha512-P2DEBKuvh5RClafLngkAuGe9OUlFV7ebu8w1kmaaOgPcpJd1RIFh7otETfI6hAR8YupOLFTY7nuvvIn7PLciUQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-int8": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.8.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.8.0.tgz",
|
||||
"integrity": "sha512-jvuYlEkL03NRvOoyoRktBK7+qU5kOvlAwvmrH8sr3wbLrOdVWsRxQfz8mMy9sZFsqJ1hEWNfdWKI4SAmoL+j7g==",
|
||||
"node_modules/pg-pool": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.0.tgz",
|
||||
"integrity": "sha512-DzZ26On4sQ0KmqnO34muPcmKbhrjmyiO4lCCR0VwEd7MjmiKf5NTg/6+apUEu0NF7ESa37CGzFxH513CoUmWnA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"peerDependencies": {
|
||||
"pg": ">=8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/pg-protocol": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.0.tgz",
|
||||
"integrity": "sha512-IpdytjudNuLv8nhlHs/UrVBhU0e78J0oIS/0AVdTbWxSOkFUVdsHC/NrorO6nXsQNDTT1kzDSOMJubBQviX18Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg-types": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"pg-int8": "1.0.1",
|
||||
"postgres-array": "~2.0.0",
|
||||
@@ -11347,6 +11493,15 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/pgpass": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"split2": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -11456,8 +11611,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
@@ -11467,8 +11620,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11478,8 +11629,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -11489,8 +11638,6 @@
|
||||
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"xtend": "^4.0.0"
|
||||
},
|
||||
@@ -12696,7 +12843,6 @@
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sass-formatter": {
|
||||
@@ -13149,6 +13295,15 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/split2": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stable-hash": {
|
||||
"version": "0.0.5",
|
||||
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
|
||||
@@ -14675,6 +14830,25 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/web-push": {
|
||||
"version": "3.6.7",
|
||||
"resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz",
|
||||
"integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"asn1.js": "^5.3.0",
|
||||
"http_ece": "1.2.0",
|
||||
"https-proxy-agent": "^7.0.0",
|
||||
"jws": "^4.0.0",
|
||||
"minimist": "^1.2.5"
|
||||
},
|
||||
"bin": {
|
||||
"web-push": "src/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
@@ -14916,8 +15090,6 @@
|
||||
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.4"
|
||||
}
|
||||
|
||||
@@ -12,13 +12,15 @@
|
||||
"db-push": "prisma migrate dev",
|
||||
"db-triggers": "just import-triggers",
|
||||
"db-update": "prisma migrate dev && just import-triggers",
|
||||
"db-reset": "prisma migrate reset && prisma migrate dev && just import-triggers && tsx scripts/faker.ts",
|
||||
"db-fill": "tsx scripts/faker.ts",
|
||||
"db-fill-clean": "tsx scripts/faker.ts --cleanup",
|
||||
"db-reset": "prisma migrate reset -f && prisma migrate dev",
|
||||
"db-seed": "prisma db seed",
|
||||
"format": "prettier --write .",
|
||||
"lint": "eslint .",
|
||||
"lint-fix": "eslint . --fix && prettier --write ."
|
||||
},
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "0.9.4",
|
||||
"@astrojs/db": "0.14.14",
|
||||
@@ -31,6 +33,7 @@
|
||||
"@prisma/client": "6.8.2",
|
||||
"@tailwindcss/vite": "4.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.7.13",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
@@ -44,6 +47,7 @@
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.0",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.0.1",
|
||||
@@ -56,6 +60,7 @@
|
||||
"tailwindcss": "4.1.7",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"web-push": "3.6.7",
|
||||
"zod-form-data": "2.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -72,6 +77,7 @@
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Manully edited to be a rename migration.
|
||||
*/
|
||||
-- AlterEnum
|
||||
BEGIN;
|
||||
ALTER TYPE "AccountStatusChange" RENAME VALUE 'VERIFIER_TRUE' TO 'MODERATOR_TRUE';
|
||||
ALTER TYPE "AccountStatusChange" RENAME VALUE 'VERIFIER_FALSE' TO 'MODERATOR_FALSE';
|
||||
COMMIT;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User"
|
||||
RENAME COLUMN "verifier" TO "moderator"
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "ServiceVisibility" ADD VALUE 'ARCHIVED';
|
||||
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "InternalServiceNote" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"serviceId" INTEGER NOT NULL,
|
||||
"addedByUserId" INTEGER,
|
||||
|
||||
CONSTRAINT "InternalServiceNote_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_serviceId_idx" ON "InternalServiceNote"("serviceId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_addedByUserId_idx" ON "InternalServiceNote"("addedByUserId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "InternalServiceNote_createdAt_idx" ON "InternalServiceNote"("createdAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_serviceId_fkey" FOREIGN KEY ("serviceId") REFERENCES "Service"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "InternalServiceNote" ADD CONSTRAINT "InternalServiceNote_addedByUserId_fkey" FOREIGN KEY ("addedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Enable pg_trgm extension for similarity functions
|
||||
CREATE EXTENSION IF NOT EXISTS pg_trgm;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "previousSlugs" TEXT[] DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_previousSlugs_idx" ON "Service"("previousSlugs");
|
||||
@@ -0,0 +1,6 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "KycLevelClarification" AS ENUM ('NONE', 'DEPENDS_ON_PARTNERS');
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "kycLevelClarification" "KycLevelClarification",
|
||||
ADD COLUMN "kycLevelDetailsId" INTEGER;
|
||||
@@ -0,0 +1,25 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "PushSubscription" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"userId" INTEGER NOT NULL,
|
||||
"endpoint" TEXT NOT NULL,
|
||||
"p256dh" TEXT NOT NULL,
|
||||
"auth" TEXT NOT NULL,
|
||||
"userAgent" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "PushSubscription_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PushSubscription_endpoint_key" ON "PushSubscription"("endpoint");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription_userId_idx" ON "PushSubscription"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PushSubscription_endpoint_idx" ON "PushSubscription"("endpoint");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "PushSubscription" ADD CONSTRAINT "PushSubscription_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `kycLevelDetailsId` on the `Service` table. All the data in the column will be lost.
|
||||
- Made the column `kycLevelClarification` on table `Service` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" DROP COLUMN "kycLevelDetailsId",
|
||||
ALTER COLUMN "kycLevelClarification" SET NOT NULL,
|
||||
ALTER COLUMN "kycLevelClarification" SET DEFAULT 'NONE';
|
||||
@@ -87,6 +87,7 @@ enum ServiceVisibility {
|
||||
PUBLIC
|
||||
UNLISTED
|
||||
HIDDEN
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
@@ -120,8 +121,8 @@ enum AccountStatusChange {
|
||||
ADMIN_FALSE
|
||||
VERIFIED_TRUE
|
||||
VERIFIED_FALSE
|
||||
VERIFIER_TRUE
|
||||
VERIFIER_FALSE
|
||||
MODERATOR_TRUE
|
||||
MODERATOR_FALSE
|
||||
SPAMMER_TRUE
|
||||
SPAMMER_FALSE
|
||||
}
|
||||
@@ -296,6 +297,11 @@ enum ServiceSuggestionType {
|
||||
EDIT_SERVICE
|
||||
}
|
||||
|
||||
enum KycLevelClarification {
|
||||
NONE
|
||||
DEPENDS_ON_PARTNERS
|
||||
}
|
||||
|
||||
model ServiceSuggestion {
|
||||
id Int @id @default(autoincrement())
|
||||
type ServiceSuggestionType
|
||||
@@ -335,9 +341,11 @@ model Service {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
previousSlugs String[] @default([])
|
||||
description String
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
kycLevelClarification KycLevelClarification @default(NONE)
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
@@ -376,6 +384,7 @@ model Service {
|
||||
attributes ServiceAttribute[]
|
||||
verificationSteps VerificationStep[]
|
||||
suggestions ServiceSuggestion[]
|
||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||
|
||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||
@@ -394,6 +403,7 @@ model Service {
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@index([slug])
|
||||
@@index([previousSlugs])
|
||||
}
|
||||
|
||||
model ServiceContactMethod {
|
||||
@@ -441,6 +451,7 @@ model Attribute {
|
||||
|
||||
model InternalUserNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -455,6 +466,23 @@ model InternalUserNote {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model InternalServiceNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
|
||||
addedByUserId Int?
|
||||
|
||||
@@index([serviceId])
|
||||
@@index([addedByUserId])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
@@ -464,7 +492,7 @@ model User {
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
verifier Boolean @default(false)
|
||||
moderator Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
@@ -481,10 +509,12 @@ model User {
|
||||
suggestionMessages ServiceSuggestionMessage[]
|
||||
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
|
||||
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
|
||||
addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
notifications Notification[] @relation("NotificationOwner")
|
||||
notificationPreferences NotificationPreferences?
|
||||
serviceAffiliations ServiceUser[] @relation("UserServices")
|
||||
pushSubscriptions PushSubscription[]
|
||||
|
||||
@@index([createdAt])
|
||||
@@index([totalKarma])
|
||||
@@ -633,3 +663,21 @@ model Announcement {
|
||||
|
||||
@@index([isActive, startDate, endDate])
|
||||
}
|
||||
|
||||
model PushSubscription {
|
||||
id Int @id @default(autoincrement())
|
||||
userId Int
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
endpoint String @unique
|
||||
/// Public key for encryption
|
||||
p256dh String
|
||||
/// Authentication secret
|
||||
auth String
|
||||
/// To identify different devices
|
||||
userAgent String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([endpoint])
|
||||
}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import crypto from 'crypto'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { parseArgs } from 'node:util'
|
||||
|
||||
import { faker } from '@faker-js/faker'
|
||||
import {
|
||||
AnnouncementType,
|
||||
AttributeCategory,
|
||||
AttributeType,
|
||||
CommentStatus,
|
||||
Currency,
|
||||
EventType,
|
||||
PrismaClient,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceUserRole,
|
||||
VerificationStatus,
|
||||
type Prisma,
|
||||
EventType,
|
||||
type User,
|
||||
ServiceUserRole,
|
||||
AnnouncementType,
|
||||
type ServiceVisibility,
|
||||
ServiceSuggestionType,
|
||||
KycLevelClarification,
|
||||
} from '@prisma/client'
|
||||
import { uniqBy } from 'lodash-es'
|
||||
import { omit, uniqBy } from 'lodash-es'
|
||||
import { generateUsername } from 'unique-username-generator'
|
||||
|
||||
import { kycLevels } from '../src/constants/kycLevels'
|
||||
@@ -85,7 +89,7 @@ async function createAccount(preGeneratedToken?: string) {
|
||||
verifiedLink,
|
||||
verified: !!verifiedLink,
|
||||
admin: faker.datatype.boolean({ probability: 0.1 }),
|
||||
verifier: faker.datatype.boolean({ probability: 0.1 }),
|
||||
moderator: faker.datatype.boolean({ probability: 0.1 }),
|
||||
},
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
@@ -95,20 +99,6 @@ async function createAccount(preGeneratedToken?: string) {
|
||||
return { token, user }
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2)
|
||||
const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
|
||||
const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
|
||||
|
||||
// Parse number of services from --services or -s flag
|
||||
const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
|
||||
const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
|
||||
|
||||
if (isNaN(numServices) || numServices < 1) {
|
||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
const generateFakeAttribute = () => {
|
||||
@@ -611,18 +601,35 @@ const generateFakeEvent = (serviceId: number) => {
|
||||
}
|
||||
|
||||
const generateFakeService = (users: User[]) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(VerificationStatus))
|
||||
const status = faker.helpers.weightedArrayElement<VerificationStatus>([
|
||||
{ weight: 20, value: 'VERIFICATION_SUCCESS' },
|
||||
{ weight: 30, value: 'APPROVED' },
|
||||
{ weight: 40, value: 'COMMUNITY_CONTRIBUTED' },
|
||||
{ weight: 10, value: 'VERIFICATION_FAILED' },
|
||||
])
|
||||
const name = faker.helpers.arrayElement(serviceNames)
|
||||
const slug = `${faker.helpers.slugify(name).toLowerCase()}-${faker.string.alphanumeric({ length: 6, casing: 'lower' })}`
|
||||
|
||||
return {
|
||||
name,
|
||||
slug,
|
||||
previousSlugs: faker.helpers.maybe(() => [`${slug}-old`], { probability: 0.5 }),
|
||||
description: faker.helpers.arrayElement(serviceDescriptions),
|
||||
kycLevel: faker.helpers.arrayElement(kycLevels.map((level) => level.value)),
|
||||
kycLevelClarification: faker.helpers.maybe(
|
||||
() =>
|
||||
faker.helpers.arrayElement(omit(Object.values(KycLevelClarification), [KycLevelClarification.NONE])),
|
||||
{ probability: 0.25 }
|
||||
),
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
serviceVisibility: faker.helpers.weightedArrayElement<ServiceVisibility>([
|
||||
{ weight: 80, value: 'PUBLIC' },
|
||||
{ weight: 10, value: 'UNLISTED' },
|
||||
{ weight: 5, value: 'HIDDEN' },
|
||||
{ weight: 5, value: 'ARCHIVED' },
|
||||
]),
|
||||
verificationStatus: status,
|
||||
verificationSummary:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraph() : null,
|
||||
@@ -636,25 +643,37 @@ const generateFakeService = (users: User[]) => {
|
||||
},
|
||||
verificationProofMd:
|
||||
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
|
||||
referral: `?ref=${faker.string.alphanumeric(6)}`,
|
||||
referral: faker.helpers.arrayElement([
|
||||
`?ref=${faker.string.alphanumeric(6)}`,
|
||||
`/ref/${faker.string.alphanumeric(6)}`,
|
||||
]),
|
||||
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
|
||||
serviceUrls: Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () => faker.internet.url()),
|
||||
tosUrls: Array.from({ length: faker.number.int({ min: 0, max: 2 }) }, () => faker.internet.url()),
|
||||
onionUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`
|
||||
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
|
||||
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
|
||||
onionUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 56, casing: 'lower' })}.onion`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
i2pUrls: Array.from(
|
||||
{ length: faker.number.int({ min: 0, max: 2 }) },
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`
|
||||
i2pUrls: faker.helpers.multiple(
|
||||
() => `http://${faker.string.alphanumeric({ length: 52, casing: 'lower' })}.b32.i2p`,
|
||||
{ count: { min: 0, max: 2 } }
|
||||
),
|
||||
imageUrl: `https://ui-avatars.com/api/?name=${encodeURIComponent(name)}&background=random&format=svg`,
|
||||
listedAt: faker.date.past(),
|
||||
verifiedAt: status === VerificationStatus.VERIFICATION_SUCCESS ? faker.date.past() : null,
|
||||
tosReview: faker.helpers.arrayElement(tosReviewExamples),
|
||||
tosReviewAt: faker.date.past(),
|
||||
userSentiment: Math.random() > 0.2 ? generateFakeUserSentiment() : undefined,
|
||||
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
|
||||
userSentimentAt: faker.date.recent(),
|
||||
internalNotes: faker.helpers.maybe(
|
||||
() => ({
|
||||
create: {
|
||||
content: faker.lorem.paragraph(),
|
||||
addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
|
||||
},
|
||||
}),
|
||||
{ probability: 0.33 }
|
||||
),
|
||||
} as const satisfies Prisma.ServiceCreateInput
|
||||
}
|
||||
|
||||
@@ -859,6 +878,22 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
{
|
||||
value: `https://x.com/${faker.internet.username()}`,
|
||||
},
|
||||
{
|
||||
value: `https://matrix.to/#/@${faker.internet.username()}:${faker.internet.domainName()}`,
|
||||
},
|
||||
{
|
||||
value: `https://instagram.com/${faker.internet.username()}`,
|
||||
},
|
||||
{
|
||||
value: `https://linkedin.com/in/${faker.helpers.slugify(faker.person.fullName())}`,
|
||||
},
|
||||
{
|
||||
label: faker.lorem.word({ length: 2 }),
|
||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||
},
|
||||
{
|
||||
value: `https://bitcointalk.org/index.php?topic=${faker.number.int({ min: 1, max: 1000000 }).toString()}.0`,
|
||||
},
|
||||
{
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
@@ -867,7 +902,7 @@ const generateFakeServiceContactMethod = (serviceId: number) => {
|
||||
value: faker.internet.url(),
|
||||
},
|
||||
{
|
||||
value: `https://www.linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
|
||||
value: `https://linkedin.com/company/${faker.helpers.slugify(faker.company.name())}`,
|
||||
},
|
||||
] as const satisfies Partial<Prisma.ServiceContactMethodCreateInput>[]
|
||||
|
||||
@@ -883,31 +918,31 @@ const specialUsersData = {
|
||||
envToken: 'DEV_ADMIN_USER_SECRET_TOKEN',
|
||||
defaultToken: 'admin',
|
||||
admin: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verified: true,
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
link: 'https://kycnot.me',
|
||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
||||
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||
},
|
||||
verifier: {
|
||||
name: 'verifier_dev',
|
||||
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
|
||||
defaultToken: 'verifier',
|
||||
moderator: {
|
||||
name: 'moderator_dev',
|
||||
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
|
||||
defaultToken: 'moderator',
|
||||
admin: false,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verified: true,
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
link: 'https://kycnot.me',
|
||||
picture: 'https://comments.kycnot.me/api/users/549f290e-0542-4c18-b437-5b64b35758f0/avatar?size=L',
|
||||
picture: 'https://kycnot.me/files/users/pictures/c277dc0f2f.png',
|
||||
},
|
||||
verified: {
|
||||
name: 'verified_dev',
|
||||
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
||||
defaultToken: 'verified',
|
||||
admin: false,
|
||||
verifier: false,
|
||||
moderator: false,
|
||||
verified: true,
|
||||
verifiedLink: 'https://kycnot.me',
|
||||
totalKarma: 1001,
|
||||
@@ -917,7 +952,7 @@ const specialUsersData = {
|
||||
envToken: 'DEV_NORMAL_USER_SECRET_TOKEN',
|
||||
defaultToken: 'normal',
|
||||
admin: false,
|
||||
verifier: false,
|
||||
moderator: false,
|
||||
verified: false,
|
||||
},
|
||||
spam: {
|
||||
@@ -925,7 +960,7 @@ const specialUsersData = {
|
||||
envToken: 'DEV_SPAM_USER_SECRET_TOKEN',
|
||||
defaultToken: 'spam',
|
||||
admin: false,
|
||||
verifier: false,
|
||||
moderator: false,
|
||||
verified: false,
|
||||
totalKarma: -100,
|
||||
spammer: true,
|
||||
@@ -987,359 +1022,378 @@ const generateFakeAnnouncement = () => {
|
||||
} as const satisfies Prisma.AnnouncementCreateInput
|
||||
}
|
||||
|
||||
async function runFaker() {
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// ---- Clean up existing data if requested ----
|
||||
if (shouldCleanup || onlyCleanup) {
|
||||
console.info('🧹 Cleaning up existing data...')
|
||||
async function cleanup() {
|
||||
console.info('🧹 Cleaning up existing data...')
|
||||
|
||||
try {
|
||||
await tx.commentVote.deleteMany()
|
||||
await tx.karmaTransaction.deleteMany()
|
||||
await tx.comment.deleteMany()
|
||||
await tx.serviceAttribute.deleteMany()
|
||||
await tx.serviceContactMethod.deleteMany()
|
||||
await tx.event.deleteMany()
|
||||
await tx.verificationStep.deleteMany()
|
||||
await tx.serviceSuggestionMessage.deleteMany()
|
||||
await tx.serviceSuggestion.deleteMany()
|
||||
await tx.serviceVerificationRequest.deleteMany()
|
||||
await tx.service.deleteMany()
|
||||
await tx.attribute.deleteMany()
|
||||
await tx.category.deleteMany()
|
||||
await tx.internalUserNote.deleteMany()
|
||||
await tx.user.deleteMany()
|
||||
await tx.announcement.deleteMany()
|
||||
console.info('✅ Existing data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('❌ Error cleaning up data:', error)
|
||||
throw error
|
||||
}
|
||||
if (onlyCleanup) return
|
||||
}
|
||||
|
||||
// ---- Get or create categories ----
|
||||
const categories = await Promise.all(
|
||||
categoriesToCreate.map(async (cat) => {
|
||||
const existing = await tx.category.findUnique({
|
||||
where: { name: cat.name },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
return await tx.category.create({
|
||||
data: cat,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create users ----
|
||||
const specialUsersUntyped = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||
|
||||
const { envToken, defaultToken, ...userCreateData } = userData
|
||||
const user = await tx.user.create({
|
||||
data: {
|
||||
notificationPreferences: { create: {} },
|
||||
...userCreateData,
|
||||
secretTokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||
|
||||
return [key, user] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const specialUsers = specialUsersUntyped as {
|
||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||
}
|
||||
|
||||
let users = await Promise.all(
|
||||
Array.from({ length: 10 }, async () => {
|
||||
const { user } = await createAccount()
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create attributes ----
|
||||
const attributes = await Promise.all(
|
||||
Array.from({ length: 16 }, async () => {
|
||||
return await tx.attribute.create({
|
||||
data: generateFakeAttribute(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create services ----
|
||||
const services = await Promise.all(
|
||||
Array.from({ length: numServices }, async () => {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create contact methods for each service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
tx.serviceContactMethod.create({
|
||||
data: generateFakeServiceContactMethod(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Link random attributes to the service
|
||||
await Promise.all(
|
||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||
tx.serviceAttribute.create({
|
||||
data: {
|
||||
serviceId: service.id,
|
||||
attributeId: attr.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Create events for the service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||
tx.event.create({
|
||||
data: generateFakeEvent(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return service
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service user affiliations for the service ----
|
||||
await Promise.all(
|
||||
users
|
||||
.filter((user) => user.verified)
|
||||
.map(async (user) => {
|
||||
const servicesToAddAffiliations = uniqBy(
|
||||
faker.helpers.arrayElements(services, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
|
||||
return tx.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
serviceAffiliations: {
|
||||
createMany: {
|
||||
data: servicesToAddAffiliations.map((service) => ({
|
||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||
serviceId: service.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
users = await tx.user.findMany({
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
},
|
||||
})
|
||||
|
||||
// ---- Create comments and replies ----
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
// Create parent comments
|
||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||
const commentData = Array.from({ length: commentCount }, () =>
|
||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||
)
|
||||
const indexesToUpdate = users.map((user) => {
|
||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||
})
|
||||
commentData.forEach((comment, index) => {
|
||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||
})
|
||||
|
||||
await tx.comment.createMany({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const comments = await tx.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
parentId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: commentCount,
|
||||
})
|
||||
|
||||
const affiliatedUsers = undefinedIfEmpty(
|
||||
users.filter((user) =>
|
||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||
)
|
||||
)
|
||||
|
||||
// Create replies to comments
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: replyCount }, () => {
|
||||
const user = faker.helpers.arrayElement(
|
||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||
)
|
||||
|
||||
return tx.comment.create({
|
||||
data: generateFakeComment(user.id, service.id, comment.id),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service suggestions for normal_dev user ----
|
||||
// First create 3 CREATE_SERVICE suggestions with their services
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const serviceData = generateFakeService(users)
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await tx.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
tx.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Then create 5 EDIT_SERVICE suggestions
|
||||
await Promise.all(
|
||||
services.slice(0, 5).map(async (service) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||
const suggestion = await tx.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
status,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||
tx.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create internal notes for users ----
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create 1-3 notes for each user
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
tx.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Add some notes to special users as well
|
||||
await Promise.all(
|
||||
Object.values(specialUsers).map(async (user) => {
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
tx.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.verifier.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create announcement ----
|
||||
await tx.announcement.create({
|
||||
data: generateFakeAnnouncement(),
|
||||
})
|
||||
},
|
||||
{
|
||||
timeout: 1000 * 60 * 10, // 10 minutes
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await runFaker()
|
||||
|
||||
console.info('✅ Fake data generated successfully')
|
||||
await prisma.commentVote.deleteMany()
|
||||
await prisma.karmaTransaction.deleteMany()
|
||||
await prisma.comment.deleteMany()
|
||||
await prisma.serviceAttribute.deleteMany()
|
||||
await prisma.serviceContactMethod.deleteMany()
|
||||
await prisma.event.deleteMany()
|
||||
await prisma.verificationStep.deleteMany()
|
||||
await prisma.serviceSuggestionMessage.deleteMany()
|
||||
await prisma.serviceSuggestion.deleteMany()
|
||||
await prisma.serviceVerificationRequest.deleteMany()
|
||||
await prisma.service.deleteMany()
|
||||
await prisma.attribute.deleteMany()
|
||||
await prisma.category.deleteMany()
|
||||
await prisma.internalUserNote.deleteMany()
|
||||
await prisma.user.deleteMany()
|
||||
await prisma.announcement.deleteMany()
|
||||
console.info('✅ Existing data cleaned up')
|
||||
} catch (error) {
|
||||
console.error('❌ Error generating fake data:', error)
|
||||
process.exit(1)
|
||||
} finally {
|
||||
await prisma.$disconnect()
|
||||
console.error('❌ Error cleaning up data:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
console.error('❌ Fatal error:', error)
|
||||
process.exit(1)
|
||||
})
|
||||
function importTriggers() {
|
||||
console.info('🔄 Importing SQL triggers...')
|
||||
try {
|
||||
execSync('just import-triggers', { stdio: 'inherit' })
|
||||
console.info('✅ Triggers imported')
|
||||
} catch (error) {
|
||||
console.error('❌ Error importing triggers:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { values: options } = parseArgs({
|
||||
options: {
|
||||
services: { type: 'string', short: 's', default: '100' },
|
||||
cleanup: { type: 'boolean', short: 'c', default: true },
|
||||
'only-cleanup': { type: 'boolean', short: 'o', default: false },
|
||||
},
|
||||
})
|
||||
const numServices = parseIntWithFallback(options.services, 100)
|
||||
if (isNaN(numServices) || numServices < 1) {
|
||||
console.error('❌ Invalid number of services specified. Must be a positive number.')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
importTriggers()
|
||||
|
||||
if (options.cleanup || options['only-cleanup']) {
|
||||
await cleanup()
|
||||
if (options['only-cleanup']) return
|
||||
}
|
||||
|
||||
// ---- Get or create categories ----
|
||||
const categories = await Promise.all(
|
||||
categoriesToCreate.map(async (cat) => {
|
||||
const existing = await prisma.category.findUnique({
|
||||
where: { name: cat.name },
|
||||
})
|
||||
if (existing) return existing
|
||||
|
||||
return await prisma.category.create({
|
||||
data: cat,
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create users ----
|
||||
const specialUsersUntyped = Object.fromEntries(
|
||||
await Promise.all(
|
||||
Object.entries(specialUsersData).map(async ([key, userData]) => {
|
||||
const secretToken = process.env[userData.envToken] ?? userData.defaultToken
|
||||
const secretTokenHash = hashUserSecretToken(secretToken)
|
||||
|
||||
const { envToken, defaultToken, ...userCreateData } = userData
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
notificationPreferences: { create: {} },
|
||||
...userCreateData,
|
||||
secretTokenHash,
|
||||
},
|
||||
})
|
||||
|
||||
console.info(`✅ Created ${user.name} with secret token "${secretToken}"`)
|
||||
|
||||
return [key, user] as const
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
const specialUsers = specialUsersUntyped as {
|
||||
[K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
|
||||
}
|
||||
|
||||
let users = await Promise.all(
|
||||
Array.from({ length: 10 }, async () => {
|
||||
const { user } = await createAccount()
|
||||
return user
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create attributes ----
|
||||
const attributes = await Promise.all(
|
||||
Array.from({ length: 16 }, async () => {
|
||||
return await prisma.attribute.create({
|
||||
data: generateFakeAttribute(),
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create services ----
|
||||
const services = await Promise.all(
|
||||
Array.from({ length: numServices }, async () => {
|
||||
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Create contact methods for each service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
prisma.serviceContactMethod.create({
|
||||
data: generateFakeServiceContactMethod(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Link random attributes to the service
|
||||
await Promise.all(
|
||||
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
|
||||
prisma.serviceAttribute.create({
|
||||
data: {
|
||||
serviceId: service.id,
|
||||
attributeId: attr.id,
|
||||
},
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// Create events for the service
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
|
||||
prisma.event.create({
|
||||
data: generateFakeEvent(service.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
return service
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service user affiliations for the service ----
|
||||
await Promise.all(
|
||||
users
|
||||
.filter((user) => user.verified)
|
||||
.map(async (user) => {
|
||||
const servicesToAddAffiliations = uniqBy(
|
||||
faker.helpers.arrayElements(services, {
|
||||
min: 1,
|
||||
max: 3,
|
||||
}),
|
||||
'id'
|
||||
)
|
||||
|
||||
return prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: {
|
||||
serviceAffiliations: {
|
||||
createMany: {
|
||||
data: servicesToAddAffiliations.map((service) => ({
|
||||
role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
|
||||
serviceId: service.id,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
users = await prisma.user.findMany({
|
||||
include: {
|
||||
serviceAffiliations: true,
|
||||
},
|
||||
})
|
||||
|
||||
// ---- Create comments and replies ----
|
||||
await Promise.all(
|
||||
services.map(async (service) => {
|
||||
// Create parent comments
|
||||
const commentCount = faker.number.int({ min: 1, max: 10 })
|
||||
const commentData = Array.from({ length: commentCount }, () =>
|
||||
generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
|
||||
)
|
||||
const indexesToUpdate = users.map((user) => {
|
||||
return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
|
||||
})
|
||||
commentData.forEach((comment, index) => {
|
||||
if (indexesToUpdate.includes(index)) comment.ratingActive = true
|
||||
})
|
||||
|
||||
await prisma.comment.createMany({
|
||||
data: commentData,
|
||||
})
|
||||
|
||||
const comments = await prisma.comment.findMany({
|
||||
where: {
|
||||
serviceId: service.id,
|
||||
parentId: null,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
take: commentCount,
|
||||
})
|
||||
|
||||
const affiliatedUsers = undefinedIfEmpty(
|
||||
users.filter((user) =>
|
||||
user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
|
||||
)
|
||||
)
|
||||
|
||||
// Create replies to comments
|
||||
await Promise.all(
|
||||
comments.map(async (comment) => {
|
||||
const replyCount = faker.number.int({ min: 0, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: replyCount }, () => {
|
||||
const user = faker.helpers.arrayElement(
|
||||
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
|
||||
)
|
||||
|
||||
return prisma.comment.create({
|
||||
data: generateFakeComment(user.id, service.id, comment.id),
|
||||
})
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create service suggestions for normal_dev user ----
|
||||
// First create 3 CREATE_SERVICE suggestions with their services
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const serviceData = generateFakeService([...users, ...Object.values(specialUsers)])
|
||||
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
categories: {
|
||||
connect: randomCategories.map((cat) => ({ id: cat.id })),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
|
||||
prisma.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
// Then create 5 EDIT_SERVICE suggestions
|
||||
await Promise.all(
|
||||
services.slice(0, 5).map(async (service) => {
|
||||
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
|
||||
const suggestion = await prisma.serviceSuggestion.create({
|
||||
data: generateFakeServiceSuggestion({
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
status,
|
||||
userId: specialUsers.normal.id,
|
||||
serviceId: service.id,
|
||||
}),
|
||||
})
|
||||
|
||||
// Create some messages for each suggestion
|
||||
await Promise.all(
|
||||
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
|
||||
prisma.serviceSuggestionMessage.create({
|
||||
data: generateFakeServiceSuggestionMessage(suggestion.id, [
|
||||
specialUsers.normal.id,
|
||||
specialUsers.admin.id,
|
||||
]),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create internal notes for users ----
|
||||
await Promise.all(
|
||||
users.map(async (user) => {
|
||||
// Create 1-3 notes for each user
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
prisma.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// Add some notes to special users as well
|
||||
await Promise.all(
|
||||
Object.values(specialUsers).map(async (user) => {
|
||||
const numNotes = faker.number.int({ min: 1, max: 3 })
|
||||
return Promise.all(
|
||||
Array.from({ length: numNotes }, () =>
|
||||
prisma.internalUserNote.create({
|
||||
data: generateFakeInternalNote(
|
||||
user.id,
|
||||
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
|
||||
),
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
)
|
||||
|
||||
// ---- Create announcement ----
|
||||
await prisma.announcement.create({
|
||||
data: generateFakeAnnouncement(),
|
||||
})
|
||||
}
|
||||
|
||||
main()
|
||||
.then(async () => {
|
||||
console.info('✅ Fake data generated successfully')
|
||||
await prisma.$disconnect()
|
||||
})
|
||||
.catch(async (error: unknown) => {
|
||||
console.error(
|
||||
'❌ Fatal error:',
|
||||
typeof error === 'object' && error !== null && 'message' in error ? error.message : 'Unknown error'
|
||||
)
|
||||
console.error(error)
|
||||
await prisma.$disconnect()
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -25,12 +25,12 @@ BEGIN
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
END IF;
|
||||
|
||||
-- Check for verifier status change
|
||||
IF OLD.verifier IS DISTINCT FROM NEW.verifier THEN
|
||||
IF NEW.verifier = true THEN
|
||||
status_change := 'VERIFIER_TRUE';
|
||||
-- Check for moderator status change
|
||||
IF OLD.moderator IS DISTINCT FROM NEW.moderator THEN
|
||||
IF NEW.moderator = true THEN
|
||||
status_change := 'MODERATOR_TRUE';
|
||||
ELSE
|
||||
status_change := 'VERIFIER_FALSE';
|
||||
status_change := 'MODERATOR_FALSE';
|
||||
END IF;
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutAccountStatusChange")
|
||||
VALUES (NEW.id, 'ACCOUNT_STATUS_CHANGE', status_change);
|
||||
@@ -57,6 +57,6 @@ DROP TRIGGER IF EXISTS user_status_change_notifications_trigger ON "User";
|
||||
|
||||
-- Create the trigger to fire after updates on specific status columns
|
||||
CREATE TRIGGER user_status_change_notifications_trigger
|
||||
AFTER UPDATE OF admin, verified, verifier, spammer ON "User"
|
||||
AFTER UPDATE OF admin, verified, moderator, spammer ON "User"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_user_status_change_notifications();
|
||||
|
||||
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
16
web/prisma/triggers/12_notification_push_trigger.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE OR REPLACE FUNCTION trigger_notification_push()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('notification_created', json_build_object('id', NEW.id)::text);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the trigger if it exists to ensure a clean setup
|
||||
DROP TRIGGER IF EXISTS notification_push_trigger ON "Notification";
|
||||
|
||||
-- Create the trigger to fire after inserts
|
||||
CREATE TRIGGER notification_push_trigger
|
||||
AFTER INSERT ON "Notification"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_notification_push();
|
||||
83
web/public/sw.js
Normal file
83
web/public/sw.js
Normal file
@@ -0,0 +1,83 @@
|
||||
// @ts-check
|
||||
|
||||
/// <reference lib="webworker" />
|
||||
|
||||
/** @type {ServiceWorkerGlobalScope} */
|
||||
// @ts-expect-error
|
||||
const typedSelf = self
|
||||
|
||||
const CACHE_NAME = 'kycnot-sw-push-notifications-v1'
|
||||
|
||||
typedSelf.addEventListener('install', (event) => {
|
||||
console.log('Service Worker installing')
|
||||
typedSelf.skipWaiting()
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('activate', (event) => {
|
||||
console.log('Service Worker activating')
|
||||
event.waitUntil(typedSelf.clients.claim())
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('push', (event) => {
|
||||
console.log('Push event received:', event)
|
||||
|
||||
if (!event.data) {
|
||||
console.log('Push event but no data')
|
||||
return
|
||||
}
|
||||
|
||||
let notificationData
|
||||
try {
|
||||
notificationData = event.data.json()
|
||||
} catch (error) {
|
||||
console.error('Error parsing push data:', error)
|
||||
notificationData = {
|
||||
title: 'New Notification',
|
||||
options: {
|
||||
body: event.data.text() || 'You have a new notification',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const { title, options } = notificationData
|
||||
|
||||
const notificationOptions = {
|
||||
body: options.body || '',
|
||||
icon: options.icon || '/favicon.svg',
|
||||
badge: options.badge || '/favicon.svg',
|
||||
data: options.data || {},
|
||||
requireInteraction: false,
|
||||
silent: false,
|
||||
...options,
|
||||
}
|
||||
|
||||
event.waitUntil(typedSelf.registration.showNotification(title, notificationOptions))
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclick', (event) => {
|
||||
console.log('Notification clicked:', event)
|
||||
|
||||
event.notification.close()
|
||||
|
||||
const url = event.notification.data?.url || '/'
|
||||
|
||||
event.waitUntil(
|
||||
typedSelf.clients.matchAll({ type: 'window' }).then((clientList) => {
|
||||
// If a window is already open, focus it
|
||||
for (const client of clientList) {
|
||||
if (client.url === url && 'focus' in client) {
|
||||
return client.focus()
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, open a new window
|
||||
if (typedSelf.clients.openWindow) {
|
||||
return typedSelf.clients.openWindow(url)
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
typedSelf.addEventListener('notificationclose', (event) => {
|
||||
console.log('Notification closed:', event)
|
||||
})
|
||||
@@ -1,11 +1,9 @@
|
||||
import { type Prisma, type PrismaClient } from '@prisma/client'
|
||||
import { type Prisma } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const selectAnnouncementReturnFields = {
|
||||
id: true,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { adminAnnouncementActions } from './announcement'
|
||||
import { adminAttributeActions } from './attribute'
|
||||
import { adminEventActions } from './event'
|
||||
import { adminNotificationActions } from './notification'
|
||||
import { adminServiceActions } from './service'
|
||||
import { adminServiceSuggestionActions } from './serviceSuggestion'
|
||||
import { adminUserActions } from './user'
|
||||
import { verificationStep } from './verificationStep'
|
||||
|
||||
export const adminActions = {
|
||||
attribute: adminAttributeActions,
|
||||
announcement: adminAnnouncementActions,
|
||||
attribute: adminAttributeActions,
|
||||
event: adminEventActions,
|
||||
notification: adminNotificationActions,
|
||||
service: adminServiceActions,
|
||||
serviceSuggestions: adminServiceSuggestionActions,
|
||||
user: adminUserActions,
|
||||
|
||||
80
web/src/actions/admin/notification.ts
Normal file
80
web/src/actions/admin/notification.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { sumBy } from 'lodash-es'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { sendPushNotification } from '../../lib/webPush'
|
||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||
|
||||
export const adminNotificationActions = {
|
||||
webPush: {
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
title: z.string().min(1).nullable(),
|
||||
body: z.string().nullable(),
|
||||
url: z.string().url().optional(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { user: { name: { in: input.userNames } } },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
userAgent: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: input.title ?? 'Test Notification',
|
||||
body: input.body ?? 'This is a test push notification from KYCNot.me',
|
||||
url: input.url ?? '/',
|
||||
}
|
||||
)
|
||||
|
||||
// If subscription is invalid, remove it from database
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({
|
||||
where: { id: subscription.id },
|
||||
})
|
||||
console.info(`Removed invalid subscription for user ${subscription.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 1 : 0))
|
||||
const failureCount = sumBy(results, (r) => (r.status === 'fulfilled' && r.value ? 0 : 1))
|
||||
const now = new Date()
|
||||
return {
|
||||
message: `Sent to ${successCount.toLocaleString()} devices, ${failureCount.toLocaleString()} failed. Sent at ${now.toLocaleString()}`,
|
||||
totalSubscriptions: subscriptions.length,
|
||||
successCount,
|
||||
failureCount,
|
||||
sentAt: now,
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
@@ -1,42 +1,20 @@
|
||||
import { Currency, ServiceVisibility, VerificationStatus } from '@prisma/client'
|
||||
import { Currency, ServiceVisibility, VerificationStatus, KycLevelClarification } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { uniq } from 'lodash-es'
|
||||
import slugify from 'slugify'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { saveFileLocally, deleteFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../../lib/urls'
|
||||
import {
|
||||
imageFileSchema,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
zodContactMethod,
|
||||
} from '../../lib/zodUtils'
|
||||
|
||||
const serviceSchemaBase = z.object({
|
||||
id: z.number().int().positive(),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
name: z.string().min(1).max(20),
|
||||
description: z.string().min(1),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||
verificationSummary: z.string().optional().nullable().default(null),
|
||||
verificationProofMd: z.string().optional().nullable().default(null),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
||||
referral: z.string().optional().nullable().default(null),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
})
|
||||
|
||||
const addSlugIfMissing = <
|
||||
T extends {
|
||||
slug?: string | null | undefined
|
||||
@@ -56,12 +34,61 @@ const addSlugIfMissing = <
|
||||
}),
|
||||
})
|
||||
|
||||
const serviceSchemaBase = z.object({
|
||||
id: z.number().int().positive(),
|
||||
slug: z
|
||||
.string()
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
name: z.string().min(1).max(40),
|
||||
description: z.string().min(1),
|
||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
kycLevel: z.coerce.number().int().min(0).max(4),
|
||||
kycLevelClarification: z.nativeEnum(KycLevelClarification).optional().nullable().default(null),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
verificationStatus: z.nativeEnum(VerificationStatus),
|
||||
verificationSummary: z.string().optional().nullable().default(null),
|
||||
verificationProofMd: z.string().optional().nullable().default(null),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)),
|
||||
referral: z
|
||||
.string()
|
||||
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(null),
|
||||
imageFile: imageFileSchema,
|
||||
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
internalNote: z.string().optional(),
|
||||
})
|
||||
|
||||
// Define schema for the create action input
|
||||
const createServiceInputSchema = serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing)
|
||||
|
||||
// Define schema for the update action input
|
||||
const updateServiceInputSchema = serviceSchemaBase
|
||||
.extend({
|
||||
removeImage: z.boolean().optional(),
|
||||
})
|
||||
.transform(addSlugIfMissing)
|
||||
|
||||
const evidenceImageAddSchema = z.object({
|
||||
serviceId: z.number().int().positive(),
|
||||
imageFile: imageFileSchema,
|
||||
})
|
||||
|
||||
const evidenceImageDeleteSchema = z.object({
|
||||
fileUrl: z.string().startsWith('/files/evidence/', 'Must be a valid evidence file URL'),
|
||||
})
|
||||
|
||||
export const adminServiceActions = {
|
||||
create: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
input: createServiceInputSchema,
|
||||
handler: async (input: z.infer<typeof createServiceInputSchema>, context) => {
|
||||
const existing = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
@@ -75,12 +102,34 @@ export const adminServiceActions = {
|
||||
})
|
||||
}
|
||||
|
||||
const { imageFile, ...serviceData } = input
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
const imageUrl = input.imageFile
|
||||
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
: undefined
|
||||
|
||||
const {
|
||||
web: serviceUrls,
|
||||
onion: onionUrls,
|
||||
i2p: i2pUrls,
|
||||
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||
|
||||
const service = await prisma.service.create({
|
||||
data: {
|
||||
...serviceData,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -92,6 +141,14 @@ export const adminServiceActions = {
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
internalNotes: input.internalNote
|
||||
? {
|
||||
create: {
|
||||
content: input.internalNote,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -106,34 +163,40 @@ export const adminServiceActions = {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: serviceSchemaBase.transform(addSlugIfMissing),
|
||||
handler: async (input) => {
|
||||
const { id, categories, attributes, imageFile, ...data } = input
|
||||
|
||||
const existing = await prisma.service.findUnique({
|
||||
input: updateServiceInputSchema,
|
||||
handler: async (input: z.infer<typeof updateServiceInputSchema>) => {
|
||||
const anotherServiceWithNewSlug = await prisma.service.findUnique({
|
||||
where: {
|
||||
slug: input.slug,
|
||||
NOT: { id },
|
||||
NOT: { id: input.id },
|
||||
},
|
||||
})
|
||||
|
||||
if (existing) {
|
||||
if (anotherServiceWithNewSlug) {
|
||||
throw new ActionError({
|
||||
code: 'CONFLICT',
|
||||
message: 'A service with this slug already exists',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = imageFile ? await saveFileLocally(imageFile, imageFile.name) : undefined
|
||||
|
||||
// Get existing attributes and categories to compute differences
|
||||
const existingService = await prisma.service.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
categories: true,
|
||||
where: { id: input.id },
|
||||
select: {
|
||||
slug: true,
|
||||
previousSlugs: true,
|
||||
categories: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
attributes: {
|
||||
include: {
|
||||
attribute: true,
|
||||
select: {
|
||||
attributeId: true,
|
||||
attribute: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -146,89 +209,259 @@ export const adminServiceActions = {
|
||||
})
|
||||
}
|
||||
|
||||
// Find categories to connect and disconnect
|
||||
const existingCategoryIds = existingService.categories.map((c) => c.id)
|
||||
const categoriesToAdd = categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !categories.includes(cId))
|
||||
const categoriesToAdd = input.categories.filter((cId) => !existingCategoryIds.includes(cId))
|
||||
const categoriesToRemove = existingCategoryIds.filter((cId) => !input.categories.includes(cId))
|
||||
|
||||
// Find attributes to connect and disconnect
|
||||
const existingAttributeIds = existingService.attributes.map((a) => a.attributeId)
|
||||
const attributesToAdd = attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !attributes.includes(aId))
|
||||
const attributesToAdd = input.attributes.filter((aId) => !existingAttributeIds.includes(aId))
|
||||
const attributesToRemove = existingAttributeIds.filter((aId) => !input.attributes.includes(aId))
|
||||
|
||||
const imageUrl = input.removeImage
|
||||
? null
|
||||
: input.imageFile
|
||||
? await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
: undefined
|
||||
|
||||
const {
|
||||
web: serviceUrls,
|
||||
onion: onionUrls,
|
||||
i2p: i2pUrls,
|
||||
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||
|
||||
const service = await prisma.service.update({
|
||||
where: { id },
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
...data,
|
||||
name: input.name,
|
||||
description: input.description,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification ?? undefined,
|
||||
verificationStatus: input.verificationStatus,
|
||||
verificationSummary: input.verificationSummary,
|
||||
verificationProofMd: input.verificationProofMd,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
referral: input.referral,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
overallScore: input.overallScore,
|
||||
previousSlugs:
|
||||
existingService.slug !== input.slug
|
||||
? {
|
||||
set: uniq([...existingService.previousSlugs, existingService.slug]).filter(
|
||||
(slug) => slug !== input.slug
|
||||
),
|
||||
}
|
||||
: undefined,
|
||||
|
||||
imageUrl,
|
||||
categories: {
|
||||
connect: categoriesToAdd.map((id) => ({ id })),
|
||||
disconnect: categoriesToRemove.map((id) => ({ id })),
|
||||
},
|
||||
attributes: {
|
||||
// Connect new attributes
|
||||
create: attributesToAdd.map((attributeId) => ({
|
||||
attribute: {
|
||||
connect: { id: attributeId },
|
||||
},
|
||||
})),
|
||||
// Delete specific attributes that are no longer needed
|
||||
|
||||
deleteMany: attributesToRemove.map((attributeId) => ({
|
||||
attributeId,
|
||||
})),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
createContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
label: z.string().min(1).max(50).optional(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
contactMethod: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: zodContactMethod,
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: input,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
updateContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive().optional(),
|
||||
label: z.string().min(1).max(50).optional(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: z.string().url(),
|
||||
serviceId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id: input.id },
|
||||
data: {
|
||||
label: input.label,
|
||||
value: input.value,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const { id, ...data } = input
|
||||
const contactMethod = await prisma.serviceContactMethod.update({
|
||||
where: { id },
|
||||
data,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
deleteContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
handler: async (input) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
internalNote: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
serviceId: z.number().int().positive(),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
handler: async (input, { locals }) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.create({
|
||||
data: {
|
||||
content: input.content,
|
||||
serviceId: input.serviceId,
|
||||
addedByUserId: locals.user.id,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.number().int().positive(),
|
||||
content: z.string().min(1),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const note = await prisma.internalServiceNote.findUnique({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Note not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.update({
|
||||
where: { id: input.noteId },
|
||||
data: { content: input.content },
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
noteId: z.number().int().positive(),
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const note = await prisma.internalServiceNote.findUnique({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
|
||||
if (!note) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Note not found',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.internalServiceNote.delete({
|
||||
where: { id: input.noteId },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
evidenceImage: {
|
||||
add: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: evidenceImageAddSchema,
|
||||
handler: async (input) => {
|
||||
const service = await prisma.service.findUnique({
|
||||
where: { id: input.serviceId },
|
||||
select: { slug: true },
|
||||
})
|
||||
|
||||
if (!service) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found to associate image with.',
|
||||
})
|
||||
}
|
||||
|
||||
if (!input.imageFile) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'Image file is required.',
|
||||
})
|
||||
}
|
||||
|
||||
const imageUrl = await saveFileLocally(
|
||||
input.imageFile,
|
||||
input.imageFile.name,
|
||||
`evidence/${service.slug}`
|
||||
)
|
||||
|
||||
return { imageUrl }
|
||||
},
|
||||
}),
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: evidenceImageDeleteSchema,
|
||||
handler: async (input) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
|
||||
await deleteFileLocally(input.fileUrl)
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ const selectUserReturnFields = {
|
||||
picture: true,
|
||||
admin: true,
|
||||
verified: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verifiedLink: true,
|
||||
secretTokenHash: true,
|
||||
totalKarma: true,
|
||||
@@ -55,7 +55,7 @@ export const adminUserActions = {
|
||||
.default(null) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
.transform((val) => val || null),
|
||||
pictureFile: z.instanceof(File).optional(),
|
||||
type: z.array(z.enum(['admin', 'verifier', 'spammer'])),
|
||||
type: z.array(z.enum(['admin', 'moderator', 'spammer'])),
|
||||
verifiedLink: z
|
||||
.string()
|
||||
.url('Invalid URL')
|
||||
@@ -101,7 +101,7 @@ export const adminUserActions = {
|
||||
verified: !!valuesToUpdate.verifiedLink,
|
||||
picture: pictureUrl,
|
||||
admin: type.includes('admin'),
|
||||
verifier: type.includes('verifier'),
|
||||
moderator: type.includes('moderator'),
|
||||
spammer: type.includes('spammer'),
|
||||
},
|
||||
select: selectUserReturnFields,
|
||||
|
||||
5
web/src/actions/api/index.ts
Normal file
5
web/src/actions/api/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { apiServiceActions } from './service'
|
||||
|
||||
export const apiActions = {
|
||||
service: apiServiceActions,
|
||||
}
|
||||
151
web/src/actions/api/service.ts
Normal file
151
web/src/actions/api/service.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { getKycLevelClarificationInfo } from '../../constants/kycLevelClarifications'
|
||||
import { getKycLevelInfo } from '../../constants/kycLevels'
|
||||
import { getVerificationStatusInfo } from '../../constants/verificationStatus'
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { zodUrlOptionalProtocol } from '../../lib/zodUtils'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
export const apiServiceActions = {
|
||||
get: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
id: z.coerce.number().int().positive().optional(),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.regex(/^[a-z0-9-]+$/, 'Allowed characters: lowercase letters, numbers, and hyphens')
|
||||
.optional(),
|
||||
url: zodUrlOptionalProtocol.optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (!input.id && !input.slug && !input.url) {
|
||||
throw new ActionError({
|
||||
code: 'BAD_REQUEST',
|
||||
message: 'At least one of the following parameters is required: id, slug, url',
|
||||
})
|
||||
}
|
||||
|
||||
const urlVariants = input.url
|
||||
? [input.url]
|
||||
.flatMap((url) =>
|
||||
[
|
||||
url,
|
||||
url.startsWith('http://') ? url.replace('http://', 'https://') : undefined,
|
||||
url.startsWith('https://') ? url.replace('https://', 'http://') : undefined,
|
||||
].filter((url) => url !== undefined)
|
||||
)
|
||||
.flatMap((url) => [url, url.endsWith('/') ? url.slice(0, -1) : `${url}/`])
|
||||
: undefined
|
||||
|
||||
const select = {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
description: true,
|
||||
kycLevel: true,
|
||||
kycLevelClarification: true,
|
||||
verificationStatus: true,
|
||||
categories: {
|
||||
select: {
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
serviceUrls: true,
|
||||
onionUrls: true,
|
||||
i2pUrls: true,
|
||||
tosUrls: true,
|
||||
referral: true,
|
||||
listedAt: true,
|
||||
verifiedAt: true,
|
||||
serviceVisibility: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
let service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
OR: [
|
||||
...(input.id ? ([{ id: input.id }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||
...(input.slug ? ([{ slug: input.slug }] satisfies Prisma.ServiceWhereInput[]) : []),
|
||||
...(urlVariants
|
||||
? ([
|
||||
{ serviceUrls: { hasSome: urlVariants } },
|
||||
{ onionUrls: { hasSome: urlVariants } },
|
||||
{ i2pUrls: { hasSome: urlVariants } },
|
||||
] satisfies Prisma.ServiceWhereInput[])
|
||||
: []),
|
||||
],
|
||||
},
|
||||
select,
|
||||
})
|
||||
|
||||
if (!service && input.slug) {
|
||||
service = await prisma.service.findFirst({
|
||||
where: {
|
||||
listedAt: { lte: new Date() },
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
previousSlugs: { has: input.slug },
|
||||
},
|
||||
select,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!service ||
|
||||
(service.serviceVisibility !== 'PUBLIC' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' &&
|
||||
service.serviceVisibility !== 'UNLISTED') ||
|
||||
!service.listedAt ||
|
||||
service.listedAt > new Date()
|
||||
) {
|
||||
throw new ActionError({
|
||||
code: 'NOT_FOUND',
|
||||
message: 'Service not found',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
id: service.id,
|
||||
slug: service.slug,
|
||||
name: service.name,
|
||||
description: service.description,
|
||||
serviceVisibility: service.serviceVisibility,
|
||||
verificationStatus: service.verificationStatus,
|
||||
verificationStatusInfo: pick(getVerificationStatusInfo(service.verificationStatus), [
|
||||
'value',
|
||||
'slug',
|
||||
'label',
|
||||
'labelShort',
|
||||
'description',
|
||||
]),
|
||||
verifiedAt: service.verifiedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||
'value',
|
||||
'name',
|
||||
'description',
|
||||
]),
|
||||
categories: service.categories,
|
||||
listedAt: service.listedAt,
|
||||
serviceUrls: [...service.serviceUrls, ...service.onionUrls, ...service.i2pUrls].map(
|
||||
(url) => url + (service.referral ?? '')
|
||||
),
|
||||
tosUrls: service.tosUrls,
|
||||
kycnotmeUrl: new URL(`/service/${service.slug}`, context.url).href,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export const commentActions = {
|
||||
}),
|
||||
|
||||
moderate: defineProtectedAction({
|
||||
permissions: ['admin', 'verifier'],
|
||||
permissions: ['admin', 'moderator'],
|
||||
input: z.object({
|
||||
commentId: z.number(),
|
||||
userId: z.number(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { accountActions } from './account'
|
||||
import { adminActions } from './admin'
|
||||
import { apiActions } from './api'
|
||||
import { commentActions } from './comment'
|
||||
import { notificationActions } from './notifications'
|
||||
import { serviceActions } from './service'
|
||||
@@ -19,6 +20,7 @@ import { serviceSuggestionActions } from './serviceSuggestion'
|
||||
export const server = {
|
||||
account: accountActions,
|
||||
admin: adminActions,
|
||||
api: apiActions,
|
||||
comment: commentActions,
|
||||
notification: notificationActions,
|
||||
service: serviceActions,
|
||||
|
||||
@@ -23,6 +23,63 @@ export const notificationActions = {
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
webPush: {
|
||||
subscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
endpoint: z.string(),
|
||||
p256dhKey: z.string(),
|
||||
authKey: z.string(),
|
||||
userAgent: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
update: {
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
userAgent: input.userAgent,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
unsubscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
endpoint: z.string().optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
if (input.endpoint) {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
} else {
|
||||
await prisma.pushSubscription.deleteMany({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}),
|
||||
},
|
||||
preferences: {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
@@ -31,7 +88,7 @@ export const notificationActions = {
|
||||
enableOnMyCommentStatusChange: z.coerce.boolean().optional(),
|
||||
enableAutowatchMyComments: z.coerce.boolean().optional(),
|
||||
enableNotifyPendingRepliesOnWatch: z.coerce.boolean().optional(),
|
||||
karmaNotificationThreshold: z.coerce.number().int().min(1).optional(),
|
||||
karmaNotificationThreshold: z.coerce.number().int().min(1).max(1_000_000).optional(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.notificationPreferences.upsert({
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
import {
|
||||
Currency,
|
||||
ServiceSuggestionStatus,
|
||||
ServiceSuggestionType,
|
||||
ServiceVisibility,
|
||||
VerificationStatus,
|
||||
} from '@prisma/client'
|
||||
import { Currency, KycLevelClarification } from '@prisma/client'
|
||||
import { z } from 'astro/zod'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { formatDistanceStrict } from 'date-fns'
|
||||
@@ -12,11 +6,13 @@ import { formatDistanceStrict } from 'date-fns'
|
||||
import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../lib/captchaValidation'
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { findServicesBySimilarity } from '../lib/findServicesBySimilarity'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { separateServiceUrlsByType } from '../lib/urls'
|
||||
import {
|
||||
imageFileSchemaRequired,
|
||||
stringListOfUrlsSchema,
|
||||
stringListOfContactMethodsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
} from '../lib/zodUtils'
|
||||
@@ -33,11 +29,12 @@ export const SUGGESTION_DESCRIPTION_MAX_LENGTH = 100
|
||||
export const SUGGESTION_MESSAGE_CONTENT_MAX_LENGTH = 1000
|
||||
|
||||
const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
const possibleDuplicates = await prisma.service.findMany({
|
||||
const matches = await findServicesBySimilarity(input.name, 0.3)
|
||||
|
||||
return await prisma.service.findMany({
|
||||
where: {
|
||||
name: {
|
||||
contains: input.name,
|
||||
mode: 'insensitive',
|
||||
id: {
|
||||
in: matches.map(({ id }) => id),
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -47,8 +44,6 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
@@ -122,9 +117,9 @@ export const serviceSuggestionActions = {
|
||||
|
||||
const serviceSuggestion = await prisma.serviceSuggestion.create({
|
||||
data: {
|
||||
type: ServiceSuggestionType.EDIT_SERVICE,
|
||||
type: 'EDIT_SERVICE',
|
||||
notes: combinedNotes,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
status: 'PENDING',
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
@@ -161,10 +156,11 @@ export const serviceSuggestionActions = {
|
||||
{ message: 'Slug must be unique, try a different one' }
|
||||
),
|
||||
description: z.string().min(1).max(SUGGESTION_DESCRIPTION_MAX_LENGTH),
|
||||
serviceUrls: stringListOfUrlsSchemaRequired,
|
||||
allServiceUrls: stringListOfUrlsSchemaRequired,
|
||||
tosUrls: stringListOfUrlsSchemaRequired,
|
||||
onionUrls: stringListOfUrlsSchema,
|
||||
contactMethods: stringListOfContactMethodsSchema,
|
||||
kycLevel: zodCohercedNumber(z.coerce.number().int().min(0).max(4)),
|
||||
kycLevelClarification: z.nativeEnum(KycLevelClarification),
|
||||
attributes: z.array(z.coerce.number().int().positive()),
|
||||
categories: z.array(z.coerce.number().int().positive()).min(1),
|
||||
acceptedCurrencies: z.array(z.nativeEnum(Currency)).min(1),
|
||||
@@ -210,6 +206,12 @@ export const serviceSuggestionActions = {
|
||||
|
||||
const imageUrl = await saveFileLocally(input.imageFile, input.imageFile.name)
|
||||
|
||||
const {
|
||||
web: serviceUrls,
|
||||
onion: onionUrls,
|
||||
i2p: i2pUrls,
|
||||
} = separateServiceUrlsByType(input.allServiceUrls)
|
||||
|
||||
const { serviceSuggestion, service } = await prisma.$transaction(async (tx) => {
|
||||
const serviceSelect = {
|
||||
id: true,
|
||||
@@ -221,18 +223,20 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
serviceUrls: input.serviceUrls,
|
||||
serviceUrls,
|
||||
tosUrls: input.tosUrls,
|
||||
onionUrls: input.onionUrls,
|
||||
onionUrls,
|
||||
i2pUrls,
|
||||
kycLevel: input.kycLevel,
|
||||
kycLevelClarification: input.kycLevelClarification,
|
||||
acceptedCurrencies: input.acceptedCurrencies,
|
||||
imageUrl,
|
||||
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
|
||||
verificationStatus: 'COMMUNITY_CONTRIBUTED',
|
||||
overallScore: 0,
|
||||
privacyScore: 0,
|
||||
trustScore: 0,
|
||||
listedAt: new Date(),
|
||||
serviceVisibility: ServiceVisibility.UNLISTED,
|
||||
serviceVisibility: 'UNLISTED',
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -241,6 +245,11 @@ export const serviceSuggestionActions = {
|
||||
attributeId: id,
|
||||
})),
|
||||
},
|
||||
contactMethods: {
|
||||
create: input.contactMethods.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: serviceSelect,
|
||||
})
|
||||
@@ -248,8 +257,8 @@ export const serviceSuggestionActions = {
|
||||
const serviceSuggestion = await tx.serviceSuggestion.create({
|
||||
data: {
|
||||
notes: input.notes,
|
||||
type: ServiceSuggestionType.CREATE_SERVICE,
|
||||
status: ServiceSuggestionStatus.PENDING,
|
||||
type: 'CREATE_SERVICE',
|
||||
status: 'PENDING',
|
||||
userId: context.locals.user.id,
|
||||
serviceId: service.id,
|
||||
},
|
||||
|
||||
@@ -2,13 +2,14 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { Polymorphic } from 'astro/types'
|
||||
|
||||
const badge = tv({
|
||||
slots: {
|
||||
base: 'inline-flex h-4 items-center justify-center gap-0.75 rounded-full px-1.25 text-[10px] font-medium',
|
||||
icon: 'size-3 shrink-0',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap',
|
||||
text: 'mx-0.25 overflow-hidden text-ellipsis whitespace-nowrap [&>a]:hover:underline [&>a]:focus-visible:underline',
|
||||
},
|
||||
variants: {
|
||||
color: {
|
||||
@@ -122,22 +123,29 @@ const badge = tv({
|
||||
},
|
||||
})
|
||||
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<
|
||||
type Props<
|
||||
Tag extends 'a' | 'div' | 'li' = 'div',
|
||||
Text extends string | undefined = string | undefined,
|
||||
> = Polymorphic<
|
||||
VariantProps<typeof badge> & {
|
||||
as: Tag
|
||||
icon?: string
|
||||
text: string
|
||||
endIcon?: string
|
||||
text?: Text
|
||||
inlineIcon?: boolean
|
||||
classNames?: {
|
||||
icon?: string
|
||||
text?: string
|
||||
endIcon?: string
|
||||
}
|
||||
children?: Text extends string ? never : AstroChildren
|
||||
}
|
||||
>
|
||||
|
||||
const {
|
||||
as: Tag = 'div',
|
||||
icon: iconName,
|
||||
endIcon: endIconName,
|
||||
text: textContent,
|
||||
inlineIcon,
|
||||
classNames,
|
||||
@@ -158,5 +166,10 @@ const { base, icon: iconSlot, text: textSlot } = badge({ color, variant })
|
||||
<Icon name={iconName} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent}</span>
|
||||
<span class={textSlot({ class: classNames?.text })}>{textContent ?? <slot />}</span>
|
||||
{
|
||||
!!endIconName && (
|
||||
<Icon name={endIconName} class={iconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon} />
|
||||
)
|
||||
}
|
||||
</Tag>
|
||||
|
||||
@@ -8,11 +8,12 @@ import type { Polymorphic } from 'astro/types'
|
||||
type Props<Tag extends 'a' | 'div' | 'li' = 'div'> = Polymorphic<{
|
||||
as: Tag
|
||||
icon: string
|
||||
endIcon?: string
|
||||
text: string
|
||||
inlineIcon?: boolean
|
||||
}>
|
||||
|
||||
const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
const { icon, text, class: className, inlineIcon, endIcon, as: Tag = 'div', ...divProps } = Astro.props
|
||||
---
|
||||
|
||||
<Tag
|
||||
@@ -24,4 +25,5 @@ const { icon, text, class: className, inlineIcon, as: Tag = 'div', ...divProps }
|
||||
>
|
||||
<Icon name={icon} class="size-4" is:inline={inlineIcon} />
|
||||
<span>{text}</span>
|
||||
{!!endIcon && <Icon name={endIcon} class="size-4" is:inline={inlineIcon} />}
|
||||
</Tag>
|
||||
|
||||
41
web/src/components/BadgeStandardFilter.astro
Normal file
41
web/src/components/BadgeStandardFilter.astro
Normal file
@@ -0,0 +1,41 @@
|
||||
---
|
||||
import { uniq } from 'lodash-es'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import BadgeStandard from './BadgeStandard.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<
|
||||
ComponentProps<typeof BadgeStandard>,
|
||||
'as' | 'endIcon' | 'href' | 'icon' | 'text' | 'variant'
|
||||
> & {
|
||||
name: string
|
||||
value: string
|
||||
label: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
const { name, value, label, icon, ...props } = Astro.props
|
||||
|
||||
const selectedValues = Astro.url.searchParams.getAll(name)
|
||||
const isSelected = selectedValues.includes(value)
|
||||
|
||||
const url = new URL(Astro.url)
|
||||
url.searchParams.delete(name)
|
||||
const valuesToSet = uniq(isSelected ? selectedValues.filter((v) => v !== value) : [...selectedValues, value])
|
||||
for (const value of valuesToSet) {
|
||||
url.searchParams.set(name, value)
|
||||
}
|
||||
---
|
||||
|
||||
<BadgeStandard
|
||||
as="a"
|
||||
href={url.href}
|
||||
class={cn(isSelected && 'bg-green-950 text-green-500')}
|
||||
text={label}
|
||||
icon={icon}
|
||||
endIcon={isSelected ? 'ri:close-fill' : undefined}
|
||||
{...props}
|
||||
/>
|
||||
@@ -2,13 +2,15 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { tv, type VariantProps } from 'tailwind-variants'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { HTMLAttributes, Polymorphic } from 'astro/types'
|
||||
|
||||
type Props<Tag extends 'a' | 'button' | 'label' = 'button'> = Polymorphic<
|
||||
type Props<Tag extends 'a' | 'button' | 'label' | 'span' = 'button'> = Polymorphic<
|
||||
Required<Pick<HTMLAttributes<'label'>, Tag extends 'label' ? 'for' : never>> &
|
||||
VariantProps<typeof button> & {
|
||||
as: Tag
|
||||
label?: string
|
||||
label: string
|
||||
icon?: string
|
||||
endIcon?: string
|
||||
classNames?: {
|
||||
@@ -55,30 +57,21 @@ const button = tv({
|
||||
iconOnly: {
|
||||
true: {
|
||||
base: 'p-0',
|
||||
label: 'sr-only',
|
||||
},
|
||||
},
|
||||
color: {
|
||||
black: {
|
||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||
},
|
||||
white: {
|
||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||
},
|
||||
gray: {
|
||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||
},
|
||||
success: {
|
||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||
},
|
||||
error: {
|
||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||
},
|
||||
warning: {
|
||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
info: {
|
||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||
},
|
||||
black: '',
|
||||
white: '',
|
||||
gray: '',
|
||||
success: '',
|
||||
danger: '',
|
||||
warning: '',
|
||||
info: '',
|
||||
},
|
||||
variant: {
|
||||
solid: '',
|
||||
faded: '',
|
||||
},
|
||||
shadow: {
|
||||
true: {
|
||||
@@ -92,6 +85,107 @@ const button = tv({
|
||||
},
|
||||
},
|
||||
compoundVariants: [
|
||||
// Color variants - solid
|
||||
{
|
||||
color: 'black',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-night-500 bg-night-800 hover:bg-night-900 hover:text-day-200 focus-visible:bg-night-500 text-white/50 focus-visible:text-white focus-visible:ring-white',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'white',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-day-300 bg-day-100 hover:bg-day-200 text-black focus-visible:ring-green-500',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-day-500 bg-day-400 hover:bg-day-500 text-black focus-visible:ring-white',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'success',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-green-600 bg-green-500 text-black hover:bg-green-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-red-600 bg-red-500 text-white hover:bg-red-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'warning',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-yellow-600 bg-yellow-500 text-white hover:bg-yellow-600',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'info',
|
||||
variant: 'solid',
|
||||
class: {
|
||||
base: 'border-blue-600 bg-blue-500 text-white hover:bg-blue-600',
|
||||
},
|
||||
},
|
||||
// Color variants - faded
|
||||
{
|
||||
color: 'black',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'bg2night-800/15 hover:bg-night-700/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'white',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'b2-day-100/15 hover:bg-day-200/30 border-current/30 text-white/70 hover:text-white/90 focus-visible:ring-white/50',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'gray',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'b2-day-400/15 hover:bg-day-500/30 text-day-300 hover:text-day-100 border-current/30 focus-visible:ring-white/50',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'success',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-green-500/15 text-green-300 hover:bg-green-500/30 hover:text-green-100',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'danger',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-red-500/15 text-red-300 hover:bg-red-500/30 hover:text-red-100',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'warning',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-yellow-500/15 text-yellow-300 hover:bg-yellow-500/30 hover:text-yellow-100',
|
||||
},
|
||||
},
|
||||
{
|
||||
color: 'info',
|
||||
variant: 'faded',
|
||||
class: {
|
||||
base: 'border-current/20 bg-blue-500/15 text-blue-300 hover:bg-blue-500/30 hover:text-blue-100',
|
||||
},
|
||||
},
|
||||
// Shadow variants
|
||||
{
|
||||
color: 'black',
|
||||
shadow: true,
|
||||
@@ -113,7 +207,7 @@ const button = tv({
|
||||
class: 'shadow-green-500/30',
|
||||
},
|
||||
{
|
||||
color: 'error',
|
||||
color: 'danger',
|
||||
shadow: true,
|
||||
class: 'shadow-red-500/30',
|
||||
},
|
||||
@@ -127,6 +221,7 @@ const button = tv({
|
||||
shadow: true,
|
||||
class: 'shadow-blue-500/30',
|
||||
},
|
||||
// Icon only variants
|
||||
{
|
||||
iconOnly: true,
|
||||
size: 'sm',
|
||||
@@ -146,6 +241,7 @@ const button = tv({
|
||||
defaultVariants: {
|
||||
size: 'md',
|
||||
color: 'black',
|
||||
variant: 'solid',
|
||||
shadow: false,
|
||||
disabled: false,
|
||||
iconOnly: false,
|
||||
@@ -153,12 +249,13 @@ const button = tv({
|
||||
})
|
||||
|
||||
const {
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label',
|
||||
as: Tag = 'button' as 'a' | 'button' | 'label' | 'span',
|
||||
label,
|
||||
icon,
|
||||
endIcon,
|
||||
size,
|
||||
color,
|
||||
variant,
|
||||
shadow,
|
||||
class: className,
|
||||
classNames,
|
||||
@@ -166,6 +263,7 @@ const {
|
||||
dataAstroReload,
|
||||
disabled,
|
||||
inlineIcon,
|
||||
iconOnly,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
@@ -174,21 +272,27 @@ const {
|
||||
icon: iconSlot,
|
||||
label: labelSlot,
|
||||
endIcon: endIconSlot,
|
||||
} = button({ size, color, shadow, disabled, iconOnly: !label && !(!!icon && !!endIcon) })
|
||||
} = button({
|
||||
size,
|
||||
color,
|
||||
variant,
|
||||
shadow,
|
||||
disabled,
|
||||
iconOnly: iconOnly ?? (!label && !(!!icon && !!endIcon)),
|
||||
})
|
||||
|
||||
const ActualTag = disabled && Tag === 'a' ? 'span' : Tag
|
||||
---
|
||||
|
||||
<ActualTag
|
||||
class={base({ class: className })}
|
||||
role={role ??
|
||||
(ActualTag === 'button' || ActualTag === 'label' || ActualTag === 'span' ? undefined : 'button')}
|
||||
class={base({ class: cn({ 'opacity-20 hover:opacity-50': disabled }, className) })}
|
||||
role={role ?? (Tag === 'button' || Tag === 'label' || (disabled && Tag === 'a') ? undefined : 'button')}
|
||||
aria-disabled={disabled}
|
||||
{...dataAstroReload && { 'data-astro-reload': dataAstroReload }}
|
||||
{...htmlProps}
|
||||
>
|
||||
{!!icon && <Icon name={icon} class={iconSlot({ class: classNames?.icon })} is:inline={inlineIcon} />}
|
||||
{!!label && <span class={labelSlot({ class: classNames?.label })}>{label}</span>}
|
||||
<span class={labelSlot({ class: classNames?.label })}>{label}</span>
|
||||
{
|
||||
!!endIcon && (
|
||||
<Icon name={endIcon} class={endIconSlot({ class: classNames?.endIcon })} is:inline={inlineIcon}>
|
||||
|
||||
@@ -76,7 +76,15 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
|
||||
/>
|
||||
|
||||
<Tooltip text="Send">
|
||||
<Button type="submit" icon="ri:send-plane-fill" size="lg" color="success" class="h-16" />
|
||||
<Button
|
||||
type="submit"
|
||||
icon="ri:send-plane-fill"
|
||||
size="lg"
|
||||
color="success"
|
||||
class="h-16"
|
||||
label="Send"
|
||||
iconOnly
|
||||
/>
|
||||
</Tooltip>
|
||||
</form>
|
||||
{!!inputErrors.content && <div class="text-sm text-red-500">{inputErrors.content}</div>}
|
||||
|
||||
@@ -61,8 +61,8 @@ const isHighlighted = comment.id === highlightedCommentId
|
||||
const userVote = user ? comment.votes.find((v) => v.userId === user.id) : null
|
||||
|
||||
const isAuthor = user?.id === comment.author.id
|
||||
const isAdminOrVerifier = !!user && (user.admin || user.verifier)
|
||||
const isAuthorOrPrivileged = isAuthor || isAdminOrVerifier
|
||||
const isAdminOrModerator = !!user && (user.admin || user.moderator)
|
||||
const isAuthorOrPrivileged = isAuthor || isAdminOrModerator
|
||||
|
||||
// Check if user is new (less than 1 week old)
|
||||
const isNewUser =
|
||||
@@ -75,7 +75,7 @@ const isRatingActive =
|
||||
!comment.suspicious &&
|
||||
(comment.status === 'APPROVED' || comment.status === 'VERIFIED')
|
||||
|
||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/verifier
|
||||
// Skip rendering if comment is not approved/verified and user is not the author or admin/moderator
|
||||
const shouldShow =
|
||||
comment.status === 'APPROVED' ||
|
||||
comment.status === 'VERIFIED' ||
|
||||
@@ -102,7 +102,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
{...htmlProps}
|
||||
id={`comment-${comment.id.toString()}`}
|
||||
class={cn([
|
||||
'group',
|
||||
'group bg-night-700',
|
||||
depth > 0 && 'ml-3 border-b-0 border-l border-zinc-800 pt-2 pl-2 sm:ml-4',
|
||||
comment.author.serviceAffiliations.some((affiliation) => affiliation.service.slug === serviceSlug) &&
|
||||
'bg-[#182a1f]',
|
||||
@@ -150,7 +150,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
checked={comment.suspicious}
|
||||
/>
|
||||
|
||||
<div class="comment-header flex items-center gap-2 text-sm">
|
||||
<div class="comment-header scrollbar-w-none flex items-center gap-2 overflow-auto text-sm">
|
||||
<label for={`collapse-${comment.id.toString()}`} class="cursor-pointer text-zinc-500 hover:text-zinc-300">
|
||||
<span class="collapse-symbol text-xs"></span>
|
||||
<span class="sr-only">Toggle comment visibility</span>
|
||||
@@ -164,10 +164,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
/>
|
||||
|
||||
{
|
||||
(comment.author.verified || comment.author.admin || comment.author.verifier) && (
|
||||
(comment.author.verified || comment.author.admin || comment.author.moderator) && (
|
||||
<Tooltip
|
||||
text={`${
|
||||
comment.author.admin || comment.author.verifier
|
||||
comment.author.admin || comment.author.moderator
|
||||
? `KYCnot.me ${comment.author.admin ? 'Admin' : 'Moderator'}${comment.author.verifiedLink ? '. ' : ''}`
|
||||
: ''
|
||||
}${comment.author.verifiedLink ? `Related to ${comment.author.verifiedLink}` : ''}`}
|
||||
@@ -186,7 +186,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
)
|
||||
}
|
||||
{
|
||||
comment.author.verifier && !comment.author.admin && (
|
||||
comment.author.moderator && !comment.author.admin && (
|
||||
<BadgeSmall
|
||||
icon="ri:graduation-cap-fill"
|
||||
color="teal"
|
||||
@@ -198,14 +198,14 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
}
|
||||
|
||||
{
|
||||
isNewUser && !comment.author.admin && !comment.author.verifier && (
|
||||
isNewUser && !comment.author.admin && !comment.author.moderator && (
|
||||
<Tooltip text={`Joined ${formatDateShort(comment.author.createdAt, { hourPrecision: true })}`}>
|
||||
<BadgeSmall icon="ri:user-add-fill" color="purple" text="New User" variant="faded" inlineIcon />
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
{
|
||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.verifier && (
|
||||
authorUnlocks.highKarmaBadge && !comment.author.admin && !comment.author.moderator && (
|
||||
<BadgeSmall
|
||||
icon={karmaUnlocksById.highKarmaBadge.icon}
|
||||
color="lime"
|
||||
@@ -243,13 +243,10 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
comment.author.serviceAffiliations.map((affiliation) => {
|
||||
const roleInfo = getServiceUserRoleInfo(affiliation.role)
|
||||
return (
|
||||
<BadgeSmall
|
||||
icon={roleInfo.icon}
|
||||
color={roleInfo.color}
|
||||
text={`${roleInfo.label} at ${affiliation.service.name}`}
|
||||
variant="faded"
|
||||
inlineIcon
|
||||
/>
|
||||
<BadgeSmall icon={roleInfo.icon} color={roleInfo.color} variant="faded" inlineIcon>
|
||||
{roleInfo.label} at
|
||||
<a href={`/service/${affiliation.service.slug}`}>{affiliation.service.name}</a>
|
||||
</BadgeSmall>
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -270,12 +267,6 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
|
||||
{comment.suspicious && <BadgeSmall icon="ri:spam-2-fill" color="red" text="Potential SPAM" inlineIcon />}
|
||||
|
||||
{
|
||||
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||
<BadgeSmall icon="ri:alert-fill" color="yellow" text="Reported" inlineIcon />
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.rating !== null && !comment.parentId && (
|
||||
<Tooltip
|
||||
@@ -320,6 +311,19 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
color={commentStatusById.REJECTED.color}
|
||||
text={commentStatusById.REJECTED.label}
|
||||
inlineIcon
|
||||
endIcon="ri:lock-line"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
{
|
||||
comment.requiresAdminReview && isAuthorOrPrivileged && (
|
||||
<BadgeSmall
|
||||
icon="ri:alert-fill"
|
||||
color="yellow"
|
||||
text="Needs admin review"
|
||||
inlineIcon
|
||||
endIcon="ri:lock-line"
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -380,7 +384,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.internalNote && (
|
||||
user && (user.admin || user.moderator) && comment.internalNote && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-red-600 bg-red-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-red-400">Internal note:</span>
|
||||
@@ -391,7 +395,7 @@ const commentUrl = makeCommentUrl({ serviceSlug, commentId: comment.id, origin:
|
||||
}
|
||||
|
||||
{
|
||||
user && (user.admin || user.verifier) && comment.privateContext && (
|
||||
user && (user.admin || user.moderator) && comment.privateContext && (
|
||||
<div class="mt-2 peer-checked/collapse:hidden">
|
||||
<div class="border-l-2 border-blue-600 bg-blue-900/20 py-0.5 pl-2 text-xs">
|
||||
<span class="font-medium text-blue-400">Private context:</span>
|
||||
|
||||
@@ -28,8 +28,8 @@ const { comment, class: className, ...divProps } = Astro.props
|
||||
|
||||
const user = Astro.locals.user
|
||||
|
||||
// Only render for admin/verifier users
|
||||
if (!user || !user.admin || !user.verifier) return null
|
||||
// Only render for admin/moderator users
|
||||
if (!user || !user.admin || !user.moderator) return null
|
||||
---
|
||||
|
||||
<div {...divProps} class={cn('text-xs', className)}>
|
||||
@@ -89,7 +89,7 @@ if (!user || !user.admin || !user.verifier) return null
|
||||
data-comment-id={comment.id}
|
||||
data-user-id={user.id}
|
||||
>
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Admin Review'}
|
||||
{comment.requiresAdminReview ? 'No Admin Review' : 'Needs Admin Review'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
@@ -92,7 +92,7 @@ const userCommentsDisabled = user ? user.karmaUnlocks.commentsDisabled : false
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<InputRating name="rating" label="Rating" />
|
||||
|
||||
<InputWrapper label="Tags" name="tags">
|
||||
<InputWrapper label="I experienced..." name="tags">
|
||||
<label class="flex cursor-pointer items-center gap-2">
|
||||
<input type="checkbox" name="issueKycRequested" class="text-red-400" />
|
||||
<span class="flex items-center gap-1 text-xs text-red-400">
|
||||
|
||||
@@ -202,7 +202,7 @@ function makeReplySchema(comment: CommentWithRepliesPopulated): Comment {
|
||||
Most Upvotes
|
||||
</a>
|
||||
{
|
||||
user && (user.admin || user.verifier) && (
|
||||
user && (user.admin || user.moderator) && (
|
||||
<a
|
||||
href={getSortUrl('status')}
|
||||
class={cn([
|
||||
|
||||
@@ -47,12 +47,9 @@ const averageUserRatingFromQuery =
|
||||
totalComments > 0 ? sum(ratings.map((stat) => stat.rating * stat.count)) / totalComments : null
|
||||
|
||||
if (averageUserRatingFromProps !== undefined) {
|
||||
if (
|
||||
averageUserRatingFromQuery !== averageUserRatingFromProps ||
|
||||
(averageUserRatingFromQuery !== null &&
|
||||
averageUserRatingFromProps !== null &&
|
||||
round(averageUserRatingFromQuery, 2) !== round(averageUserRatingFromProps, 2))
|
||||
) {
|
||||
const a = averageUserRatingFromQuery === null ? null : round(averageUserRatingFromQuery, 2)
|
||||
const b = averageUserRatingFromProps === null ? null : round(averageUserRatingFromProps, 2)
|
||||
if (a !== b) {
|
||||
console.error(
|
||||
`The averageUserRating of the comments shown is different from the averageUserRating from the database. Service ID: ${serviceId} ratingUi: ${averageUserRatingFromQuery} ratingDb: ${averageUserRatingFromProps}`
|
||||
)
|
||||
|
||||
@@ -27,6 +27,12 @@ const links = [
|
||||
icon: 'i2p',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
href: '/docs/api',
|
||||
label: 'API',
|
||||
icon: 'ri:plug-line',
|
||||
external: false,
|
||||
},
|
||||
{
|
||||
href: '/about',
|
||||
label: 'About',
|
||||
@@ -52,7 +58,7 @@ const { class: className, ...htmlProps } = Astro.props
|
||||
href={href}
|
||||
target={external ? '_blank' : undefined}
|
||||
rel={external ? 'noopener noreferrer' : undefined}
|
||||
class="text-day-500 dark:text-day-400 dark:hover:text-day-300 flex items-center gap-1 text-sm transition-colors hover:text-gray-700"
|
||||
class="text-day-500 flex items-center gap-1 text-sm transition-colors hover:text-gray-200 hover:underline"
|
||||
>
|
||||
<Icon name={icon} class="h-4 w-4" />
|
||||
{label}
|
||||
|
||||
24
web/src/components/FormSection.astro
Normal file
24
web/src/components/FormSection.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'section'> & {
|
||||
title: string
|
||||
subtitle?: string
|
||||
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
const { title, subtitle, class: className, heading = 'h2', ...props } = Astro.props
|
||||
|
||||
const HeadingTag = heading
|
||||
---
|
||||
|
||||
<section class={cn('mt-24 space-y-2 first:mt-0', className)} {...props}>
|
||||
<HeadingTag class="font-title text-center text-3xl leading-none font-bold">{title}</HeadingTag>
|
||||
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
24
web/src/components/FormSubSection.astro
Normal file
24
web/src/components/FormSubSection.astro
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import type { AstroChildren } from '../lib/astro'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'section'> & {
|
||||
title: string
|
||||
subtitle?: string
|
||||
heading?: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'
|
||||
children: AstroChildren
|
||||
}
|
||||
|
||||
const { title, subtitle, class: className, heading = 'h3', ...props } = Astro.props
|
||||
|
||||
const HeadingTag = heading
|
||||
---
|
||||
|
||||
<section class={cn('mt-6 space-y-2 first:mt-0', className)} {...props}>
|
||||
<HeadingTag class="font-title text-day-400 text-center text-lg font-medium">{title}</HeadingTag>
|
||||
{subtitle && <p class="text-day-400 text-center">{subtitle}</p>}
|
||||
|
||||
<slot />
|
||||
</section>
|
||||
@@ -160,6 +160,7 @@ const splashText = showSplashText ? sample(splashTexts) : null
|
||||
<a
|
||||
href={makeUnimpersonateUrl(Astro.url)}
|
||||
data-astro-reload
|
||||
data-astro-prefetch="tap"
|
||||
class="xs:px-3 2xs:px-2 last:xs:-mr-3 last:2xs:-mr-2 flex h-full items-center px-1 text-sm text-stone-100 transition-colors last:-mr-1 hover:text-stone-200"
|
||||
transition:name="header-unimpersonate-link"
|
||||
aria-label="Unimpersonate"
|
||||
|
||||
@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
)}
|
||||
>
|
||||
<input
|
||||
transition:persist={option.noTransitionPersist ? undefined : true}
|
||||
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
|
||||
type={multiple ? 'checkbox' : 'radio'}
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
|
||||
@@ -12,13 +12,15 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
||||
options: {
|
||||
label: string
|
||||
value: string
|
||||
icon?: string
|
||||
icon?: string[] | string
|
||||
iconClassName?: string[] | string
|
||||
}[]
|
||||
disabled?: boolean
|
||||
selectedValues?: string[]
|
||||
size?: 'lg' | 'md'
|
||||
}
|
||||
|
||||
const { options, disabled, selectedValues = [], ...wrapperProps } = Astro.props
|
||||
const { options, disabled, selectedValues = [], size = 'md', ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
@@ -26,23 +28,38 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<div class={cn(baseInputClassNames.div, hasError && baseInputClassNames.error)}>
|
||||
<div class="h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5">
|
||||
<div
|
||||
class={cn('h-48 overflow-y-auto mask-y-from-[calc(100%-var(--spacing)*8)] py-5', {
|
||||
'h-96': size === 'lg',
|
||||
'h-48': size === 'md',
|
||||
})}
|
||||
>
|
||||
{
|
||||
options.map((option) => (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{option.icon && <Icon name={option.icon} class="size-4" />}
|
||||
<span class="text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
))
|
||||
options.map((option) => {
|
||||
const icons = option.icon ? (Array.isArray(option.icon) ? option.icon : [option.icon]) : []
|
||||
const iconClassName = option.iconClassName
|
||||
? Array.isArray(option.iconClassName)
|
||||
? option.iconClassName
|
||||
: Array.from({ length: icons.length }, () => option.iconClassName)
|
||||
: []
|
||||
return (
|
||||
<label class="hover:bg-night-500 flex cursor-pointer items-center gap-2 px-5 py-1 has-checked:bg-green-800/20 has-checked:hover:bg-green-800/30">
|
||||
<input
|
||||
transition:persist
|
||||
type="checkbox"
|
||||
name={wrapperProps.name}
|
||||
value={option.value}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
class={cn(hasError && baseInputClassNames.error, disabled && baseInputClassNames.disabled)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{icons.map((icon, index) => (
|
||||
<Icon name={icon} class={cn('size-4 shrink-0', iconClassName[index])} />
|
||||
))}
|
||||
<span class="truncate text-sm leading-none">{option.label}</span>
|
||||
</label>
|
||||
)
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,13 +16,20 @@ type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId'> &
|
||||
}
|
||||
}
|
||||
|
||||
const { accept, disabled, multiple, removeCheckbox, ...wrapperProps } = Astro.props
|
||||
const { accept, disabled, multiple, removeCheckbox, classNames, ...wrapperProps } = Astro.props
|
||||
|
||||
const inputId = Astro.locals.makeId(`input-${wrapperProps.name}`)
|
||||
const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
|
||||
---
|
||||
|
||||
<InputWrapper inputId={inputId} {...wrapperProps}>
|
||||
<InputWrapper
|
||||
inputId={inputId}
|
||||
classNames={{
|
||||
...classNames,
|
||||
description: cn(classNames?.description, '[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'),
|
||||
}}
|
||||
{...wrapperProps}
|
||||
>
|
||||
{
|
||||
!!removeCheckbox && (
|
||||
<label
|
||||
|
||||
@@ -2,16 +2,24 @@
|
||||
import { cn } from '../lib/cn'
|
||||
import { ACCEPTED_IMAGE_TYPES } from '../lib/zodUtils'
|
||||
|
||||
import Button from './Button.astro'
|
||||
import InputFile from './InputFile.astro'
|
||||
import Tooltip from './Tooltip.astro'
|
||||
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputFile>, 'accept'> & {
|
||||
square?: boolean
|
||||
value?: string | null
|
||||
downloadButton?: boolean
|
||||
}
|
||||
|
||||
const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
const { class: className, square, value, downloadButton, ...inputFileProps } = Astro.props
|
||||
|
||||
function makeDownloadFilename(value: string) {
|
||||
const url = new URL(value, Astro.url.origin)
|
||||
return url.pathname.split('/').pop() ?? 'service-image'
|
||||
}
|
||||
---
|
||||
|
||||
<div class={cn('flex flex-wrap items-center justify-center gap-4', className)} data-preview-image>
|
||||
@@ -30,6 +38,31 @@ const { class: className, square, value, ...inputFileProps } = Astro.props
|
||||
'[&:is(:has([data-remove-checkbox]:checked)_~_*)]:hidden'
|
||||
)}
|
||||
/>
|
||||
{
|
||||
downloadButton && value && (
|
||||
<Tooltip
|
||||
text="Download"
|
||||
classNames={{
|
||||
tooltip: 'min-2xs:[&:is(:has([data-remove-checkbox]:checked)_~_*_*)]:hidden',
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
as="a"
|
||||
href={value}
|
||||
download={makeDownloadFilename(value)}
|
||||
icon="ri:download-line"
|
||||
size="sm"
|
||||
label="Download"
|
||||
class={cn(
|
||||
'bg-night-600 border-night-400 text-day-200 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:h-24 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:px-0 2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:w-8 shrink-0 rounded-md border'
|
||||
)}
|
||||
classNames={{
|
||||
label: '2xs:[&:is(:has([data-remove-checkbox]:not(:checked))_~_*_*)]:hidden block ',
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
|
||||
@@ -3,24 +3,28 @@ import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
hideCancel?: boolean
|
||||
icon?: string
|
||||
label?: string
|
||||
disabled?: boolean
|
||||
color?: ComponentProps<typeof Button>['color']
|
||||
}
|
||||
|
||||
const {
|
||||
hideCancel = false,
|
||||
icon = 'ri:send-plane-2-line',
|
||||
label = 'Submit',
|
||||
disabled = false,
|
||||
class: className,
|
||||
color = 'success',
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
---
|
||||
|
||||
<div class={cn('flex justify-between gap-2', className)} {...htmlProps}>
|
||||
{!hideCancel && <Button as="a" href="/" label="Cancel" icon="ri:close-line" color="gray" />}
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color="success" />
|
||||
<Button type="submit" label={label} icon={icon} class="ml-auto" color={color} disabled={disabled} />
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import type { ComponentProps, HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof InputWrapper>, 'children' | 'inputId' | 'required'> & {
|
||||
inputProps?: Omit<HTMLAttributes<'textarea'>, 'name'>
|
||||
value?: string
|
||||
value?: string | null | undefined
|
||||
}
|
||||
|
||||
const { inputProps, value, ...wrapperProps } = Astro.props
|
||||
|
||||
@@ -19,6 +19,9 @@ type Props = HTMLAttributes<'div'> & {
|
||||
icon?: string
|
||||
inputId?: string
|
||||
hideLabel?: boolean
|
||||
classNames?: {
|
||||
description?: string
|
||||
}
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -32,13 +35,14 @@ const {
|
||||
class: className,
|
||||
inputId,
|
||||
hideLabel,
|
||||
classNames,
|
||||
...htmlProps
|
||||
} = Astro.props
|
||||
|
||||
const hasError = !!error && error.length > 0
|
||||
---
|
||||
|
||||
<fieldset class={cn('space-y-1', className)} {...htmlProps}>
|
||||
<fieldset class={cn('min-w-0 space-y-1', className)} {...htmlProps}>
|
||||
{
|
||||
!hideLabel && (
|
||||
<div class={cn('contents', !!descriptionLabel && 'flex flex-wrap items-center gap-x-4')}>
|
||||
@@ -71,7 +75,12 @@ const hasError = !!error && error.length > 0
|
||||
|
||||
{
|
||||
!!description && (
|
||||
<div class="prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty">
|
||||
<div
|
||||
class={cn(
|
||||
'prose prose-sm prose-invert prose-a:text-current prose-a:font-normal hover:prose-a:text-day-300 prose-a:transition-colors text-day-400 max-w-none text-xs text-pretty',
|
||||
classNames?.description
|
||||
)}
|
||||
>
|
||||
<Markdown content={description} />
|
||||
</div>
|
||||
)
|
||||
|
||||
374
web/src/components/PushNotificationBanner.astro
Normal file
374
web/src/components/PushNotificationBanner.astro
Normal file
@@ -0,0 +1,374 @@
|
||||
---
|
||||
import { Icon } from 'astro-icon/components'
|
||||
import { VAPID_PUBLIC_KEY } from 'astro:env/server'
|
||||
|
||||
import { cn } from '../lib/cn'
|
||||
|
||||
import Button from './Button.astro'
|
||||
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import type { HTMLAttributes } from 'astro/types'
|
||||
|
||||
type Props = HTMLAttributes<'div'> & {
|
||||
dismissable?: boolean
|
||||
hideIfEnabled?: boolean
|
||||
pushSubscriptions: Prisma.PushSubscriptionGetPayload<{
|
||||
select: {
|
||||
endpoint: true
|
||||
userAgent: true
|
||||
}
|
||||
}>[]
|
||||
}
|
||||
|
||||
const { class: className, dismissable = false, pushSubscriptions, hideIfEnabled, ...props } = Astro.props
|
||||
|
||||
// TODO: Feature flag, enabled only for admins
|
||||
if (!Astro.locals.user?.admin) {
|
||||
return null
|
||||
}
|
||||
---
|
||||
|
||||
<div
|
||||
data-push-notification-banner
|
||||
data-dismissed={undefined /* Updated by client script */}
|
||||
data-supports-push-notifications={undefined /* Updated by client script */}
|
||||
data-push-subscriptions={JSON.stringify(pushSubscriptions)}
|
||||
data-is-enabled={undefined /* Updated by client script */}
|
||||
class={cn(
|
||||
'no-js:hidden relative isolate flex items-center justify-between gap-x-4 overflow-hidden rounded-xl bg-gradient-to-r from-blue-950/80 to-blue-900/60 p-4',
|
||||
'data-dismissed:hidden',
|
||||
hideIfEnabled && 'data-is-enabled:hidden',
|
||||
'not-data-supports-push-notifications:hidden',
|
||||
'data-is-enabled:**:data-show-if-disabled:hidden not-data-is-enabled:**:data-show-if-enabled:hidden',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div aria-hidden="true" class="pointer-events-none absolute inset-0 -z-10 overflow-hidden">
|
||||
<div
|
||||
class="absolute top-0 -left-16 h-full w-1/3 bg-gradient-to-r from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="absolute top-0 -right-16 h-full w-1/3 bg-gradient-to-l from-blue-500/20 to-transparent opacity-50 blur-xl"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-x-3">
|
||||
<div class="rounded-md bg-blue-800/30 p-2">
|
||||
<Icon name="ri:notification-4-line" class="size-5 text-blue-300" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-medium text-blue-100">
|
||||
<span data-show-if-enabled>Push notifications enabled</span>
|
||||
<span data-show-if-disabled>Turn on push notifications?</span>
|
||||
</h3>
|
||||
<p class="text-sm text-blue-200/80">
|
||||
<span data-show-if-enabled>Turn notifications off for this device?</span>
|
||||
<span data-show-if-disabled>Get notifications on this device.</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
{dismissable && <Button as="span" label="Skip" variant="faded" data-dismiss-button />}
|
||||
<Button
|
||||
as="span"
|
||||
label="Yes, notify me"
|
||||
color="white"
|
||||
data-push-action="subscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-disabled
|
||||
/>
|
||||
<Button
|
||||
as="span"
|
||||
label="Stop notifications"
|
||||
color="white"
|
||||
variant="faded"
|
||||
data-push-action="unsubscribe"
|
||||
data-vapid-public-key={VAPID_PUBLIC_KEY}
|
||||
data-show-if-enabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification banner dismissal. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
|
||||
import { typedLocalStorage } from '../lib/localstorage'
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
let pushNotificationsBannerDismissedAt = typedLocalStorage.pushNotificationsBannerDismissedAt.get()
|
||||
|
||||
if (
|
||||
pushNotificationsBannerDismissedAt &&
|
||||
pushNotificationsBannerDismissedAt < new Date(Date.now() - 1000 * 60 * 60 * 24 * 365) // 1 year
|
||||
) {
|
||||
typedLocalStorage.pushNotificationsBannerDismissedAt.remove()
|
||||
pushNotificationsBannerDismissedAt = undefined
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((banner) => {
|
||||
const skipButton = banner.querySelector<HTMLElement>('[data-dismiss-button]')
|
||||
if (!skipButton) return
|
||||
|
||||
if (pushNotificationsBannerDismissedAt) {
|
||||
banner.dataset.dismissed = ''
|
||||
}
|
||||
|
||||
skipButton.addEventListener('click', (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
banner.dataset.dismissed = ''
|
||||
|
||||
const now = new Date()
|
||||
typedLocalStorage.pushNotificationsBannerDismissedAt.set(now)
|
||||
pushNotificationsBannerDismissedAt = now
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////
|
||||
// Script to style when notifications enabled. //
|
||||
////////////////////////////////////////////////////////
|
||||
|
||||
type ServerSubscription = {
|
||||
endpoint: string
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
/** Parse push subscriptions from string */
|
||||
function parsePushSubscriptions(subscriptionsAsString: string | undefined) {
|
||||
try {
|
||||
if (typeof subscriptionsAsString !== 'string') throw new Error('Push subscriptions must be a string')
|
||||
|
||||
const subscriptions = JSON.parse(subscriptionsAsString)
|
||||
|
||||
if (!Array.isArray(subscriptions)) throw new Error('Push subscriptions must be an array')
|
||||
if (!subscriptions.every((s) => typeof s === 'object' && s !== null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.endpoint === 'string')) {
|
||||
throw new Error('Push subscriptions must be an array of objects with endpoint property')
|
||||
}
|
||||
if (!subscriptions.every((s) => typeof s.userAgent === 'string' || s.userAgent === null)) {
|
||||
throw new Error('Push subscriptions must be an array of objects with userAgent property')
|
||||
}
|
||||
|
||||
return subscriptions as ServerSubscription[]
|
||||
} catch (error) {
|
||||
console.error('Failed to parse push subscriptions:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current device has an active push subscription */
|
||||
async function getCurrentPushSubscription() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) return null
|
||||
|
||||
return await registration.pushManager.getSubscription()
|
||||
} catch (error) {
|
||||
console.error('Error getting current push subscription:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Check if current subscription matches any server subscription */
|
||||
function isCurrentDeviceSubscribed(
|
||||
currentSubscription: PushSubscription | null,
|
||||
serverSubscriptions: ServerSubscription[]
|
||||
) {
|
||||
if (!currentSubscription || serverSubscriptions.length === 0) return false
|
||||
|
||||
const currentEndpoint = currentSubscription.endpoint
|
||||
const currentUserAgent = navigator.userAgent
|
||||
|
||||
return serverSubscriptions.some(
|
||||
(sub) =>
|
||||
sub.endpoint === currentEndpoint && (sub.userAgent === currentUserAgent || sub.userAgent === null)
|
||||
)
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', async () => {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach(async (banner) => {
|
||||
const serverSubscriptions = parsePushSubscriptions(banner.dataset.pushSubscriptions)
|
||||
const currentSubscription = await getCurrentPushSubscription()
|
||||
const isSubscribed = isCurrentDeviceSubscribed(currentSubscription, serverSubscriptions)
|
||||
|
||||
if (isSubscribed) banner.dataset.isEnabled = ''
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<script>
|
||||
/////////////////////////////////////////////////////////////
|
||||
// Script to handle push notification subscription. //
|
||||
/////////////////////////////////////////////////////////////
|
||||
|
||||
import type { actions } from 'astro:actions'
|
||||
import type { ActionInput } from '../lib/astroActions'
|
||||
|
||||
/** Utility function to convert VAPID key */
|
||||
function urlB64ToUint8Array(base64String: string) {
|
||||
const cleaned = base64String.trim().replace(/\s+/g, '').replace(/\-/g, '+').replace(/_/g, '/')
|
||||
const padding = '='.repeat((4 - (cleaned.length % 4)) % 4)
|
||||
const base64 = cleaned + padding
|
||||
|
||||
const rawData = window.atob(base64)
|
||||
const outputArray = new Uint8Array(rawData.length)
|
||||
|
||||
for (let i = 0; i < rawData.length; ++i) {
|
||||
outputArray[i] = rawData.charCodeAt(i)
|
||||
}
|
||||
return outputArray
|
||||
}
|
||||
|
||||
/** Check for browser support */
|
||||
function checkSupport() {
|
||||
const isSecure =
|
||||
window.isSecureContext ||
|
||||
window.location.hostname === 'localhost' ||
|
||||
window.location.hostname === '127.0.0.1'
|
||||
return isSecure && 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
|
||||
}
|
||||
|
||||
async function registerServiceWorker() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.register('/sw.js')
|
||||
console.log('Service Worker registered:', registration)
|
||||
|
||||
const readyRegistration = await navigator.serviceWorker.ready
|
||||
console.log('Service Worker is active and ready:', readyRegistration)
|
||||
|
||||
return readyRegistration
|
||||
} catch (error) {
|
||||
console.error('Service Worker registration failed:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function subscribeToPush(vapidPublicKey: string) {
|
||||
try {
|
||||
if (!checkSupport()) return
|
||||
|
||||
// Request notification permission
|
||||
const permission = await Notification.requestPermission()
|
||||
if (permission !== 'granted') {
|
||||
alert('Push notifications permission denied')
|
||||
return
|
||||
}
|
||||
|
||||
const registration = await registerServiceWorker()
|
||||
|
||||
// Subscribe to push manager
|
||||
const subscription = await registration.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlB64ToUint8Array(vapidPublicKey),
|
||||
})
|
||||
|
||||
const p256dh = subscription.getKey('p256dh')
|
||||
const auth = subscription.getKey('auth')
|
||||
|
||||
// Send subscription to server
|
||||
const response = await fetch('/internal-api/notifications/subscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
userAgent: navigator.userAgent,
|
||||
p256dhKey: p256dh ? btoa(String.fromCharCode(...new Uint8Array(p256dh))) : '',
|
||||
authKey: auth ? btoa(String.fromCharCode(...new Uint8Array(auth))) : '',
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.subscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push subscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push subscription failed:', error)
|
||||
alert('Error enabling push notifications. This may be due to browser settings or other restrictions.')
|
||||
}
|
||||
}
|
||||
|
||||
async function unsubscribeFromPush() {
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration()
|
||||
if (!registration) {
|
||||
console.log('No service worker registration found')
|
||||
return
|
||||
}
|
||||
|
||||
const subscription = await registration.pushManager.getSubscription()
|
||||
if (!subscription) {
|
||||
console.log('No push subscription found')
|
||||
return
|
||||
}
|
||||
|
||||
// Unsubscribe from browser
|
||||
await subscription.unsubscribe()
|
||||
|
||||
// Remove from server
|
||||
const response = await fetch('/internal-api/notifications/unsubscribe', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
endpoint: subscription.endpoint,
|
||||
} satisfies ActionInput<typeof actions.notification.webPush.unsubscribe>),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('Push unsubscription successful')
|
||||
|
||||
// Reload page to update UI
|
||||
window.location.reload()
|
||||
} catch (error) {
|
||||
console.error('Push unsubscription failed:', error)
|
||||
alert('Failed to unsubscribe from push notifications')
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('astro:page-load', () => {
|
||||
const supportsPushNotifications = checkSupport()
|
||||
if (supportsPushNotifications) {
|
||||
document.querySelectorAll<HTMLElement>('[data-push-notification-banner]').forEach((element) => {
|
||||
element.dataset.supportsPushNotifications = ''
|
||||
})
|
||||
}
|
||||
|
||||
document.querySelectorAll<HTMLElement>('[data-push-action]').forEach((button) => {
|
||||
const vapidPublicKey = button.dataset.vapidPublicKey
|
||||
if (!vapidPublicKey) {
|
||||
console.error('Environment variable VAPID_PUBLIC_KEY is not set')
|
||||
return
|
||||
}
|
||||
|
||||
button.addEventListener('click', async (event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const action = button.dataset.pushAction
|
||||
if (action === 'subscribe') {
|
||||
await subscribeToPush(vapidPublicKey)
|
||||
} else if (action === 'unsubscribe') {
|
||||
await unsubscribeFromPush()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Icon } from 'astro-icon/components'
|
||||
|
||||
import { currencies } from '../constants/currencies'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
import { cn } from '../lib/cn'
|
||||
import { makeOverallScoreInfo } from '../lib/overallScore'
|
||||
@@ -25,6 +26,7 @@ type Props = HTMLAttributes<'a'> & {
|
||||
kycLevel: true
|
||||
imageUrl: true
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
acceptedCurrencies: true
|
||||
categories: {
|
||||
select: {
|
||||
@@ -43,11 +45,11 @@ const {
|
||||
slug,
|
||||
description,
|
||||
overallScore,
|
||||
|
||||
kycLevel,
|
||||
imageUrl,
|
||||
categories,
|
||||
verificationStatus,
|
||||
serviceVisibility,
|
||||
acceptedCurrencies,
|
||||
},
|
||||
class: className,
|
||||
@@ -69,7 +71,9 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
href={Element === 'a' ? `/service/${slug}` : undefined}
|
||||
{...aProps}
|
||||
class={cn(
|
||||
'border-night-600 bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
'border-night-600 group/card bg-night-800 flex flex-col gap-(--gap) rounded-xl border p-(--gap) [--gap:calc(var(--spacing)*3)]',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'opacity-75 transition-opacity hover:opacity-100 focus-visible:opacity-100',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -79,7 +83,11 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
src={imageUrl}
|
||||
fallback="service"
|
||||
alt={name || 'Service logo'}
|
||||
class="size-12 shrink-0 rounded-sm object-contain text-white"
|
||||
class={cn(
|
||||
'size-12 shrink-0 rounded-sm object-contain text-white',
|
||||
(serviceVisibility === 'ARCHIVED' || verificationStatus === 'VERIFICATION_FAILED') &&
|
||||
'grayscale-67 transition-all group-hover/card:grayscale-0 group-focus-visible/card:grayscale-0'
|
||||
)}
|
||||
width={48}
|
||||
height={48}
|
||||
/>
|
||||
@@ -110,6 +118,23 @@ const overallScoreInfo = makeOverallScoreInfo(overallScore)
|
||||
]}
|
||||
</Tooltip>
|
||||
)
|
||||
}{
|
||||
serviceVisibility === 'ARCHIVED' && (
|
||||
<Tooltip
|
||||
text={serviceVisibilitiesById.ARCHIVED.label}
|
||||
position="right"
|
||||
class="-my-2 shrink-0 whitespace-nowrap"
|
||||
>
|
||||
<Icon
|
||||
is:inline={inlineIcons}
|
||||
name={serviceVisibilitiesById.ARCHIVED.icon}
|
||||
class={cn(
|
||||
'inline-block size-6 shrink-0 rounded-lg p-1 align-[-0.37em]',
|
||||
serviceVisibilitiesById.ARCHIVED.iconClass
|
||||
)}
|
||||
/>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
</h3>
|
||||
<div class="max-h-2 flex-1"></div>
|
||||
|
||||
@@ -87,6 +87,25 @@ function makeLink(url: string, referral: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
const bitcointalkMatch = /^(?:https?:\/\/)?(?:www\.)?bitcointalk\.org$/.exec(hostname)
|
||||
if (bitcointalkMatch) {
|
||||
return {
|
||||
type: 'clearnet' as const,
|
||||
url: urlWithReferral,
|
||||
textBits: [
|
||||
{
|
||||
style: 'normal',
|
||||
text: 'BitcoinTalk ',
|
||||
},
|
||||
{
|
||||
style: 'irrelevant',
|
||||
text: 'thread',
|
||||
},
|
||||
],
|
||||
icon: networksBySlug.clearnet.icon,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'clearnet' as const,
|
||||
url: urlWithReferral,
|
||||
|
||||
@@ -40,6 +40,7 @@ const {
|
||||
hx-select={`#${searchResultsId}`}
|
||||
hx-push-url="true"
|
||||
hx-indicator="#search-indicator"
|
||||
hx-swap="outerHTML"
|
||||
data-services-filters-form
|
||||
data-default-verification-filter={options.verification
|
||||
.filter((verification) => verification.default)
|
||||
@@ -48,6 +49,7 @@ const {
|
||||
class={cn(
|
||||
// Check the scam filter when there is a text quey and the user has checked verified and approved
|
||||
'has-[input[name=q]:not(:placeholder-shown)]:has-[&_input[name=verification][value=verified]:checked]:has-[&_input[name=verification][value=approved]:checked]:[&_input[name=verification][value=scam]]:checkbox-force-checked',
|
||||
'has-[input[name=q]:placeholder-shown]:[&_[data-hide-if-q-is-empty]]:hidden has-[input[name=q]:not(:placeholder-shown)]:[&_[data-hide-if-q-is-filled]]:hidden',
|
||||
className
|
||||
)}
|
||||
>
|
||||
@@ -79,16 +81,20 @@ const {
|
||||
))
|
||||
}
|
||||
</select>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm">
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-filled>
|
||||
<Icon name="ri:shuffle-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Ties randomly sorted
|
||||
</p>
|
||||
<p class="text-day-500 mt-1.5 text-center text-sm" data-hide-if-q-is-empty>
|
||||
<Icon name="ri:seo-line" class="inline-block size-3.5 align-[-0.125em]" />
|
||||
Sorted by match first
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Text Search -->
|
||||
<fieldset class="mb-6">
|
||||
<legend class="font-title mb-3 leading-none text-green-500">
|
||||
<label for="q">Text</label>
|
||||
<label for="q">Name</label>
|
||||
</legend>
|
||||
<input
|
||||
type="text"
|
||||
@@ -107,7 +113,7 @@ const {
|
||||
{
|
||||
options.categories?.map((category) => (
|
||||
<li data-show-always={category.showAlways ? '' : undefined}>
|
||||
<label class="flex cursor-pointer items-center space-x-2 text-sm text-white">
|
||||
<label class="flex cursor-pointer items-center gap-2 text-sm text-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="peer text-green-500"
|
||||
@@ -116,6 +122,7 @@ const {
|
||||
checked={category.checked}
|
||||
data-trigger-on-change
|
||||
/>
|
||||
<Icon name={category.icon} class="size-4" />
|
||||
<span class="peer-checked:font-bold">
|
||||
{category.name}
|
||||
<span class="text-day-500 font-normal">{category._count.services}</span>
|
||||
|
||||
@@ -21,7 +21,6 @@ type Props = HTMLAttributes<'div'> & {
|
||||
pageSize: number
|
||||
sortSeed?: string
|
||||
filters: ServicesFiltersObject
|
||||
includeScams: boolean
|
||||
countCommunityOnly: number | null
|
||||
inlineIcons?: boolean
|
||||
}
|
||||
@@ -35,15 +34,12 @@ const {
|
||||
sortSeed,
|
||||
class: className,
|
||||
filters,
|
||||
includeScams,
|
||||
countCommunityOnly,
|
||||
inlineIcons,
|
||||
...divProps
|
||||
} = Astro.props
|
||||
|
||||
const hasScams =
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
filters.verification.includes('VERIFICATION_FAILED') || includeScams
|
||||
const hasScams = filters.verification.includes('VERIFICATION_FAILED')
|
||||
const hasSomeScam = !!services?.some((service) => service.verificationStatus.includes('VERIFICATION_FAILED'))
|
||||
|
||||
const hasCommunityContributed = filters.verification.includes('COMMUNITY_CONTRIBUTED')
|
||||
@@ -75,7 +71,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
/>
|
||||
|
||||
{
|
||||
countCommunityOnly && (
|
||||
!!countCommunityOnly && (
|
||||
<>
|
||||
<Button
|
||||
as="a"
|
||||
@@ -196,7 +192,7 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
)}
|
||||
{countCommunityOnly && (
|
||||
{!!countCommunityOnly && (
|
||||
<Button
|
||||
as="a"
|
||||
href={urlIfIncludingCommunity}
|
||||
@@ -205,6 +201,13 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
)}
|
||||
<Button
|
||||
as="a"
|
||||
href="/service-suggestion/new"
|
||||
label="Add service"
|
||||
icon="ri:add-line"
|
||||
inlineIcon={inlineIcons}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
@@ -241,14 +244,18 @@ const urlIfIncludingCommunity = urlWithParams(Astro.url, {
|
||||
</>
|
||||
)
|
||||
}
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
as="a"
|
||||
href="/service-suggestion/new"
|
||||
label="Add service"
|
||||
icon="ri:add-line"
|
||||
inlineIcon={inlineIcons}
|
||||
class="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
{
|
||||
services && services.length > 0 && (
|
||||
<div class="mt-4 text-center">
|
||||
<Button
|
||||
as="a"
|
||||
href="/service-suggestion/new"
|
||||
label="Add service"
|
||||
icon="ri:add-line"
|
||||
inlineIcon={inlineIcons}
|
||||
class="mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -56,7 +56,15 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
) : service.verificationStatus === 'COMMUNITY_CONTRIBUTED' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-yellow-600/30 p-2 text-sm text-yellow-200">
|
||||
<Icon name="ri:alert-line" class="size-5 text-yellow-400" />
|
||||
<span>Community-contributed. Information not reviewed.</span>
|
||||
<span>
|
||||
Community-contributed. Information not reviewed.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : wasRecentlyAdded ? (
|
||||
<div class="mb-3 rounded-md bg-red-900/50 p-2 text-sm text-red-400">
|
||||
@@ -64,11 +72,25 @@ const wasRecentlyAdded = isPast(listedDate) && differenceInDays(new Date(), list
|
||||
<TimeFormatted date={listedDate} daysUntilDate={RECENTLY_ADDED_DAYS} />
|
||||
{service.verificationStatus !== 'VERIFICATION_SUCCESS' && ' and it is not verified'}. Proceed with
|
||||
caution.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</div>
|
||||
) : service.verificationStatus !== 'VERIFICATION_SUCCESS' ? (
|
||||
<div class="mb-3 flex items-center gap-2 rounded-md bg-blue-600/30 p-2 text-sm text-blue-200">
|
||||
<Icon name="ri:information-line" class="size-5 text-blue-400" />
|
||||
<span>Basic checks passed, but not fully verified.</span>
|
||||
<span>
|
||||
Basic checks passed, but not fully verified.
|
||||
<a
|
||||
href="/about#suggestion-review-process"
|
||||
class="text-yellow-100 underline opacity-50 transition-opacity hover:opacity-100 focus-visible:opacity-100"
|
||||
>
|
||||
Learn more
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
|
||||
@@ -43,14 +43,14 @@ export const {
|
||||
notificationTitle: 'Your account is no longer verified',
|
||||
},
|
||||
{
|
||||
value: 'VERIFIER_TRUE',
|
||||
label: 'Verifier role granted',
|
||||
notificationTitle: 'Verifier role granted',
|
||||
value: 'MODERATOR_TRUE',
|
||||
label: 'Moderator role granted',
|
||||
notificationTitle: 'Moderator role granted',
|
||||
},
|
||||
{
|
||||
value: 'VERIFIER_FALSE',
|
||||
label: 'Verifier role revoked',
|
||||
notificationTitle: 'Verifier role revoked',
|
||||
value: 'MODERATOR_FALSE',
|
||||
label: 'Moderator role revoked',
|
||||
notificationTitle: 'Moderator role revoked',
|
||||
},
|
||||
{
|
||||
value: 'SPAMMER_TRUE',
|
||||
|
||||
@@ -3,13 +3,17 @@ import { parsePhoneNumberWithError } from 'libphonenumber-js'
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { Assert } from '../lib/assert'
|
||||
import type { Equals } from 'ts-toolbelt/out/Any/Equals'
|
||||
|
||||
type ContactMethodInfo<T extends string | null | undefined = string> = {
|
||||
type: T
|
||||
label: string
|
||||
/** Notice that the first capture group is then used to format the value */
|
||||
matcher: RegExp
|
||||
formatter: (value: string) => string | null
|
||||
formatter: (match: RegExpMatchArray) => string | null
|
||||
icon: string
|
||||
urlType: string
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -22,94 +26,132 @@ export const {
|
||||
(type): ContactMethodInfo<typeof type> => ({
|
||||
type,
|
||||
label: type ? transformCase(type, 'title') : String(type),
|
||||
icon: 'ri:shield-fill',
|
||||
icon: 'ri:link',
|
||||
matcher: /(.*)/,
|
||||
formatter: (value) => value,
|
||||
formatter: ([, value]) => value ?? String(value),
|
||||
urlType: type ?? 'unknown',
|
||||
}),
|
||||
[
|
||||
{
|
||||
type: 'email',
|
||||
label: 'Email',
|
||||
matcher: /mailto:(.*)/,
|
||||
formatter: (value) => value,
|
||||
matcher: /mailto:(.+)/,
|
||||
formatter: ([, value]) => value ?? 'Email',
|
||||
icon: 'ri:mail-line',
|
||||
urlType: 'email',
|
||||
},
|
||||
{
|
||||
type: 'telephone',
|
||||
label: 'Telephone',
|
||||
matcher: /tel:(.*)/,
|
||||
formatter: (value) => {
|
||||
return parsePhoneNumberWithError(value).formatInternational()
|
||||
matcher: /tel:(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
try {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'Telephone'
|
||||
} catch (_error) {
|
||||
console.error(`Invalid telephone number: ${value ?? 'undefined'}`, _error)
|
||||
return value ?? 'Telephone'
|
||||
}
|
||||
},
|
||||
icon: 'ri:phone-line',
|
||||
urlType: 'telephone',
|
||||
},
|
||||
{
|
||||
type: 'whatsapp',
|
||||
label: 'WhatsApp',
|
||||
matcher: /https?:\/\/(?:www\.)?wa\.me\/(.*)\/?/,
|
||||
formatter: (value) => {
|
||||
return parsePhoneNumberWithError(value).formatInternational()
|
||||
matcher: /^https?:\/\/(?:www\.)?wa\.me\/(.+)/,
|
||||
formatter: ([, value]) => {
|
||||
try {
|
||||
return value ? parsePhoneNumberWithError(value).formatInternational() : 'WhatsApp'
|
||||
} catch (_error) {
|
||||
console.error(`Invalid WhatsApp number: ${value ?? 'undefined'}`, _error)
|
||||
return value ?? 'WhatsApp'
|
||||
}
|
||||
},
|
||||
icon: 'ri:whatsapp-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'telegram',
|
||||
label: 'Telegram',
|
||||
matcher: /https?:\/\/(?:www\.)?t\.me\/(.*)\/?/,
|
||||
formatter: (value) => `t.me/${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?t\.me\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `t.me/${value}` : 'Telegram'),
|
||||
icon: 'ri:telegram-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'linkedin',
|
||||
label: 'LinkedIn',
|
||||
matcher: /https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.*)\/?/,
|
||||
formatter: (value) => `in/${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?linkedin\.com\/(?:in|company)\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `in/${value}` : 'LinkedIn'),
|
||||
icon: 'ri:linkedin-box-line',
|
||||
},
|
||||
{
|
||||
type: 'website',
|
||||
label: 'Website',
|
||||
matcher: /https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: (value) => value,
|
||||
icon: 'ri:global-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'x',
|
||||
label: 'X',
|
||||
matcher: /https?:\/\/(?:www\.)?x\.com\/(.*)\/?/,
|
||||
formatter: (value) => `@${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?x\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'X'),
|
||||
icon: 'ri:twitter-x-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'instagram',
|
||||
label: 'Instagram',
|
||||
matcher: /https?:\/\/(?:www\.)?instagram\.com\/(.*)\/?/,
|
||||
formatter: (value) => `@${value}`,
|
||||
matcher: /^https?:\/\/(?:www\.)?instagram\.com\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `@${value}` : 'Instagram'),
|
||||
icon: 'ri:instagram-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'matrix',
|
||||
label: 'Matrix',
|
||||
matcher: /https?:\/\/(?:www\.)?matrix\.to\/#\/(.*)\/?/,
|
||||
formatter: (value) => value,
|
||||
matcher: /^https?:\/\/(?:www\.)?matrix\.to\/#\/(.+)/,
|
||||
formatter: ([, value]) => (value ? `#${value}` : 'Matrix'),
|
||||
icon: 'ri:hashtag',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'bitcointalk',
|
||||
label: 'BitcoinTalk',
|
||||
matcher: /https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
matcher: /^https?:\/\/(?:www\.)?bitcointalk\.org/,
|
||||
formatter: () => 'BitcoinTalk',
|
||||
icon: 'ri:btc-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'simplex',
|
||||
label: 'SimpleX Chat',
|
||||
matcher: /^https?:\/\/(?:www\.)?(simplex\.chat)\//,
|
||||
formatter: () => 'SimpleX Chat',
|
||||
icon: 'simplex',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
type: 'nostr',
|
||||
label: 'Nostr',
|
||||
matcher: /\b(npub1[a-zA-Z0-9]{58})\b/,
|
||||
formatter: () => 'Nostr',
|
||||
icon: 'nostr',
|
||||
urlType: 'url',
|
||||
},
|
||||
{
|
||||
// Website must go last because it's a catch-all
|
||||
type: 'website',
|
||||
label: 'Website',
|
||||
matcher: /^https?:\/\/(?:www\.)?((?:[a-zA-Z0-9-]+\.)+[a-zA-Z]+)/,
|
||||
formatter: ([, value]) => value ?? 'Website',
|
||||
icon: 'ri:global-line',
|
||||
urlType: 'url',
|
||||
},
|
||||
] as const satisfies ContactMethodInfo[]
|
||||
)
|
||||
|
||||
export function formatContactMethod(url: string) {
|
||||
for (const contactMethod of contactMethods) {
|
||||
const captureGroup = url.match(contactMethod.matcher)?.[1]
|
||||
if (!captureGroup) continue
|
||||
const match = url.match(contactMethod.matcher)
|
||||
if (!match) continue
|
||||
|
||||
const formattedValue = contactMethod.formatter(captureGroup)
|
||||
const formattedValue = contactMethod.formatter(match)
|
||||
if (!formattedValue) continue
|
||||
|
||||
return {
|
||||
@@ -120,3 +162,38 @@ export function formatContactMethod(url: string) {
|
||||
|
||||
return { ...getContactMethodInfo('unknown'), formattedValue: url } as const
|
||||
}
|
||||
|
||||
type ContactMethodUrlTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
labelPlural: string
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: contactMethodUrlTypes,
|
||||
dataObject: contactMethodUrlTypesById,
|
||||
getFn: getContactMethodUrlTypeInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): ContactMethodUrlTypeInfo<typeof value> => ({
|
||||
value,
|
||||
labelPlural: value ? transformCase(value, 'title') : String(value),
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'email',
|
||||
labelPlural: 'emails',
|
||||
},
|
||||
{
|
||||
value: 'telephone',
|
||||
labelPlural: 'phone numbers',
|
||||
},
|
||||
{
|
||||
value: 'url',
|
||||
labelPlural: 'URLs',
|
||||
},
|
||||
] as const satisfies ContactMethodUrlTypeInfo<(typeof contactMethods)[number]['urlType']>[]
|
||||
)
|
||||
|
||||
type _ExpectUrlTypesToHaveAllValues = Assert<
|
||||
Equals<(typeof contactMethods)[number]['urlType'], keyof typeof contactMethodUrlTypesById>
|
||||
>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { EventType } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
id: T
|
||||
@@ -12,6 +14,9 @@ type EventTypeInfo<T extends string | null | undefined = string> = {
|
||||
dot: string
|
||||
}
|
||||
icon: string
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
isSolved: boolean
|
||||
showBanner: boolean
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -32,6 +37,9 @@ export const {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
},
|
||||
icon: 'ri:question-fill',
|
||||
color: 'gray',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -42,7 +50,10 @@ export const {
|
||||
classNames: {
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
},
|
||||
icon: 'ri:error-warning-fill',
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'yellow',
|
||||
isSolved: false,
|
||||
showBanner: true,
|
||||
},
|
||||
{
|
||||
id: 'WARNING_SOLVED',
|
||||
@@ -50,9 +61,12 @@ export const {
|
||||
label: 'Warning Solved',
|
||||
description: 'A previously reported warning has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
||||
dot: 'bg-amber-900 text-amber-300 ring-amber-900/50',
|
||||
},
|
||||
icon: 'ri:check-fill',
|
||||
icon: 'ri:alert-fill',
|
||||
color: 'green',
|
||||
isSolved: true,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'ALERT',
|
||||
@@ -62,7 +76,10 @@ export const {
|
||||
classNames: {
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
},
|
||||
icon: 'ri:alert-fill',
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'red',
|
||||
isSolved: false,
|
||||
showBanner: true,
|
||||
},
|
||||
{
|
||||
id: 'ALERT_SOLVED',
|
||||
@@ -70,9 +87,12 @@ export const {
|
||||
label: 'Alert Solved',
|
||||
description: 'A previously reported alert has been solved',
|
||||
classNames: {
|
||||
dot: 'bg-green-900 text-green-300 ring-green-900/50',
|
||||
dot: 'bg-red-900 text-red-300 ring-red-900/50',
|
||||
},
|
||||
icon: 'ri:check-fill',
|
||||
icon: 'ri:spam-fill',
|
||||
color: 'green',
|
||||
isSolved: true,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'INFO',
|
||||
@@ -83,6 +103,9 @@ export const {
|
||||
dot: 'bg-blue-900 text-blue-300 ring-blue-900/50',
|
||||
},
|
||||
icon: 'ri:information-fill',
|
||||
color: 'sky',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'NORMAL',
|
||||
@@ -93,6 +116,9 @@ export const {
|
||||
dot: 'bg-zinc-700 text-zinc-300 ring-zinc-700/50',
|
||||
},
|
||||
icon: 'ri:notification-fill',
|
||||
color: 'green',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
{
|
||||
id: 'UPDATE',
|
||||
@@ -103,6 +129,9 @@ export const {
|
||||
dot: 'bg-sky-900 text-sky-300 ring-sky-900/50',
|
||||
},
|
||||
icon: 'ri:pencil-fill',
|
||||
color: 'sky',
|
||||
isSolved: false,
|
||||
showBanner: false,
|
||||
},
|
||||
] as const satisfies EventTypeInfo<EventType>[]
|
||||
)
|
||||
|
||||
39
web/src/constants/kycLevelClarifications.ts
Normal file
39
web/src/constants/kycLevelClarifications.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type { KycLevelClarification } from '@prisma/client'
|
||||
|
||||
type KycLevelClarificationInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
label: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: kycLevelClarifications,
|
||||
dataObject: kycLevelClarificationsById,
|
||||
getFn: getKycLevelClarificationInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): KycLevelClarificationInfo<typeof value> => ({
|
||||
value,
|
||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
description: '',
|
||||
icon: 'ri:question-line',
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'NONE',
|
||||
label: 'None',
|
||||
description: 'No clarification needed.',
|
||||
icon: 'ri:file-copy-line',
|
||||
},
|
||||
{
|
||||
value: 'DEPENDS_ON_PARTNERS',
|
||||
label: 'Depends on partners',
|
||||
description: 'May vary across partners.',
|
||||
icon: 'ri:share-forward-line',
|
||||
},
|
||||
] as const satisfies KycLevelClarificationInfo<KycLevelClarification>[]
|
||||
)
|
||||
@@ -1 +1 @@
|
||||
export const SUPPORT_EMAIL = 'support@kycnot.me'
|
||||
export const SUPPORT_EMAIL = 'contact@kycnot.me'
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { ServiceSuggestionType } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type ServiceSuggestionTypeInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
slug: string
|
||||
label: string
|
||||
icon: string
|
||||
order: number
|
||||
default: boolean
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
}
|
||||
|
||||
export const {
|
||||
@@ -25,9 +29,11 @@ export const {
|
||||
(value): ServiceSuggestionTypeInfo<typeof value> => ({
|
||||
value,
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
label: value ? transformCase(value.replace('_', ' '), 'title') : String(value),
|
||||
icon: 'ri:question-line',
|
||||
order: Infinity,
|
||||
default: false,
|
||||
color: 'zinc',
|
||||
}),
|
||||
[
|
||||
{
|
||||
@@ -35,14 +41,18 @@ export const {
|
||||
slug: 'create',
|
||||
label: 'Create',
|
||||
icon: 'ri:add-line',
|
||||
order: 1,
|
||||
default: true,
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'EDIT_SERVICE',
|
||||
slug: 'edit',
|
||||
label: 'Edit',
|
||||
icon: 'ri:pencil-line',
|
||||
order: 2,
|
||||
default: false,
|
||||
color: 'blue',
|
||||
},
|
||||
] as const satisfies ServiceSuggestionTypeInfo<ServiceSuggestionType>[]
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ type ServiceVisibilityInfo<T extends string | null | undefined = string> = {
|
||||
slug: string
|
||||
label: string
|
||||
description: string
|
||||
longDescription: string
|
||||
icon: string
|
||||
iconClass: string
|
||||
}
|
||||
@@ -28,6 +29,7 @@ export const {
|
||||
slug: value ? value.toLowerCase() : '',
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
description: '',
|
||||
longDescription: '',
|
||||
icon: 'ri:eye-line',
|
||||
iconClass: 'text-current/60',
|
||||
}),
|
||||
@@ -37,6 +39,7 @@ export const {
|
||||
slug: 'public',
|
||||
label: 'Public',
|
||||
description: 'Listed in search and browse.',
|
||||
longDescription: 'Listed in search and browse.',
|
||||
icon: 'ri:global-line',
|
||||
iconClass: 'text-green-500',
|
||||
},
|
||||
@@ -45,6 +48,7 @@ export const {
|
||||
slug: 'unlisted',
|
||||
label: 'Unlisted',
|
||||
description: 'Only accessible via direct link.',
|
||||
longDescription: "Unlisted service, only accessible via direct link and won't appear in searches.",
|
||||
icon: 'ri:link',
|
||||
iconClass: 'text-yellow-500',
|
||||
},
|
||||
@@ -53,8 +57,19 @@ export const {
|
||||
slug: 'hidden',
|
||||
label: 'Hidden',
|
||||
description: 'Only visible to moderators.',
|
||||
longDescription: 'Hidden service, only visible to moderators.',
|
||||
icon: 'ri:lock-line',
|
||||
iconClass: 'text-red-500',
|
||||
},
|
||||
{
|
||||
value: 'ARCHIVED',
|
||||
slug: 'archived',
|
||||
label: 'Archived',
|
||||
description: 'No longer operational.',
|
||||
longDescription:
|
||||
'Archived service, no longer exists or ceased operations. Information may be outdated.',
|
||||
icon: 'ri:archive-line',
|
||||
iconClass: 'text-day-100',
|
||||
},
|
||||
] as const satisfies ServiceVisibilityInfo<ServiceVisibility>[]
|
||||
)
|
||||
|
||||
53
web/src/constants/verificationStepStatus.ts
Normal file
53
web/src/constants/verificationStepStatus.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { makeHelpersForOptions } from '../lib/makeHelpersForOptions'
|
||||
import { transformCase } from '../lib/strings'
|
||||
|
||||
import type BadgeSmall from '../components/BadgeSmall.astro'
|
||||
import type { VerificationStepStatus } from '@prisma/client'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type VerificationStepStatusInfo<T extends string | null | undefined = string> = {
|
||||
value: T
|
||||
label: string
|
||||
icon: string
|
||||
color: ComponentProps<typeof BadgeSmall>['color']
|
||||
}
|
||||
|
||||
export const {
|
||||
dataArray: verificationStepStatuses,
|
||||
dataObject: verificationStepStatusesByValue,
|
||||
getFn: getVerificationStepStatusInfo,
|
||||
} = makeHelpersForOptions(
|
||||
'value',
|
||||
(value): VerificationStepStatusInfo<typeof value> => ({
|
||||
value,
|
||||
label: value ? transformCase(value, 'title') : String(value),
|
||||
icon: 'ri:question-line',
|
||||
color: 'gray',
|
||||
}),
|
||||
[
|
||||
{
|
||||
value: 'PASSED',
|
||||
label: 'Passed',
|
||||
icon: 'ri:verified-badge-fill',
|
||||
color: 'green',
|
||||
},
|
||||
{
|
||||
value: 'IN_PROGRESS',
|
||||
label: 'In Progress',
|
||||
icon: 'ri:loader-line',
|
||||
color: 'yellow',
|
||||
},
|
||||
{
|
||||
value: 'FAILED',
|
||||
label: 'Failed',
|
||||
icon: 'ri:alert-line',
|
||||
color: 'red',
|
||||
},
|
||||
{
|
||||
value: 'PENDING',
|
||||
label: 'Pending',
|
||||
icon: 'ri:time-line',
|
||||
color: 'sky',
|
||||
},
|
||||
] as const satisfies VerificationStepStatusInfo<VerificationStepStatus>[]
|
||||
)
|
||||
4
web/src/icons/nostr.svg
Normal file
4
web/src/icons/nostr.svg
Normal file
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 620 620">
|
||||
<path
|
||||
d="M620 270v328c0 12-10 22-22 22H332c-12 0-22-10-22-22v-61c1-75 9-147 26-179 9-20 26-30 44-36 36-11 98-4 124-5 0 0 80 3 80-42 0-37-36-34-36-34-39 1-69-1-88-9-33-13-34-36-34-44-1-91-134-102-252-79-128 24 2 209 2 456v33c0 12-10 22-22 22H22c-12 0-22-10-22-22V31C0 19 10 9 22 9h124c12 0 22 10 22 22 0 19 20 29 35 18C248 17 305 0 369 0c143 0 251 84 251 270Zm-238-66c0-27-21-48-47-48s-47 21-47 48c0 26 21 48 47 48s47-22 47-48Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 526 B |
5
web/src/icons/simplex.svg
Normal file
5
web/src/icons/simplex.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path fill-rule="evenodd"
|
||||
d="m2.14 6.31 3.95 3.79 3.97-3.81L6.11 2.5 8.13.57l3.94 3.79L16.1.5l1.98 1.9-4.03 3.85L18 10.03l4.03-3.85L24 8.07l-4.03 3.86 3.95 3.78-2.01 1.93-3.95-3.78-4.03 3.85 3.95 3.79-2 1.93-3.96-3.79L7.9 23.5l-1.98-1.9 4.06-3.89-3.95-3.78-4.06 3.89L0 15.92l4.06-3.88L.1 8.26 2.14 6.3Zm13.85 5.65L12 15.77 8.06 12l3.98-3.81 3.95 3.78Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
@@ -8,7 +8,7 @@ import BaseLayout from './BaseLayout.astro'
|
||||
import type { ComponentProps } from 'astro/types'
|
||||
|
||||
type Props = Omit<ComponentProps<typeof BaseLayout>, 'widthClassName'> & {
|
||||
layoutHeader: { icon: string; title: string; subtitle: string }
|
||||
layoutHeader: { icon: string; title: string; subtitle?: string }
|
||||
}
|
||||
|
||||
const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
@@ -28,10 +28,19 @@ const { layoutHeader, ...baseLayoutProps } = Astro.props
|
||||
<Icon name={layoutHeader.icon} class="text-night-800 size-8" />
|
||||
</div>
|
||||
|
||||
<h1 class="font-title text-day-200 mt-1 text-center text-3xl font-semibold">
|
||||
<h1
|
||||
class={cn(
|
||||
'font-title text-day-200 mt-1 text-center text-3xl font-semibold',
|
||||
!layoutHeader.subtitle && 'xs:mb-8 mb-6'
|
||||
)}
|
||||
>
|
||||
{layoutHeader.title}
|
||||
</h1>
|
||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||
{
|
||||
!!layoutHeader.subtitle && (
|
||||
<p class="text-day-500 xs:mb-8 mt-1 mb-6 text-center">{layoutHeader.subtitle}</p>
|
||||
)
|
||||
}
|
||||
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
10
web/src/lib/assert.ts
Normal file
10
web/src/lib/assert.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Gives an error if the type is not equal to 1.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'a'>> // Gives an error
|
||||
* type _ExpectEquals = Assert<Equals<'a' | 'b', 'b' | 'a'>> // No error
|
||||
* ```
|
||||
*/
|
||||
export type Assert<T extends 1> = T
|
||||
@@ -2,6 +2,7 @@ import { orderBy } from 'lodash-es'
|
||||
|
||||
import { getAttributeCategoryInfo } from '../constants/attributeCategories'
|
||||
import { getAttributeTypeInfo } from '../constants/attributeTypes'
|
||||
import { serviceVisibilitiesById } from '../constants/serviceVisibility'
|
||||
import { READ_MORE_SENTENCE_LINK, verificationStatusesByValue } from '../constants/verificationStatus'
|
||||
|
||||
import { formatDateShort } from './timeAgo'
|
||||
@@ -36,6 +37,7 @@ export function makeNonDbAttributes(
|
||||
service: Prisma.ServiceGetPayload<{
|
||||
select: {
|
||||
verificationStatus: true
|
||||
serviceVisibility: true
|
||||
isRecentlyListed: true
|
||||
listedAt: true
|
||||
createdAt: true
|
||||
@@ -134,6 +136,16 @@ export function makeNonDbAttributes(
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: serviceVisibilitiesById.ARCHIVED.label,
|
||||
show: service.serviceVisibility === 'ARCHIVED',
|
||||
type: 'WARNING',
|
||||
category: 'TRUST',
|
||||
description: serviceVisibilitiesById.ARCHIVED.longDescription,
|
||||
privacyPoints: 0,
|
||||
trustPoints: 0,
|
||||
links: [],
|
||||
},
|
||||
{
|
||||
title: 'Recently listed',
|
||||
show: service.isRecentlyListed,
|
||||
|
||||
@@ -31,7 +31,7 @@ const commentReplyQuery = {
|
||||
name: true,
|
||||
verified: true,
|
||||
admin: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
createdAt: true,
|
||||
displayName: true,
|
||||
picture: true,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
import type { MaybePromise } from 'astro/actions/runtime/utils.js'
|
||||
import type { z } from 'astro/zod'
|
||||
|
||||
type SpecialUserPermission = 'admin' | 'verified' | 'verifier'
|
||||
type SpecialUserPermission = 'admin' | 'moderator' | 'verified'
|
||||
type Permission = SpecialUserPermission | 'guest' | 'not-spammer' | 'user'
|
||||
|
||||
type ActionAPIContextWithUser = ActionAPIContext & {
|
||||
@@ -87,8 +87,8 @@ export function defineProtectedAction<
|
||||
}
|
||||
|
||||
if (
|
||||
(permissions === 'verifier' || (Array.isArray(permissions) && permissions.includes('verifier'))) &&
|
||||
!context.locals.user.verifier
|
||||
(permissions === 'moderator' || (Array.isArray(permissions) && permissions.includes('moderator'))) &&
|
||||
!context.locals.user.moderator
|
||||
) {
|
||||
if (context.locals.user.spammer) {
|
||||
throw new ActionError({
|
||||
@@ -98,7 +98,7 @@ export function defineProtectedAction<
|
||||
}
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Verifier privileges required.',
|
||||
message: 'Moderator privileges required.',
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
32
web/src/lib/endpoints.ts
Normal file
32
web/src/lib/endpoints.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { type ActionClient } from 'astro:actions'
|
||||
|
||||
import type { APIRoute } from 'astro'
|
||||
import type { z } from 'astro/zod'
|
||||
|
||||
export function makeEndpointFromAction<Action extends ActionClient<unknown, 'json', z.ZodType> & string>(
|
||||
action: Action
|
||||
): APIRoute {
|
||||
return async (context) => {
|
||||
try {
|
||||
const input = await context.request.json()
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
|
||||
const result = await context.callAction(action, input)
|
||||
|
||||
if (result.error) {
|
||||
console.error('Error on endpoint', result.error)
|
||||
return new Response(JSON.stringify({ error: result.error.message }), {
|
||||
status: result.error.status,
|
||||
})
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result.data), {
|
||||
status: 200,
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error on endpoint', error)
|
||||
return new Response(JSON.stringify({ error: 'Internal server error' }), {
|
||||
status: 500,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -114,7 +114,13 @@ export class ErrorBanners {
|
||||
return result
|
||||
} catch (error) {
|
||||
this.handler(uiMessage)(error)
|
||||
return fallback as F
|
||||
return fallback as F extends never[]
|
||||
? T extends [infer _First, ...infer _Rest]
|
||||
? []
|
||||
: T extends unknown[]
|
||||
? T[number][]
|
||||
: F
|
||||
: F
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -69,6 +69,53 @@ export async function saveFileLocally(
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* List all files in a specific subdirectory of the upload directory.
|
||||
* Returns an array of web-accessible URLs.
|
||||
*/
|
||||
export async function listFiles(subDir: string): Promise<string[]> {
|
||||
const { fsPath: uploadDir, webPath: webUploadPath } = getUploadDir(subDir)
|
||||
try {
|
||||
const files = await fs.readdir(uploadDir)
|
||||
return files.map((file) => sanitizePath(`${webUploadPath}/${file}`))
|
||||
} catch (error: unknown) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
console.error(`Error listing files in ${uploadDir}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file locally given its web-accessible URL path
|
||||
*/
|
||||
export async function deleteFileLocally(fileUrl: string): Promise<void> {
|
||||
// Extract the subpath and filename from the webPath
|
||||
// Example: /files/evidence/service-slug/image.jpg -> evidence/service-slug/image.jpg
|
||||
const basePath = '/files'
|
||||
if (!fileUrl.startsWith(basePath)) {
|
||||
throw new Error('Invalid file URL for deletion. Must start with /files')
|
||||
}
|
||||
|
||||
const subPathAndFile = fileUrl.substring(basePath.length).replace(/^\/+/, '') // Remove leading /files/ and any extra leading slashes
|
||||
const { fsPath: uploadDirWithoutSubDir } = getUploadDir() // Get base upload directory
|
||||
const filePath = path.join(uploadDirWithoutSubDir, subPathAndFile)
|
||||
|
||||
try {
|
||||
await fs.unlink(filePath)
|
||||
} catch (error: unknown) {
|
||||
const err = error as NodeJS.ErrnoException
|
||||
if (err.code === 'ENOENT') {
|
||||
console.warn(`File not found for deletion, but treating as success: ${filePath}`)
|
||||
return
|
||||
}
|
||||
console.error(`Error deleting file ${filePath}:`, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizePath(inputPath: string): string {
|
||||
let sanitized = inputPath.replace(/\\+/g, '/')
|
||||
// Collapse multiple slashes, but preserve protocol (e.g., http://)
|
||||
|
||||
16
web/src/lib/findServicesBySimilarity.ts
Normal file
16
web/src/lib/findServicesBySimilarity.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
import { prisma } from './prisma'
|
||||
|
||||
export async function findServicesBySimilarity(value: string, similarityThreshold = 0.01) {
|
||||
const data = await prisma.$queryRaw`
|
||||
SELECT id, similarity(name, ${value}) AS similarity_score
|
||||
FROM "Service"
|
||||
WHERE similarity(name, ${value}) >= ${similarityThreshold}
|
||||
ORDER BY similarity(name, ${value}) desc`
|
||||
|
||||
const schema = z.array(z.object({ id: z.number(), similarity_score: z.number() }))
|
||||
const parsedData = schema.parse(data)
|
||||
|
||||
return parsedData.map(({ id, similarity_score }) => ({ id, similarityScore: similarity_score }))
|
||||
}
|
||||
35
web/src/lib/json.ts
Normal file
35
web/src/lib/json.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { z } from 'astro:content'
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions
|
||||
interface JSONObject {
|
||||
[k: string]: JSONValue
|
||||
}
|
||||
type JSONList = JSONValue[]
|
||||
type JSONPrimitive = boolean | number | string | null
|
||||
type JSONValue = Date | JSONList | JSONObject | JSONPrimitive
|
||||
|
||||
export type ZodJSON = z.ZodType<JSONValue>
|
||||
|
||||
export function zodParseJSON<T extends z.ZodType<JSONValue>, D extends z.output<T> | undefined = undefined>(
|
||||
schema: T,
|
||||
stringValue: string | null | undefined,
|
||||
defaultValue?: D
|
||||
): D | z.output<T> {
|
||||
if (!stringValue) return defaultValue as D
|
||||
|
||||
let jsonValue: D | z.output<typeof schema> = defaultValue as D
|
||||
try {
|
||||
jsonValue = JSON.parse(stringValue)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
return defaultValue as D
|
||||
}
|
||||
|
||||
const parsedValue = schema.safeParse(jsonValue)
|
||||
if (!parsedValue.success) {
|
||||
console.error(parsedValue.error)
|
||||
return defaultValue as D
|
||||
}
|
||||
|
||||
return parsedValue.data
|
||||
}
|
||||
53
web/src/lib/localstorage.ts
Normal file
53
web/src/lib/localstorage.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { z } from 'astro:schema'
|
||||
|
||||
import { zodParseJSON, type ZodJSON } from './json'
|
||||
import { typedObjectEntries } from './objects'
|
||||
|
||||
function makeTypedLocalStorage<
|
||||
Schemas extends Record<string, ZodJSON>,
|
||||
T extends {
|
||||
[K in keyof Schemas]: {
|
||||
schema: Schemas[K]
|
||||
default?: z.output<Schemas[K]> | undefined
|
||||
key?: string
|
||||
}
|
||||
},
|
||||
>(options: T) {
|
||||
return Object.fromEntries(
|
||||
typedObjectEntries(options).map(([originalKey, option]) => {
|
||||
const key = option.key ?? originalKey
|
||||
|
||||
return [
|
||||
key,
|
||||
{
|
||||
get: () => {
|
||||
return zodParseJSON(option.schema, localStorage.getItem(key), option.default)
|
||||
},
|
||||
|
||||
set: (value: z.input<typeof option.schema>) => {
|
||||
localStorage.setItem(key, JSON.stringify(value))
|
||||
},
|
||||
|
||||
remove: () => {
|
||||
localStorage.removeItem(key)
|
||||
},
|
||||
|
||||
default: option.default,
|
||||
},
|
||||
]
|
||||
})
|
||||
) as {
|
||||
[K in keyof T]: {
|
||||
get: () => z.output<T[K]['schema']> | (T[K] extends { default: infer D } ? D : undefined)
|
||||
set: (value: z.input<T[K]['schema']>) => void
|
||||
remove: () => void
|
||||
default: z.output<T[K]['schema']> | undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const typedLocalStorage = makeTypedLocalStorage({
|
||||
pushNotificationsBannerDismissedAt: {
|
||||
schema: z.coerce.date(),
|
||||
},
|
||||
})
|
||||
50
web/src/lib/makeAdminApiCallInfo.ts
Normal file
50
web/src/lib/makeAdminApiCallInfo.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { Misc } from 'ts-toolbelt'
|
||||
|
||||
export async function makeAdminApiCallInfo<T extends Misc.JSON.Object>({
|
||||
method,
|
||||
path,
|
||||
input,
|
||||
baseUrl,
|
||||
}: {
|
||||
method: 'POST' | 'QUERY'
|
||||
path: `/${string}`
|
||||
input: T
|
||||
baseUrl: URL | string
|
||||
}) {
|
||||
const fullPath = new URL(`/api/v1${path}`, baseUrl).href
|
||||
|
||||
const fetchProsmise = fetch(fullPath, {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(input),
|
||||
}).then((res) => {
|
||||
try {
|
||||
return res.json() as Promise<Misc.JSON.Value>
|
||||
} catch (errJson: unknown) {
|
||||
console.error(errJson)
|
||||
|
||||
try {
|
||||
return res.text()
|
||||
} catch (errText: unknown) {
|
||||
console.error(errText)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
let output: Misc.JSON.Value = ''
|
||||
try {
|
||||
output = await fetchProsmise
|
||||
} catch (err: unknown) {
|
||||
console.error(err)
|
||||
output = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
|
||||
return {
|
||||
method,
|
||||
path,
|
||||
fullPath,
|
||||
input,
|
||||
output,
|
||||
}
|
||||
}
|
||||
@@ -162,3 +162,16 @@ export function areEqualObjectsWithoutOrder<T extends Record<string, unknown>>(
|
||||
return undefined
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as {@link Object.entries}, but with proper typing.
|
||||
* @example
|
||||
* typedObjectEntries({ a: 1, b: 2 }) // [['a', 1], ['b', 2]]
|
||||
*/
|
||||
export function typedObjectEntries<T extends Record<string, unknown>>(obj: T) {
|
||||
return Object.entries(obj) as Prettify<
|
||||
{
|
||||
[K in Extract<keyof T, string>]: [K, T[K]]
|
||||
}[Extract<keyof T, string>]
|
||||
>[]
|
||||
}
|
||||
|
||||
248
web/src/lib/postgresListenerIntegration.ts
Normal file
248
web/src/lib/postgresListenerIntegration.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { z } from 'astro/zod'
|
||||
import { Client } from 'pg'
|
||||
|
||||
import { zodParseJSON } from './json'
|
||||
import { makeNotificationContent, makeNotificationLink, makeNotificationTitle } from './notifications'
|
||||
import { prisma } from './prisma'
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
import { sendPushNotification, type NotificationData } from './webPush'
|
||||
|
||||
import type { AstroIntegration, HookParameters } from 'astro'
|
||||
|
||||
const DATABASE_URL = getServerEnvVariable('DATABASE_URL')
|
||||
const SITE_URL = getServerEnvVariable('SITE_URL')
|
||||
|
||||
let pgClient: Client | null = null
|
||||
|
||||
const INTEGRATION_NAME = 'postgres-listener'
|
||||
|
||||
async function handleNotificationCreated(
|
||||
notificationId: number,
|
||||
options: HookParameters<'astro:server:start'>
|
||||
) {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
try {
|
||||
logger.info(`Processing notification with ID: ${String(notificationId)}`)
|
||||
|
||||
const notification = await prisma.notification.findUnique({
|
||||
where: { id: notificationId },
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
userId: true,
|
||||
aboutAccountStatusChange: true,
|
||||
aboutCommentStatusChange: true,
|
||||
aboutServiceVerificationStatusChange: true,
|
||||
aboutSuggestionStatusChange: true,
|
||||
aboutComment: {
|
||||
select: {
|
||||
id: true,
|
||||
author: { select: { id: true } },
|
||||
status: true,
|
||||
content: true,
|
||||
communityNote: true,
|
||||
parent: {
|
||||
select: {
|
||||
author: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionId: true,
|
||||
aboutServiceSuggestion: {
|
||||
select: {
|
||||
status: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutServiceSuggestionMessage: {
|
||||
select: {
|
||||
id: true,
|
||||
content: true,
|
||||
suggestion: {
|
||||
select: {
|
||||
id: true,
|
||||
service: {
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutEvent: {
|
||||
select: {
|
||||
title: true,
|
||||
type: true,
|
||||
service: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
aboutService: {
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
verificationStatus: true,
|
||||
},
|
||||
},
|
||||
aboutKarmaTransaction: {
|
||||
select: {
|
||||
points: true,
|
||||
action: true,
|
||||
description: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (!notification) {
|
||||
logger.warn(`Notification with ID ${String(notificationId)} not found`)
|
||||
return
|
||||
}
|
||||
|
||||
const subscriptions = await prisma.pushSubscription.findMany({
|
||||
where: { userId: notification.userId },
|
||||
select: {
|
||||
id: true,
|
||||
endpoint: true,
|
||||
p256dh: true,
|
||||
auth: true,
|
||||
},
|
||||
})
|
||||
|
||||
if (subscriptions.length === 0) {
|
||||
logger.info(`No push subscriptions found for user ${notification.user.name}`)
|
||||
return
|
||||
}
|
||||
const notificationData = {
|
||||
title: makeNotificationTitle(notification, notification.user),
|
||||
body: makeNotificationContent(notification) ?? undefined,
|
||||
url: makeNotificationLink(notification, SITE_URL) ?? undefined,
|
||||
} satisfies NotificationData
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
subscriptions.map(async (subscription) => {
|
||||
const result = await sendPushNotification(
|
||||
{
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.p256dh,
|
||||
auth: subscription.auth,
|
||||
},
|
||||
},
|
||||
notificationData
|
||||
)
|
||||
|
||||
// Remove invalid subscriptions
|
||||
if (result.error && (result.error.statusCode === 410 || result.error.statusCode === 404)) {
|
||||
await prisma.pushSubscription.delete({ where: { id: subscription.id } })
|
||||
logger.info(`Removed invalid subscription for user ${notification.user.name}`)
|
||||
}
|
||||
|
||||
return result.success
|
||||
})
|
||||
)
|
||||
|
||||
const successCount = results.filter((r) => r.status === 'fulfilled' && r.value).length
|
||||
const failureCount = results.filter((r) => !(r.status === 'fulfilled' && r.value)).length
|
||||
|
||||
logger.info(
|
||||
`Push notification sent for notification ${String(notificationId)} to user ${notification.user.name}: ${String(successCount)} successful, ${String(failureCount)} failed`
|
||||
)
|
||||
} catch (error) {
|
||||
logger.error(`Error processing notification ${String(notificationId)}: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function postgresListener(): AstroIntegration {
|
||||
return {
|
||||
name: 'postgres-listener',
|
||||
hooks: {
|
||||
'astro:server:start': async (options) => {
|
||||
const logger = options.logger.fork(INTEGRATION_NAME)
|
||||
|
||||
try {
|
||||
logger.info('Starting PostgreSQL notification listener...')
|
||||
|
||||
pgClient = new Client({ connectionString: DATABASE_URL })
|
||||
|
||||
await pgClient.connect()
|
||||
logger.info('Connected to PostgreSQL for notifications')
|
||||
|
||||
await pgClient.query('LISTEN notification_created')
|
||||
logger.info('Listening for notification_created events')
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
if (msg.channel === 'notification_created') {
|
||||
const payload = zodParseJSON(z.object({ id: z.number().int().positive() }), msg.payload)
|
||||
if (!payload) {
|
||||
logger.warn(`Invalid notification ID in payload: ${String(msg.payload)}`)
|
||||
return
|
||||
}
|
||||
|
||||
// NOTE: Don't await to avoid blocking
|
||||
void handleNotificationCreated(payload.id, options)
|
||||
}
|
||||
})
|
||||
|
||||
pgClient.on('error', (error) => {
|
||||
logger.error(`PostgreSQL client error: ${getErrorMessage(error)}`)
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
logger.info('PostgreSQL client connection ended')
|
||||
})
|
||||
} catch (error) {
|
||||
logger.error(`Failed to start PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
'astro:server:done': async ({ logger: originalLogger }) => {
|
||||
const logger = originalLogger.fork(INTEGRATION_NAME)
|
||||
|
||||
if (pgClient) {
|
||||
try {
|
||||
logger.info('Stopping PostgreSQL notification listener...')
|
||||
await pgClient.end()
|
||||
pgClient = null
|
||||
logger.info('PostgreSQL listener stopped')
|
||||
} catch (error) {
|
||||
logger.error(`Error stopping PostgreSQL listener: ${getErrorMessage(error)}`)
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown) {
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return String(error)
|
||||
}
|
||||
@@ -25,24 +25,23 @@ const findManyAndCount = {
|
||||
},
|
||||
}
|
||||
|
||||
type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||
// NOTE: This used to be necessary to cast the prismaClientSingleton return type, but it seems not anymore. I left it, just in case we need it again
|
||||
// type FindManyAndCountType = typeof findManyAndCount.model.$allModels.findManyAndCount
|
||||
|
||||
type ModelsWithCustomMethods = {
|
||||
[Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||
findMany: (...args: any[]) => Promise<any>
|
||||
}
|
||||
? PrismaClient[Model] & {
|
||||
findManyAndCount: FindManyAndCountType
|
||||
}
|
||||
: PrismaClient[Model]
|
||||
}
|
||||
// type ModelsWithCustomMethods = {
|
||||
// [Model in keyof PrismaClient]: PrismaClient[Model] extends {
|
||||
// findMany: (...args: any[]) => Promise<any>
|
||||
// }
|
||||
// ? PrismaClient[Model] & {
|
||||
// findManyAndCount: FindManyAndCountType
|
||||
// }
|
||||
// : PrismaClient[Model]
|
||||
// }
|
||||
|
||||
type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||
// type ExtendedPrismaClient = ModelsWithCustomMethods & PrismaClient
|
||||
|
||||
function prismaClientSingleton(): ExtendedPrismaClient {
|
||||
const prisma = new PrismaClient().$extends(findManyAndCount)
|
||||
|
||||
return prisma as unknown as ExtendedPrismaClient
|
||||
function prismaClientSingleton() {
|
||||
return new PrismaClient().$extends(findManyAndCount)
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
14
web/src/lib/serverEnvVariables.ts
Normal file
14
web/src/lib/serverEnvVariables.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { loadEnv } from 'vite'
|
||||
|
||||
/** Only use when you can't import the variables from `astro:env/server` */
|
||||
// @ts-expect-error process.env actually exists
|
||||
const untypedServerEnvVariables = loadEnv(process.env.NODE_ENV, process.cwd(), '')
|
||||
|
||||
/** Only use when you can't import the variables from `astro:env/server` */
|
||||
export function getServerEnvVariable<T extends keyof typeof untypedServerEnvVariables>(
|
||||
name: T
|
||||
): NonNullable<(typeof untypedServerEnvVariables)[T]> {
|
||||
const value = untypedServerEnvVariables[name]
|
||||
if (!value) throw new Error(`${name} environment variable is not set`)
|
||||
return value
|
||||
}
|
||||
@@ -113,3 +113,28 @@ export function urlDomain(url: URL | string) {
|
||||
}
|
||||
return url.origin
|
||||
}
|
||||
|
||||
export function separateServiceUrlsByType(allServiceUrls: string[]) {
|
||||
const result: {
|
||||
web: string[]
|
||||
onion: string[]
|
||||
i2p: string[]
|
||||
} = {
|
||||
web: [],
|
||||
onion: [],
|
||||
i2p: [],
|
||||
}
|
||||
|
||||
for (const url of allServiceUrls) {
|
||||
const parsedUrl = new URL(url)
|
||||
if (parsedUrl.origin.endsWith('.onion')) {
|
||||
result.onion.push(url)
|
||||
} else if (parsedUrl.origin.endsWith('.b32.i2p')) {
|
||||
result.i2p.push(url)
|
||||
} else {
|
||||
result.web.push(url)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
|
||||
defaultToken: 'admin',
|
||||
},
|
||||
{
|
||||
envToken: 'DEV_VERIFIER_USER_SECRET_TOKEN',
|
||||
defaultToken: 'verifier',
|
||||
envToken: 'DEV_MODERATOR_USER_SECRET_TOKEN',
|
||||
defaultToken: 'moderator',
|
||||
},
|
||||
{
|
||||
envToken: 'DEV_VERIFIED_USER_SECRET_TOKEN',
|
||||
@@ -50,7 +50,7 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => {
|
||||
}[]
|
||||
|
||||
const env =
|
||||
// This file can also be called from faker.ts, where import.meta.env is not available
|
||||
// This file can also be called from seed.ts, where import.meta.env is not available
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
(import.meta.env
|
||||
? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]]))
|
||||
|
||||
59
web/src/lib/webPush.ts
Normal file
59
web/src/lib/webPush.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable import/no-named-as-default-member */
|
||||
import webpush, { WebPushError } from 'web-push'
|
||||
|
||||
import { getServerEnvVariable } from './serverEnvVariables'
|
||||
|
||||
const VAPID_PUBLIC_KEY = getServerEnvVariable('VAPID_PUBLIC_KEY')
|
||||
const VAPID_PRIVATE_KEY = getServerEnvVariable('VAPID_PRIVATE_KEY')
|
||||
const VAPID_SUBJECT = getServerEnvVariable('VAPID_SUBJECT')
|
||||
|
||||
// Configure VAPID keys
|
||||
webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY)
|
||||
|
||||
export { webpush }
|
||||
|
||||
export type NotificationData = {
|
||||
title: string
|
||||
body?: string
|
||||
icon?: string
|
||||
badge?: string
|
||||
url?: string
|
||||
}
|
||||
|
||||
export async function sendPushNotification(
|
||||
subscription: {
|
||||
endpoint: string
|
||||
keys: {
|
||||
p256dh: string
|
||||
auth: string
|
||||
}
|
||||
},
|
||||
data: NotificationData
|
||||
) {
|
||||
try {
|
||||
const result = await webpush.sendNotification(
|
||||
subscription,
|
||||
JSON.stringify({
|
||||
title: data.title,
|
||||
options: {
|
||||
body: data.body,
|
||||
icon: data.icon ?? '/favicon.svg',
|
||||
badge: data.badge ?? '/favicon.svg',
|
||||
data: {
|
||||
url: data.url,
|
||||
},
|
||||
},
|
||||
}),
|
||||
{
|
||||
TTL: 24 * 60 * 60, // 24 hours
|
||||
}
|
||||
)
|
||||
return { success: true, result } as const
|
||||
} catch (error) {
|
||||
console.error('Error sending push notification:', error)
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof WebPushError ? error : undefined,
|
||||
} as const
|
||||
}
|
||||
}
|
||||
@@ -13,17 +13,44 @@ const addZodPipe = (schema: ZodTypeAny, zodPipe?: ZodTypeAny) => {
|
||||
export const zodCohercedNumber = (zodPipe?: ZodTypeAny) =>
|
||||
addZodPipe(z.number().or(z.string().nonempty()), zodPipe)
|
||||
|
||||
const cleanUrl = (input: unknown) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const cleanInput = input.trim().replace(/\/$/, '')
|
||||
return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput
|
||||
}
|
||||
|
||||
export const zodUrlOptionalProtocol = z.preprocess(
|
||||
(input) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const trimmedVal = input.trim()
|
||||
return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal
|
||||
},
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
cleanUrl,
|
||||
z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]*$/i.test(value), {
|
||||
message: 'Invalid URL',
|
||||
})
|
||||
)
|
||||
|
||||
export const zodContactMethod = z.preprocess(
|
||||
(input) => {
|
||||
if (typeof input !== 'string') return input
|
||||
const cleanInput = input.trim()
|
||||
|
||||
if (/^([\d\s+\-_/()[\]*#.,]|ext|x){7,}$/i.test(cleanInput)) return `tel:${cleanInput}`
|
||||
|
||||
if (/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(cleanInput)) return `mailto:${cleanInput}`
|
||||
|
||||
return cleanUrl(cleanInput)
|
||||
},
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(
|
||||
(value) =>
|
||||
/^((https?):\/\/(?=.*\.[a-z0-9]{2,})[^\s$.?#].[^\s]|([\d\s+\-_/()[\]*#.,]|ext|x){7,}|[0-9\s+-_\\/()[\]*#.]|[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})*$/i.test(
|
||||
value
|
||||
),
|
||||
{
|
||||
message: 'Invalid contact method',
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
|
||||
return <T>(input: T) =>
|
||||
typeof input !== 'string'
|
||||
@@ -34,6 +61,11 @@ const stringToArrayFactory = (delimiter: RegExp | string = ',') => {
|
||||
.filter((item) => item !== '')
|
||||
}
|
||||
|
||||
export const stringListOfSlugsSchemaRequired = z.preprocess(
|
||||
stringToArrayFactory(/[\s,\n]+/),
|
||||
z.array(z.string().regex(/^[a-z0-9-_A-Z]+$/)).min(1)
|
||||
)
|
||||
|
||||
export const stringListOfUrlsSchema = z.preprocess(
|
||||
stringToArrayFactory(/[\s,\n]+/),
|
||||
z.array(zodUrlOptionalProtocol).default([])
|
||||
@@ -44,6 +76,11 @@ export const stringListOfUrlsSchemaRequired = z.preprocess(
|
||||
z.array(zodUrlOptionalProtocol).min(1)
|
||||
)
|
||||
|
||||
export const stringListOfContactMethodsSchema = z.preprocess(
|
||||
stringToArrayFactory(/[\s,\n]+/),
|
||||
z.array(zodContactMethod).default([])
|
||||
)
|
||||
|
||||
export const MAX_IMAGE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user