Compare commits
87 Commits
release-20
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9285d952a5 | ||
|
|
fd5c7ab475 | ||
|
|
9a78a9b377 | ||
|
|
9e0193fc3c | ||
|
|
a68523fc73 | ||
|
|
a465849a76 | ||
|
|
25f6dba3eb | ||
|
|
7e7046e7d2 | ||
|
|
a5d1fb9a5d | ||
|
|
28b84a7d9b | ||
|
|
7a294cb0a1 | ||
|
|
349c26a4df | ||
|
|
86b1afb2c7 | ||
|
|
99bc1f4e0f | ||
|
|
3166349dfb | ||
|
|
5a54352d95 | ||
|
|
a545726abf | ||
|
|
01488b8b3b | ||
|
|
b456af9448 | ||
|
|
b7ae6dc22c | ||
|
|
e4a5fa8fa7 | ||
|
|
6ed07c8386 | ||
|
|
6a9f5f5e99 | ||
|
|
e6edee2dbe | ||
|
|
c7ee1606e4 | ||
|
|
f3c9b92ddb | ||
|
|
effb6689d7 | ||
|
|
cf5f3b3228 | ||
|
|
5a41816ac8 | ||
|
|
bf30a6cb2b | ||
|
|
4ca9b9a5c2 | ||
|
|
03abdef4f1 | ||
|
|
d9880fd83d | ||
|
|
39afcad089 | ||
|
|
99cb730bc0 | ||
|
|
d43402e162 | ||
|
|
9bb316b85f | ||
|
|
4aea68ee58 | ||
|
|
2f88c43236 | ||
|
|
ad3c561419 | ||
|
|
812937d2c7 | ||
|
|
459d7c91f7 | ||
|
|
b8b2dee4a4 | ||
|
|
eb0af871e1 | ||
|
|
3ccd7fd395 | ||
|
|
87f0f36aa1 | ||
|
|
8b90b3eef6 | ||
|
|
2489e94b0e | ||
|
|
144af17a70 | ||
|
|
02e52d7351 | ||
|
|
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 | ||
|
|
cdfdcfc122 | ||
|
|
f4525e3d32 | ||
|
|
ecc8f67fc4 | ||
|
|
72c238a4dc | ||
|
|
d79bedf219 |
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>[]
|
||||
);
|
||||
```
|
||||
49
.env.example
Normal file
@@ -0,0 +1,49 @@
|
||||
# Database
|
||||
POSTGRES_USER=your_db_user
|
||||
POSTGRES_PASSWORD=your_db_password
|
||||
POSTGRES_DATABASE=your_db_name
|
||||
DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DATABASE}?schema=public"
|
||||
DATABASE_UI_URL="https://db.example.com"
|
||||
|
||||
# Generic Config
|
||||
UPLOAD_DIR=/app/uploads
|
||||
SITE_URL="https://your-site.example.com"
|
||||
SOURCE_CODE_URL="https://your-source-code.example.com"
|
||||
TIME_TRAP_SECRET=your_time_trap_secret
|
||||
LOGS_UI_URL="https://logs.example.com"
|
||||
|
||||
# Release Info
|
||||
RELEASE_NUMBER=
|
||||
RELEASE_DATE=
|
||||
|
||||
# Redis
|
||||
REDIS_URL="redis://redis:6379"
|
||||
|
||||
# Crawl4AI
|
||||
CRAWL4AI_BASE_URL="http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN=your_crawl4ai_token
|
||||
|
||||
# Tor and I2P
|
||||
ONION_ADDRESS="http://youronionaddress.onion"
|
||||
I2P_ADDRESS="http://youri2paddress.b32.i2p"
|
||||
I2P_PASS=your_i2p_password
|
||||
|
||||
# Push Notifications
|
||||
VAPID_PUBLIC_KEY=your_vapid_public_key
|
||||
VAPID_PRIVATE_KEY=your_vapid_private_key
|
||||
VAPID_SUBJECT="mailto:your-email@example.com"
|
||||
|
||||
# OpenAI
|
||||
OPENAI_API_KEY=your_openai_api_key
|
||||
OPENAI_BASE_URL="https://your-openai-base-url.example.com"
|
||||
OPENAI_MODEL=your_openai_model
|
||||
OPENAI_RETRY=3
|
||||
|
||||
# Task schedules ---------------------------------------------------
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||
3
.gitignore
vendored
@@ -13,4 +13,5 @@ dump*.sql
|
||||
*.log
|
||||
*.bak
|
||||
migrate.py
|
||||
sync-all.sh
|
||||
sync-all.sh
|
||||
.DS_Store
|
||||
|
||||
4
.platform/hooks/predeploy/01_dump_database.sh
Normal file
@@ -0,0 +1,4 @@
|
||||
#!/bin/bash
|
||||
|
||||
pwd
|
||||
just dump-db
|
||||
3
.vscode/extensions.json
vendored
@@ -7,7 +7,8 @@
|
||||
"golang.go",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"craigrbroughton.htmx-attributes",
|
||||
"nefrob.vscode-just-syntax"
|
||||
"nefrob.vscode-just-syntax",
|
||||
"prisma.prisma"
|
||||
],
|
||||
"unwantedRecommendations": []
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,12 +7,14 @@ services:
|
||||
volumes:
|
||||
- database:/var/lib/postgresql/data:z
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
||||
POSTGRES_DB: ${POSTGRES_DATABASE:-kycnot}
|
||||
env_file:
|
||||
- .env
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}"]
|
||||
test:
|
||||
[
|
||||
'CMD-SHELL',
|
||||
'pg_isready -U ${POSTGRES_USER:-kycnot} -d ${POSTGRES_DATABASE:-kycnot}',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
@@ -20,19 +22,17 @@ services:
|
||||
pyworker:
|
||||
build:
|
||||
context: ./pyworker
|
||||
image: kycnotme/pyworker:${PYWORKER_IMAGE_TAG:-latest}
|
||||
restart: always
|
||||
environment:
|
||||
DATABASE_URL: "postgresql://${POSTGRES_USER:-kycnot}:${POSTGRES_PASSWORD:-kycnot}@database:5432/${POSTGRES_DATABASE:-kycnot}?schema=public"
|
||||
CRAWL4AI_BASE_URL: "http://crawl4ai:11235"
|
||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing}
|
||||
env_file:
|
||||
- .env
|
||||
|
||||
crawl4ai:
|
||||
image: unclecode/crawl4ai:basic-amd64
|
||||
expose:
|
||||
- "11235"
|
||||
environment:
|
||||
CRAWL4AI_API_TOKEN: ${CRAWL4AI_API_TOKEN:-testing} # Optional API security
|
||||
MAX_CONCURRENT_TASKS: 10
|
||||
- '11235'
|
||||
env_file:
|
||||
- .env
|
||||
volumes:
|
||||
- /dev/shm:/dev/shm
|
||||
deploy:
|
||||
@@ -46,22 +46,18 @@ services:
|
||||
image: redis:latest
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
test: ['CMD', 'redis-cli', 'ping']
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
astro:
|
||||
build:
|
||||
context: ./web
|
||||
dockerfile: web/Dockerfile
|
||||
image: kycnotme/astro:${ASTRO_IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-kycnot}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-kycnot}
|
||||
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
|
||||
@@ -70,7 +66,15 @@ services:
|
||||
expose:
|
||||
- 4321
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-k", "--silent", "--fail", "http://localhost:4321"]
|
||||
test:
|
||||
[
|
||||
'CMD',
|
||||
'curl',
|
||||
'-k',
|
||||
'--silent',
|
||||
'--fail',
|
||||
'http://localhost:4321/internal-api/healthcheck',
|
||||
]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
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
|
||||
|
||||
@@ -14,8 +14,11 @@ OPENAI_BASE_URL="https://xxxxxx/api/v1"
|
||||
OPENAI_MODEL="xxxxxxxxx"
|
||||
OPENAI_RETRY=3
|
||||
|
||||
CRON_TOSREVIEW_TASK=0 0 1 * * # Every month
|
||||
CRON_USER_SENTIMENT_TASK=0 0 * * * # Every day
|
||||
CRON_COMMENT_MODERATION_TASK=0 0 * * * # Every hour
|
||||
CRON_FORCE_TRIGGERS_TASK=0 2 * * * # Every day
|
||||
CRON_SERVICE_SCORE_RECALC_TASK=*/5 * * * * # Every 10 minutes
|
||||
# Task schedules ---------------------------------------------------
|
||||
CRON_TOSREVIEW_TASK="0 0 1 * *" # every month
|
||||
CRON_USER_SENTIMENT_TASK="0 0 * * *" # daily
|
||||
CRON_COMMENT_MODERATION_TASK="0 * * * *" # hourly
|
||||
CRON_FORCE_TRIGGERS_TASK="0 2 * * *" # daily 02:00
|
||||
CRON_INACTIVE_USERS_TASK="0 6 * * *" # daily 06:00
|
||||
CRON_SERVICE_SCORE_RECALC_TASK="*0 0 * * *" # dayly
|
||||
CRON_SERVICE_SCORE_RECALC_ALL_TASK="0 0 * * *" # daily
|
||||
@@ -38,6 +38,7 @@ Required environment variables:
|
||||
- `CRON_MODERATION_TASK`: Cron expression for comment moderation task
|
||||
- `CRON_FORCE_TRIGGERS_TASK`: Cron expression for force triggers task
|
||||
- `CRON_SERVICE_SCORE_RECALC_TASK`: Cron expression for service score recalculation task
|
||||
- `CRON_INACTIVE_USERS_TASK`: Cron expression for inactive users cleanup task
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -60,6 +61,9 @@ uv run -m pyworker force-triggers
|
||||
|
||||
# Run service score recalculation task
|
||||
uv run -m pyworker service-score-recalc [--service-id ID]
|
||||
|
||||
# Run inactive users cleanup task
|
||||
uv run -m pyworker inactive-users
|
||||
```
|
||||
|
||||
### Worker Mode
|
||||
@@ -96,7 +100,7 @@ Tasks will run according to their configured cron schedules.
|
||||
### Force Triggers Task
|
||||
|
||||
- Maintains database triggers by forcing them to run under certain conditions
|
||||
- Currently handles updating the "isRecentlyListed" flag for services after 15 days
|
||||
- Currently handles updating the "isRecentlyApproved" flag for services after 15 days
|
||||
- Scheduled via `CRON_FORCE-TRIGGERS_TASK`
|
||||
|
||||
### Service Score Recalculation Task
|
||||
@@ -106,6 +110,15 @@ Tasks will run according to their configured cron schedules.
|
||||
- Calculates privacy, trust, and overall scores
|
||||
- Scheduled via `CRON_SERVICE-SCORE-RECALC_TASK`
|
||||
|
||||
### Inactive Users Task
|
||||
|
||||
- Handles cleanup of inactive user accounts
|
||||
- Identifies users who have been inactive for 1 year (no comments, votes, suggestions, and 0 karma)
|
||||
- Sends deletion warning notifications at 30, 15, 5, and 1 day intervals
|
||||
- Deletes accounts that remain inactive after the warning period
|
||||
- Cancels deletion for users who become active again
|
||||
- Scheduled via `CRON_INACTIVE_USERS_TASK`
|
||||
|
||||
## Development
|
||||
|
||||
### Project Structure
|
||||
@@ -124,6 +137,7 @@ pyworker/
|
||||
│ │ ├── base.py
|
||||
│ │ ├── comment_moderation.py
|
||||
│ │ ├── force_triggers.py
|
||||
│ │ ├── inactive_users.py
|
||||
│ │ ├── service_score_recalc.py
|
||||
│ │ ├── tos_review.py
|
||||
│ │ └── user_sentiment.py
|
||||
|
||||
@@ -17,6 +17,7 @@ from pyworker.scheduler import TaskScheduler
|
||||
from .tasks import (
|
||||
CommentModerationTask,
|
||||
ForceTriggersTask,
|
||||
InactiveUsersTask,
|
||||
ServiceScoreRecalculationTask,
|
||||
TosReviewTask,
|
||||
UserSentimentTask,
|
||||
@@ -89,6 +90,23 @@ def parse_args(args: List[str]) -> argparse.Namespace:
|
||||
score_recalc_parser.add_argument(
|
||||
"--service-id", type=int, help="Specific service ID to process (optional)"
|
||||
)
|
||||
score_recalc_parser.add_argument(
|
||||
"--all",
|
||||
action="store_true",
|
||||
help="Recalculate scores for all services (ignores --service-id)",
|
||||
)
|
||||
|
||||
# Service Score Recalculation task for all services
|
||||
subparsers.add_parser(
|
||||
"service-score-recalc-all",
|
||||
help="Recalculate service scores for all services",
|
||||
)
|
||||
|
||||
# Inactive users task
|
||||
subparsers.add_parser(
|
||||
"inactive-users",
|
||||
help="Handle inactive users - send deletion warnings and clean up accounts",
|
||||
)
|
||||
|
||||
return parser.parse_args(args)
|
||||
|
||||
@@ -295,12 +313,15 @@ def run_force_triggers_task() -> int:
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
def run_service_score_recalc_task(
|
||||
service_id: Optional[int] = None, all_services: bool = False
|
||||
) -> int:
|
||||
"""
|
||||
Run the service score recalculation task.
|
||||
|
||||
Args:
|
||||
service_id: Optional specific service ID to process.
|
||||
all_services: Whether to recalculate scores for all services.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
@@ -310,7 +331,34 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
try:
|
||||
# Initialize task and use as context manager
|
||||
with ServiceScoreRecalculationTask() as task: # type: ignore
|
||||
result = task.run(service_id) # type: ignore
|
||||
if all_services:
|
||||
queued = task.recalculate_all_services() # type: ignore
|
||||
if not queued:
|
||||
logger.warning(
|
||||
"Failed to queue recalculation jobs for all services"
|
||||
)
|
||||
|
||||
# Continuously process queued jobs in batches until none remain
|
||||
while True:
|
||||
_ = task.run() # type: ignore
|
||||
|
||||
# Check if there are still unprocessed jobs
|
||||
remaining = 0
|
||||
if task.conn:
|
||||
with task.conn.cursor() as cursor:
|
||||
cursor.execute(
|
||||
'SELECT COUNT(*) FROM "ServiceScoreRecalculationJob" WHERE "processedAt" IS NULL'
|
||||
)
|
||||
remaining = cursor.fetchone()[0]
|
||||
|
||||
if remaining == 0:
|
||||
break
|
||||
|
||||
result = True # All jobs processed successfully
|
||||
|
||||
else:
|
||||
result = task.run(service_id) # type: ignore
|
||||
|
||||
if result:
|
||||
logger.info("Successfully recalculated service scores")
|
||||
else:
|
||||
@@ -323,6 +371,37 @@ def run_service_score_recalc_task(service_id: Optional[int] = None) -> int:
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_service_score_recalc_all_task() -> int:
|
||||
"""
|
||||
Run the service score recalculation task for all services.
|
||||
"""
|
||||
return run_service_score_recalc_task(all_services=True)
|
||||
|
||||
|
||||
def run_inactive_users_task() -> int:
|
||||
"""
|
||||
Run the inactive users task.
|
||||
|
||||
Returns:
|
||||
Exit code.
|
||||
"""
|
||||
logger.info("Starting inactive users task")
|
||||
|
||||
try:
|
||||
# Initialize task and use as context manager
|
||||
with InactiveUsersTask() as task: # type: ignore
|
||||
result = task.run() # type: ignore
|
||||
logger.info(f"Inactive users task completed. Results: {result}")
|
||||
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.exception(f"Error running inactive users task: {e}")
|
||||
return 1
|
||||
finally:
|
||||
# Ensure connection pool is closed even if an error occurs
|
||||
close_db_pool()
|
||||
|
||||
|
||||
def run_worker_mode() -> int:
|
||||
"""
|
||||
Run in worker mode, scheduling tasks to run periodically.
|
||||
@@ -334,42 +413,37 @@ def run_worker_mode() -> int:
|
||||
|
||||
# Get task schedules from config
|
||||
task_schedules = config.task_schedules
|
||||
if not task_schedules:
|
||||
logger.info(
|
||||
"Found %s cron schedule%s from environment variables: %s",
|
||||
len(task_schedules),
|
||||
"s" if len(task_schedules) != 1 else "",
|
||||
", ".join(task_schedules.keys()) if task_schedules else "<none>",
|
||||
)
|
||||
|
||||
required_tasks: dict[str, Any] = {
|
||||
"tosreview": run_tos_task,
|
||||
"user_sentiment": run_sentiment_task,
|
||||
"comment_moderation": run_moderation_task,
|
||||
"force_triggers": run_force_triggers_task,
|
||||
"inactive_users": run_inactive_users_task,
|
||||
"service_score_recalc": run_service_score_recalc_task,
|
||||
"service_score_recalc_all": run_service_score_recalc_all_task,
|
||||
}
|
||||
|
||||
missing_tasks = [t for t in required_tasks if t not in task_schedules]
|
||||
if missing_tasks:
|
||||
logger.error(
|
||||
"No task schedules defined. Set CRON_TASKNAME_TASK environment variables."
|
||||
"Missing cron schedule for task%s: %s. Set the corresponding CRON_<TASKNAME>_TASK environment variable%s.",
|
||||
"s" if len(missing_tasks) != 1 else "",
|
||||
", ".join(missing_tasks),
|
||||
"s" if len(missing_tasks) != 1 else "",
|
||||
)
|
||||
return 1
|
||||
|
||||
logger.info(
|
||||
f"Found {len(task_schedules)} scheduled tasks: {', '.join(task_schedules.keys())}"
|
||||
)
|
||||
|
||||
# Initialize the scheduler
|
||||
scheduler = TaskScheduler()
|
||||
|
||||
# Register tasks with their schedules
|
||||
for task_name, cron_expression in task_schedules.items():
|
||||
if task_name.lower() == "tosreview":
|
||||
scheduler.register_task(task_name, cron_expression, run_tos_task)
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
scheduler.register_task(task_name, cron_expression, run_sentiment_task)
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
scheduler.register_task(task_name, cron_expression, run_moderation_task)
|
||||
elif task_name.lower() == "force_triggers":
|
||||
scheduler.register_task(task_name, cron_expression, run_force_triggers_task)
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
scheduler.register_task(
|
||||
task_name, cron_expression, run_service_score_recalc_task
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
|
||||
# Register service score recalculation task (every 5 minutes)
|
||||
scheduler.register_task(
|
||||
"service-score-recalc",
|
||||
"*/5 * * * *",
|
||||
run_service_score_recalc_task,
|
||||
)
|
||||
for task_name, task_callable in required_tasks.items():
|
||||
scheduler.register_task(task_name, task_schedules[task_name], task_callable)
|
||||
|
||||
# Start the scheduler if tasks were registered
|
||||
if scheduler.tasks:
|
||||
@@ -419,7 +493,13 @@ def main() -> int:
|
||||
elif args.task == "force-triggers":
|
||||
return run_force_triggers_task()
|
||||
elif args.task == "service-score-recalc":
|
||||
return run_service_score_recalc_task(args.service_id)
|
||||
return run_service_score_recalc_task(
|
||||
args.service_id, getattr(args, "all", False)
|
||||
)
|
||||
elif args.task == "service-score-recalc-all":
|
||||
return run_service_score_recalc_all_task()
|
||||
elif args.task == "inactive-users":
|
||||
return run_inactive_users_task()
|
||||
elif args.task:
|
||||
logger.error(f"Unknown task: {args.task}")
|
||||
return 1
|
||||
|
||||
@@ -332,29 +332,33 @@ def remove_service_attribute_by_slug(service_id: int, attribute_slug: str) -> bo
|
||||
return remove_service_attribute(service_id, attribute_id)
|
||||
|
||||
|
||||
def save_tos_review(service_id: int, review: TosReviewType):
|
||||
"""
|
||||
Save a TOS review for a specific service.
|
||||
def save_tos_review(service_id: int, review: Optional[TosReviewType]):
|
||||
"""Persist a TOS review and/or update the timestamp for a service.
|
||||
|
||||
Args:
|
||||
service_id: The ID of the service.
|
||||
review: A TypedDict containing the review data.
|
||||
If *review* is ``None`` the existing review (if any) is preserved while
|
||||
only the ``tosReviewAt`` column is updated. This ensures we still track
|
||||
when the review task last ran even if the review generation failed or
|
||||
produced no changes.
|
||||
"""
|
||||
try:
|
||||
# Serialize the dictionary to a JSON string for the database
|
||||
review_json = json.dumps(review)
|
||||
with get_db_connection() as conn:
|
||||
with conn.cursor(row_factory=dict_row) as cursor:
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE "Service"
|
||||
SET "tosReview" = %s, "tosReviewAt" = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(review_json, service_id),
|
||||
)
|
||||
if review is None:
|
||||
cursor.execute(
|
||||
'UPDATE "Service" SET "tosReviewAt" = NOW() WHERE id = %s AND "tosReview" IS NULL',
|
||||
(service_id,),
|
||||
)
|
||||
else:
|
||||
review_json = json.dumps(review)
|
||||
cursor.execute(
|
||||
'UPDATE "Service" SET "tosReview" = %s, "tosReviewAt" = NOW() WHERE id = %s',
|
||||
(review_json, service_id),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Successfully saved TOS review for service {service_id}")
|
||||
logger.info(
|
||||
f"Successfully saved TOS review (updated={review is not None}) for service {service_id}"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error saving TOS review for service {service_id}: {e}")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from pyworker.database import close_db_pool
|
||||
from .tasks import (
|
||||
CommentModerationTask,
|
||||
ForceTriggersTask,
|
||||
InactiveUsersTask,
|
||||
ServiceScoreRecalculationTask,
|
||||
TosReviewTask,
|
||||
UserSentimentTask,
|
||||
@@ -62,25 +63,29 @@ class TaskScheduler:
|
||||
cron_expression: Cron expression defining the schedule.
|
||||
task_func: Function to execute.
|
||||
*args: Arguments to pass to the task function.
|
||||
**kwargs: Keyword arguments to pass to the task function.
|
||||
**kwargs: Keyword arguments to pass to the task function. `instantiate` is a special kwarg.
|
||||
"""
|
||||
instantiate = kwargs.pop("instantiate", True)
|
||||
# Declare task_instance variable with type annotation upfront
|
||||
task_instance: Any = None
|
||||
|
||||
# Initialize the appropriate task class based on the task name
|
||||
if task_name.lower() == "tosreview":
|
||||
task_instance = TosReviewTask()
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
task_instance = UserSentimentTask()
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
task_instance = CommentModerationTask()
|
||||
elif task_name.lower() == "force_triggers":
|
||||
task_instance = ForceTriggersTask()
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
task_instance = ServiceScoreRecalculationTask()
|
||||
else:
|
||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
return
|
||||
if instantiate:
|
||||
# Initialize the appropriate task class based on the task name
|
||||
if task_name.lower() == "tosreview":
|
||||
task_instance = TosReviewTask()
|
||||
elif task_name.lower() == "user_sentiment":
|
||||
task_instance = UserSentimentTask()
|
||||
elif task_name.lower() == "comment_moderation":
|
||||
task_instance = CommentModerationTask()
|
||||
elif task_name.lower() == "force_triggers":
|
||||
task_instance = ForceTriggersTask()
|
||||
elif task_name.lower() == "service_score_recalc":
|
||||
task_instance = ServiceScoreRecalculationTask()
|
||||
elif task_name.lower() == "inactive_users":
|
||||
task_instance = InactiveUsersTask()
|
||||
else:
|
||||
self.logger.warning(f"Unknown task '{task_name}', skipping")
|
||||
return
|
||||
|
||||
self.tasks[task_name] = {
|
||||
"cron": cron_expression,
|
||||
@@ -126,8 +131,12 @@ class TaskScheduler:
|
||||
self.logger.info(f"Running task '{task_name}'")
|
||||
# Use task instance as a context manager to ensure
|
||||
# a single database connection is used for the entire task
|
||||
with task_info["instance"]:
|
||||
# Execute the registered task function with its arguments
|
||||
if task_info["instance"]:
|
||||
with task_info["instance"]:
|
||||
# Execute the registered task function with its arguments
|
||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||
else:
|
||||
# Execute the registered task function without a context manager
|
||||
task_info["func"](*task_info["args"], **task_info["kwargs"])
|
||||
self.logger.info(f"Task '{task_name}' completed")
|
||||
except Exception as e:
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from .base import Task
|
||||
from .comment_moderation import CommentModerationTask
|
||||
from .force_triggers import ForceTriggersTask
|
||||
from .inactive_users import InactiveUsersTask
|
||||
from .service_score_recalc import ServiceScoreRecalculationTask
|
||||
from .tos_review import TosReviewTask
|
||||
from .user_sentiment import UserSentimentTask
|
||||
@@ -11,6 +12,7 @@ __all__ = [
|
||||
"Task",
|
||||
"CommentModerationTask",
|
||||
"ForceTriggersTask",
|
||||
"InactiveUsersTask",
|
||||
"ServiceScoreRecalculationTask",
|
||||
"TosReviewTask",
|
||||
"UserSentimentTask",
|
||||
|
||||
@@ -9,7 +9,7 @@ class ForceTriggersTask(Task):
|
||||
Force triggers to run under certain conditions.
|
||||
"""
|
||||
|
||||
RECENT_LISTED_INTERVAL_DAYS = 15
|
||||
RECENT_APPROVED_INTERVAL_DAYS = 15
|
||||
|
||||
def __init__(self):
|
||||
super().__init__("force_triggers")
|
||||
@@ -24,10 +24,10 @@ class ForceTriggersTask(Task):
|
||||
|
||||
update_query = f"""
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyListed" = TRUE
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "listedAt" < NOW() - INTERVAL '{self.RECENT_LISTED_INTERVAL_DAYS} days'
|
||||
SET "isRecentlyApproved" = FALSE, "updatedAt" = NOW()
|
||||
WHERE "isRecentlyApproved" = TRUE
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "approvedAt" < NOW() - INTERVAL '{self.RECENT_APPROVED_INTERVAL_DAYS} days'
|
||||
"""
|
||||
try:
|
||||
with self.conn.cursor() as cursor:
|
||||
|
||||
258
pyworker/pyworker/tasks/inactive_users.py
Normal file
@@ -0,0 +1,258 @@
|
||||
"""
|
||||
Task for handling inactive users - sending deletion warnings and cleaning up accounts.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pyworker.database import execute_db_command, run_db_query
|
||||
from pyworker.tasks.base import Task
|
||||
|
||||
|
||||
class InactiveUsersTask(Task):
|
||||
"""Task for handling inactive users"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the inactive users task."""
|
||||
super().__init__("inactive_users")
|
||||
|
||||
def run(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Run the inactive users task.
|
||||
|
||||
This task:
|
||||
1. Identifies users who have been inactive for 1 year
|
||||
2. Schedules them for deletion
|
||||
3. Sends warning notifications at 30, 15, 5, and 1 day intervals
|
||||
4. Deletes accounts that have reached their deletion date
|
||||
"""
|
||||
results = {
|
||||
"users_scheduled_for_deletion": 0,
|
||||
"notifications_sent": 0,
|
||||
"accounts_deleted": 0,
|
||||
"deletions_cancelled": 0,
|
||||
}
|
||||
|
||||
# Step 1: Cancel deletion for users who became active again
|
||||
results["deletions_cancelled"] = self._cancel_deletion_for_active_users()
|
||||
|
||||
# Step 2: Schedule new inactive users for deletion
|
||||
results["users_scheduled_for_deletion"] = self._schedule_inactive_users_for_deletion()
|
||||
|
||||
# Step 3: Send warning notifications
|
||||
results["notifications_sent"] = self._send_deletion_warnings()
|
||||
|
||||
# Step 4: Delete accounts that have reached their deletion date
|
||||
results["accounts_deleted"] = self._delete_scheduled_accounts()
|
||||
|
||||
self.logger.info(
|
||||
f"Inactive users task completed. "
|
||||
f"Deletions cancelled: {results['deletions_cancelled']}, "
|
||||
f"Scheduled: {results['users_scheduled_for_deletion']}, "
|
||||
f"Notifications sent: {results['notifications_sent']}, "
|
||||
f"Accounts deleted: {results['accounts_deleted']}"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
def _schedule_inactive_users_for_deletion(self) -> int:
|
||||
"""
|
||||
Schedule inactive users for deletion.
|
||||
|
||||
A user is considered inactive if:
|
||||
- Account was created more than 1 year ago
|
||||
- Has 0 karma
|
||||
- Has no comments, comment votes, or service suggestions
|
||||
- Is not scheduled for deletion already
|
||||
- Is not an admin or moderator
|
||||
"""
|
||||
one_year_ago = datetime.now() - timedelta(days=365)
|
||||
deletion_date = date.today() + timedelta(days=30) # 30 days from today
|
||||
|
||||
# Find inactive users
|
||||
query = """
|
||||
UPDATE "User"
|
||||
SET "scheduledDeletionAt" = %s, "updatedAt" = NOW()
|
||||
WHERE "id" IN (
|
||||
SELECT u."id"
|
||||
FROM "User" u
|
||||
WHERE u."createdAt" < %s
|
||||
AND u."scheduledDeletionAt" IS NULL
|
||||
AND u."admin" = false
|
||||
AND u."moderator" = false
|
||||
AND u."totalKarma" = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Comment" c WHERE c."authorId" = u."id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv WHERE cv."userId" = u."id"
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss WHERE ss."userId" = u."id"
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
count = execute_db_command(query, (deletion_date, one_year_ago))
|
||||
self.logger.info(f"Scheduled {count} inactive users for deletion on {deletion_date}")
|
||||
return count
|
||||
|
||||
def _send_deletion_warnings(self) -> int:
|
||||
"""
|
||||
Send deletion warning notifications to users at appropriate intervals.
|
||||
"""
|
||||
today = date.today()
|
||||
notifications_sent = 0
|
||||
|
||||
# Define warning intervals and their corresponding notification types
|
||||
warning_intervals = [
|
||||
(30, 'ACCOUNT_DELETION_WARNING_30_DAYS'),
|
||||
(15, 'ACCOUNT_DELETION_WARNING_15_DAYS'),
|
||||
(5, 'ACCOUNT_DELETION_WARNING_5_DAYS'),
|
||||
(1, 'ACCOUNT_DELETION_WARNING_1_DAY'),
|
||||
]
|
||||
|
||||
for days_before, notification_type in warning_intervals:
|
||||
# Find users who should receive this warning (exact date match)
|
||||
target_date = today + timedelta(days=days_before)
|
||||
|
||||
# Check if user is still inactive (no recent activity)
|
||||
users_query = """
|
||||
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||
FROM "User" u
|
||||
WHERE u."scheduledDeletionAt" = %s
|
||||
AND u."admin" = false
|
||||
AND u."moderator" = false
|
||||
AND u."totalKarma" = 0
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = u."id"
|
||||
AND n."type" = %s
|
||||
AND n."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
-- Still check if user is inactive (no activity since being scheduled)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Comment" c
|
||||
WHERE c."authorId" = u."id"
|
||||
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv
|
||||
WHERE cv."userId" = u."id"
|
||||
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss
|
||||
WHERE ss."userId" = u."id"
|
||||
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
"""
|
||||
|
||||
users = run_db_query(users_query, (target_date, notification_type))
|
||||
|
||||
# Create notifications for these users
|
||||
for user in users:
|
||||
insert_notification_query = """
|
||||
INSERT INTO "Notification" ("userId", "type", "createdAt", "updatedAt")
|
||||
VALUES (%s, %s, NOW(), NOW())
|
||||
ON CONFLICT DO NOTHING
|
||||
"""
|
||||
|
||||
execute_db_command(insert_notification_query, (user['id'], notification_type))
|
||||
notifications_sent += 1
|
||||
|
||||
self.logger.info(
|
||||
f"Sent {notification_type} notification to user {user['name']} "
|
||||
f"(ID: {user['id']}) scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||
)
|
||||
|
||||
return notifications_sent
|
||||
|
||||
def _delete_scheduled_accounts(self) -> int:
|
||||
"""
|
||||
Delete accounts that have reached their scheduled deletion date and are still inactive.
|
||||
"""
|
||||
today = date.today()
|
||||
|
||||
# Find users scheduled for deletion who are still inactive
|
||||
users_to_delete_query = """
|
||||
SELECT u."id", u."name", u."scheduledDeletionAt"
|
||||
FROM "User" u
|
||||
WHERE u."scheduledDeletionAt" <= %s
|
||||
AND u."admin" = false
|
||||
AND u."moderator" = false
|
||||
AND u."totalKarma" = 0
|
||||
-- Double-check they're still inactive
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Comment" c
|
||||
WHERE c."authorId" = u."id"
|
||||
AND c."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv
|
||||
WHERE cv."userId" = u."id"
|
||||
AND cv."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss
|
||||
WHERE ss."userId" = u."id"
|
||||
AND ss."createdAt" > (u."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
"""
|
||||
|
||||
users_to_delete = run_db_query(users_to_delete_query, (today,))
|
||||
deleted_count = 0
|
||||
|
||||
for user in users_to_delete:
|
||||
try:
|
||||
# Delete the user (this will cascade and delete related records)
|
||||
delete_query = 'DELETE FROM "User" WHERE "id" = %s'
|
||||
execute_db_command(delete_query, (user['id'],))
|
||||
deleted_count += 1
|
||||
|
||||
self.logger.info(
|
||||
f"Deleted inactive user {user['name']} (ID: {user['id']}) "
|
||||
f"scheduled for deletion on {user['scheduledDeletionAt']}"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
f"Failed to delete user {user['name']} (ID: {user['id']}): {e}"
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
def _cancel_deletion_for_active_users(self) -> int:
|
||||
"""
|
||||
Cancel scheduled deletion for users who have become active again.
|
||||
"""
|
||||
# Find users scheduled for deletion who have recent activity or gained karma
|
||||
query = """
|
||||
UPDATE "User"
|
||||
SET "scheduledDeletionAt" = NULL, "updatedAt" = NOW()
|
||||
WHERE "scheduledDeletionAt" IS NOT NULL
|
||||
AND (
|
||||
"totalKarma" > 0
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "Comment" c
|
||||
WHERE c."authorId" = "User"."id"
|
||||
AND c."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "CommentVote" cv
|
||||
WHERE cv."userId" = "User"."id"
|
||||
AND cv."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM "ServiceSuggestion" ss
|
||||
WHERE ss."userId" = "User"."id"
|
||||
AND ss."createdAt" > ("User"."scheduledDeletionAt" - INTERVAL '30 days')
|
||||
)
|
||||
)
|
||||
"""
|
||||
|
||||
count = execute_db_command(query)
|
||||
if count > 0:
|
||||
self.logger.info(f"Cancelled deletion for {count} users who became active again or gained karma")
|
||||
|
||||
return count
|
||||
@@ -205,8 +205,7 @@ class ServiceScoreRecalculationTask(Task):
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id
|
||||
FROM "Service"
|
||||
WHERE "isActive" = TRUE
|
||||
FROM "Service"
|
||||
"""
|
||||
)
|
||||
services = cursor.fetchall()
|
||||
|
||||
@@ -5,6 +5,8 @@ Task for retrieving Terms of Service (TOS) text.
|
||||
import hashlib
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
import requests
|
||||
|
||||
from pyworker.database import TosReviewType, save_tos_review, update_kyc_level
|
||||
from pyworker.tasks.base import Task
|
||||
from pyworker.utils.ai import prompt_check_tos_review, prompt_tos_review
|
||||
@@ -32,8 +34,8 @@ class TosReviewTask(Task):
|
||||
service_name = service["name"]
|
||||
verification_status = service.get("verificationStatus")
|
||||
|
||||
# Only process verified or approved services
|
||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED"]:
|
||||
# Only process verified, approved, or community contributed services
|
||||
if verification_status not in ["VERIFICATION_SUCCESS", "APPROVED", "COMMUNITY_CONTRIBUTED"]:
|
||||
self.logger.info(
|
||||
f"Skipping TOS review for service: {service_name} (ID: {service_id}) - Status: {verification_status}"
|
||||
)
|
||||
@@ -52,65 +54,97 @@ class TosReviewTask(Task):
|
||||
)
|
||||
self.logger.info(f"TOS URLs: {tos_urls}")
|
||||
|
||||
review = self.get_tos_review(tos_urls, service.get("tosReview"))
|
||||
|
||||
# Always update the processed timestamp, even if review is None
|
||||
save_tos_review(service_id, review)
|
||||
|
||||
if review is None:
|
||||
self.logger.warning(
|
||||
f"TOS review could not be generated for service {service_name} (ID: {service_id})"
|
||||
)
|
||||
return None
|
||||
|
||||
# Update the KYC level based on the review, when present
|
||||
if "kycLevel" in review:
|
||||
new_level = review["kycLevel"]
|
||||
old_level = service.get("kycLevel")
|
||||
|
||||
# Update DB
|
||||
if update_kyc_level(service_id, new_level):
|
||||
msg = f"{service.get('slug', service_name)}: kycLevel {old_level} -> {new_level}"
|
||||
|
||||
# Log to console
|
||||
self.logger.info(msg)
|
||||
|
||||
# Send notification via ntfy
|
||||
try:
|
||||
requests.post(
|
||||
"https://ntfy.sh/knm-kyc-lvl-changes-knm", data=msg.encode()
|
||||
)
|
||||
except requests.RequestException as e:
|
||||
self.logger.error(
|
||||
f"Failed to send ntfy notification for KYC level change: {e}"
|
||||
)
|
||||
|
||||
return review
|
||||
|
||||
def get_tos_review(
|
||||
self, tos_urls: list[str], current_review: Optional[TosReviewType]
|
||||
) -> Optional[TosReviewType]:
|
||||
"""
|
||||
Get TOS review from a list of URLs.
|
||||
|
||||
Args:
|
||||
tos_urls: List of TOS URLs to check
|
||||
current_review: Current review data from the database
|
||||
|
||||
Returns:
|
||||
Dict containing:
|
||||
- status: Literal["skipped", "failed", "success"]
|
||||
- review: Optional[TosReviewType] - The review data if successful
|
||||
"""
|
||||
all_skipped = True
|
||||
|
||||
for tos_url in tos_urls:
|
||||
api_url = f"{tos_url}"
|
||||
self.logger.info(f"Fetching TOS from URL: {api_url}")
|
||||
|
||||
# Sleep for 1 second to avoid rate limiting
|
||||
content = fetch_markdown(api_url)
|
||||
|
||||
if content:
|
||||
# Hash the content to avoid repeating the same content
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
self.logger.info(f"Content hash: {content_hash}")
|
||||
|
||||
# service.get("tosReview") can be None if the DB field is NULL.
|
||||
# Default to an empty dict to prevent AttributeError on .get()
|
||||
tos_review_data_from_service: Optional[Dict[str, Any]] = service.get(
|
||||
"tosReview"
|
||||
)
|
||||
tos_review: Dict[str, Any] = (
|
||||
tos_review_data_from_service
|
||||
if tos_review_data_from_service is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
stored_hash = tos_review.get("contentHash")
|
||||
|
||||
# Skip processing if we've seen this content before
|
||||
if stored_hash == content_hash:
|
||||
self.logger.info(
|
||||
f"Skipping already processed TOS content with hash: {content_hash}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Skip incomplete TOS content
|
||||
check = prompt_check_tos_review(content)
|
||||
if not check:
|
||||
continue
|
||||
elif not check["isComplete"]:
|
||||
continue
|
||||
|
||||
# Query OpenAI to summarize the content
|
||||
review = prompt_tos_review(content)
|
||||
|
||||
if review:
|
||||
review["contentHash"] = content_hash
|
||||
# Save the review to the database
|
||||
save_tos_review(service_id, review)
|
||||
|
||||
# Update the KYC level based on the review
|
||||
if "kycLevel" in review:
|
||||
kyc_level = review["kycLevel"]
|
||||
self.logger.info(
|
||||
f"Updating KYC level to {kyc_level} for service {service_name}"
|
||||
)
|
||||
update_kyc_level(service_id, kyc_level)
|
||||
# no need to check other TOS URLs
|
||||
break
|
||||
|
||||
return review
|
||||
else:
|
||||
if not content:
|
||||
self.logger.warning(
|
||||
f"Failed to retrieve TOS content for URL: {tos_url}"
|
||||
)
|
||||
all_skipped = False
|
||||
continue
|
||||
|
||||
# Hash the content to avoid repeating the same content
|
||||
content_hash = hashlib.sha256(content.encode()).hexdigest()
|
||||
self.logger.info(f"Content hash: {content_hash}")
|
||||
|
||||
# Skip processing if we've seen this content before
|
||||
if current_review and current_review.get("contentHash") == content_hash:
|
||||
self.logger.info(
|
||||
f"Skipping already processed TOS content with hash: {content_hash}"
|
||||
)
|
||||
continue
|
||||
|
||||
all_skipped = False
|
||||
|
||||
# Skip incomplete TOS content
|
||||
check = prompt_check_tos_review(content)
|
||||
if not check or not check["isComplete"]:
|
||||
continue
|
||||
|
||||
# Query OpenAI to summarize the content
|
||||
review = prompt_tos_review(content)
|
||||
|
||||
if review:
|
||||
review["contentHash"] = content_hash
|
||||
return review
|
||||
|
||||
if all_skipped:
|
||||
return current_review
|
||||
|
||||
return None
|
||||
|
||||
@@ -92,7 +92,9 @@ def prompt_check_tos_review(content: str) -> TosReviewCheck:
|
||||
{"role": "user", "content": content},
|
||||
]
|
||||
|
||||
result_dict = query_openai_json(messages, model="openai/gpt-4.1-mini")
|
||||
result_dict = query_openai_json(
|
||||
messages, model="openai/gemini-2.5-flash-preview-05-20"
|
||||
)
|
||||
|
||||
return cast(TosReviewCheck, result_dict)
|
||||
|
||||
@@ -173,27 +175,27 @@ type TosReview = {
|
||||
/** In regards to KYC, Privacy, Anonymity, Self-Sovereignity, etc. */
|
||||
/** anything that could harm the user's privacy, identity, self-sovereignity or anonymity is negative, anything that otherwise helps is positive. else it is neutral. */
|
||||
rating: 'negative' | 'neutral' | 'positive'
|
||||
}[]
|
||||
}[] // max 8 highlights, try to provide at least 3.
|
||||
}
|
||||
|
||||
The rating is a number between 0 and 2, where 0 is informative, 1 is warning, and 2 is critical.
|
||||
|
||||
Be concise but thorough, and make sure your output is properly formatted JSON.
|
||||
Focus on the most important information for the user. Be concise and thorough, and make sure your output is properly formatted JSON.
|
||||
"""
|
||||
|
||||
PROMPT_COMMENT_SENTIMENT_SUMMARY = """
|
||||
You will be given a list of user comments to a service.
|
||||
Your task is to summarize the comments in a way that is easy to understand and to the point.
|
||||
The summary should be concise and to the point, no more than 150 words.
|
||||
The summary should be concise and to the point, no more than 100 words. Keep it short and concise.
|
||||
Use markdown formatting to highlight in bold the most important information. Only bold is allowed.
|
||||
|
||||
You must format your response as a valid JSON object with the following structure:
|
||||
|
||||
interface CommentSummary {
|
||||
summary: string;
|
||||
summary: string; // Concise, 100 words max
|
||||
sentiment: 'positive'|'negative'|'neutral';
|
||||
whatUsersLike: string[]; // Concise, 2-3 words, max 4
|
||||
whatUsersDislike: string[]; // Concise, 2-3 words, max 4
|
||||
whatUsersLike: string[]; // Concise, 2-3 words max
|
||||
whatUsersDislike: string[]; // Concise, 2-3 words max
|
||||
}
|
||||
|
||||
Always avoid repeating information in the list of what users like or dislike. Also, make sure you keep the summary short and concise, no more than 150 words. Ignore irrelevant comments. Make an item for each like/dislike, avoid something like 'No logs / Audited', it should be 'No logs' and 'Audited' as separate items.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1 +1 @@
|
||||
23
|
||||
24
|
||||
|
||||
@@ -1,27 +1,29 @@
|
||||
FROM node:lts AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
COPY .env .env
|
||||
COPY web/package.json web/package-lock.json ./
|
||||
|
||||
COPY .npmrc .npmrc
|
||||
COPY web/.npmrc .npmrc
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
COPY web/ .
|
||||
|
||||
ARG ASTRO_BUILD_MODE=production
|
||||
|
||||
# Generate Prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
RUN npm run build -- --mode ${ASTRO_BUILD_MODE}
|
||||
RUN npm run build:astro -- --mode ${ASTRO_BUILD_MODE} && npm run build:server-init
|
||||
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=4321
|
||||
EXPOSE 4321
|
||||
|
||||
# Add entrypoint script and make it executable
|
||||
COPY docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
# Add knm-migrate command script and make it executable
|
||||
COPY web/migrate.sh /usr/local/bin/knm-migrate
|
||||
RUN chmod +x /usr/local/bin/knm-migrate
|
||||
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["node", "./dist/server/entry.mjs"]
|
||||
CMD ["sh", "-c", "node ./dist/server/server-init.js & node ./dist/server/entry.mjs"]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,26 +4,82 @@ import mdx from '@astrojs/mdx'
|
||||
import node from '@astrojs/node'
|
||||
import sitemap from '@astrojs/sitemap'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { minimal2023Preset } from '@vite-pwa/assets-generator/config'
|
||||
import AstroPWA from '@vite-pwa/astro'
|
||||
import { defineConfig, envField } from 'astro/config'
|
||||
import icon from 'astro-icon'
|
||||
import { loadEnv } from 'vite'
|
||||
import devtoolsJson from 'vite-plugin-devtools-json'
|
||||
|
||||
// @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')
|
||||
const ONION_ADDRESS = getServerEnvVariable('ONION_ADDRESS')
|
||||
const I2P_ADDRESS = getServerEnvVariable('I2P_ADDRESS')
|
||||
|
||||
export default defineConfig({
|
||||
site: SITE_URL,
|
||||
vite: {
|
||||
build: {
|
||||
sourcemap: true,
|
||||
sourcemap: true, // Enable sourcemaps on production, so users can inspect the code
|
||||
},
|
||||
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [devtoolsJson(), tailwindcss()],
|
||||
},
|
||||
integrations: [
|
||||
postgresListener(),
|
||||
icon(),
|
||||
mdx(),
|
||||
AstroPWA({
|
||||
mode: 'development',
|
||||
base: '/',
|
||||
scope: '/',
|
||||
registerType: 'autoUpdate',
|
||||
manifest: {
|
||||
name: 'KYCnot.me',
|
||||
short_name: 'KYCnot.me',
|
||||
description: 'Find services that respect your privacy',
|
||||
theme_color: '#040505',
|
||||
background_color: '#171c1b',
|
||||
display: 'minimal-ui',
|
||||
},
|
||||
pwaAssets: {
|
||||
image: './public/favicon.svg',
|
||||
preset: {
|
||||
...minimal2023Preset,
|
||||
maskable: {
|
||||
...minimal2023Preset.maskable,
|
||||
padding: 0.1,
|
||||
resizeOptions: {
|
||||
...minimal2023Preset.maskable.resizeOptions,
|
||||
background: '#3bdb78',
|
||||
},
|
||||
},
|
||||
apple: {
|
||||
...minimal2023Preset.apple,
|
||||
padding: 0.1,
|
||||
resizeOptions: {
|
||||
...minimal2023Preset.apple.resizeOptions,
|
||||
background: '#3bdb78',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/404',
|
||||
globPatterns: ['**/*.{js,css,html,ico,jpg,jpeg,png,svg,webp,avif}'],
|
||||
},
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'sw.ts',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
},
|
||||
experimental: {
|
||||
directoryAndTrailingSlashHandler: true,
|
||||
},
|
||||
}),
|
||||
sitemap({
|
||||
filter: (page) => {
|
||||
const url = new URL(page)
|
||||
@@ -41,6 +97,19 @@ export default defineConfig({
|
||||
server: {
|
||||
open: false,
|
||||
allowedHosts: [new URL(SITE_URL).hostname],
|
||||
headers: {
|
||||
'Onion-Location': ONION_ADDRESS,
|
||||
'X-I2P-Location': I2P_ADDRESS,
|
||||
'X-Frame-Options': 'DENY',
|
||||
// Astro is working on this feature, when it's stable use it instead of this.
|
||||
// https://astro.build/blog/astro-590/#experimental-content-security-policy-support
|
||||
'Content-Security-Policy':
|
||||
SITE_URL === 'http://localhost:4321'
|
||||
? "frame-ancestors 'none'; upgrade-insecure-requests"
|
||||
: "default-src 'self'; img-src 'self' *; frame-ancestors 'none'; upgrade-insecure-requests",
|
||||
'Strict-Transport-Security':
|
||||
SITE_URL === 'http://localhost:4321' ? undefined : 'max-age=31536000; includeSubdomains; preload;',
|
||||
},
|
||||
},
|
||||
image: {
|
||||
domains: [new URL(SITE_URL).hostname],
|
||||
@@ -56,6 +125,8 @@ export default defineConfig({
|
||||
'/attribute/[...slug]': '/attributes',
|
||||
'/attr/[...slug]': '/attributes',
|
||||
// #endregion
|
||||
|
||||
'/service/[...slug]/review': '/service/[...slug]#comments',
|
||||
},
|
||||
env: {
|
||||
schema: {
|
||||
@@ -69,7 +140,7 @@ export default defineConfig({
|
||||
}),
|
||||
// Public URLs (can be accessed from both server and client)
|
||||
SOURCE_CODE_URL: envField.string({
|
||||
context: 'server',
|
||||
context: 'client',
|
||||
access: 'public',
|
||||
url: true,
|
||||
optional: false,
|
||||
@@ -94,35 +165,6 @@ export default defineConfig({
|
||||
startsWith: 'redis://',
|
||||
default: 'redis://redis:6379',
|
||||
}),
|
||||
REDIS_USER_SESSION_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 60 * 24, // 24 hours in seconds
|
||||
}),
|
||||
REDIS_IMPERSONATION_SESSION_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 60 * 24, // 24 hours in seconds
|
||||
}),
|
||||
REDIS_PREGENERATED_TOKEN_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 5, // 5 minutes in seconds
|
||||
}),
|
||||
|
||||
REDIS_ACTIONS_SESSION_EXPIRY_SECONDS: envField.number({
|
||||
context: 'server',
|
||||
access: 'secret',
|
||||
int: true,
|
||||
gt: 0,
|
||||
default: 60 * 5, // 5 minutes in seconds
|
||||
}),
|
||||
|
||||
// Development tokens
|
||||
DEV_ADMIN_USER_SECRET_TOKEN: envField.string({
|
||||
@@ -131,11 +173,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 +212,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,
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@@ -121,7 +121,7 @@ export default tseslint.config(
|
||||
'import/first': 'error',
|
||||
'import/newline-after-import': 'error',
|
||||
'import/no-duplicates': 'error',
|
||||
'import/no-unresolved': ['error', { ignore: ['^astro:'] }],
|
||||
'import/no-unresolved': ['error', { ignore: ['^astro:', '^virtual:'] }],
|
||||
'@typescript-eslint/no-explicit-any': 'warn',
|
||||
'no-console': ['warn', { allow: without(Object.keys(console), 'log') }],
|
||||
'import/namespace': 'off',
|
||||
|
||||
@@ -16,6 +16,4 @@ for trigger_file in prisma/triggers/*.sql; do
|
||||
fi
|
||||
done
|
||||
|
||||
# Start the application
|
||||
echo "Starting the application..."
|
||||
exec "$@"
|
||||
echo "Migrations completed."
|
||||
5910
web/package-lock.json
generated
102
web/package.json
@@ -4,92 +4,110 @@
|
||||
"version": "0.0.1",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build --remote",
|
||||
"preview": "astro preview",
|
||||
"build": "npm run build:astro && npm run build:server-init",
|
||||
"build:astro": "astro build --remote",
|
||||
"build:server-init": "esbuild src/server-init.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/server/server-init.js",
|
||||
"preview": "node dist/server/server-init.js & astro preview",
|
||||
"astro": "astro",
|
||||
"db-admin": "prisma studio --browser=none",
|
||||
"db-gen": "prisma generate",
|
||||
"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",
|
||||
"@astrojs/mdx": "4.2.6",
|
||||
"@astrojs/node": "9.2.1",
|
||||
"@astrojs/sitemap": "3.4.0",
|
||||
"@fontsource-variable/space-grotesk": "5.2.7",
|
||||
"@fontsource/inter": "5.2.5",
|
||||
"@fontsource/space-grotesk": "5.2.7",
|
||||
"@prisma/client": "6.8.2",
|
||||
"@tailwindcss/vite": "4.1.7",
|
||||
"@types/mime-types": "2.1.4",
|
||||
"@vercel/og": "0.6.8",
|
||||
"astro": "5.7.13",
|
||||
"@astrojs/db": "0.15.0",
|
||||
"@astrojs/mdx": "4.3.0",
|
||||
"@astrojs/node": "9.3.0",
|
||||
"@astrojs/rss": "4.0.12",
|
||||
"@astrojs/sitemap": "3.4.1",
|
||||
"@fontsource-variable/space-grotesk": "5.2.8",
|
||||
"@fontsource/inter": "5.2.6",
|
||||
"@fontsource/space-grotesk": "5.2.8",
|
||||
"@prisma/client": "6.11.1",
|
||||
"@tailwindcss/vite": "4.1.11",
|
||||
"@types/mime-types": "3.0.1",
|
||||
"@types/pg": "8.15.4",
|
||||
"@vercel/og": "0.7.2",
|
||||
"astro": "5.9.0",
|
||||
"astro-loading-indicator": "0.7.0",
|
||||
"astro-remote": "0.3.4",
|
||||
"astro-seo-schema": "5.0.0",
|
||||
"canvas": "3.1.0",
|
||||
"canvas": "3.1.2",
|
||||
"clsx": "2.1.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"countries-list": "3.1.1",
|
||||
"country-flag-icons": "1.5.19",
|
||||
"he": "1.2.0",
|
||||
"htmx.org": "2.0.6",
|
||||
"javascript-time-ago": "2.5.11",
|
||||
"libphonenumber-js": "1.12.8",
|
||||
"libphonenumber-js": "1.12.9",
|
||||
"lodash-es": "4.17.21",
|
||||
"mime-types": "3.0.1",
|
||||
"object-to-formdata": "4.5.1",
|
||||
"pg": "8.16.3",
|
||||
"qrcode": "1.5.4",
|
||||
"react": "19.1.0",
|
||||
"redis": "5.0.1",
|
||||
"redis": "5.6.0",
|
||||
"schema-dts": "1.1.5",
|
||||
"seedrandom": "3.0.5",
|
||||
"sharp": "0.34.1",
|
||||
"sharp": "0.34.2",
|
||||
"slugify": "1.6.6",
|
||||
"tailwind-merge": "3.3.0",
|
||||
"tailwind-merge": "3.3.1",
|
||||
"tailwind-variants": "1.0.0",
|
||||
"tailwindcss": "4.1.7",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.3",
|
||||
"unique-username-generator": "1.4.0",
|
||||
"zod-form-data": "2.0.7"
|
||||
"web-push": "3.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.27.0",
|
||||
"@faker-js/faker": "9.8.0",
|
||||
"@iconify-json/material-symbols": "1.2.21",
|
||||
"@eslint/js": "9.30.1",
|
||||
"@faker-js/faker": "9.9.0",
|
||||
"@iconify-json/material-symbols": "1.2.29",
|
||||
"@iconify-json/mdi": "1.2.3",
|
||||
"@iconify-json/ri": "1.2.5",
|
||||
"@stylistic/eslint-plugin": "4.2.0",
|
||||
"@stylistic/eslint-plugin": "5.1.0",
|
||||
"@tailwindcss/forms": "0.5.10",
|
||||
"@tailwindcss/typography": "0.5.16",
|
||||
"@types/eslint__js": "9.14.0",
|
||||
"@types/he": "1.2.3",
|
||||
"@types/lodash-es": "4.17.12",
|
||||
"@types/qrcode": "1.5.5",
|
||||
"@types/react": "19.1.4",
|
||||
"@types/react": "19.1.8",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@typescript-eslint/parser": "8.32.1",
|
||||
"@types/web-push": "3.6.4",
|
||||
"@typescript-eslint/parser": "8.36.0",
|
||||
"@vite-pwa/assets-generator": "1.0.0",
|
||||
"@vite-pwa/astro": "1.1.0",
|
||||
"astro-icon": "1.1.5",
|
||||
"date-fns": "4.1.0",
|
||||
"eslint": "9.27.0",
|
||||
"eslint-import-resolver-typescript": "4.3.5",
|
||||
"esbuild": "0.25.6",
|
||||
"eslint": "9.30.1",
|
||||
"eslint-import-resolver-typescript": "4.4.4",
|
||||
"eslint-plugin-astro": "1.3.1",
|
||||
"eslint-plugin-import": "2.31.0",
|
||||
"eslint-plugin-import": "2.32.0",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"globals": "16.1.0",
|
||||
"prettier": "3.5.3",
|
||||
"globals": "16.3.0",
|
||||
"prettier": "3.6.2",
|
||||
"prettier-plugin-astro": "0.14.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.11",
|
||||
"prisma": "6.8.2",
|
||||
"prisma-json-types-generator": "3.4.1",
|
||||
"prettier-plugin-tailwindcss": "0.6.13",
|
||||
"prisma": "6.11.1",
|
||||
"prisma-json-types-generator": "3.5.1",
|
||||
"tailwind-htmx": "0.1.2",
|
||||
"ts-essentials": "10.0.4",
|
||||
"ts-essentials": "10.1.1",
|
||||
"ts-toolbelt": "9.6.0",
|
||||
"tsx": "4.19.4",
|
||||
"typescript-eslint": "8.32.1"
|
||||
"tsx": "4.20.3",
|
||||
"typescript-eslint": "8.36.0",
|
||||
"vite-plugin-devtools-json": "0.2.1",
|
||||
"workbox-core": "7.3.0",
|
||||
"workbox-precaching": "7.3.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `iconId` on the `ServiceContactMethod` table. All the data in the column will be lost.
|
||||
- You are about to drop the column `info` on the `ServiceContactMethod` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "ServiceContactMethod" DROP COLUMN "iconId",
|
||||
DROP COLUMN "info",
|
||||
ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ADD COLUMN "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
ALTER COLUMN "label" DROP NOT NULL;
|
||||
@@ -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';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'TEST';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "OrderIdStatus" ADD VALUE 'WITHDRAWN';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'SUGGESTION_CREATED';
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- A unique constraint covering the columns `[feedId]` on the table `User` will be added. If there are existing duplicate values, this will fail.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "feedId" TEXT;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_feedId_key" ON "User"("feedId");
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- Made the column `feedId` on table `User` required. This step will fail if there are existing NULL values in that column.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ALTER COLUMN "feedId" SET NOT NULL;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterEnum
|
||||
ALTER TYPE "VerificationStepStatus" ADD VALUE 'WARNING';
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "approvedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "spamAt" TIMESTAMP(3);
|
||||
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `isRecentlyListed` on the `Service` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" DROP COLUMN "isRecentlyListed",
|
||||
ADD COLUMN "isRecentlyApproved" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_approvedAt_idx" ON "Service"("approvedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_verifiedAt_idx" ON "Service"("verifiedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_spamAt_idx" ON "Service"("spamAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Service_serviceVisibility_idx" ON "Service"("serviceVisibility");
|
||||
@@ -0,0 +1,8 @@
|
||||
/*
|
||||
Warnings:
|
||||
|
||||
- You are about to drop the column `userAgent` on the `PushSubscription` table. All the data in the column will be lost.
|
||||
|
||||
*/
|
||||
-- AlterTable
|
||||
ALTER TABLE "PushSubscription" DROP COLUMN "userAgent";
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "operatingSince" TIMESTAMP(3);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ALTER COLUMN "operatingSince" SET DATA TYPE DATE;
|
||||
@@ -0,0 +1,15 @@
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_30_DAYS';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_15_DAYS';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_5_DAYS';
|
||||
ALTER TYPE "NotificationType" ADD VALUE 'ACCOUNT_DELETION_WARNING_1_DAY';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "scheduledDeletionAt" DATE;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Category" ADD COLUMN "namePluralLong" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "strictCommentingEnabled" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "commentSectionMessage" TEXT;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "registrationCountryCode" VARCHAR(2);
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Service" ADD COLUMN "registeredCompanyName" TEXT;
|
||||
@@ -25,6 +25,7 @@ enum OrderIdStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
WITHDRAWN
|
||||
}
|
||||
|
||||
model Comment {
|
||||
@@ -87,6 +88,7 @@ enum ServiceVisibility {
|
||||
PUBLIC
|
||||
UNLISTED
|
||||
HIDDEN
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum Currency {
|
||||
@@ -120,18 +122,20 @@ enum AccountStatusChange {
|
||||
ADMIN_FALSE
|
||||
VERIFIED_TRUE
|
||||
VERIFIED_FALSE
|
||||
VERIFIER_TRUE
|
||||
VERIFIER_FALSE
|
||||
MODERATOR_TRUE
|
||||
MODERATOR_FALSE
|
||||
SPAMMER_TRUE
|
||||
SPAMMER_FALSE
|
||||
}
|
||||
|
||||
enum NotificationType {
|
||||
TEST
|
||||
COMMENT_STATUS_CHANGE
|
||||
REPLY_COMMENT_CREATED
|
||||
COMMUNITY_NOTE_ADDED
|
||||
/// Comment that is not a reply. May include a rating.
|
||||
ROOT_COMMENT_CREATED
|
||||
SUGGESTION_CREATED
|
||||
SUGGESTION_MESSAGE
|
||||
SUGGESTION_STATUS_CHANGE
|
||||
// KARMA_UNLOCK // TODO: [KARMA_UNLOCK] Will be added later, when karma unloks are in the database, not in the code.
|
||||
@@ -140,6 +144,10 @@ enum NotificationType {
|
||||
ACCOUNT_STATUS_CHANGE
|
||||
EVENT_CREATED
|
||||
SERVICE_VERIFICATION_STATUS_CHANGE
|
||||
ACCOUNT_DELETION_WARNING_30_DAYS
|
||||
ACCOUNT_DELETION_WARNING_15_DAYS
|
||||
ACCOUNT_DELETION_WARNING_5_DAYS
|
||||
ACCOUNT_DELETION_WARNING_1_DAY
|
||||
}
|
||||
|
||||
enum CommentStatusChange {
|
||||
@@ -296,6 +304,11 @@ enum ServiceSuggestionType {
|
||||
EDIT_SERVICE
|
||||
}
|
||||
|
||||
enum KycLevelClarification {
|
||||
NONE
|
||||
DEPENDS_ON_PARTNERS
|
||||
}
|
||||
|
||||
model ServiceSuggestion {
|
||||
id Int @id @default(autoincrement())
|
||||
type ServiceSuggestionType
|
||||
@@ -332,50 +345,64 @@ model ServiceSuggestionMessage {
|
||||
}
|
||||
|
||||
model Service {
|
||||
id Int @id @default(autoincrement())
|
||||
name String
|
||||
slug String @unique
|
||||
description String
|
||||
categories Category[] @relation("ServiceToCategory")
|
||||
kycLevel Int @default(4)
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
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)
|
||||
/// Date only, no time.
|
||||
operatingSince DateTime? @db.Date
|
||||
overallScore Int @default(0)
|
||||
privacyScore Int @default(0)
|
||||
trustScore Int @default(0)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyListed Boolean @default(false)
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
averageUserRating Float?
|
||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||
serviceInfoBannerNotes String?
|
||||
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
||||
verificationSummary String?
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
verificationProofMd String?
|
||||
/// Computed via trigger when the service status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
averageUserRating Float?
|
||||
serviceVisibility ServiceVisibility @default(PUBLIC)
|
||||
serviceInfoBanner ServiceInfoBanner @default(NONE)
|
||||
serviceInfoBannerNotes String?
|
||||
verificationStatus VerificationStatus @default(COMMUNITY_CONTRIBUTED)
|
||||
verificationSummary String?
|
||||
verificationRequests ServiceVerificationRequest[]
|
||||
verificationProofMd String?
|
||||
/// [UserSentiment]
|
||||
userSentiment Json?
|
||||
userSentimentAt DateTime?
|
||||
referral String?
|
||||
acceptedCurrencies Currency[] @default([])
|
||||
serviceUrls String[]
|
||||
tosUrls String[] @default([])
|
||||
onionUrls String[] @default([])
|
||||
i2pUrls String[] @default([])
|
||||
imageUrl String?
|
||||
userSentiment Json?
|
||||
userSentimentAt DateTime?
|
||||
referral String?
|
||||
acceptedCurrencies Currency[] @default([])
|
||||
serviceUrls String[]
|
||||
tosUrls String[] @default([])
|
||||
onionUrls String[] @default([])
|
||||
i2pUrls String[] @default([])
|
||||
imageUrl String?
|
||||
/// ISO 3166-1 alpha-2 country code where the service company is registered
|
||||
registrationCountryCode String? @db.VarChar(2)
|
||||
/// Official name of the registered company
|
||||
registeredCompanyName String?
|
||||
/// [TosReview]
|
||||
tosReview Json?
|
||||
tosReviewAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
listedAt DateTime?
|
||||
comments Comment[]
|
||||
events Event[]
|
||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||
attributes ServiceAttribute[]
|
||||
verificationSteps VerificationStep[]
|
||||
suggestions ServiceSuggestion[]
|
||||
tosReview Json?
|
||||
tosReviewAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
/// Computed via trigger when the visibility is PUBLIC or (ARCHIVED and listedAt was null). Do not update through prisma.
|
||||
listedAt DateTime?
|
||||
/// Computed via trigger when the verification status is APPROVED. Do not update through prisma.
|
||||
approvedAt DateTime?
|
||||
/// Computed via trigger when the verification status is VERIFICATION_SUCCESS. Do not update through prisma.
|
||||
verifiedAt DateTime?
|
||||
/// Computed via trigger when the verification status is VERIFICATION_FAILED. Do not update through prisma.
|
||||
spamAt DateTime?
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
isRecentlyApproved Boolean @default(false)
|
||||
comments Comment[]
|
||||
events Event[]
|
||||
contactMethods ServiceContactMethod[] @relation("ServiceToContactMethod")
|
||||
attributes ServiceAttribute[]
|
||||
verificationSteps VerificationStep[]
|
||||
suggestions ServiceSuggestion[]
|
||||
internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
|
||||
|
||||
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
|
||||
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
|
||||
@@ -383,7 +410,13 @@ model Service {
|
||||
Notification Notification[]
|
||||
affiliatedUsers ServiceUser[] @relation("ServiceUsers")
|
||||
|
||||
strictCommentingEnabled Boolean @default(false)
|
||||
commentSectionMessage String?
|
||||
|
||||
@@index([listedAt])
|
||||
@@index([approvedAt])
|
||||
@@index([verifiedAt])
|
||||
@@index([spamAt])
|
||||
@@index([overallScore])
|
||||
@@index([privacyScore])
|
||||
@@index([trustScore])
|
||||
@@ -394,15 +427,20 @@ model Service {
|
||||
@@index([createdAt])
|
||||
@@index([updatedAt])
|
||||
@@index([slug])
|
||||
@@index([previousSlugs])
|
||||
@@index([serviceVisibility])
|
||||
}
|
||||
|
||||
model ServiceContactMethod {
|
||||
id Int @id @default(autoincrement())
|
||||
label String
|
||||
id Int @id @default(autoincrement())
|
||||
/// Only include it if you want to override the formatted value.
|
||||
label String?
|
||||
/// Including the protocol (e.g. "mailto:", "tel:", "https://")
|
||||
value String
|
||||
iconId String
|
||||
info String
|
||||
value String
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
|
||||
services Service @relation("ServiceToContactMethod", fields: [serviceId], references: [id], onDelete: Cascade)
|
||||
serviceId Int
|
||||
}
|
||||
@@ -438,6 +476,7 @@ model Attribute {
|
||||
|
||||
model InternalUserNote {
|
||||
id Int @id @default(autoincrement())
|
||||
/// Markdown
|
||||
content String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -452,20 +491,40 @@ 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
|
||||
displayName String?
|
||||
link String?
|
||||
picture String?
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
verifier Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
displayName String?
|
||||
link String?
|
||||
picture String?
|
||||
spammer Boolean @default(false)
|
||||
verified Boolean @default(false)
|
||||
admin Boolean @default(false)
|
||||
moderator Boolean @default(false)
|
||||
verifiedLink String?
|
||||
secretTokenHash String @unique
|
||||
feedId String @unique @default(cuid(2))
|
||||
/// Computed via trigger. Do not update through prisma.
|
||||
totalKarma Int @default(0)
|
||||
totalKarma Int @default(0)
|
||||
/// Date when user is scheduled for deletion due to inactivity
|
||||
scheduledDeletionAt DateTime? @db.Date
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -478,10 +537,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])
|
||||
@@ -541,6 +602,7 @@ enum VerificationStepStatus {
|
||||
IN_PROGRESS
|
||||
PASSED
|
||||
FAILED
|
||||
WARNING
|
||||
}
|
||||
|
||||
model VerificationStep {
|
||||
@@ -560,10 +622,11 @@ model VerificationStep {
|
||||
}
|
||||
|
||||
model Category {
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
icon String
|
||||
slug String @unique
|
||||
id Int @id @default(autoincrement())
|
||||
name String @unique
|
||||
namePluralLong String?
|
||||
icon String
|
||||
slug String @unique
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @default(now()) @updatedAt
|
||||
@@ -630,3 +693,19 @@ 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
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId])
|
||||
@@index([endpoint])
|
||||
}
|
||||
|
||||
@@ -65,18 +65,36 @@ CREATE OR REPLACE FUNCTION handle_comment_approval(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
is_user_related_to_service BOOLEAN;
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
IF OLD.status = 'PENDING' AND NEW.status = 'APPROVED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'COMMENT_APPROVED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
-- Check if the user is related to the service (e.g., owns/manages it)
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM "ServiceUser"
|
||||
WHERE "userId" = NEW."authorId" AND "serviceId" = NEW."serviceId"
|
||||
) INTO is_user_related_to_service;
|
||||
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."authorId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT related to the service AND is NOT an admin/moderator
|
||||
IF NOT is_user_related_to_service AND NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
1,
|
||||
'COMMENT_APPROVED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been approved!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 1);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -86,18 +104,29 @@ CREATE OR REPLACE FUNCTION handle_comment_verification(
|
||||
NEW RECORD,
|
||||
OLD RECORD
|
||||
) RETURNS VOID AS $$
|
||||
DECLARE
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
IF NEW.status = 'VERIFIED' AND OLD.status != 'VERIFIED' THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'COMMENT_VERIFIED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
-- Check if the comment author is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."authorId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."authorId",
|
||||
5,
|
||||
'COMMENT_VERIFIED',
|
||||
NEW.id,
|
||||
format('Your comment #comment-%s in %s has been verified!',
|
||||
NEW.id,
|
||||
(SELECT name FROM "Service" WHERE id = NEW."serviceId"))
|
||||
);
|
||||
PERFORM update_user_karma(NEW."authorId", 5);
|
||||
END IF;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -146,12 +175,19 @@ DECLARE
|
||||
comment_author_id INT;
|
||||
service_name TEXT;
|
||||
upvote_change INT := 0; -- Variable to track change in upvotes
|
||||
is_author_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Get comment author and service info
|
||||
SELECT c."authorId", s.name INTO comment_author_id, service_name
|
||||
FROM "Comment" c
|
||||
JOIN "Service" s ON c.id = COALESCE(NEW."commentId", OLD."commentId") AND c."serviceId" = s.id;
|
||||
|
||||
-- Check if the comment author is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = comment_author_id
|
||||
INTO is_author_admin_or_moderator;
|
||||
|
||||
-- Calculate karma impact based on vote type
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
-- New vote
|
||||
@@ -181,16 +217,19 @@ BEGIN
|
||||
upvote_change := CASE WHEN NEW.downvote THEN -2 ELSE 2 END; -- -2 if upvote->downvote, +2 if downvote->upvote
|
||||
END IF;
|
||||
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
-- Only award karma if the author is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_author_admin_or_moderator, false) THEN
|
||||
-- Record karma transaction and update user karma
|
||||
PERFORM insert_karma_transaction(
|
||||
comment_author_id,
|
||||
karma_points,
|
||||
vote_action,
|
||||
COALESCE(NEW."commentId", OLD."commentId"),
|
||||
vote_description
|
||||
);
|
||||
|
||||
PERFORM update_user_karma(comment_author_id, karma_points);
|
||||
END IF;
|
||||
|
||||
-- Update comment's upvotes count incrementally
|
||||
UPDATE "Comment"
|
||||
@@ -236,26 +275,40 @@ CREATE OR REPLACE FUNCTION handle_suggestion_status_change()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
service_name TEXT;
|
||||
service_visibility "ServiceVisibility";
|
||||
is_user_admin_or_moderator BOOLEAN;
|
||||
BEGIN
|
||||
-- Award karma for first approval
|
||||
-- Check that OLD.status is not NULL to handle the initial creation case if needed,
|
||||
-- and ensure it wasn't already APPROVED.
|
||||
IF OLD.status IS DISTINCT FROM 'APPROVED' AND NEW.status = 'APPROVED' THEN
|
||||
-- Fetch service name for the description
|
||||
SELECT name INTO service_name FROM "Service" WHERE id = NEW."serviceId";
|
||||
-- Fetch service details for the description
|
||||
SELECT name, "serviceVisibility" INTO service_name, service_visibility FROM "Service" WHERE id = NEW."serviceId";
|
||||
|
||||
-- Only award karma if the service is public
|
||||
IF service_visibility = 'PUBLIC' THEN
|
||||
-- Check if the user is an admin or moderator
|
||||
SELECT (admin = true OR moderator = true)
|
||||
FROM "User"
|
||||
WHERE id = NEW."userId"
|
||||
INTO is_user_admin_or_moderator;
|
||||
|
||||
-- Only award karma if the user is NOT an admin/moderator
|
||||
IF NOT COALESCE(is_user_admin_or_moderator, false) THEN
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
10,
|
||||
'SUGGESTION_APPROVED',
|
||||
NULL, -- p_comment_id (not applicable)
|
||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||
NEW.id -- p_suggestion_id
|
||||
);
|
||||
|
||||
-- Insert karma transaction, linking it to the suggestion
|
||||
PERFORM insert_karma_transaction(
|
||||
NEW."userId",
|
||||
10,
|
||||
'SUGGESTION_APPROVED',
|
||||
NULL, -- p_comment_id (not applicable)
|
||||
format('Your suggestion for service ''%s'' has been approved!', service_name),
|
||||
NEW.id -- p_suggestion_id
|
||||
);
|
||||
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
-- Update user's total karma
|
||||
PERFORM update_user_karma(NEW."userId", 10);
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW; -- Result is ignored since this is an AFTER trigger
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- This script defines PostgreSQL functions and triggers for managing service scores:
|
||||
-- 1. Automatically calculates and updates privacy, trust, and overall scores
|
||||
-- for services when services or their attributes change.
|
||||
-- 2. Updates the isRecentlyListed flag for services listed within the last 15 days.
|
||||
-- 2. Updates the isRecentlyApproved flag for services approved within the last 15 days.
|
||||
-- 3. Queues asynchronous score recalculation in "ServiceScoreRecalculationJob"
|
||||
-- when an "Attribute" definition (e.g., points) is updated, ensuring
|
||||
-- efficient handling of widespread score updates.
|
||||
@@ -22,14 +22,11 @@ DROP FUNCTION IF EXISTS recalculate_scores_for_attribute();
|
||||
CREATE OR REPLACE FUNCTION calculate_privacy_score(service_id INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
privacy_score INT := 50; -- Start from middle value (50)
|
||||
privacy_score INT := 0;
|
||||
kyc_factor INT;
|
||||
onion_factor INT := 0;
|
||||
i2p_factor INT := 0;
|
||||
clarification_factor INT := 0;
|
||||
onion_or_i2p_factor INT := 0;
|
||||
monero_factor INT := 0;
|
||||
open_source_factor INT := 0;
|
||||
p2p_factor INT := 0;
|
||||
decentralized_factor INT := 0;
|
||||
attributes_score INT := 0;
|
||||
BEGIN
|
||||
-- Get service data
|
||||
@@ -46,20 +43,22 @@ BEGIN
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for onion URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("onionUrls", 1) > 0
|
||||
) THEN
|
||||
onion_factor := 5;
|
||||
END IF;
|
||||
-- Adjust score based on KYC level clarification modifiers
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "kycLevelClarification" = 'DEPENDS_ON_PARTNERS' THEN -5
|
||||
ELSE 0 -- Default modifier when no clarification or unrecognized value
|
||||
END
|
||||
INTO clarification_factor
|
||||
FROM "Service"
|
||||
WHERE "id" = service_id;
|
||||
|
||||
-- Check for i2p URLs
|
||||
-- Check for onion or i2p URLs
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE "id" = service_id AND array_length("i2pUrls", 1) > 0
|
||||
WHERE "id" = service_id AND (array_length("onionUrls", 1) > 0 OR array_length("i2pUrls", 1) > 0)
|
||||
) THEN
|
||||
i2p_factor := 5;
|
||||
onion_or_i2p_factor := 5;
|
||||
END IF;
|
||||
|
||||
-- Check for Monero acceptance
|
||||
@@ -75,10 +74,10 @@ BEGIN
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a."id"
|
||||
WHERE sa."serviceId" = service_id AND a."category" = 'PRIVACY';
|
||||
WHERE sa."serviceId" = service_id;
|
||||
|
||||
-- Calculate final privacy score (base 100)
|
||||
privacy_score := privacy_score + kyc_factor + onion_factor + i2p_factor + monero_factor + open_source_factor + p2p_factor + decentralized_factor + attributes_score;
|
||||
privacy_score := 50 + kyc_factor + clarification_factor + onion_or_i2p_factor + monero_factor + attributes_score;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
privacy_score := GREATEST(0, LEAST(100, privacy_score));
|
||||
@@ -91,9 +90,13 @@ $$ LANGUAGE plpgsql;
|
||||
CREATE OR REPLACE FUNCTION calculate_trust_score(service_id INT)
|
||||
RETURNS INT AS $$
|
||||
DECLARE
|
||||
trust_score INT := 50; -- Start from middle value (50)
|
||||
trust_score INT := 0;
|
||||
verification_factor INT;
|
||||
attributes_score INT := 0;
|
||||
recently_approved_factor INT := 0;
|
||||
tos_penalty_factor INT := 0;
|
||||
operating_since_factor INT := 0;
|
||||
legally_registered_factor INT := 0;
|
||||
BEGIN
|
||||
-- Get verification status factor
|
||||
SELECT
|
||||
@@ -113,32 +116,63 @@ BEGIN
|
||||
INTO attributes_score
|
||||
FROM "ServiceAttribute" sa
|
||||
JOIN "Attribute" a ON sa."attributeId" = a.id
|
||||
WHERE sa."serviceId" = service_id AND a.category = 'TRUST';
|
||||
WHERE sa."serviceId" = service_id;
|
||||
|
||||
-- Apply penalty if service was listed within the last 15 days
|
||||
-- Apply penalty if service was approved within the last 15 days
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "Service"
|
||||
WHERE id = service_id
|
||||
AND "listedAt" IS NOT NULL
|
||||
AND "approvedAt" IS NOT NULL
|
||||
AND "verificationStatus" = 'APPROVED'
|
||||
AND (NOW() - "listedAt") <= INTERVAL '15 days'
|
||||
AND (NOW() - "approvedAt") <= INTERVAL '15 days'
|
||||
) THEN
|
||||
trust_score := trust_score - 10;
|
||||
-- Update the isRecentlyListed flag to true
|
||||
recently_approved_factor := -10;
|
||||
-- Update the isRecentlyApproved flag to true
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = TRUE
|
||||
SET "isRecentlyApproved" = TRUE
|
||||
WHERE id = service_id;
|
||||
ELSE
|
||||
-- Update the isRecentlyListed flag to false
|
||||
-- Update the isRecentlyApproved flag to false
|
||||
UPDATE "Service"
|
||||
SET "isRecentlyListed" = FALSE
|
||||
SET "isRecentlyApproved" = FALSE
|
||||
WHERE id = service_id;
|
||||
END IF;
|
||||
|
||||
-- Apply penalty if ToS cannot be analyzed
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "Service"
|
||||
WHERE id = service_id
|
||||
AND "tosReviewAt" IS NOT NULL
|
||||
AND "tosReview" IS NULL
|
||||
) THEN
|
||||
tos_penalty_factor := -3;
|
||||
END IF;
|
||||
|
||||
-- Determine trust adjustment based on operatingSince
|
||||
SELECT
|
||||
CASE
|
||||
WHEN "operatingSince" IS NULL THEN 0
|
||||
WHEN AGE(NOW(), "operatingSince") < INTERVAL '1 year' THEN -4 -- New service penalty
|
||||
WHEN AGE(NOW(), "operatingSince") >= INTERVAL '2 years' THEN 5 -- Mature service bonus
|
||||
ELSE 0
|
||||
END
|
||||
INTO operating_since_factor
|
||||
FROM "Service"
|
||||
WHERE id = service_id;
|
||||
|
||||
-- Check for legal registration (country code or company name)
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM "Service"
|
||||
WHERE id = service_id AND ("registrationCountryCode" IS NOT NULL OR "registeredCompanyName" IS NOT NULL)
|
||||
) THEN
|
||||
legally_registered_factor := 2;
|
||||
END IF;
|
||||
|
||||
-- Calculate final trust score (base 100)
|
||||
trust_score := trust_score + verification_factor + attributes_score;
|
||||
|
||||
trust_score := 50 + verification_factor + attributes_score + recently_approved_factor + tos_penalty_factor + operating_since_factor + legally_registered_factor;
|
||||
|
||||
-- Ensure the score is in reasonable bounds (0-100)
|
||||
trust_score := GREATEST(0, LEAST(100, trust_score));
|
||||
|
||||
@@ -152,7 +186,7 @@ RETURNS INT AS $$
|
||||
DECLARE
|
||||
overall_score INT;
|
||||
BEGIN
|
||||
overall_score := CAST(ROUND(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0) AS INT);
|
||||
overall_score := CAST(((privacy_score * 0.6) + (trust_score * 0.4)) / 10.0 AS INT);
|
||||
RETURN GREATEST(0, LEAST(10, overall_score));
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
@@ -1,48 +1,60 @@
|
||||
-- This script manages the `listedAt`, `verifiedAt`, and `isRecentlyListed` timestamps
|
||||
-- for services based on changes to their `verificationStatus`. It ensures these timestamps
|
||||
-- are set or cleared appropriately when a service's verification status is updated.
|
||||
|
||||
CREATE OR REPLACE FUNCTION manage_service_timestamps()
|
||||
CREATE OR REPLACE FUNCTION manage_service_visibility_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Manage listedAt timestamp
|
||||
IF NEW."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Set listedAt only on the first time status becomes APPROVED or VERIFICATION_SUCCESS
|
||||
IF NEW."serviceVisibility" = 'PUBLIC' OR NEW."serviceVisibility" = 'ARCHIVED' THEN
|
||||
IF OLD."listedAt" IS NULL THEN
|
||||
NEW."listedAt" := NOW();
|
||||
NEW."isRecentlyListed" := TRUE;
|
||||
END IF;
|
||||
ELSIF OLD."verificationStatus" IN ('APPROVED', 'VERIFICATION_SUCCESS') THEN
|
||||
-- Clear listedAt if the status changes FROM APPROVED or VERIFICATION_SUCCESS to something else
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
ELSE
|
||||
NEW."listedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
-- Manage verifiedAt timestamp
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Set verifiedAt when status changes TO VERIFICATION_SUCCESS
|
||||
NEW."verifiedAt" := NOW();
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
ELSIF OLD."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
-- Clear verifiedAt when status changes FROM VERIFICATION_SUCCESS
|
||||
-- The trigger's WHEN clause ensures NEW."verificationStatus" is different.
|
||||
NEW."verifiedAt" := NULL;
|
||||
NEW."isRecentlyListed" := FALSE;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old trigger first if it exists under the old name
|
||||
CREATE OR REPLACE FUNCTION manage_service_verification_timestamps()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF (NEW."verificationStatus" = 'APPROVED' OR NEW."verificationStatus" = 'VERIFICATION_SUCCESS') THEN
|
||||
IF OLD."approvedAt" IS NULL THEN
|
||||
NEW."approvedAt" := NOW();
|
||||
NEW."isRecentlyApproved" := TRUE;
|
||||
END IF;
|
||||
ELSE
|
||||
NEW."approvedAt" := NULL;
|
||||
NEW."isRecentlyApproved" := FALSE;
|
||||
END IF;
|
||||
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_SUCCESS' THEN
|
||||
NEW."verifiedAt" := NOW();
|
||||
ELSE
|
||||
NEW."verifiedAt" := NULL;
|
||||
END IF;
|
||||
|
||||
IF NEW."verificationStatus" = 'VERIFICATION_FAILED' THEN
|
||||
NEW."spamAt" := NOW();
|
||||
ELSE
|
||||
NEW."spamAt" := NULL;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Drop the old triggers TODO: remove this some day
|
||||
DROP TRIGGER IF EXISTS trigger_set_service_listed_at ON "Service";
|
||||
-- Drop the trigger if it exists under the new name
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_timestamps
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_visibility_timestamps ON "Service";
|
||||
DROP TRIGGER IF EXISTS trigger_manage_service_verification_timestamps ON "Service";
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_visibility_timestamps
|
||||
BEFORE UPDATE OF "serviceVisibility" ON "Service"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION manage_service_visibility_timestamps();
|
||||
|
||||
CREATE TRIGGER trigger_manage_service_verification_timestamps
|
||||
BEFORE UPDATE OF "verificationStatus" ON "Service"
|
||||
FOR EACH ROW
|
||||
-- Only execute the function if the verificationStatus value has actually changed
|
||||
WHEN (OLD."verificationStatus" IS DISTINCT FROM NEW."verificationStatus")
|
||||
EXECUTE FUNCTION manage_service_timestamps();
|
||||
EXECUTE FUNCTION manage_service_verification_timestamps();
|
||||
|
||||
@@ -3,7 +3,20 @@ RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
suggestion_status_change "ServiceSuggestionStatusChange";
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||
IF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestion' THEN -- Corresponds to ServiceSuggestion insert
|
||||
-- Notify all admins when a new suggestion is created
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId")
|
||||
SELECT u."id", 'SUGGESTION_CREATED', NEW."id"
|
||||
FROM "User" u
|
||||
WHERE u."admin" = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM "Notification" n
|
||||
WHERE n."userId" = u."id"
|
||||
AND n."type" = 'SUGGESTION_CREATED'
|
||||
AND n."aboutServiceSuggestionId" = NEW."id"
|
||||
);
|
||||
|
||||
ELSIF TG_OP = 'INSERT' AND TG_TABLE_NAME = 'ServiceSuggestionMessage' THEN -- Corresponds to ServiceSuggestionMessage insert
|
||||
-- Notify suggestion author (if not the sender)
|
||||
INSERT INTO "Notification" ("userId", "type", "aboutServiceSuggestionId", "aboutServiceSuggestionMessageId")
|
||||
SELECT s."userId", 'SUGGESTION_MESSAGE', NEW."suggestionId", NEW."id"
|
||||
@@ -55,6 +68,13 @@ BEGIN
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Trigger for new suggestions
|
||||
DROP TRIGGER IF EXISTS service_suggestion_created_notifications_trigger ON "ServiceSuggestion";
|
||||
CREATE TRIGGER service_suggestion_created_notifications_trigger
|
||||
AFTER INSERT ON "ServiceSuggestion"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_service_suggestion_notifications();
|
||||
|
||||
-- Trigger for new messages
|
||||
DROP TRIGGER IF EXISTS service_suggestion_message_notifications_trigger ON "ServiceSuggestionMessage";
|
||||
CREATE TRIGGER service_suggestion_message_notifications_trigger
|
||||
|
||||
@@ -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
@@ -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();
|
||||
8
web/public/favicon-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#00bfff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#3BDB78" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 619 B |
8
web/public/favicon-dev-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#FF0040" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
8
web/public/favicon-dev-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#FF0040" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 613 B |
7
web/public/favicon-dev-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 607 B |
@@ -1,5 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#FF0000" viewBox="0 0 32 32" height="32" width="32">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#ff0040" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#040505" d="M4 4h24v24H4z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
|
||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 610 B |
8
web/public/favicon-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#0080FF" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#33BE00" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 616 B |
@@ -1,5 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#33BE00" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#fff" d="M4 4h24v24H4z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
|
||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 607 B |
8
web/public/favicon-stage-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#fff" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#040505" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 626 B |
8
web/public/favicon-stage-lightmode-badge.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32">
|
||||
<title>KYCnot.me logo with badge</title>
|
||||
<path fill="#000" d="M32 8a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z" />
|
||||
<path fill="#fff" d="M12.7 4A12 12 0 0 0 28 19.3V28H4V4h8.7Z" />
|
||||
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||
d="M15 0a12 12 0 0 0-1.4 14H11a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h6.4l.6.4V21c0 .6.4 1 1 1h3v-2.2A12 12 0 0 0 32 17V28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h11Zm7 25c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3v3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 623 B |
7
web/public/favicon-stage-lightmode.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#fff" class="b" d="M4 4h24v24H4z" />
|
||||
<path fill="#0080ff" class="a" fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 639 B |
@@ -1,13 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<style>
|
||||
@media (prefers-color-scheme: light) {
|
||||
path {
|
||||
fill: #0080ff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<path fill="#00ffff" fill-rule="evenodd"
|
||||
<path fill="#040505" class="b" d="M4 4h24v24H4z" />
|
||||
<path fill="#00ffff" class="a" fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 692 B After Width: | Height: | Size: 642 B |
@@ -1,5 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill="#040505" d="M4 4h24v24H4z" />
|
||||
<path fill-rule="evenodd"
|
||||
d="M32 28a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4h24a4 4 0 0 1 4 4v24ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
|
||||
|
Before Width: | Height: | Size: 566 B After Width: | Height: | Size: 610 B |
6
web/public/notification-icon.svg
Normal file
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="#3BDB78" viewBox="0 0 32 32" height="32" width="32">
|
||||
<title>KYCnot.me logo</title>
|
||||
<path fill-rule="evenodd"
|
||||
d="M30 26a4 4 0 0 1-4 4H6a4 4 0 0 1-4-4V6a4 4 0 0 1 4-4h20a4 4 0 0 1 4 4v20ZM7 6a1 1 0 0 0-1 1v18c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-6c0-.6.4-1 1-1h7v-3c0-.6-.4-1-1-1h-6a1 1 0 0 1-1-1V7c0-.6-.4-1-1-1H7Zm15 16v3c0 .6.4 1 1 1h2c.6 0 1-.4 1-1v-2c0-.6-.4-1-1-1h-3Zm-4-4v3c0 .6.4 1 1 1h3v-3c0-.6-.4-1-1-1h-3Zm1-12a1 1 0 0 0-1 1v3c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V7c0-.6-.4-1-1-1h-3Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 566 B |
@@ -1,5 +1,6 @@
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'astro:content'
|
||||
import { pick } from 'lodash-es'
|
||||
|
||||
import { karmaUnlocksById } from '../constants/karmaUnlocks'
|
||||
import { createAccount } from '../lib/accountCreate'
|
||||
@@ -7,7 +8,7 @@ import { captchaFormSchemaProperties, captchaFormSchemaSuperRefine } from '../li
|
||||
import { defineProtectedAction } from '../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../lib/fileStorage'
|
||||
import { handleHoneypotTrap } from '../lib/honeypot'
|
||||
import { startImpersonating } from '../lib/impersonation'
|
||||
import { startImpersonating, stopImpersonating } from '../lib/impersonation'
|
||||
import { makeKarmaUnlockMessage, makeUserWithKarmaUnlocks } from '../lib/karmaUnlocks'
|
||||
import { prisma } from '../lib/prisma'
|
||||
import { redisPreGeneratedSecretTokens } from '../lib/redis/redisPreGeneratedSecretTokens'
|
||||
@@ -225,4 +226,36 @@ export const accountActions = {
|
||||
return { user }
|
||||
},
|
||||
}),
|
||||
|
||||
delete: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'user',
|
||||
input: z
|
||||
.object({
|
||||
...captchaFormSchemaProperties,
|
||||
})
|
||||
.superRefine(captchaFormSchemaSuperRefine),
|
||||
handler: async (_input, context) => {
|
||||
if (context.locals.user.admin || context.locals.user.moderator) {
|
||||
throw new ActionError({
|
||||
code: 'FORBIDDEN',
|
||||
message: 'Admins and moderators cannot delete their own accounts.',
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.user.delete({
|
||||
where: { id: context.locals.user.id },
|
||||
})
|
||||
|
||||
const deletedUser = pick(context.locals.user, ['id', 'name', 'displayName', 'picture'])
|
||||
|
||||
if (context.locals.actualUser) {
|
||||
await stopImpersonating(context)
|
||||
} else {
|
||||
await logout(context)
|
||||
}
|
||||
|
||||
return { deletedUser }
|
||||
},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
web/src/actions/admin/notification.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { z } from 'astro/zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { prisma } from '../../lib/prisma'
|
||||
import { stringListOfSlugsSchemaRequired } from '../../lib/zodUtils'
|
||||
|
||||
export const adminNotificationActions = {
|
||||
test: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
userNames: stringListOfSlugsSchemaRequired,
|
||||
}),
|
||||
handler: async (input) => {
|
||||
const users = await prisma.user.findMany({
|
||||
where: { name: { in: input.userNames } },
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
const notifications = await prisma.notification.createManyAndReturn({
|
||||
data: users.map((user) => ({
|
||||
type: 'TEST',
|
||||
userId: user.id,
|
||||
})),
|
||||
select: { id: true },
|
||||
})
|
||||
|
||||
return {
|
||||
message: `Created ${notifications.length.toString()} notifications.`,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -1,42 +1,22 @@
|
||||
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 { countriesZodEnumById } from '../../constants/countries'
|
||||
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,
|
||||
stringListOfContactMethodsSchema,
|
||||
stringListOfUrlsSchemaRequired,
|
||||
zodCohercedNumber,
|
||||
zodContactMethod,
|
||||
} from '../../lib/zodUtils'
|
||||
|
||||
const serviceSchemaBase = z.object({
|
||||
id: z.number(),
|
||||
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,21 +36,72 @@ const addSlugIfMissing = <
|
||||
}),
|
||||
})
|
||||
|
||||
const contactMethodSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
label: z.string().min(1).max(50),
|
||||
value: z.string().min(1).max(200),
|
||||
iconId: z.string().min(1).max(50),
|
||||
info: z.string().max(200).optional().default(''),
|
||||
serviceId: z.number(),
|
||||
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,
|
||||
contactMethods: stringListOfContactMethodsSchema,
|
||||
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)).min(1),
|
||||
referral: z
|
||||
.string()
|
||||
.regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
|
||||
.optional()
|
||||
.nullable()
|
||||
.default(null),
|
||||
operatingSince: z.coerce.date().optional().nullable(),
|
||||
registrationCountryCode: z
|
||||
.union([countriesZodEnumById, z.literal('')])
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
|
||||
message: 'Country code must be a valid 2-character code or empty',
|
||||
}),
|
||||
registeredCompanyName: z.string().trim().max(100).optional().nullable(),
|
||||
imageFile: imageFileSchema,
|
||||
serviceVisibility: z.nativeEnum(ServiceVisibility),
|
||||
internalNote: z.string().optional(),
|
||||
strictCommentingEnabled: z.boolean().optional().default(false),
|
||||
commentSectionMessage: z.string().trim().min(3).max(1000).optional().nullable().default(null),
|
||||
})
|
||||
|
||||
// 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,
|
||||
@@ -84,12 +115,36 @@ 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,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
categories: {
|
||||
connect: input.categories.map((id) => ({ id })),
|
||||
},
|
||||
@@ -100,7 +155,23 @@ export const adminServiceActions = {
|
||||
},
|
||||
})),
|
||||
},
|
||||
contactMethods: {
|
||||
create: input.contactMethods.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
imageUrl,
|
||||
internalNotes: input.internalNote
|
||||
? {
|
||||
create: {
|
||||
content: input.internalNote,
|
||||
addedByUserId: context.locals.user.id,
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
operatingSince: input.operatingSince,
|
||||
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||
registeredCompanyName: input.registeredCompanyName,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -115,34 +186,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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -155,80 +232,260 @@ 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,
|
||||
strictCommentingEnabled: input.strictCommentingEnabled,
|
||||
commentSectionMessage: input.commentSectionMessage,
|
||||
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||||
referral: input.referral || null,
|
||||
serviceVisibility: input.serviceVisibility,
|
||||
slug: input.slug,
|
||||
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,
|
||||
})),
|
||||
},
|
||||
operatingSince: input.operatingSince,
|
||||
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||
registeredCompanyName: input.registeredCompanyName,
|
||||
},
|
||||
})
|
||||
|
||||
return { service }
|
||||
},
|
||||
}),
|
||||
|
||||
createContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: contactMethodSchema.omit({ id: true }),
|
||||
handler: async (input) => {
|
||||
const contactMethod = await prisma.serviceContactMethod.create({
|
||||
data: input,
|
||||
})
|
||||
return { contactMethod }
|
||||
},
|
||||
}),
|
||||
|
||||
updateContactMethod: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: contactMethodSchema,
|
||||
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(),
|
||||
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) => {
|
||||
await prisma.serviceContactMethod.delete({
|
||||
where: { id: input.id },
|
||||
})
|
||||
return { success: true }
|
||||
},
|
||||
}),
|
||||
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
permissions: 'admin',
|
||||
input: z.object({
|
||||
id: z.number().int().positive(),
|
||||
label: z.string().min(1).max(50).nullable(),
|
||||
value: zodContactMethod,
|
||||
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 }
|
||||
},
|
||||
}),
|
||||
|
||||
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 },
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
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) => {
|
||||
await deleteFileLocally(input.fileUrl)
|
||||
},
|
||||
}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { type Prisma, type ServiceUserRole, type PrismaClient } from '@prisma/client'
|
||||
import { type Prisma, type ServiceUserRole } from '@prisma/client'
|
||||
import { ActionError } from 'astro:actions'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { defineProtectedAction } from '../../lib/defineProtectedAction'
|
||||
import { saveFileLocally } from '../../lib/fileStorage'
|
||||
import { prisma as prismaInstance } from '../../lib/prisma'
|
||||
|
||||
const prisma = prismaInstance as PrismaClient
|
||||
import { prisma } from '../../lib/prisma'
|
||||
|
||||
const selectUserReturnFields = {
|
||||
id: true,
|
||||
@@ -16,7 +14,7 @@ const selectUserReturnFields = {
|
||||
picture: true,
|
||||
admin: true,
|
||||
verified: true,
|
||||
verifier: true,
|
||||
moderator: true,
|
||||
verifiedLink: true,
|
||||
secretTokenHash: true,
|
||||
totalKarma: true,
|
||||
@@ -55,7 +53,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 +99,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
@@ -0,0 +1,5 @@
|
||||
import { apiServiceActions } from './service'
|
||||
|
||||
export const apiActions = {
|
||||
service: apiServiceActions,
|
||||
}
|
||||
149
web/src/actions/api/service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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,
|
||||
approvedAt: true,
|
||||
verifiedAt: true,
|
||||
serviceVisibility: true,
|
||||
} as const satisfies Prisma.ServiceSelect
|
||||
|
||||
let service = await prisma.service.findFirst({
|
||||
where: {
|
||||
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: {
|
||||
serviceVisibility: { in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'] },
|
||||
|
||||
previousSlugs: { has: input.slug },
|
||||
},
|
||||
select,
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
!service ||
|
||||
(service.serviceVisibility !== 'PUBLIC' &&
|
||||
service.serviceVisibility !== 'ARCHIVED' &&
|
||||
service.serviceVisibility !== 'UNLISTED')
|
||||
) {
|
||||
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,
|
||||
approvedAt: service.approvedAt,
|
||||
kycLevel: service.kycLevel,
|
||||
kycLevelInfo: pick(getKycLevelInfo(service.kycLevel.toString()), ['value', 'name', 'description']),
|
||||
kycLevelClarification: service.kycLevelClarification,
|
||||
kycLevelClarificationInfo: pick(getKycLevelClarificationInfo(service.kycLevelClarification), [
|
||||
'value',
|
||||
'label',
|
||||
'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,
|
||||
}
|
||||
},
|
||||
}),
|
||||
}
|
||||
@@ -14,9 +14,10 @@ import { timeTrapSecretKey } from '../lib/timeTrapSecret'
|
||||
|
||||
import type { CommentStatus, Prisma } from '@prisma/client'
|
||||
|
||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 5
|
||||
const COMMENT_RATE_LIMIT_WINDOW_MINUTES = 2
|
||||
const MAX_COMMENTS_PER_WINDOW = 1
|
||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 5
|
||||
const MAX_COMMENTS_PER_WINDOW_VERIFIED_USER = 10
|
||||
export const COMMENT_ORDER_ID_MAX_LENGTH = 600
|
||||
|
||||
export const commentActions = {
|
||||
vote: defineProtectedAction({
|
||||
@@ -103,7 +104,7 @@ export const commentActions = {
|
||||
issueFundsBlocked: z.coerce.boolean().optional(),
|
||||
issueScam: z.coerce.boolean().optional(),
|
||||
issueDetails: z.string().max(120).optional(),
|
||||
orderId: z.string().max(100).optional(),
|
||||
orderId: z.string().max(COMMENT_ORDER_ID_MAX_LENGTH).optional(),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.rating && data.parentId) {
|
||||
@@ -270,6 +271,18 @@ export const commentActions = {
|
||||
}
|
||||
}
|
||||
|
||||
const isRelatedToService = !!(await tx.serviceUser.findUnique({
|
||||
where: {
|
||||
userId_serviceId: {
|
||||
userId: context.locals.user.id,
|
||||
serviceId: input.serviceId,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
}))
|
||||
|
||||
// Prepare data object with proper type safety
|
||||
const commentData: Prisma.CommentCreateInput = {
|
||||
content: input.content,
|
||||
@@ -277,7 +290,12 @@ export const commentActions = {
|
||||
author: { connect: { id: context.locals.user.id } },
|
||||
|
||||
// Change status to HUMAN_PENDING if there's an issue report, this is so that the AI worker does not pick it up for review
|
||||
status: context.locals.user.admin ? 'APPROVED' : isIssueReport ? 'HUMAN_PENDING' : 'PENDING',
|
||||
status:
|
||||
context.locals.user.admin || context.locals.user.moderator || isRelatedToService
|
||||
? 'APPROVED'
|
||||
: isIssueReport
|
||||
? 'HUMAN_PENDING'
|
||||
: 'PENDING',
|
||||
requiresAdminReview,
|
||||
orderId: input.orderId?.trim() ?? null,
|
||||
kycRequested: input.issueKycRequested === true,
|
||||
@@ -331,7 +349,7 @@ export const commentActions = {
|
||||
}),
|
||||
|
||||
moderate: defineProtectedAction({
|
||||
permissions: ['admin', 'verifier'],
|
||||
permissions: ['admin', 'moderator'],
|
||||
input: z.object({
|
||||
commentId: z.number(),
|
||||
userId: z.number(),
|
||||
@@ -345,10 +363,11 @@ export const commentActions = {
|
||||
'order-id-status',
|
||||
'kyc-requested',
|
||||
'funds-blocked',
|
||||
'toggle-rating-active',
|
||||
]),
|
||||
value: z.union([
|
||||
z.enum(['PENDING', 'APPROVED', 'VERIFIED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED']),
|
||||
z.enum(['PENDING', 'APPROVED', 'REJECTED', 'WITHDRAWN']),
|
||||
z.boolean(),
|
||||
z.string(),
|
||||
]),
|
||||
@@ -411,7 +430,7 @@ export const commentActions = {
|
||||
updateData.privateContext = input.value as string
|
||||
break
|
||||
case 'order-id-status':
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED'
|
||||
updateData.orderIdStatus = input.value as 'APPROVED' | 'PENDING' | 'REJECTED' | 'WITHDRAWN'
|
||||
break
|
||||
case 'kyc-requested':
|
||||
updateData.kycRequested = !!input.value
|
||||
@@ -419,6 +438,9 @@ export const commentActions = {
|
||||
case 'funds-blocked':
|
||||
updateData.fundsBlocked = !!input.value
|
||||
break
|
||||
case 'toggle-rating-active':
|
||||
updateData.ratingActive = !!input.value
|
||||
break
|
||||
}
|
||||
|
||||
// Update the comment
|
||||
|
||||
@@ -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,52 @@ export const notificationActions = {
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
webPush: {
|
||||
subscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'user',
|
||||
input: z.object({
|
||||
endpoint: z.string(),
|
||||
p256dhKey: z.string(),
|
||||
authKey: z.string(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.pushSubscription.upsert({
|
||||
where: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
update: {
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
},
|
||||
create: {
|
||||
userId: context.locals.user.id,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.p256dhKey,
|
||||
auth: input.authKey,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
|
||||
unsubscribe: defineProtectedAction({
|
||||
accept: 'json',
|
||||
permissions: 'guest',
|
||||
input: z.object({
|
||||
endpoint: z.string(),
|
||||
}),
|
||||
handler: async (input, context) => {
|
||||
await prisma.pushSubscription.delete({
|
||||
where: {
|
||||
userId: context.locals.user?.id ?? undefined,
|
||||
endpoint: input.endpoint,
|
||||
},
|
||||
})
|
||||
},
|
||||
}),
|
||||
},
|
||||
preferences: {
|
||||
update: defineProtectedAction({
|
||||
accept: 'form',
|
||||
@@ -31,7 +77,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,22 +1,19 @@
|
||||
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'
|
||||
|
||||
import { countriesZodEnumById } from '../constants/countries'
|
||||
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 +30,15 @@ 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),
|
||||
},
|
||||
serviceVisibility: {
|
||||
in: ['PUBLIC', 'ARCHIVED', 'UNLISTED'],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
@@ -47,35 +48,53 @@ const findPossibleDuplicates = async (input: { name: string }) => {
|
||||
description: true,
|
||||
},
|
||||
})
|
||||
|
||||
return possibleDuplicates
|
||||
}
|
||||
|
||||
const serializeExtraNotes = <T extends Record<string, unknown>>(
|
||||
const serializeExtraNotes = async <T extends Record<string, unknown>, NK extends keyof T>(
|
||||
input: T,
|
||||
skipKeys: (keyof T)[] = []
|
||||
): string => {
|
||||
return Object.entries(input)
|
||||
.filter(([key]) => !skipKeys.includes(key as keyof T))
|
||||
.map(([key, value]) => {
|
||||
let serializedValue = ''
|
||||
if (typeof value === 'string') {
|
||||
serializedValue = value
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (typeof value === 'object' && 'toString' in value && typeof value.toString === 'function') {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
serializedValue = value.toString()
|
||||
} else {
|
||||
try {
|
||||
serializedValue = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
return `- ${key}: ${serializedValue}`
|
||||
})
|
||||
.join('\n')
|
||||
skipKeys: NK[] = [],
|
||||
mapKeys: { [P in Exclude<Extract<keyof T, string>, NK>]?: (value: T[P]) => unknown } = {}
|
||||
): Promise<string> => {
|
||||
return (
|
||||
await Promise.all(
|
||||
Object.entries(input)
|
||||
.filter(
|
||||
(
|
||||
entry
|
||||
): entry is [Exclude<Extract<keyof T, string>, NK>, T[Exclude<Extract<keyof T, string>, NK>]] =>
|
||||
!skipKeys.some((k) => k === entry[0])
|
||||
)
|
||||
.map(async ([key, originalValue]) => {
|
||||
const value = mapKeys[key] ? await mapKeys[key](originalValue) : originalValue
|
||||
let serializedValue = ''
|
||||
if (typeof value === 'string') {
|
||||
serializedValue = value
|
||||
} else if (value === undefined || value === null) {
|
||||
serializedValue = ''
|
||||
} else if (Array.isArray(value)) {
|
||||
serializedValue = value.map((item) => String(item)).join(', ')
|
||||
} else if (typeof value === 'object' && value instanceof Date) {
|
||||
serializedValue = value.toISOString()
|
||||
} else if (
|
||||
typeof value === 'object' &&
|
||||
'toString' in value &&
|
||||
typeof value.toString === 'function' &&
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
value.toString() !== '[object Object]'
|
||||
) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
||||
serializedValue = value.toString()
|
||||
} else {
|
||||
try {
|
||||
serializedValue = JSON.stringify(value)
|
||||
} catch (error) {
|
||||
serializedValue = `Error serializing value: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
}
|
||||
}
|
||||
return `- ${key}: ${serializedValue}`
|
||||
})
|
||||
)
|
||||
).join('\n')
|
||||
}
|
||||
|
||||
export const serviceSuggestionActions = {
|
||||
@@ -122,9 +141,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,
|
||||
},
|
||||
@@ -149,26 +168,31 @@ export const serviceSuggestionActions = {
|
||||
.max(SUGGESTION_SLUG_MAX_LENGTH)
|
||||
.regex(/^[a-z0-9-]+$/, {
|
||||
message: 'Slug must contain only lowercase letters, numbers, and hyphens',
|
||||
})
|
||||
.refine(
|
||||
async (slug) => {
|
||||
const exists = await prisma.service.findUnique({
|
||||
select: { id: true },
|
||||
where: { slug },
|
||||
})
|
||||
return !exists
|
||||
},
|
||||
{ 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),
|
||||
imageFile: imageFileSchemaRequired,
|
||||
rulesConfirm: z.literal('on', {
|
||||
errorMap: () => ({
|
||||
message: 'You must accept the suggestion rules and process to continue',
|
||||
}),
|
||||
}),
|
||||
operatingSince: z.coerce.date().optional(),
|
||||
registrationCountryCode: z
|
||||
.union([countriesZodEnumById, z.literal('')])
|
||||
.optional()
|
||||
.nullable()
|
||||
.refine((val) => val === null || val === undefined || val === '' || val.length === 2, {
|
||||
message: 'Country code must be a valid 2-character code or empty',
|
||||
}),
|
||||
registeredCompanyName: z.string().trim().max(100).optional(),
|
||||
/** @deprecated Honey pot field, do not use */
|
||||
message: z.unknown().optional(),
|
||||
skipDuplicateCheck: z
|
||||
@@ -188,28 +212,73 @@ export const serviceSuggestionActions = {
|
||||
location: 'serviceSuggestion.createService',
|
||||
})
|
||||
|
||||
const serviceWithSameSlug = await prisma.service.findUnique({
|
||||
select: { id: true, name: true, slug: true, description: true },
|
||||
where: { slug: input.slug },
|
||||
})
|
||||
|
||||
if (!input.skipDuplicateCheck) {
|
||||
const possibleDuplicates = await findPossibleDuplicates(input)
|
||||
const possibleDuplicates = [
|
||||
...(serviceWithSameSlug ? [serviceWithSameSlug] : []),
|
||||
...(await findPossibleDuplicates(input)),
|
||||
]
|
||||
|
||||
if (possibleDuplicates.length > 0) {
|
||||
return {
|
||||
hasDuplicates: true,
|
||||
possibleDuplicates,
|
||||
extraNotes: serializeExtraNotes(input, [
|
||||
'skipDuplicateCheck',
|
||||
'message',
|
||||
'imageFile',
|
||||
'captcha-value',
|
||||
'captcha-solution-hash',
|
||||
]),
|
||||
extraNotes: await serializeExtraNotes(
|
||||
input,
|
||||
[
|
||||
'skipDuplicateCheck',
|
||||
'message',
|
||||
'imageFile',
|
||||
'captcha-value',
|
||||
'captcha-solution-hash',
|
||||
'rulesConfirm',
|
||||
],
|
||||
{
|
||||
attributes: async (value) => {
|
||||
const dbAttributes = await prisma.attribute.findMany({
|
||||
select: {
|
||||
title: true,
|
||||
},
|
||||
where: { id: { in: value } },
|
||||
})
|
||||
return dbAttributes.map((attribute) => `\n - ${attribute.title}`).join('')
|
||||
},
|
||||
categories: async (value) => {
|
||||
const dbCategories = await prisma.category.findMany({
|
||||
select: {
|
||||
name: true,
|
||||
},
|
||||
where: { id: { in: value } },
|
||||
})
|
||||
return dbCategories.map((category) => category.name)
|
||||
},
|
||||
}
|
||||
),
|
||||
serviceSuggestion: undefined,
|
||||
service: undefined,
|
||||
} as const
|
||||
}
|
||||
} else {
|
||||
if (serviceWithSameSlug) {
|
||||
throw new ActionError({
|
||||
message: 'Slug already in use, try a different one',
|
||||
code: 'BAD_REQUEST',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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 +290,22 @@ export const serviceSuggestionActions = {
|
||||
name: input.name,
|
||||
slug: input.slug,
|
||||
description: input.description,
|
||||
serviceUrls: input.serviceUrls,
|
||||
operatingSince: input.operatingSince,
|
||||
registrationCountryCode: input.registrationCountryCode ?? null,
|
||||
registeredCompanyName: input.registeredCompanyName,
|
||||
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 +314,11 @@ export const serviceSuggestionActions = {
|
||||
attributeId: id,
|
||||
})),
|
||||
},
|
||||
contactMethods: {
|
||||
create: input.contactMethods.map((value) => ({
|
||||
value,
|
||||
})),
|
||||
},
|
||||
},
|
||||
select: serviceSelect,
|
||||
})
|
||||
@@ -248,8 +326,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,
|
||||
},
|
||||
|
||||
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.7 KiB |
8
web/src/assets/review-badge/long-black.svg
Normal file
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="32" width="216" viewBox="0 0 432 64">
|
||||
<rect width="431" height="63" x=".5" y=".5" fill="#101413" stroke="#292B2A" rx="7.5" />
|
||||
<path fill="#3BDB78" d="m37.5 18 4.1 8.3 9.2 1.4-6.6 6.5 1.5 9.1-8.2-4.3-8.2 4.3 1.5-9.1-6.6-6.5 9.2-1.4 4.1-8.3Z" />
|
||||
<path fill="#BEBEBE"
|
||||
d="M63.7 42V22.4h7c1.5 0 2.7.2 3.7.7 1 .6 1.8 1.3 2.3 2.2.5 1 .8 2 .8 3.2 0 1.2-.3 2.3-.8 3.2-.5.9-1.3 1.6-2.3 2.1-1 .5-2.2.8-3.8.8h-5.3V32h5c1 0 1.8-.1 2.4-.4.6-.3 1-.7 1.4-1.2a4 4 0 0 0 .4-1.9 4 4 0 0 0-.5-2c-.2-.5-.7-.9-1.3-1.2-.6-.2-1.4-.4-2.4-.4h-3.7V42h-3Zm9.7-8.9 4.8 8.9h-3.4l-4.7-8.9h3.3ZM87 42.3c-1.5 0-2.7-.3-3.8-1-1-.6-1.8-1.4-2.4-2.6a9 9 0 0 1-.8-4 9 9 0 0 1 .8-4c.6-1.1 1.4-2 2.4-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.3 1 .5 2 .5 3.4v1H81.6v-2.1h8.9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.8 0-1.5.2-2.1.6a4 4 0 0 0-1.4 1.6c-.3.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.6 2.5.3.7.8 1.2 1.4 1.6.7.4 1.4.5 2.2.5.6 0 1 0 1.5-.2l1.2-.7c.3-.3.5-.7.7-1.2l2.7.5a5 5 0 0 1-1.1 2.1c-.6.6-1.3 1-2.1 1.4-.9.3-1.8.5-3 .5Zm21.7-15L103.3 42h-3l-5.4-14.7h3l3.8 11.3h.2l3.7-11.3h3Zm2.7 14.7V27.3h2.8V42h-2.8Zm1.4-17c-.5 0-1-.2-1.3-.5-.3-.3-.5-.7-.5-1.2s.2-.9.5-1.2c.4-.4.8-.5 1.3-.5s1 .1 1.3.5c.3.3.5.7.5 1.2s-.2.9-.5 1.2c-.4.3-.8.5-1.3.5Zm11.6 17.3c-1.4 0-2.7-.3-3.7-1-1-.6-1.9-1.4-2.4-2.6a9 9 0 0 1-.9-4 9 9 0 0 1 .9-4c.5-1.1 1.3-2 2.3-2.7a7.2 7.2 0 0 1 6-.6 5.9 5.9 0 0 1 3.6 3.7c.4 1 .6 2 .6 3.4v1H119v-2.1h9c0-.8-.2-1.5-.5-2a3.5 3.5 0 0 0-3.2-2c-.9 0-1.6.2-2.2.6a4 4 0 0 0-1.3 1.6c-.4.6-.5 1.3-.5 2v1.7c0 1 .2 1.8.5 2.5.4.7.8 1.2 1.5 1.6.6.4 1.3.5 2.2.5.5 0 1 0 1.4-.2.5-.2.9-.4 1.2-.7.3-.3.6-.7.8-1.2l2.7.5a5 5 0 0 1-1.2 2.1c-.6.6-1.3 1-2.1 1.4-.8.3-1.8.5-2.9.5Zm12.7-.3-4.3-14.7h3l2.8 10.8h.2l2.9-10.8h3l2.8 10.7h.1l2.9-10.7h3L149 42h-2.9l-3-10.6h-.2L140 42h-2.9Zm32.4.3c-1.3 0-2.6-.3-3.6-1-1-.6-1.8-1.5-2.4-2.6a8.8 8.8 0 0 1-.8-4c0-1.5.3-2.9.8-4a6.4 6.4 0 0 1 6-3.6c1.4 0 2.6.3 3.7 1 1 .6 1.8 1.5 2.3 2.6.6 1.1.9 2.5.9 4s-.3 2.9-.9 4a6.4 6.4 0 0 1-6 3.6Zm0-2.4c1 0 1.7-.2 2.3-.7.6-.5 1-1.1 1.3-2 .3-.7.4-1.6.4-2.5 0-1-.1-1.8-.4-2.6-.3-.8-.7-1.4-1.3-1.9-.6-.5-1.4-.7-2.3-.7-.9 0-1.6.2-2.2.7-.6.5-1 1.1-1.3 2-.3.7-.4 1.6-.4 2.5 0 1 .1 1.8.4 2.6.3.8.7 1.4 1.3 1.9.6.5 1.3.7 2.2.7Zm13-6.6V42h-2.9V27.3h2.8v2.4h.1c.4-.8.9-1.4 1.6-2a5 5 0 0 1 2.8-.6c1 0 1.9.2 2.6.6.8.4 1.4 1 1.8 1.9.4.8.6 1.8.6 3V42H189v-9c0-1-.3-2-.8-2.5a3 3 0 0 0-2.3-1c-.7 0-1.3.2-1.8.5s-.9.7-1.2 1.3c-.3.5-.4 1.2-.4 2Z" />
|
||||
<path fill="#3BDB78"
|
||||
d="M205.5 18a1 1 0 0 0-1 1v26a1 1 0 0 0 1 1h74a1 1 0 0 0 1-1V19a1 1 0 0 0-1-1h-74Zm4 4h2a1 1 0 0 1 1 1v6a1 1 0 0 0 1 1h6a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v3h3a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1v-3h-7a1 1 0 0 0-1 1v6a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1V23a1 1 0 0 1 1-1Zm12 0h3a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-3a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1Zm12.8 0h2.4a1 1 0 0 1 .8.5l5 7.8 5-7.8a1 1 0 0 1 .8-.5h2.4a1 1 0 0 1 .8 1.5l-6.8 10.8a1 1 0 0 0-.2.6V41a1 1 0 0 1-1 1h-2a1 1 0 0 1-1-1v-6.1c0-.2 0-.4-.2-.6l-6.8-10.8a1 1 0 0 1 .3-1.4l.5-.1Zm27.2 0h14a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-15v12h15a1 1 0 0 1 1 1v2a1 1 0 0 1-1 1h-14a1 1 0 0 1-1-1v-3h-3a1 1 0 0 1-1-1V27a1 1 0 0 1 1-1h3v-3a1 1 0 0 1 1-1Zm24 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V25.6l9.2 15.9c.2.3.5.5.8.5h5a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-2a1 1 0 0 0-1 1v15.4l-9.2-15.9a1 1 0 0 0-.8-.5h-5Zm29 0a1 1 0 0 0-1 1v3h12v-3a1 1 0 0 0-1-1h-10Zm11 4v12h3a1 1 0 0 0 1-1V27a1 1 0 0 0-1-1h-3Zm0 12h-12v3a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-3Zm-12 0V26h-3a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h3Zm21-16a1 1 0 0 0-1 1v6a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-3h4v15a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V26h4v3a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-6a1 1 0 0 0-1-1h-18Zm27 0a1 1 0 0 0-1 1v18a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V29.4l5.5 12a1 1 0 0 0 1 .6h3a1 1 0 0 0 1-.6l5.5-12V41a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1V23a1 1 0 0 0-1-1h-3.4a1 1 0 0 0-.9.6l-6.7 14.6-6.7-14.6a1 1 0 0 0-1-.6h-3.3Zm32 0a1 1 0 0 0-1 1v3h15a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14Zm-1 4h-3a1 1 0 0 0-1 1v14a1 1 0 0 0 1 1h18a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-14a1 1 0 0 1-1-1v-3h7a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-7v-4Zm-38 12a1 1 0 0 0-1 1v2a1 1 0 0 0 1 1h2a1 1 0 0 0 1-1v-2a1 1 0 0 0-1-1h-2Z" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.1 KiB |