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 newline at end of file diff --git a/web/src/lib/userSecretToken.ts b/web/src/lib/userSecretToken.ts index f05e9ba..fa648a1 100644 --- a/web/src/lib/userSecretToken.ts +++ b/web/src/lib/userSecretToken.ts @@ -50,7 +50,7 @@ const USER_SECRET_TOKEN_DEV_USERS_REGEX = (() => { }[] const env = - // This file can also be called from faker.ts, where import.meta.env is not available + // This file can also be called from seed.ts, where import.meta.env is not available // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition (import.meta.env ? Object.fromEntries(specialUsersData.map(({ envToken }) => [envToken, import.meta.env[envToken]])) diff --git a/web/src/lib/zodUtils.ts b/web/src/lib/zodUtils.ts index 34cfa35..e1d1b95 100644 --- a/web/src/lib/zodUtils.ts +++ b/web/src/lib/zodUtils.ts @@ -16,8 +16,8 @@ export const zodCohercedNumber = (zodPipe?: ZodTypeAny) => export const zodUrlOptionalProtocol = z.preprocess( (input) => { if (typeof input !== 'string') return input - const trimmedVal = input.trim() - return !/^\w+:\/\//i.test(trimmedVal) ? `https://${trimmedVal}` : trimmedVal + const cleanInput = input.trim().replace(/\/$/, '') + return !/^\w+:\/\//i.test(cleanInput) ? `https://${cleanInput}` : cleanInput }, z.string().refine((value) => /^(https?):\/\/(?=.*\.[a-z]{2,})[^\s$.?#].[^\s]*$/i.test(value), { message: 'Invalid URL', diff --git a/web/src/pages/admin/services/[slug]/edit.astro b/web/src/pages/admin/services/[slug]/edit.astro index 2d744b1..fa4a563 100644 --- a/web/src/pages/admin/services/[slug]/edit.astro +++ b/web/src/pages/admin/services/[slug]/edit.astro @@ -1,5 +1,6 @@ --- import { Icon } from 'astro-icon/components' +import { Markdown } from 'astro-remote' import { actions, isInputError } from 'astro:actions' import { orderBy } from 'lodash-es' @@ -76,6 +77,15 @@ const verificationStepUpdateInputErrors = isInputError(verificationStepUpdateRes const verificationStepDeleteResult = Astro.getActionResult(actions.admin.verificationStep.delete) Astro.locals.banners.addIfSuccess(verificationStepDeleteResult, 'Verification step deleted successfully') +const internalNoteCreateResult = Astro.getActionResult(actions.admin.service.internalNote.add) +Astro.locals.banners.addIfSuccess(internalNoteCreateResult, 'Internal note added successfully') +const internalNoteInputErrors = isInputError(internalNoteCreateResult?.error) + ? internalNoteCreateResult.error.fields + : {} + +const internalNoteDeleteResult = Astro.getActionResult(actions.admin.service.internalNote.delete) +Astro.locals.banners.addIfSuccess(internalNoteDeleteResult, 'Internal note deleted successfully') + const [service, categories, attributes] = await Astro.locals.banners.tryMany([ [ 'Error fetching service', @@ -130,6 +140,20 @@ const [service, categories, attributes] = await Astro.locals.banners.tryMany([ label: 'asc', }, }, + internalNotes: { + include: { + addedByUser: { + select: { + name: true, + displayName: true, + picture: true, + }, + }, + }, + orderBy: { + createdAt: 'desc', + }, + }, _count: { select: { verificationRequests: true, @@ -290,13 +314,14 @@ if (!service) return Astro.rewrite('/404')
@@ -448,6 +473,108 @@ if (!service) return Astro.rewrite('/404') + + + { + service.internalNotes.length === 0 ? ( +

+ No internal notes yet. +

+ ) : ( +
+ {service.internalNotes.map((note) => ( +
+ + +
+
+
+ +
+
+ + {note.addedByUser && ( + + by + + )} +
+
+ +
+
+
+ +
+ ))} +
+ ) + } +
+ + +
+ + + + +
+
+ { @@ -898,7 +1025,7 @@ if (!service) return Astro.rewrite('/404')
@@ -919,7 +1046,7 @@ if (!service) return Astro.rewrite('/404') @@ -954,7 +1081,7 @@ if (!service) return Astro.rewrite('/404') - + diff --git a/web/src/pages/admin/services/index.astro b/web/src/pages/admin/services/index.astro index f5edf34..55242ca 100644 --- a/web/src/pages/admin/services/index.astro +++ b/web/src/pages/admin/services/index.astro @@ -111,6 +111,7 @@ const services = await Astro.locals.banners.try( _count: { select: { verificationRequests: true, + internalNotes: true, }, }, }, @@ -440,9 +441,18 @@ const truncate = (text: string, length: number) => {
- - {service._count.verificationRequests} - +
+ + {service._count.verificationRequests} + + + {service._count.internalNotes > 0 && ( + + + {service._count.internalNotes} + + )} +
{service.formattedDate} diff --git a/web/src/pages/admin/services/new.astro b/web/src/pages/admin/services/new.astro index e1c04ff..4de7d21 100644 --- a/web/src/pages/admin/services/new.astro +++ b/web/src/pages/admin/services/new.astro @@ -350,13 +350,13 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
- + { @@ -366,6 +366,26 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {} }
+
+ +
+