diff --git a/.cursor/rules/database.mdc b/.cursor/rules/database.mdc
index 0bca92b..ac47237 100644
--- a/.cursor/rules/database.mdc
+++ b/.cursor/rules/database.mdc
@@ -5,7 +5,7 @@ alwaysApply: false
---
- We use Prisma as ORM.
- Remember to check the prisma schema [schema.prisma](mdc:web/prisma/schema.prisma) when doing things related to the database.
-- After making changes to the [schema.prisma](mdc:web/prisma/schema.prisma) database or [faker.ts](mdc:web/scripts/faker.ts), you can run `npm run db-reset` (from `/web/` folder) [package.json](mdc:web/package.json).
+- 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 = {
@@ -52,4 +52,4 @@ const [user, services] = await Astro.locals.banners.tryMany([
],
])
```
-- When editing the database, remember to edit the db seeding file [faker.ts](mdc:web/scripts/faker.ts) to generate data for the new schema.
+- When editing the database, remember to edit the db seeding file [seed.ts](mdc:web/prisma/seed.ts) to generate data for the new schema.
diff --git a/README.md b/README.md
index 5889d61..d52344b 100644
--- a/README.md
+++ b/README.md
@@ -23,8 +23,7 @@ cd web
nvm install
npm i
cp -n .env.example .env
-npm run db-push
-npm run db-fill-clean
+npm run db-reset
```
Now open the [.env](web/.env) file and fill in the missing values.
diff --git a/web/README.md b/web/README.md
index 3d80ca6..ba13999 100644
--- a/web/README.md
+++ b/web/README.md
@@ -6,24 +6,23 @@
All commands are run from the root of the project, from a terminal:
-| Command | Action |
-| :------------------------ | :------------------------------------------------------------------- |
-| `nvm install` | Installs and uses the correct version of node |
-| `npm install` | Installs dependencies |
-| `npm run dev` | Starts local dev server at `localhost:4321` |
-| `npm run build` | Build your production site to `./dist/` |
-| `npm run preview` | Preview your build locally, before deploying |
-| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
-| `npm run astro -- --help` | Get help using the Astro CLI |
-| `npm run db-admin` | Runs Prisma Studio (database admin) |
-| `npm run db-gen` | Generates the Prisma client without running migrations |
-| `npm run db-push` | Updates the database schema with latest changes (development mode). |
-| `npm run db-fill` | Fills the database with fake data (development mode) |
-| `npm run db-fill-clean` | Cleans existing data and fills with new fake data (development mode) |
-| `npm run format` | Formats the code with Prettier |
-| `npm run lint` | Lints the code with ESLint |
-| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
+| Command | Action |
+| :------------------------ | :------------------------------------------------------------------ |
+| `nvm install` | Installs and uses the correct version of node |
+| `npm install` | Installs dependencies |
+| `npm run dev` | Starts local dev server at `localhost:4321` |
+| `npm run build` | Build your production site to `./dist/` |
+| `npm run preview` | Preview your build locally, before deploying |
+| `npm run astro ...` | Run CLI commands like `astro add`, `astro check` |
+| `npm run astro -- --help` | Get help using the Astro CLI |
+| `npm run db-admin` | Runs Prisma Studio (database admin) |
+| `npm run db-gen` | Generates the Prisma client without running migrations |
+| `npm run db-push` | Updates the database schema with latest changes (development mode). |
+| `npm run db-seed` | Seeds the database with fake data (development mode) |
+| `npm run format` | Formats the code with Prettier |
+| `npm run lint` | Lints the code with ESLint |
+| `npm run lint-fix` | Lints the code with ESLint and fixes the issues |
-> **Note**: `db-fill` and `db-fill-clean` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-fill -- --services=5` will add 5 fake services.
+> **Note**: `db-seed` support the `-- --services=n` flag, where n is the number of fake services to add. It defaults to 10. For example, `npm run db-seed -- --services=5` will add 5 fake services.
-> **Note**: `db-fill` and `db-fill-clean` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
+> **Note**: `db-seed` create default users with tokens: `admin`, `moderator`, `verified`, `normal` (override with `DEV_*****_USER_SECRET_TOKEN` env vars)
diff --git a/web/package.json b/web/package.json
index 355ba84..039123b 100644
--- a/web/package.json
+++ b/web/package.json
@@ -12,13 +12,15 @@
"db-push": "prisma migrate dev",
"db-triggers": "just import-triggers",
"db-update": "prisma migrate dev && just import-triggers",
- "db-reset": "prisma migrate reset -f && 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",
diff --git a/web/prisma/migrations/20250526094537_service_internal_notes/migration.sql b/web/prisma/migrations/20250526094537_service_internal_notes/migration.sql
new file mode 100644
index 0000000..9dc2a6a
--- /dev/null
+++ b/web/prisma/migrations/20250526094537_service_internal_notes/migration.sql
@@ -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;
diff --git a/web/prisma/schema.prisma b/web/prisma/schema.prisma
index c5f719a..032c036 100644
--- a/web/prisma/schema.prisma
+++ b/web/prisma/schema.prisma
@@ -377,6 +377,7 @@ model Service {
attributes ServiceAttribute[]
verificationSteps VerificationStep[]
suggestions ServiceSuggestion[]
+ internalNotes InternalServiceNote[] @relation("ServiceRecievedNotes")
onEventCreatedForServices NotificationPreferences[] @relation("onEventCreatedForServices")
onRootCommentCreatedForServices NotificationPreferences[] @relation("onRootCommentCreatedForServices")
@@ -442,6 +443,7 @@ model Attribute {
model InternalUserNote {
id Int @id @default(autoincrement())
+ /// Markdown
content String
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
@@ -456,6 +458,23 @@ model InternalUserNote {
@@index([createdAt])
}
+model InternalServiceNote {
+ id Int @id @default(autoincrement())
+ /// Markdown
+ content String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @default(now()) @updatedAt
+
+ service Service @relation("ServiceRecievedNotes", fields: [serviceId], references: [id], onDelete: Cascade)
+ serviceId Int
+ addedByUser User? @relation("UserAddedServiceNotes", fields: [addedByUserId], references: [id], onDelete: SetNull)
+ addedByUserId Int?
+
+ @@index([serviceId])
+ @@index([addedByUserId])
+ @@index([createdAt])
+}
+
model User {
id Int @id @default(autoincrement())
name String @unique
@@ -482,6 +501,7 @@ model User {
suggestionMessages ServiceSuggestionMessage[]
internalNotes InternalUserNote[] @relation("UserRecievedNotes")
addedInternalNotes InternalUserNote[] @relation("UserAddedNotes")
+ addedServiceNotes InternalServiceNote[] @relation("UserAddedServiceNotes")
verificationRequests ServiceVerificationRequest[]
notifications Notification[] @relation("NotificationOwner")
notificationPreferences NotificationPreferences?
diff --git a/web/scripts/faker.ts b/web/prisma/seed.ts
similarity index 76%
rename from web/scripts/faker.ts
rename to web/prisma/seed.ts
index b6ddcc0..2f21e55 100755
--- a/web/scripts/faker.ts
+++ b/web/prisma/seed.ts
@@ -1,4 +1,6 @@
import crypto from 'crypto'
+import { execSync } from 'node:child_process'
+import { parseArgs } from 'node:util'
import { faker } from '@faker-js/faker'
import {
@@ -10,12 +12,12 @@ import {
EventType,
PrismaClient,
ServiceSuggestionStatus,
- ServiceSuggestionType,
ServiceUserRole,
VerificationStatus,
type Prisma,
type User,
type ServiceVisibility,
+ ServiceSuggestionType,
} from '@prisma/client'
import { uniqBy } from 'lodash-es'
import { generateUsername } from 'unique-username-generator'
@@ -96,20 +98,6 @@ async function createAccount(preGeneratedToken?: string) {
return { token, user }
}
-// Parse command line arguments
-const args = process.argv.slice(2)
-const shouldCleanup = args.includes('--cleanup') || args.includes('-c')
-const onlyCleanup = args.includes('--only-cleanup') || args.includes('-oc')
-
-// Parse number of services from --services or -s flag
-const servicesArg = args.find((arg) => arg.startsWith('--services=') || arg.startsWith('-s='))
-const numServices = parseIntWithFallback(servicesArg?.split('=')[1], 100) // Default to 100 if not specified
-
-if (isNaN(numServices) || numServices < 1) {
- console.error('โ Invalid number of services specified. Must be a positive number.')
- process.exit(1)
-}
-
const prisma = new PrismaClient()
const generateFakeAttribute = () => {
@@ -648,7 +636,10 @@ const generateFakeService = (users: User[]) => {
},
verificationProofMd:
status === 'VERIFICATION_SUCCESS' || status === 'VERIFICATION_FAILED' ? faker.lorem.paragraphs() : null,
- referral: `?ref=${faker.string.alphanumeric(6)}`,
+ referral: faker.helpers.arrayElement([
+ `?ref=${faker.string.alphanumeric(6)}`,
+ `/ref/${faker.string.alphanumeric(6)}`,
+ ]),
acceptedCurrencies: faker.helpers.arrayElements(Object.values(Currency), { min: 1, max: 5 }),
serviceUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 3 } }),
tosUrls: faker.helpers.multiple(() => faker.internet.url(), { count: { min: 1, max: 2 } }),
@@ -667,6 +658,15 @@ const generateFakeService = (users: User[]) => {
tosReviewAt: faker.date.past(),
userSentiment: faker.helpers.maybe(() => generateFakeUserSentiment(), { probability: 0.8 }),
userSentimentAt: faker.date.recent(),
+ internalNotes: faker.helpers.maybe(
+ () => ({
+ create: {
+ content: faker.lorem.paragraph(),
+ addedByUserId: faker.helpers.arrayElement(users.filter((user) => user.admin)).id,
+ },
+ }),
+ { probability: 0.33 }
+ ),
} as const satisfies Prisma.ServiceCreateInput
}
@@ -1015,359 +1015,378 @@ const generateFakeAnnouncement = () => {
} as const satisfies Prisma.AnnouncementCreateInput
}
-async function runFaker() {
- await prisma.$transaction(
- async (tx) => {
- // ---- Clean up existing data if requested ----
- if (shouldCleanup || onlyCleanup) {
- console.info('๐งน Cleaning up existing data...')
+async function cleanup() {
+ console.info('๐งน Cleaning up existing data...')
- try {
- await tx.commentVote.deleteMany()
- await tx.karmaTransaction.deleteMany()
- await tx.comment.deleteMany()
- await tx.serviceAttribute.deleteMany()
- await tx.serviceContactMethod.deleteMany()
- await tx.event.deleteMany()
- await tx.verificationStep.deleteMany()
- await tx.serviceSuggestionMessage.deleteMany()
- await tx.serviceSuggestion.deleteMany()
- await tx.serviceVerificationRequest.deleteMany()
- await tx.service.deleteMany()
- await tx.attribute.deleteMany()
- await tx.category.deleteMany()
- await tx.internalUserNote.deleteMany()
- await tx.user.deleteMany()
- await tx.announcement.deleteMany()
- console.info('โ
Existing data cleaned up')
- } catch (error) {
- console.error('โ Error cleaning up data:', error)
- throw error
- }
- if (onlyCleanup) return
- }
-
- // ---- Get or create categories ----
- const categories = await Promise.all(
- categoriesToCreate.map(async (cat) => {
- const existing = await tx.category.findUnique({
- where: { name: cat.name },
- })
- if (existing) return existing
-
- return await tx.category.create({
- data: cat,
- })
- })
- )
-
- // ---- Create users ----
- const specialUsersUntyped = Object.fromEntries(
- await Promise.all(
- Object.entries(specialUsersData).map(async ([key, userData]) => {
- const secretToken = process.env[userData.envToken] ?? userData.defaultToken
- const secretTokenHash = hashUserSecretToken(secretToken)
-
- const { envToken, defaultToken, ...userCreateData } = userData
- const user = await tx.user.create({
- data: {
- notificationPreferences: { create: {} },
- ...userCreateData,
- secretTokenHash,
- },
- })
-
- console.info(`โ
Created ${user.name} with secret token "${secretToken}"`)
-
- return [key, user] as const
- })
- )
- )
-
- const specialUsers = specialUsersUntyped as {
- [K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
- }
-
- let users = await Promise.all(
- Array.from({ length: 10 }, async () => {
- const { user } = await createAccount()
- return user
- })
- )
-
- // ---- Create attributes ----
- const attributes = await Promise.all(
- Array.from({ length: 16 }, async () => {
- return await tx.attribute.create({
- data: generateFakeAttribute(),
- })
- })
- )
-
- // ---- Create services ----
- const services = await Promise.all(
- Array.from({ length: numServices }, async () => {
- const serviceData = generateFakeService(users)
- const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
-
- const service = await tx.service.create({
- data: {
- ...serviceData,
- categories: {
- connect: randomCategories.map((cat) => ({ id: cat.id })),
- },
- },
- })
-
- // Create contact methods for each service
- await Promise.all(
- Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
- tx.serviceContactMethod.create({
- data: generateFakeServiceContactMethod(service.id),
- })
- )
- )
-
- // Link random attributes to the service
- await Promise.all(
- faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
- tx.serviceAttribute.create({
- data: {
- serviceId: service.id,
- attributeId: attr.id,
- },
- })
- )
- )
-
- // Create events for the service
- await Promise.all(
- Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
- tx.event.create({
- data: generateFakeEvent(service.id),
- })
- )
- )
-
- return service
- })
- )
-
- // ---- Create service user affiliations for the service ----
- await Promise.all(
- users
- .filter((user) => user.verified)
- .map(async (user) => {
- const servicesToAddAffiliations = uniqBy(
- faker.helpers.arrayElements(services, {
- min: 1,
- max: 3,
- }),
- 'id'
- )
-
- return tx.user.update({
- where: { id: user.id },
- data: {
- serviceAffiliations: {
- createMany: {
- data: servicesToAddAffiliations.map((service) => ({
- role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
- serviceId: service.id,
- })),
- },
- },
- },
- })
- })
- )
-
- users = await tx.user.findMany({
- include: {
- serviceAffiliations: true,
- },
- })
-
- // ---- Create comments and replies ----
- await Promise.all(
- services.map(async (service) => {
- // Create parent comments
- const commentCount = faker.number.int({ min: 1, max: 10 })
- const commentData = Array.from({ length: commentCount }, () =>
- generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
- )
- const indexesToUpdate = users.map((user) => {
- return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
- })
- commentData.forEach((comment, index) => {
- if (indexesToUpdate.includes(index)) comment.ratingActive = true
- })
-
- await tx.comment.createMany({
- data: commentData,
- })
-
- const comments = await tx.comment.findMany({
- where: {
- serviceId: service.id,
- parentId: null,
- },
- orderBy: {
- createdAt: 'desc',
- },
- take: commentCount,
- })
-
- const affiliatedUsers = undefinedIfEmpty(
- users.filter((user) =>
- user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
- )
- )
-
- // Create replies to comments
- await Promise.all(
- comments.map(async (comment) => {
- const replyCount = faker.number.int({ min: 0, max: 3 })
- return Promise.all(
- Array.from({ length: replyCount }, () => {
- const user = faker.helpers.arrayElement(
- faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
- )
-
- return tx.comment.create({
- data: generateFakeComment(user.id, service.id, comment.id),
- })
- })
- )
- })
- )
- })
- )
-
- // ---- Create service suggestions for normal_dev user ----
- // First create 3 CREATE_SERVICE suggestions with their services
- for (let i = 0; i < 3; i++) {
- const serviceData = generateFakeService(users)
- const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
-
- const service = await tx.service.create({
- data: {
- ...serviceData,
- verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
- categories: {
- connect: randomCategories.map((cat) => ({ id: cat.id })),
- },
- },
- })
-
- const serviceSuggestion = await tx.serviceSuggestion.create({
- data: generateFakeServiceSuggestion({
- type: ServiceSuggestionType.CREATE_SERVICE,
- userId: specialUsers.normal.id,
- serviceId: service.id,
- }),
- })
-
- // Create some messages for each suggestion
- await Promise.all(
- Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
- tx.serviceSuggestionMessage.create({
- data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
- specialUsers.normal.id,
- specialUsers.admin.id,
- ]),
- })
- )
- )
- }
-
- // Then create 5 EDIT_SERVICE suggestions
- await Promise.all(
- services.slice(0, 5).map(async (service) => {
- const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
- const suggestion = await tx.serviceSuggestion.create({
- data: generateFakeServiceSuggestion({
- type: ServiceSuggestionType.EDIT_SERVICE,
- status,
- userId: specialUsers.normal.id,
- serviceId: service.id,
- }),
- })
-
- // Create some messages for each suggestion
- await Promise.all(
- Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
- tx.serviceSuggestionMessage.create({
- data: generateFakeServiceSuggestionMessage(suggestion.id, [
- specialUsers.normal.id,
- specialUsers.admin.id,
- ]),
- })
- )
- )
- })
- )
-
- // ---- Create internal notes for users ----
- await Promise.all(
- users.map(async (user) => {
- // Create 1-3 notes for each user
- const numNotes = faker.number.int({ min: 1, max: 3 })
- return Promise.all(
- Array.from({ length: numNotes }, () =>
- tx.internalUserNote.create({
- data: generateFakeInternalNote(
- user.id,
- faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
- ),
- })
- )
- )
- })
- )
-
- // Add some notes to special users as well
- await Promise.all(
- Object.values(specialUsers).map(async (user) => {
- const numNotes = faker.number.int({ min: 1, max: 3 })
- return Promise.all(
- Array.from({ length: numNotes }, () =>
- tx.internalUserNote.create({
- data: generateFakeInternalNote(
- user.id,
- faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
- ),
- })
- )
- )
- })
- )
-
- // ---- Create announcement ----
- await tx.announcement.create({
- data: generateFakeAnnouncement(),
- })
- },
- {
- timeout: 1000 * 60 * 10, // 10 minutes
- }
- )
-}
-
-async function main() {
try {
- await runFaker()
-
- console.info('โ
Fake data generated successfully')
+ await prisma.commentVote.deleteMany()
+ await prisma.karmaTransaction.deleteMany()
+ await prisma.comment.deleteMany()
+ await prisma.serviceAttribute.deleteMany()
+ await prisma.serviceContactMethod.deleteMany()
+ await prisma.event.deleteMany()
+ await prisma.verificationStep.deleteMany()
+ await prisma.serviceSuggestionMessage.deleteMany()
+ await prisma.serviceSuggestion.deleteMany()
+ await prisma.serviceVerificationRequest.deleteMany()
+ await prisma.service.deleteMany()
+ await prisma.attribute.deleteMany()
+ await prisma.category.deleteMany()
+ await prisma.internalUserNote.deleteMany()
+ await prisma.user.deleteMany()
+ await prisma.announcement.deleteMany()
+ console.info('โ
Existing data cleaned up')
} catch (error) {
- console.error('โ Error generating fake data:', error)
- process.exit(1)
- } finally {
- await prisma.$disconnect()
+ console.error('โ Error cleaning up data:', error)
+ throw error
}
}
-main().catch((error: unknown) => {
- console.error('โ Fatal error:', error)
- process.exit(1)
-})
+function importTriggers() {
+ console.info('๐ Importing SQL triggers...')
+ try {
+ execSync('just import-triggers', { stdio: 'inherit' })
+ console.info('โ
Triggers imported')
+ } catch (error) {
+ console.error('โ Error importing triggers:', error)
+ throw error
+ }
+}
+
+async function main() {
+ const { values: options } = parseArgs({
+ options: {
+ services: { type: 'string', short: 's', default: '100' },
+ cleanup: { type: 'boolean', short: 'c', default: true },
+ 'only-cleanup': { type: 'boolean', short: 'o', default: false },
+ },
+ })
+ const numServices = parseIntWithFallback(options.services, 100)
+ if (isNaN(numServices) || numServices < 1) {
+ console.error('โ Invalid number of services specified. Must be a positive number.')
+ process.exit(1)
+ }
+
+ importTriggers()
+
+ if (options.cleanup || options['only-cleanup']) {
+ await cleanup()
+ if (options['only-cleanup']) return
+ }
+
+ // ---- Get or create categories ----
+ const categories = await Promise.all(
+ categoriesToCreate.map(async (cat) => {
+ const existing = await prisma.category.findUnique({
+ where: { name: cat.name },
+ })
+ if (existing) return existing
+
+ return await prisma.category.create({
+ data: cat,
+ })
+ })
+ )
+
+ // ---- Create users ----
+ const specialUsersUntyped = Object.fromEntries(
+ await Promise.all(
+ Object.entries(specialUsersData).map(async ([key, userData]) => {
+ const secretToken = process.env[userData.envToken] ?? userData.defaultToken
+ const secretTokenHash = hashUserSecretToken(secretToken)
+
+ const { envToken, defaultToken, ...userCreateData } = userData
+ const user = await prisma.user.create({
+ data: {
+ notificationPreferences: { create: {} },
+ ...userCreateData,
+ secretTokenHash,
+ },
+ })
+
+ console.info(`โ
Created ${user.name} with secret token "${secretToken}"`)
+
+ return [key, user] as const
+ })
+ )
+ )
+
+ const specialUsers = specialUsersUntyped as {
+ [K in keyof typeof specialUsersData]: (typeof specialUsersUntyped)[K]
+ }
+
+ let users = await Promise.all(
+ Array.from({ length: 10 }, async () => {
+ const { user } = await createAccount()
+ return user
+ })
+ )
+
+ // ---- Create attributes ----
+ const attributes = await Promise.all(
+ Array.from({ length: 16 }, async () => {
+ return await prisma.attribute.create({
+ data: generateFakeAttribute(),
+ })
+ })
+ )
+
+ // ---- Create services ----
+ const services = await Promise.all(
+ Array.from({ length: numServices }, async () => {
+ const serviceData = generateFakeService(users)
+ const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
+
+ const service = await prisma.service.create({
+ data: {
+ ...serviceData,
+ categories: {
+ connect: randomCategories.map((cat) => ({ id: cat.id })),
+ },
+ },
+ })
+
+ // Create contact methods for each service
+ await Promise.all(
+ Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
+ prisma.serviceContactMethod.create({
+ data: generateFakeServiceContactMethod(service.id),
+ })
+ )
+ )
+
+ // Link random attributes to the service
+ await Promise.all(
+ faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
+ prisma.serviceAttribute.create({
+ data: {
+ serviceId: service.id,
+ attributeId: attr.id,
+ },
+ })
+ )
+ )
+
+ // Create events for the service
+ await Promise.all(
+ Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
+ prisma.event.create({
+ data: generateFakeEvent(service.id),
+ })
+ )
+ )
+
+ return service
+ })
+ )
+
+ // ---- Create service user affiliations for the service ----
+ await Promise.all(
+ users
+ .filter((user) => user.verified)
+ .map(async (user) => {
+ const servicesToAddAffiliations = uniqBy(
+ faker.helpers.arrayElements(services, {
+ min: 1,
+ max: 3,
+ }),
+ 'id'
+ )
+
+ return prisma.user.update({
+ where: { id: user.id },
+ data: {
+ serviceAffiliations: {
+ createMany: {
+ data: servicesToAddAffiliations.map((service) => ({
+ role: faker.helpers.arrayElement(Object.values(ServiceUserRole)),
+ serviceId: service.id,
+ })),
+ },
+ },
+ },
+ })
+ })
+ )
+
+ users = await prisma.user.findMany({
+ include: {
+ serviceAffiliations: true,
+ },
+ })
+
+ // ---- Create comments and replies ----
+ await Promise.all(
+ services.map(async (service) => {
+ // Create parent comments
+ const commentCount = faker.number.int({ min: 1, max: 10 })
+ const commentData = Array.from({ length: commentCount }, () =>
+ generateFakeComment(faker.helpers.arrayElement(users).id, service.id)
+ )
+ const indexesToUpdate = users.map((user) => {
+ return commentData.findIndex((comment) => comment.authorId === user.id && comment.rating !== null)
+ })
+ commentData.forEach((comment, index) => {
+ if (indexesToUpdate.includes(index)) comment.ratingActive = true
+ })
+
+ await prisma.comment.createMany({
+ data: commentData,
+ })
+
+ const comments = await prisma.comment.findMany({
+ where: {
+ serviceId: service.id,
+ parentId: null,
+ },
+ orderBy: {
+ createdAt: 'desc',
+ },
+ take: commentCount,
+ })
+
+ const affiliatedUsers = undefinedIfEmpty(
+ users.filter((user) =>
+ user.serviceAffiliations.some((affiliation) => affiliation.serviceId === service.id)
+ )
+ )
+
+ // Create replies to comments
+ await Promise.all(
+ comments.map(async (comment) => {
+ const replyCount = faker.number.int({ min: 0, max: 3 })
+ return Promise.all(
+ Array.from({ length: replyCount }, () => {
+ const user = faker.helpers.arrayElement(
+ faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
+ )
+
+ return prisma.comment.create({
+ data: generateFakeComment(user.id, service.id, comment.id),
+ })
+ })
+ )
+ })
+ )
+ })
+ )
+
+ // ---- Create service suggestions for normal_dev user ----
+ // First create 3 CREATE_SERVICE suggestions with their services
+ for (let i = 0; i < 3; i++) {
+ const serviceData = generateFakeService(users)
+ const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
+
+ const service = await prisma.service.create({
+ data: {
+ ...serviceData,
+ verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
+ categories: {
+ connect: randomCategories.map((cat) => ({ id: cat.id })),
+ },
+ },
+ })
+
+ const serviceSuggestion = await prisma.serviceSuggestion.create({
+ data: generateFakeServiceSuggestion({
+ type: ServiceSuggestionType.CREATE_SERVICE,
+ userId: specialUsers.normal.id,
+ serviceId: service.id,
+ }),
+ })
+
+ // Create some messages for each suggestion
+ await Promise.all(
+ Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
+ prisma.serviceSuggestionMessage.create({
+ data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
+ specialUsers.normal.id,
+ specialUsers.admin.id,
+ ]),
+ })
+ )
+ )
+ }
+
+ // Then create 5 EDIT_SERVICE suggestions
+ await Promise.all(
+ services.slice(0, 5).map(async (service) => {
+ const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
+ const suggestion = await prisma.serviceSuggestion.create({
+ data: generateFakeServiceSuggestion({
+ type: ServiceSuggestionType.EDIT_SERVICE,
+ status,
+ userId: specialUsers.normal.id,
+ serviceId: service.id,
+ }),
+ })
+
+ // Create some messages for each suggestion
+ await Promise.all(
+ Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
+ prisma.serviceSuggestionMessage.create({
+ data: generateFakeServiceSuggestionMessage(suggestion.id, [
+ specialUsers.normal.id,
+ specialUsers.admin.id,
+ ]),
+ })
+ )
+ )
+ })
+ )
+
+ // ---- Create internal notes for users ----
+ await Promise.all(
+ users.map(async (user) => {
+ // Create 1-3 notes for each user
+ const numNotes = faker.number.int({ min: 1, max: 3 })
+ return Promise.all(
+ Array.from({ length: numNotes }, () =>
+ prisma.internalUserNote.create({
+ data: generateFakeInternalNote(
+ user.id,
+ faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
+ ),
+ })
+ )
+ )
+ })
+ )
+
+ // Add some notes to special users as well
+ await Promise.all(
+ Object.values(specialUsers).map(async (user) => {
+ const numNotes = faker.number.int({ min: 1, max: 3 })
+ return Promise.all(
+ Array.from({ length: numNotes }, () =>
+ prisma.internalUserNote.create({
+ data: generateFakeInternalNote(
+ user.id,
+ faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
+ ),
+ })
+ )
+ )
+ })
+ )
+
+ // ---- Create announcement ----
+ await prisma.announcement.create({
+ data: generateFakeAnnouncement(),
+ })
+}
+
+main()
+ .then(async () => {
+ console.info('โ
Fake data generated successfully')
+ await prisma.$disconnect()
+ })
+ .catch(async (error: unknown) => {
+ console.error(
+ 'โ Fatal error:',
+ typeof error === 'object' && error !== null && 'message' in error ? error.message : 'Unknown error'
+ )
+ console.error(error)
+ await prisma.$disconnect()
+ process.exit(1)
+ })
diff --git a/web/src/actions/admin/service.ts b/web/src/actions/admin/service.ts
index 8c196e3..a504dca 100644
--- a/web/src/actions/admin/service.ts
+++ b/web/src/actions/admin/service.ts
@@ -31,10 +31,16 @@ const serviceSchemaBase = z.object({
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),
+ referral: z
+ .string()
+ .regex(/^(\?\w+=.|\/.+)/, 'Referral must be a valid URL parameter or path, not a full URL')
+ .optional()
+ .nullable()
+ .default(null),
imageFile: imageFileSchema,
overallScore: zodCohercedNumber(z.number().int().min(0).max(10)).optional(),
serviceVisibility: z.nativeEnum(ServiceVisibility),
+ internalNote: z.string().optional(),
})
const addSlugIfMissing = <
@@ -61,7 +67,7 @@ export const adminServiceActions = {
accept: 'form',
permissions: 'admin',
input: serviceSchemaBase.omit({ id: true }).transform(addSlugIfMissing),
- handler: async (input) => {
+ handler: async (input, context) => {
const existing = await prisma.service.findUnique({
where: {
slug: input.slug,
@@ -75,12 +81,26 @@ 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 service = await prisma.service.create({
data: {
- ...serviceData,
+ name: input.name,
+ description: input.description,
+ serviceUrls: input.serviceUrls,
+ tosUrls: input.tosUrls,
+ onionUrls: input.onionUrls,
+ kycLevel: input.kycLevel,
+ verificationStatus: input.verificationStatus,
+ verificationSummary: input.verificationSummary,
+ verificationProofMd: input.verificationProofMd,
+ acceptedCurrencies: input.acceptedCurrencies,
+ referral: input.referral,
+ serviceVisibility: input.serviceVisibility,
+ slug: input.slug,
+ overallScore: input.overallScore,
categories: {
connect: input.categories.map((id) => ({ id })),
},
@@ -92,6 +112,14 @@ export const adminServiceActions = {
})),
},
imageUrl,
+ internalNotes: input.internalNote
+ ? {
+ create: {
+ content: input.internalNote,
+ addedByUserId: context.locals.user.id,
+ },
+ }
+ : undefined,
},
select: {
id: true,
@@ -112,31 +140,28 @@ export const adminServiceActions = {
})
.transform(addSlugIfMissing),
handler: async (input) => {
- const { id, categories, attributes, imageFile, removeImage, ...data } = input
-
- const existing = await prisma.service.findUnique({
+ 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 = removeImage
+ const imageUrl = input.removeImage
? null
- : imageFile
- ? await saveFileLocally(imageFile, imageFile.name)
+ : input.imageFile
+ ? await saveFileLocally(input.imageFile, input.imageFile.name)
: undefined
- // Get existing attributes and categories to compute differences
const existingService = await prisma.service.findUnique({
- where: { id },
+ where: { id: input.id },
include: {
categories: true,
attributes: {
@@ -154,96 +179,191 @@ 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 service = await prisma.service.update({
- where: { id },
+ where: { id: input.id },
data: {
- ...data,
+ name: input.name,
+ description: input.description,
+ serviceUrls: input.serviceUrls,
+ tosUrls: input.tosUrls,
+ onionUrls: input.onionUrls,
+ kycLevel: input.kycLevel,
+ verificationStatus: input.verificationStatus,
+ verificationSummary: input.verificationSummary,
+ verificationProofMd: input.verificationProofMd,
+ acceptedCurrencies: input.acceptedCurrencies,
+ referral: input.referral,
+ serviceVisibility: input.serviceVisibility,
+ slug: input.slug,
+ overallScore: input.overallScore,
+
imageUrl,
categories: {
connect: categoriesToAdd.map((id) => ({ id })),
disconnect: categoriesToRemove.map((id) => ({ id })),
},
attributes: {
- // Connect new attributes
create: attributesToAdd.map((attributeId) => ({
attribute: {
connect: { id: attributeId },
},
})),
- // Delete specific attributes that are no longer needed
+
deleteMany: attributesToRemove.map((attributeId) => ({
attributeId,
})),
},
},
})
+
return { service }
},
}),
- createContactMethod: defineProtectedAction({
- accept: 'form',
- permissions: 'admin',
- input: z.object({
- label: z.string().min(1).max(50).nullable(),
- value: z.string().url(),
- serviceId: z.number().int().positive(),
+ contactMethod: {
+ add: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ label: z.string().min(1).max(50).nullable(),
+ value: z.string().url(),
+ serviceId: z.number().int().positive(),
+ }),
+ handler: async (input) => {
+ const contactMethod = await prisma.serviceContactMethod.create({
+ data: {
+ label: input.label,
+ value: input.value,
+ serviceId: input.serviceId,
+ },
+ })
+ return { contactMethod }
+ },
}),
- handler: async (input) => {
- const contactMethod = await prisma.serviceContactMethod.create({
- data: {
- label: input.label,
- value: input.value,
- serviceId: input.serviceId,
- },
- })
- return { contactMethod }
- },
- }),
- updateContactMethod: defineProtectedAction({
- accept: 'form',
- permissions: 'admin',
- input: z.object({
- id: z.number().int().positive(),
- label: z.string().min(1).max(50).nullable(),
- value: z.string().url(),
- serviceId: z.number().int().positive(),
+ update: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ id: z.number().int().positive(),
+ label: z.string().min(1).max(50).nullable(),
+ value: z.string().url(),
+ serviceId: z.number().int().positive(),
+ }),
+ handler: async (input) => {
+ const contactMethod = await prisma.serviceContactMethod.update({
+ where: { id: input.id },
+ data: {
+ label: input.label,
+ value: input.value,
+ serviceId: input.serviceId,
+ },
+ })
+ return { contactMethod }
+ },
}),
- handler: async (input) => {
- const contactMethod = await prisma.serviceContactMethod.update({
- where: { id: input.id },
- data: {
- label: input.label,
- value: input.value,
- serviceId: input.serviceId,
- },
- })
- return { contactMethod }
- },
- }),
- deleteContactMethod: defineProtectedAction({
- accept: 'form',
- permissions: 'admin',
- input: z.object({
- id: z.number().int().positive(),
+ delete: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ id: z.number().int().positive(),
+ }),
+ handler: async (input) => {
+ await prisma.serviceContactMethod.delete({
+ where: { id: input.id },
+ })
+ return { success: true }
+ },
}),
- handler: async (input) => {
- await prisma.serviceContactMethod.delete({
- where: { id: input.id },
- })
- return { success: true }
- },
- }),
+ },
+
+ internalNote: {
+ add: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ serviceId: z.number().int().positive(),
+ content: z.string().min(1),
+ }),
+ handler: async (input, { locals }) => {
+ const service = await prisma.service.findUnique({
+ where: { id: input.serviceId },
+ })
+
+ if (!service) {
+ throw new ActionError({
+ code: 'NOT_FOUND',
+ message: 'Service not found',
+ })
+ }
+
+ await prisma.internalServiceNote.create({
+ data: {
+ content: input.content,
+ serviceId: input.serviceId,
+ addedByUserId: locals.user.id,
+ },
+ })
+ },
+ }),
+
+ update: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ noteId: z.number().int().positive(),
+ content: z.string().min(1),
+ }),
+ handler: async (input) => {
+ const note = await prisma.internalServiceNote.findUnique({
+ where: { id: input.noteId },
+ })
+
+ if (!note) {
+ throw new ActionError({
+ code: 'NOT_FOUND',
+ message: 'Note not found',
+ })
+ }
+
+ await prisma.internalServiceNote.update({
+ where: { id: input.noteId },
+ data: { content: input.content },
+ })
+ },
+ }),
+
+ delete: defineProtectedAction({
+ accept: 'form',
+ permissions: 'admin',
+ input: z.object({
+ noteId: z.number().int().positive(),
+ }),
+ handler: async (input) => {
+ const note = await prisma.internalServiceNote.findUnique({
+ where: { id: input.noteId },
+ })
+
+ if (!note) {
+ throw new ActionError({
+ code: 'NOT_FOUND',
+ message: 'Note not found',
+ })
+ }
+
+ await prisma.internalServiceNote.delete({
+ where: { id: input.noteId },
+ })
+ },
+ }),
+ },
}
diff --git a/web/src/components/InputCardGroup.astro b/web/src/components/InputCardGroup.astro
index fbf6819..6072249 100644
--- a/web/src/components/InputCardGroup.astro
+++ b/web/src/components/InputCardGroup.astro
@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)}
>
+
+ No internal notes yet. +
+ ) : ( +{inputErrors.internalNote.join(', ')}
+ ) + } +