Release 202505261445

This commit is contained in:
pluja
2025-05-26 14:45:22 +00:00
parent f2021a3027
commit ba809840c6
19 changed files with 910 additions and 486 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -7,7 +7,7 @@
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` |
@@ -18,12 +18,11 @@ All commands are run from the root of the project, from a terminal:
| `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 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)

View File

@@ -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",

View File

@@ -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;

View File

@@ -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?

View File

@@ -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,47 +1015,74 @@ const generateFakeAnnouncement = () => {
} as const satisfies Prisma.AnnouncementCreateInput
}
async function runFaker() {
await prisma.$transaction(
async (tx) => {
// ---- Clean up existing data if requested ----
if (shouldCleanup || onlyCleanup) {
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()
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 cleaning up data:', error)
throw error
}
if (onlyCleanup) return
}
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 tx.category.findUnique({
const existing = await prisma.category.findUnique({
where: { name: cat.name },
})
if (existing) return existing
return await tx.category.create({
return await prisma.category.create({
data: cat,
})
})
@@ -1069,7 +1096,7 @@ async function runFaker() {
const secretTokenHash = hashUserSecretToken(secretToken)
const { envToken, defaultToken, ...userCreateData } = userData
const user = await tx.user.create({
const user = await prisma.user.create({
data: {
notificationPreferences: { create: {} },
...userCreateData,
@@ -1098,7 +1125,7 @@ async function runFaker() {
// ---- Create attributes ----
const attributes = await Promise.all(
Array.from({ length: 16 }, async () => {
return await tx.attribute.create({
return await prisma.attribute.create({
data: generateFakeAttribute(),
})
})
@@ -1110,7 +1137,7 @@ async function runFaker() {
const serviceData = generateFakeService(users)
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await tx.service.create({
const service = await prisma.service.create({
data: {
...serviceData,
categories: {
@@ -1122,7 +1149,7 @@ async function runFaker() {
// Create contact methods for each service
await Promise.all(
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
tx.serviceContactMethod.create({
prisma.serviceContactMethod.create({
data: generateFakeServiceContactMethod(service.id),
})
)
@@ -1131,7 +1158,7 @@ async function runFaker() {
// Link random attributes to the service
await Promise.all(
faker.helpers.arrayElements(attributes, { min: 2, max: 5 }).map((attr) =>
tx.serviceAttribute.create({
prisma.serviceAttribute.create({
data: {
serviceId: service.id,
attributeId: attr.id,
@@ -1143,7 +1170,7 @@ async function runFaker() {
// Create events for the service
await Promise.all(
Array.from({ length: faker.number.int({ min: 0, max: 5 }) }, () =>
tx.event.create({
prisma.event.create({
data: generateFakeEvent(service.id),
})
)
@@ -1166,7 +1193,7 @@ async function runFaker() {
'id'
)
return tx.user.update({
return prisma.user.update({
where: { id: user.id },
data: {
serviceAffiliations: {
@@ -1182,7 +1209,7 @@ async function runFaker() {
})
)
users = await tx.user.findMany({
users = await prisma.user.findMany({
include: {
serviceAffiliations: true,
},
@@ -1203,11 +1230,11 @@ async function runFaker() {
if (indexesToUpdate.includes(index)) comment.ratingActive = true
})
await tx.comment.createMany({
await prisma.comment.createMany({
data: commentData,
})
const comments = await tx.comment.findMany({
const comments = await prisma.comment.findMany({
where: {
serviceId: service.id,
parentId: null,
@@ -1234,7 +1261,7 @@ async function runFaker() {
faker.helpers.maybe(() => affiliatedUsers, { probability: 0.3 }) ?? users
)
return tx.comment.create({
return prisma.comment.create({
data: generateFakeComment(user.id, service.id, comment.id),
})
})
@@ -1250,7 +1277,7 @@ async function runFaker() {
const serviceData = generateFakeService(users)
const randomCategories = faker.helpers.arrayElements(categories, { min: 1, max: 3 })
const service = await tx.service.create({
const service = await prisma.service.create({
data: {
...serviceData,
verificationStatus: VerificationStatus.COMMUNITY_CONTRIBUTED,
@@ -1260,7 +1287,7 @@ async function runFaker() {
},
})
const serviceSuggestion = await tx.serviceSuggestion.create({
const serviceSuggestion = await prisma.serviceSuggestion.create({
data: generateFakeServiceSuggestion({
type: ServiceSuggestionType.CREATE_SERVICE,
userId: specialUsers.normal.id,
@@ -1271,7 +1298,7 @@ async function runFaker() {
// Create some messages for each suggestion
await Promise.all(
Array.from({ length: faker.number.int({ min: 1, max: 3 }) }, () =>
tx.serviceSuggestionMessage.create({
prisma.serviceSuggestionMessage.create({
data: generateFakeServiceSuggestionMessage(serviceSuggestion.id, [
specialUsers.normal.id,
specialUsers.admin.id,
@@ -1285,7 +1312,7 @@ async function runFaker() {
await Promise.all(
services.slice(0, 5).map(async (service) => {
const status = faker.helpers.arrayElement(Object.values(ServiceSuggestionStatus))
const suggestion = await tx.serviceSuggestion.create({
const suggestion = await prisma.serviceSuggestion.create({
data: generateFakeServiceSuggestion({
type: ServiceSuggestionType.EDIT_SERVICE,
status,
@@ -1297,7 +1324,7 @@ async function runFaker() {
// Create some messages for each suggestion
await Promise.all(
Array.from({ length: faker.number.int({ min: 0, max: 3 }) }, () =>
tx.serviceSuggestionMessage.create({
prisma.serviceSuggestionMessage.create({
data: generateFakeServiceSuggestionMessage(suggestion.id, [
specialUsers.normal.id,
specialUsers.admin.id,
@@ -1315,7 +1342,7 @@ async function runFaker() {
const numNotes = faker.number.int({ min: 1, max: 3 })
return Promise.all(
Array.from({ length: numNotes }, () =>
tx.internalUserNote.create({
prisma.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
@@ -1332,7 +1359,7 @@ async function runFaker() {
const numNotes = faker.number.int({ min: 1, max: 3 })
return Promise.all(
Array.from({ length: numNotes }, () =>
tx.internalUserNote.create({
prisma.internalUserNote.create({
data: generateFakeInternalNote(
user.id,
faker.helpers.arrayElement([specialUsers.admin.id, specialUsers.moderator.id])
@@ -1344,30 +1371,22 @@ async function runFaker() {
)
// ---- Create announcement ----
await tx.announcement.create({
await prisma.announcement.create({
data: generateFakeAnnouncement(),
})
},
{
timeout: 1000 * 60 * 10, // 10 minutes
}
)
}
async function main() {
try {
await runFaker()
main()
.then(async () => {
console.info('✅ Fake data generated successfully')
} catch (error) {
console.error('❌ Error generating fake data:', error)
process.exit(1)
} finally {
await prisma.$disconnect()
}
}
main().catch((error: unknown) => {
console.error('❌ Fatal error:', error)
})
.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)
})

View File

@@ -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,44 +179,57 @@ 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({
contactMethod: {
add: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
@@ -211,7 +249,7 @@ export const adminServiceActions = {
},
}),
updateContactMethod: defineProtectedAction({
update: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
@@ -233,7 +271,7 @@ export const adminServiceActions = {
},
}),
deleteContactMethod: defineProtectedAction({
delete: defineProtectedAction({
accept: 'form',
permissions: 'admin',
input: z.object({
@@ -246,4 +284,86 @@ export const adminServiceActions = {
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 },
})
},
}),
},
}

View File

@@ -70,7 +70,7 @@ const hasError = !!wrapperProps.error && wrapperProps.error.length > 0
)}
>
<input
transition:persist={option.noTransitionPersist ? undefined : true}
transition:persist={option.noTransitionPersist || !multiple ? undefined : true}
type={multiple ? 'checkbox' : 'radio'}
name={wrapperProps.name}
value={option.value}

4
web/src/icons/nostr.svg Normal file
View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 620 620">
<path
d="M620 270v328c0 12-10 22-22 22H332c-12 0-22-10-22-22v-61c1-75 9-147 26-179 9-20 26-30 44-36 36-11 98-4 124-5 0 0 80 3 80-42 0-37-36-34-36-34-39 1-69-1-88-9-33-13-34-36-34-44-1-91-134-102-252-79-128 24 2 209 2 456v33c0 12-10 22-22 22H22c-12 0-22-10-22-22V31C0 19 10 9 22 9h124c12 0 22 10 22 22 0 19 20 29 35 18C248 17 305 0 369 0c143 0 251 84 251 270Zm-238-66c0-27-21-48-47-48s-47 21-47 48c0 26 21 48 47 48s47-22 47-48Z" />
</svg>

After

Width:  |  Height:  |  Size: 526 B

View File

@@ -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]]))

View File

@@ -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',

View File

@@ -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')
</div>
<InputText
label="Referral Code/Link"
label="Referral link path"
name="referral"
inputProps={{
value: service.referral ?? undefined,
placeholder: 'e.g., REFCODE123 or https://example.com?ref=123',
value: service.referral,
placeholder: 'e.g. ?ref=123 or /ref/123',
}}
error={serviceInputErrors.referral}
description="Will be appended to the service URL"
/>
<div class="flex items-center justify-between gap-2">
@@ -448,6 +473,108 @@ if (!service) return Astro.rewrite('/404')
</form>
</FormSection>
<FormSection title="Internal Notes" id="internal-notes">
<FormSubSection title="Existing Notes">
{
service.internalNotes.length === 0 ? (
<p class="border-night-600 bg-night-800 text-day-300 rounded-xl border p-6 text-center">
No internal notes yet.
</p>
) : (
<div class="space-y-4">
{service.internalNotes.map((note) => (
<div class="border-night-600 bg-night-800 rounded-md border p-4">
<input
type="checkbox"
class="peer/edit-note sr-only"
data-edit-note-checkbox
id={`edit-note-${note.id}`}
/>
<div class="flex items-start justify-between">
<div class="flex-grow space-y-1">
<div
data-note-content
class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty"
>
<Markdown content={note.content} />
</div>
<div class="text-day-500 flex items-center gap-2 text-xs">
<TimeFormatted date={note.createdAt} hourPrecision />
{note.addedByUser && (
<span class="flex items-center gap-1">
by <UserBadge user={note.addedByUser} size="sm" />
</span>
)}
</div>
</div>
<div class="flex items-center gap-1">
<Button
as="label"
for={`edit-note-${note.id}`}
variant="faded"
size="sm"
icon="ri:edit-line"
iconOnly
label="Edit"
/>
<form method="POST" action={actions.admin.service.internalNote.delete} class="contents">
<input type="hidden" name="noteId" value={note.id} />
<Button
type="submit"
size="sm"
variant="faded"
icon="ri:delete-bin-line"
iconOnly
label="Delete"
/>
</form>
</div>
</div>
<form
method="POST"
action={actions.admin.service.internalNote.update}
data-note-edit-form
data-astro-reload
class="mt-4 hidden space-y-4 peer-checked/edit-note:block"
>
<input type="hidden" name="noteId" value={note.id} />
<InputTextArea
label="Note Content"
name="content"
value={note.content}
inputProps={{ class: 'bg-night-700' }}
/>
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</div>
))}
</div>
)
}
</FormSubSection>
<FormSubSection title="Add New Note">
<form method="POST" action={actions.admin.service.internalNote.add} class="space-y-4">
<input type="hidden" name="serviceId" value={service.id} />
<InputTextArea
label="Note Content"
name="content"
inputProps={{
required: true,
rows: 4,
placeholder: 'Add internal note about this service...',
}}
error={internalNoteInputErrors.content}
/>
<InputSubmitButton label="Add Note" icon="ri:add-line" hideCancel />
</form>
</FormSubSection>
</FormSection>
<FormSection title="Events">
<FormSubSection title="Existing Events">
{
@@ -898,7 +1025,7 @@ if (!service) return Astro.rewrite('/404')
<Tooltip text="Delete">
<form
method="POST"
action={actions.admin.service.deleteContactMethod}
action={actions.admin.service.contactMethod.delete}
class="contents"
>
<input type="hidden" name="id" value={method.id} />
@@ -919,7 +1046,7 @@ if (!service) return Astro.rewrite('/404')
<form
id={`edit-contact-method-${method.id}`}
method="POST"
action={actions.admin.service.updateContactMethod}
action={actions.admin.service.contactMethod.update}
class="border-night-500 bg-night-700 mt-3 hidden space-y-3 rounded-md border p-3"
>
<input type="hidden" name="id" value={method.id} />
@@ -954,7 +1081,7 @@ if (!service) return Astro.rewrite('/404')
</FormSubSection>
<FormSubSection title="Add New Contact Method">
<form method="POST" action={actions.admin.service.createContactMethod} class="space-y-2">
<form method="POST" action={actions.admin.service.contactMethod.add} class="space-y-2">
<input type="hidden" name="serviceId" value={service.id} />
<InputText label="Override Label" name="label" />

View File

@@ -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) => {
</div>
</td>
<td class="px-4 py-3 text-center">
<div class="flex flex-col items-center justify-center gap-1">
<span class="inline-flex items-center rounded-full bg-orange-900/30 px-2.5 py-0.5 text-xs text-orange-400">
{service._count.verificationRequests}
</span>
{service._count.internalNotes > 0 && (
<span class="inline-flex items-center text-purple-400">
<Icon name="ri:sticky-note-line" class="mr-0.5 size-4" />
{service._count.internalNotes}
</span>
)}
</div>
</td>
<td class="px-4 py-3 text-center text-xs text-zinc-400">{service.formattedDate}</td>
<td class="px-4 py-3 text-right">

View File

@@ -350,13 +350,13 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
</div>
<div>
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral</label>
<label class="font-title mb-2 block text-sm text-green-500" for="referral">referral link path</label>
<input
transition:persist
type="text"
name="referral"
id="referral"
placeholder="Optional referral code/link"
placeholder="e.g. ?ref=123 or /ref/123"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
/>
{
@@ -366,6 +366,26 @@ const inputErrors = isInputError(result?.error) ? result.error.fields : {}
}
</div>
<div>
<label for="internalNote" class="font-title mb-2 block text-sm text-green-500">internalNote</label>
<div class="space-y-2">
<textarea
transition:persist
name="internalNote"
id="internalNote"
rows={4}
placeholder="Markdown supported"
class="font-title w-full rounded-md border border-green-500/30 bg-black/50 p-2 text-gray-300 placeholder-gray-500 focus:border-green-500 focus:ring-green-500"
set:text=""
/>
</div>
{
inputErrors.internalNote && (
<p class="font-title mt-1 text-sm text-red-500">{inputErrors.internalNote.join(', ')}</p>
)
}
</div>
<button
type="submit"
class="font-title inline-flex justify-center rounded-md border border-green-500/30 bg-green-500/10 px-4 py-2 text-sm text-green-400 shadow-xs transition-colors duration-200 hover:bg-green-500/20 focus:ring-2 focus:ring-green-500 focus:ring-offset-2 focus:ring-offset-black focus:outline-hidden"

View File

@@ -1,5 +1,6 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions, isInputError } from 'astro:actions'
import BadgeSmall from '../../../components/BadgeSmall.astro'
@@ -304,8 +305,8 @@ if (!user) return Astro.rewrite('/404')
</div>
</div>
<div data-note-content>
<p class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
<div data-note-content class="prose prose-sm text-day-200 prose-invert max-w-none text-pretty">
<Markdown content={note.content} />
</div>
<form

View File

@@ -53,6 +53,7 @@ const service = await Astro.locals.banners.try(
icon: true,
},
},
serviceVisibility: true,
},
where: { id: params.serviceId },
}),
@@ -101,7 +102,7 @@ if (!service) return Astro.rewrite('/404')
error={inputErrors.notes}
/>
<Captcha action={actions.serviceSuggestion.createService} />
<Captcha action={actions.serviceSuggestion.editService} />
<InputHoneypotTrap name="message" />

View File

@@ -16,6 +16,8 @@ import DropdownButton from '../../components/DropdownButton.astro'
import DropdownButtonItemForm from '../../components/DropdownButtonItemForm.astro'
import DropdownButtonItemLink from '../../components/DropdownButtonItemLink.astro'
import FormatTimeInterval from '../../components/FormatTimeInterval.astro'
import InputSubmitButton from '../../components/InputSubmitButton.astro'
import InputTextArea from '../../components/InputTextArea.astro'
import MyPicture from '../../components/MyPicture.astro'
import { makeOgImageUrl, type OgImageAllTemplatesWithProps } from '../../components/OgImage'
import ScoreGauge from '../../components/ScoreGauge.astro'
@@ -23,6 +25,7 @@ import ScoreSquare from '../../components/ScoreSquare.astro'
import ServiceLinkButton from '../../components/ServiceLinkButton.astro'
import TimeFormatted from '../../components/TimeFormatted.astro'
import Tooltip from '../../components/Tooltip.astro'
import UserBadge from '../../components/UserBadge.astro'
import VerificationWarningBanner from '../../components/VerificationWarningBanner.astro'
import { getAttributeCategoryInfo } from '../../constants/attributeCategories'
import { getAttributeTypeInfo } from '../../constants/attributeTypes'
@@ -159,6 +162,21 @@ const [service, dbNotificationPreferences] = await Astro.locals.banners.tryMany(
updatedAt: true,
},
},
internalNotes: {
select: {
id: true,
content: true,
createdAt: true,
addedByUser: {
select: {
name: true,
displayName: true,
picture: true,
},
},
},
orderBy: { createdAt: 'desc' },
},
_count: {
select: {
comments: {
@@ -749,6 +767,61 @@ const serviceVisibilityInfo = getServiceVisibilityInfo(service.serviceVisibility
)
}
<AdminOnly>
<div class="border-day-500 mt-6 mb-3 flex items-center justify-between border-b">
<h2 class="font-title text-day-100 text-lg font-bold">Internal Notes</h2>
<a
href={`/admin/services/${service.slug}/edit#internal-notes`}
class="text-day-500 hover:text-day-200 inline-flex items-center gap-1 text-xs leading-none transition-colors hover:underline"
>
Manage
<Icon name="ri:arrow-right-s-line" class="size-4" />
</a>
</div>
{
service.internalNotes.length === 0 ? (
<p class="text-day-400 mt-2 text-center text-xs">No internal notes yet.</p>
) : (
<div
class={cn(
'grid grid-cols-1 items-start gap-2',
service.internalNotes.length > 1 && 'sm:grid-cols-2'
)}
>
{service.internalNotes.map((note) => (
<div class="border-night-600 bg-night-800 rounded-lg border p-3">
<div class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty">
<Markdown content={note.content} />
</div>
<div class="text-day-500 mt-2 flex items-center gap-1 text-xs">
<TimeFormatted date={note.createdAt} hourPrecision />
{note.addedByUser && (
<span class="flex items-center gap-1">
by <UserBadge user={note.addedByUser} size="sm" />
</span>
)}
</div>
</div>
))}
</div>
)
}
<form
method="POST"
action={actions.admin.service.internalNote.add}
data-note-edit-form
class="mt-4 space-y-4"
>
<input type="hidden" name="serviceId" value={service.id} />
<InputTextArea label="Add a note" name="content" />
<InputSubmitButton label="Save" icon="ri:save-line" hideCancel />
</form>
</AdminOnly>
<h2 class="font-title border-day-500 text-day-200 mt-6 mb-3 border-b text-lg font-bold" id="scores">
Scores
</h2>

View File

@@ -1,5 +1,6 @@
---
import { Icon } from 'astro-icon/components'
import { Markdown } from 'astro-remote'
import { actions } from 'astro:actions'
import { sortBy } from 'lodash-es'
@@ -525,7 +526,9 @@ const isCurrentUser = !!Astro.locals.user && user.id === Astro.locals.user.id
<TimeFormatted date={note.createdAt} hourPrecision />
</span>
</div>
<div class="text-day-200 wrap-anywhere whitespace-pre-wrap" set:text={note.content} />
<div class="prose text-day-200 prose-sm prose-invert max-w-none text-pretty">
<Markdown content={note.content} />
</div>
</div>
))}
</div>