@igstack/app-catalog-backend-core 6.3.1-alpha-20261003051755 → 6.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/db/syncAppCatalog.mjs +1 -0
- package/dist/db/syncAppCatalog.mjs.map +1 -1
- package/dist/generated/prisma/client.mjs.map +1 -1
- package/dist/generated/prisma/commonInputTypes.d.mts +72 -72
- package/dist/generated/prisma/commonInputTypes.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.d.mts +17 -5
- package/dist/generated/prisma/internal/class.d.mts.map +1 -1
- package/dist/generated/prisma/internal/class.mjs +4 -4
- package/dist/generated/prisma/internal/class.mjs.map +1 -1
- package/dist/generated/prisma/internal/prismaNamespace.d.mts +145 -51
- package/dist/generated/prisma/internal/prismaNamespace.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbApprovalMethod.d.mts +30 -3
- package/dist/generated/prisma/models/DbApprovalMethod.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbAsset.d.mts +2 -11
- package/dist/generated/prisma/models/DbAsset.d.mts.map +1 -1
- package/dist/generated/prisma/models/DbFeedback.d.mts +1565 -0
- package/dist/generated/prisma/models/DbFeedback.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts +1431 -0
- package/dist/generated/prisma/models/DbFeedbackAttachment.d.mts.map +1 -0
- package/dist/generated/prisma/models/DbResource.d.mts +108 -60
- package/dist/generated/prisma/models/DbResource.d.mts.map +1 -1
- package/dist/generated/prisma/models/SourceReference.d.mts +1 -1
- package/dist/generated/prisma/models.d.mts +4 -3
- package/dist/index.d.mts +5 -3
- package/dist/index.mjs +5 -3
- package/dist/middleware/createAcMiddleware.mjs +2 -2
- package/dist/middleware/createAcMiddleware.mjs.map +1 -1
- package/dist/middleware/featureRegistry.mjs +3 -1
- package/dist/middleware/featureRegistry.mjs.map +1 -1
- package/dist/modules/appCatalog/service.d.mts.map +1 -1
- package/dist/modules/appCatalog/service.mjs +1 -0
- package/dist/modules/appCatalog/service.mjs.map +1 -1
- package/dist/modules/feedback/feedbackAttachmentController.d.mts +22 -0
- package/dist/modules/feedback/feedbackAttachmentController.d.mts.map +1 -0
- package/dist/modules/feedback/feedbackAttachmentController.mjs +192 -0
- package/dist/modules/feedback/feedbackAttachmentController.mjs.map +1 -0
- package/dist/modules/feedback/feedbackRouter.d.mts +54 -0
- package/dist/modules/feedback/feedbackRouter.d.mts.map +1 -0
- package/dist/modules/feedback/feedbackRouter.mjs +59 -0
- package/dist/modules/feedback/feedbackRouter.mjs.map +1 -0
- package/dist/modules/feedback/service.d.mts +42 -0
- package/dist/modules/feedback/service.d.mts.map +1 -0
- package/dist/modules/feedback/service.mjs +165 -0
- package/dist/modules/feedback/service.mjs.map +1 -0
- package/dist/modules/visitor/visitorIdentity.d.mts +86 -0
- package/dist/modules/visitor/visitorIdentity.d.mts.map +1 -0
- package/dist/modules/{comments → visitor}/visitorIdentity.mjs +53 -2
- package/dist/modules/visitor/visitorIdentity.mjs.map +1 -0
- package/dist/server/acTrpcContext.d.mts +8 -7
- package/dist/server/acTrpcContext.d.mts.map +1 -1
- package/dist/server/acTrpcContext.mjs +2 -2
- package/dist/server/acTrpcContext.mjs.map +1 -1
- package/dist/server/controller.d.mts +15 -8
- package/dist/server/controller.d.mts.map +1 -1
- package/dist/server/controller.mjs +2 -2
- package/dist/server/controller.mjs.map +1 -1
- package/dist/types/common/appCatalogTypes.d.mts +12 -0
- package/dist/types/common/appCatalogTypes.d.mts.map +1 -1
- package/dist/types/common/approvalMethodTypes.d.mts +12 -0
- package/dist/types/common/approvalMethodTypes.d.mts.map +1 -1
- package/package.json +5 -5
- package/prisma/migrations/20261004000000_feedback_replaces_comments/migration.sql +74 -0
- package/prisma/schema.prisma +84 -24
- package/src/__tests__/feedbackAttachmentUpload.test.ts +192 -0
- package/src/__tests__/{comments.test.ts → visitorIdentity.test.ts} +1 -1
- package/src/db/syncAppCatalog.ts +1 -0
- package/src/generated/prisma/browser.ts +22 -7
- package/src/generated/prisma/client.ts +22 -7
- package/src/generated/prisma/commonInputTypes.ts +85 -85
- package/src/generated/prisma/internal/class.ts +18 -8
- package/src/generated/prisma/internal/prismaNamespace.ts +143 -47
- package/src/generated/prisma/internal/prismaNamespaceBrowser.ts +24 -3
- package/src/generated/prisma/models/DbApprovalMethod.ts +29 -1
- package/src/generated/prisma/models/DbAsset.ts +0 -12
- package/src/generated/prisma/models/DbFeedback.ts +1814 -0
- package/src/generated/prisma/models/DbFeedbackAttachment.ts +1661 -0
- package/src/generated/prisma/models/DbResource.ts +107 -58
- package/src/generated/prisma/models.ts +2 -1
- package/src/index.ts +24 -4
- package/src/middleware/createAcMiddleware.ts +4 -3
- package/src/middleware/featureRegistry.ts +7 -0
- package/src/modules/appCatalog/service.ts +1 -0
- package/src/modules/feedback/feedbackAttachmentController.ts +248 -0
- package/src/modules/feedback/feedbackRouter.ts +102 -0
- package/src/modules/feedback/service.ts +295 -0
- package/src/modules/{comments → visitor}/visitorIdentity.ts +84 -0
- package/src/server/acTrpcContext.ts +9 -8
- package/src/server/controller.ts +2 -2
- package/src/types/common/appCatalogTypes.ts +12 -0
- package/src/types/common/approvalMethodTypes.ts +12 -0
- package/dist/generated/prisma/models/DbComment.d.mts +0 -1340
- package/dist/generated/prisma/models/DbComment.d.mts.map +0 -1
- package/dist/modules/comments/commentsRouter.d.mts +0 -47
- package/dist/modules/comments/commentsRouter.d.mts.map +0 -1
- package/dist/modules/comments/commentsRouter.mjs +0 -55
- package/dist/modules/comments/commentsRouter.mjs.map +0 -1
- package/dist/modules/comments/service.d.mts +0 -40
- package/dist/modules/comments/service.d.mts.map +0 -1
- package/dist/modules/comments/service.mjs +0 -88
- package/dist/modules/comments/service.mjs.map +0 -1
- package/dist/modules/comments/visitorIdentity.d.mts +0 -15
- package/dist/modules/comments/visitorIdentity.d.mts.map +0 -1
- package/dist/modules/comments/visitorIdentity.mjs.map +0 -1
- package/src/generated/prisma/models/DbComment.ts +0 -1561
- package/src/modules/comments/commentsRouter.ts +0 -85
- package/src/modules/comments/service.ts +0 -174
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/appCatalog/service.ts"],"sourcesContent":["import { getDbClient } from '../../db/client'\nimport type { Prisma } from '../../generated/prisma/client'\nimport type {\n AppApprovalMethod,\n AppCatalogData,\n AppCategory,\n GroupingTagDefinition,\n Resource,\n SourceCheck,\n} from '../../types/common/appCatalogTypes'\nimport type { ServiceConfig } from '../../types/common/approvalMethodTypes'\nimport type { Group, Person } from '../../types/common/personGroupTypes'\nimport { omit } from 'radashi'\nimport { parseSourceSlug } from '../../utils/parseSourceSlug'\nimport { computeFreshness } from './freshness'\nimport { computeSourcePulse } from './sourcePulse'\n\n/** Prisma query result for DbResource with sourceRefs included (used by rowToResource) */\ntype ResourceRowWithSourceRefs = Prisma.DbResourceGetPayload<{\n include: { sourceRefs: true }\n}>\n\nfunction capitalize(word: string): string {\n if (!word) return word\n return word.charAt(0).toUpperCase() + word.slice(1)\n}\n\nexport async function getGroupingTagDefinitionsFromPrisma(): Promise<\n GroupingTagDefinition[]\n> {\n const prisma = getDbClient()\n\n // Fetch all tag definitions\n const rows = await prisma.dbAppTagDefinition.findMany()\n\n // Fetch all resources to count tag usage\n const resources = await prisma.dbResource.findMany({\n select: { tags: true },\n })\n\n // Count tag values across all resources\n const tagCounts = new Map<string, Map<string, number>>()\n\n for (const resource of resources) {\n const tags = (resource.tags as unknown as string[] | null) ?? []\n for (const tag of tags) {\n const [prefix, value] = tag.split(':')\n if (prefix && value) {\n if (!tagCounts.has(prefix)) {\n tagCounts.set(prefix, new Map())\n }\n const prefixCounts = tagCounts.get(prefix)!\n prefixCounts.set(value, (prefixCounts.get(value) ?? 0) + 1)\n }\n }\n }\n\n // Sort values by count and return definitions\n return rows.map((row) => {\n const definition = omit(row, ['id', 'updatedAt', 'createdAt'])\n const counts = tagCounts.get(definition.prefix) ?? new Map()\n\n // Sort values by count (descending)\n const sortedValues = [...definition.values].sort((a, b) => {\n const countA = counts.get(a.value) ?? 0\n const countB = counts.get(b.value) ?? 0\n return countB - countA\n })\n\n return {\n ...definition,\n values: sortedValues,\n }\n })\n}\n\nexport async function getApprovalMethodsFromPrisma(): Promise<\n AppApprovalMethod[]\n> {\n const prisma = getDbClient()\n\n // Fetch all approval methods\n const rows = await prisma.dbApprovalMethod.findMany()\n\n return rows.map((row) => {\n // Handle discriminated union by explicitly narrowing based on type\n const baseFields = {\n slug: row.slug,\n displayName: row.displayName,\n }\n\n // Provide default empty config if null, as AppApprovalMethod discriminated union requires config\n const config = row.config ?? {}\n\n switch (row.type) {\n case 'service':\n return {\n ...baseFields,\n type: 'service' as const,\n config: config as ServiceConfig,\n }\n case 'custom':\n return {\n ...baseFields,\n type: 'custom' as const,\n config: config,\n }\n case 'noAccessRequired':\n return {\n ...baseFields,\n type: 'noAccessRequired' as const,\n config: config,\n }\n case 'unknown':\n return {\n ...baseFields,\n type: 'unknown' as const,\n config: config,\n }\n case 'personTeam':\n // Legacy: map personTeam to custom\n return {\n ...baseFields,\n type: 'custom' as const,\n config: config,\n }\n }\n })\n}\n\nfunction rowToResource(row: ResourceRowWithSourceRefs): Resource {\n const accessRequest =\n row.accessRequest as unknown as Resource['accessRequest']\n const teams = (row.teams as unknown as string[] | null) ?? []\n const tags = (row.tags as unknown as Resource['tags']) ?? []\n const screenshotIds =\n (row.screenshotIds as unknown as Resource['screenshotIds']) ?? []\n const sources = row.sourceRefs.map((ref) => ({\n sourceSlug: ref.sourceSlug,\n url: ref.url,\n parseDate: ref.parseDate ? ref.parseDate.toISOString() : null,\n // Derived here rather than shipped raw so the pulse mark can stay a dumb\n // renderer, the same way `freshness` is computed for the entry below.\n pulse: computeSourcePulse({\n lastCheckedAt: ref.lastCheckedAt?.toISOString() ?? null,\n nextCheckAfter: ref.nextCheckAfter?.toISOString() ?? null,\n lastContentChangeAt: ref.lastContentChangeAt?.toISOString() ?? null,\n checkIntervalHours: ref.checkIntervalHours,\n changeHistory: ref.changeHistory as SourceCheck[] | null,\n }),\n }))\n const notes = row.notes == null ? undefined : row.notes\n const background = row.background == null ? undefined : row.background\n const appUrl = row.appUrl == null ? undefined : row.appUrl\n const iconName = row.iconName == null ? undefined : row.iconName\n const abbreviation = row.abbreviation == null ? undefined : row.abbreviation\n const nicknames = (row.nicknames as unknown as string[] | null)?.length\n ? row.nicknames\n : undefined\n const deprecated =\n row.deprecated == null\n ? undefined\n : (row.deprecated as unknown as Resource['deprecated'])\n const aiPrompt = row.aiPrompt == null ? undefined : row.aiPrompt\n const aiMemory = row.aiMemory == null ? undefined : row.aiMemory\n const urlIssues = (row.urlIssues as unknown as string[] | null)?.length\n ? row.urlIssues\n : undefined\n const tiers =\n row.tiers == null ? undefined : (row.tiers as unknown as Resource['tiers'])\n const quickJumps =\n row.quickJumps == null\n ? undefined\n : (row.quickJumps as unknown as Resource['quickJumps'])\n const freshness = computeFreshness({\n lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,\n nextCheckAfter: row.nextCheckAfter\n ? row.nextCheckAfter.toISOString()\n : null,\n lastContentChangeAt: row.lastContentChangeAt\n ? row.lastContentChangeAt.toISOString()\n : null,\n })\n\n return {\n id: row.id,\n slug: row.slug,\n type: row.type,\n displayName: row.displayName,\n abbreviation,\n nicknames,\n description: row.description,\n accessRequest,\n teams,\n notes,\n background,\n tags,\n appUrl,\n iconName,\n screenshotIds,\n sources,\n deprecated,\n aiPrompt,\n aiMemory,\n urlIssues,\n tiers,\n quickJumps,\n // Fields from former SubResource\n parentSlug: row.parentSlug ?? undefined,\n tier: row.tier ?? undefined,\n aliases: row.aliases.length ? row.aliases : undefined,\n ownerPersonSlug: row.ownerPersonSlug ?? undefined,\n approverSlugs: row.approverSlugs.length ? row.approverSlugs : undefined,\n accessComments: row.accessComments ?? undefined,\n extra: row.extra ? row.extra : undefined,\n // Only surface freshness once the app has actually been scanned.\n freshness: freshness.lastCheckedAt ? freshness : undefined,\n createdAt: row.createdAt.toISOString(),\n }\n}\n\nexport async function getResourcesFromPrisma(): Promise<Resource[]> {\n const prisma = getDbClient()\n\n const rows = await prisma.dbResource.findMany({\n include: {\n sourceRefs: true,\n },\n })\n\n return rows.map(rowToResource)\n}\n\n/** @deprecated Use getResourcesFromPrisma instead */\nexport const getAppsFromPrisma = getResourcesFromPrisma\n\nexport interface UpdateAppInput {\n id: string\n data: {\n displayName?: string\n abbreviation?: string | null\n slug?: string\n appUrl?: string\n description?: string\n sources?: string[]\n aiPrompt?: string | null\n }\n}\n\nexport async function updateApp(input: UpdateAppInput): Promise<Resource> {\n const prisma = getDbClient()\n const { id, data } = input\n\n const updatePayload: {\n displayName?: string\n abbreviation?: string | null\n slug?: string\n appUrl?: string\n description?: string\n aiPrompt?: string | null\n } = {}\n if (data.displayName !== undefined)\n updatePayload.displayName = data.displayName\n if (data.abbreviation !== undefined)\n updatePayload.abbreviation = data.abbreviation\n if (data.slug !== undefined) updatePayload.slug = data.slug\n if (data.appUrl !== undefined) updatePayload.appUrl = data.appUrl\n if (data.description !== undefined)\n updatePayload.description = data.description\n if (data.aiPrompt !== undefined) updatePayload.aiPrompt = data.aiPrompt\n\n const updated = await prisma.dbResource.update({\n where: { id },\n data: updatePayload,\n include: { sourceRefs: true },\n })\n\n if (data.sources !== undefined) {\n const urls = [...new Set(data.sources.map((u) => u.trim()).filter(Boolean))]\n const uniqueSourceSlugs = [...new Set(urls.map(parseSourceSlug))]\n\n for (const sourceSlug of uniqueSourceSlugs) {\n await prisma.source.upsert({\n where: { slug: sourceSlug },\n create: { slug: sourceSlug },\n update: {},\n })\n }\n\n await prisma.sourceReference.deleteMany({\n where: { resourceId: updated.id },\n })\n\n if (urls.length > 0) {\n await prisma.sourceReference.createMany({\n data: urls.map((url) => ({\n resourceId: updated.id,\n sourceSlug: parseSourceSlug(url),\n url: url.trim(),\n })),\n })\n }\n\n const refetched = await prisma.dbResource.findUnique({\n where: { id },\n include: { sourceRefs: true },\n })\n if (!refetched) throw new Error('Resource not found after update')\n return rowToResource(refetched)\n }\n\n return rowToResource(updated)\n}\n\nexport interface UpdateAiMemoryInput {\n /** Resource id (DB id, not slug) */\n id: string\n /** New value for aiMemory. Pass null to clear. */\n aiMemory: string | null\n}\n\n/**\n * Updates the AI-owned scratchpad for a resource.\n * This mutation is intentionally separate from updateApp so AI agents can\n * write knowledge without a full MR cycle. It does NOT touch aiPrompt.\n */\nexport async function updateAiMemory(\n input: UpdateAiMemoryInput,\n): Promise<Resource> {\n const prisma = getDbClient()\n const updated = await prisma.dbResource.update({\n where: { id: input.id },\n data: { aiMemory: input.aiMemory },\n include: { sourceRefs: true },\n })\n return rowToResource(updated)\n}\n\nexport function deriveCategories(resources: Resource[]): AppCategory[] {\n const tagSet = new Set<string>()\n for (const resource of resources) {\n for (const tag of resource.tags ?? []) {\n const normalized = tag.trim().toLowerCase()\n if (normalized) tagSet.add(normalized)\n }\n }\n const categories: AppCategory[] = [{ id: 'all', name: 'All' }]\n for (const tag of Array.from(tagSet).sort()) {\n categories.push({ id: tag, name: capitalize(tag) })\n }\n return categories\n}\n\nexport async function getPersonsFromPrisma(): Promise<Person[]> {\n const prisma = getDbClient()\n const rows = await prisma.dbPerson.findMany()\n return rows.map((row) => ({\n slug: row.slug,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email ?? undefined,\n }))\n}\n\nexport async function getGroupsFromPrisma(): Promise<Group[]> {\n const prisma = getDbClient()\n const rows = await prisma.dbGroup.findMany({\n include: { memberships: true },\n })\n return rows.map((row) => ({\n slug: row.slug,\n displayName: row.displayName ?? undefined,\n email: row.email ?? undefined,\n memberSlugs: row.memberships.map((m) => m.personSlug),\n }))\n}\n\nexport async function getAppCatalogData(\n getResourcesOptional?: () => Promise<Resource[]>,\n): Promise<AppCatalogData> {\n const resources = getResourcesOptional\n ? await getResourcesOptional()\n : await getResourcesFromPrisma()\n\n return {\n resources,\n tagsDefinitions: await getGroupingTagDefinitionsFromPrisma(),\n approvalMethods: await getApprovalMethodsFromPrisma(),\n persons: await getPersonsFromPrisma(),\n groups: await getGroupsFromPrisma(),\n }\n}\n"],"mappings":";;;;;;;AA2BA,eAAsB,sCAEpB;CACA,MAAM,SAAS,aAAa;CAG5B,MAAM,OAAO,MAAM,OAAO,mBAAmB,UAAU;CAGvD,MAAM,YAAY,MAAM,OAAO,WAAW,SAAS,EACjD,QAAQ,EAAE,MAAM,MAAM,EACvB,CAAC;CAGF,MAAM,4BAAY,IAAI,KAAkC;AAExD,MAAK,MAAM,YAAY,WAAW;EAChC,MAAM,OAAQ,SAAS,QAAuC,EAAE;AAChE,OAAK,MAAM,OAAO,MAAM;GACtB,MAAM,CAAC,QAAQ,SAAS,IAAI,MAAM,IAAI;AACtC,OAAI,UAAU,OAAO;AACnB,QAAI,CAAC,UAAU,IAAI,OAAO,CACxB,WAAU,IAAI,wBAAQ,IAAI,KAAK,CAAC;IAElC,MAAM,eAAe,UAAU,IAAI,OAAO;AAC1C,iBAAa,IAAI,QAAQ,aAAa,IAAI,MAAM,IAAI,KAAK,EAAE;;;;AAMjE,QAAO,KAAK,KAAK,QAAQ;EACvB,MAAM,aAAa,KAAK,KAAK;GAAC;GAAM;GAAa;GAAY,CAAC;EAC9D,MAAM,SAAS,UAAU,IAAI,WAAW,OAAO,oBAAI,IAAI,KAAK;EAG5D,MAAM,eAAe,CAAC,GAAG,WAAW,OAAO,CAAC,MAAM,GAAG,MAAM;GACzD,MAAM,SAAS,OAAO,IAAI,EAAE,MAAM,IAAI;AAEtC,WADe,OAAO,IAAI,EAAE,MAAM,IAAI,KACtB;IAChB;AAEF,SAAO;GACL,GAAG;GACH,QAAQ;GACT;GACD;;AAGJ,eAAsB,+BAEpB;AAMA,SAFa,MAHE,aAAa,CAGF,iBAAiB,UAAU,EAEzC,KAAK,QAAQ;EAEvB,MAAM,aAAa;GACjB,MAAM,IAAI;GACV,aAAa,IAAI;GAClB;EAGD,MAAM,SAAS,IAAI,UAAU,EAAE;AAE/B,UAAQ,IAAI,MAAZ;GACE,KAAK,UACH,QAAO;IACL,GAAG;IACH,MAAM;IACE;IACT;GACH,KAAK,SACH,QAAO;IACL,GAAG;IACH,MAAM;IACE;IACT;GACH,KAAK,mBACH,QAAO;IACL,GAAG;IACH,MAAM;IACE;IACT;GACH,KAAK,UACH,QAAO;IACL,GAAG;IACH,MAAM;IACE;IACT;GACH,KAAK,aAEH,QAAO;IACL,GAAG;IACH,MAAM;IACE;IACT;;GAEL;;AAGJ,SAAS,cAAc,KAA0C;CAC/D,MAAM,gBACJ,IAAI;CACN,MAAM,QAAS,IAAI,SAAwC,EAAE;CAC7D,MAAM,OAAQ,IAAI,QAAwC,EAAE;CAC5D,MAAM,gBACH,IAAI,iBAA0D,EAAE;CACnE,MAAM,UAAU,IAAI,WAAW,KAAK,SAAS;EAC3C,YAAY,IAAI;EAChB,KAAK,IAAI;EACT,WAAW,IAAI,YAAY,IAAI,UAAU,aAAa,GAAG;EAGzD,OAAO,mBAAmB;GACxB,eAAe,IAAI,eAAe,aAAa,IAAI;GACnD,gBAAgB,IAAI,gBAAgB,aAAa,IAAI;GACrD,qBAAqB,IAAI,qBAAqB,aAAa,IAAI;GAC/D,oBAAoB,IAAI;GACxB,eAAe,IAAI;GACpB,CAAC;EACH,EAAE;CACH,MAAM,QAAQ,IAAI,SAAS,OAAO,SAAY,IAAI;CAClD,MAAM,aAAa,IAAI,cAAc,OAAO,SAAY,IAAI;CAC5D,MAAM,SAAS,IAAI,UAAU,OAAO,SAAY,IAAI;CACpD,MAAM,WAAW,IAAI,YAAY,OAAO,SAAY,IAAI;CACxD,MAAM,eAAe,IAAI,gBAAgB,OAAO,SAAY,IAAI;CAChE,MAAM,YAAa,IAAI,WAA0C,SAC7D,IAAI,YACJ;CACJ,MAAM,aACJ,IAAI,cAAc,OACd,SACC,IAAI;CACX,MAAM,WAAW,IAAI,YAAY,OAAO,SAAY,IAAI;CACxD,MAAM,WAAW,IAAI,YAAY,OAAO,SAAY,IAAI;CACxD,MAAM,YAAa,IAAI,WAA0C,SAC7D,IAAI,YACJ;CACJ,MAAM,QACJ,IAAI,SAAS,OAAO,SAAa,IAAI;CACvC,MAAM,aACJ,IAAI,cAAc,OACd,SACC,IAAI;CACX,MAAM,YAAY,iBAAiB;EACjC,eAAe,IAAI,gBAAgB,IAAI,cAAc,aAAa,GAAG;EACrE,gBAAgB,IAAI,iBAChB,IAAI,eAAe,aAAa,GAChC;EACJ,qBAAqB,IAAI,sBACrB,IAAI,oBAAoB,aAAa,GACrC;EACL,CAAC;AAEF,QAAO;EACL,IAAI,IAAI;EACR,MAAM,IAAI;EACV,MAAM,IAAI;EACV,aAAa,IAAI;EACjB;EACA;EACA,aAAa,IAAI;EACjB;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA,YAAY,IAAI,cAAc;EAC9B,MAAM,IAAI,QAAQ;EAClB,SAAS,IAAI,QAAQ,SAAS,IAAI,UAAU;EAC5C,iBAAiB,IAAI,mBAAmB;EACxC,eAAe,IAAI,cAAc,SAAS,IAAI,gBAAgB;EAC9D,gBAAgB,IAAI,kBAAkB;EACtC,OAAO,IAAI,QAAQ,IAAI,QAAQ;EAE/B,WAAW,UAAU,gBAAgB,YAAY;EACjD,WAAW,IAAI,UAAU,aAAa;EACvC;;AAGH,eAAsB,yBAA8C;AASlE,SANa,MAFE,aAAa,CAEF,WAAW,SAAS,EAC5C,SAAS,EACP,YAAY,MACb,EACF,CAAC,EAEU,IAAI,cAAc;;AAmBhC,eAAsB,UAAU,OAA0C;CACxE,MAAM,SAAS,aAAa;CAC5B,MAAM,EAAE,IAAI,SAAS;CAErB,MAAM,gBAOF,EAAE;AACN,KAAI,KAAK,gBAAgB,OACvB,eAAc,cAAc,KAAK;AACnC,KAAI,KAAK,iBAAiB,OACxB,eAAc,eAAe,KAAK;AACpC,KAAI,KAAK,SAAS,OAAW,eAAc,OAAO,KAAK;AACvD,KAAI,KAAK,WAAW,OAAW,eAAc,SAAS,KAAK;AAC3D,KAAI,KAAK,gBAAgB,OACvB,eAAc,cAAc,KAAK;AACnC,KAAI,KAAK,aAAa,OAAW,eAAc,WAAW,KAAK;CAE/D,MAAM,UAAU,MAAM,OAAO,WAAW,OAAO;EAC7C,OAAO,EAAE,IAAI;EACb,MAAM;EACN,SAAS,EAAE,YAAY,MAAM;EAC9B,CAAC;AAEF,KAAI,KAAK,YAAY,QAAW;EAC9B,MAAM,OAAO,CAAC,GAAG,IAAI,IAAI,KAAK,QAAQ,KAAK,MAAM,EAAE,MAAM,CAAC,CAAC,OAAO,QAAQ,CAAC,CAAC;EAC5E,MAAM,oBAAoB,CAAC,GAAG,IAAI,IAAI,KAAK,IAAI,gBAAgB,CAAC,CAAC;AAEjE,OAAK,MAAM,cAAc,kBACvB,OAAM,OAAO,OAAO,OAAO;GACzB,OAAO,EAAE,MAAM,YAAY;GAC3B,QAAQ,EAAE,MAAM,YAAY;GAC5B,QAAQ,EAAE;GACX,CAAC;AAGJ,QAAM,OAAO,gBAAgB,WAAW,EACtC,OAAO,EAAE,YAAY,QAAQ,IAAI,EAClC,CAAC;AAEF,MAAI,KAAK,SAAS,EAChB,OAAM,OAAO,gBAAgB,WAAW,EACtC,MAAM,KAAK,KAAK,SAAS;GACvB,YAAY,QAAQ;GACpB,YAAY,gBAAgB,IAAI;GAChC,KAAK,IAAI,MAAM;GAChB,EAAE,EACJ,CAAC;EAGJ,MAAM,YAAY,MAAM,OAAO,WAAW,WAAW;GACnD,OAAO,EAAE,IAAI;GACb,SAAS,EAAE,YAAY,MAAM;GAC9B,CAAC;AACF,MAAI,CAAC,UAAW,OAAM,IAAI,MAAM,kCAAkC;AAClE,SAAO,cAAc,UAAU;;AAGjC,QAAO,cAAc,QAAQ;;;;;;;AAe/B,eAAsB,eACpB,OACmB;AAOnB,QAAO,cALS,MADD,aAAa,CACC,WAAW,OAAO;EAC7C,OAAO,EAAE,IAAI,MAAM,IAAI;EACvB,MAAM,EAAE,UAAU,MAAM,UAAU;EAClC,SAAS,EAAE,YAAY,MAAM;EAC9B,CAAC,CAC2B;;AAkB/B,eAAsB,uBAA0C;AAG9D,SADa,MADE,aAAa,CACF,SAAS,UAAU,EACjC,KAAK,SAAS;EACxB,MAAM,IAAI;EACV,WAAW,IAAI;EACf,UAAU,IAAI;EACd,OAAO,IAAI,SAAS;EACrB,EAAE;;AAGL,eAAsB,sBAAwC;AAK5D,SAHa,MADE,aAAa,CACF,QAAQ,SAAS,EACzC,SAAS,EAAE,aAAa,MAAM,EAC/B,CAAC,EACU,KAAK,SAAS;EACxB,MAAM,IAAI;EACV,aAAa,IAAI,eAAe;EAChC,OAAO,IAAI,SAAS;EACpB,aAAa,IAAI,YAAY,KAAK,MAAM,EAAE,WAAW;EACtD,EAAE;;AAGL,eAAsB,kBACpB,sBACyB;AAKzB,QAAO;EACL,WALgB,uBACd,MAAM,sBAAsB,GAC5B,MAAM,wBAAwB;EAIhC,iBAAiB,MAAM,qCAAqC;EAC5D,iBAAiB,MAAM,8BAA8B;EACrD,SAAS,MAAM,sBAAsB;EACrC,QAAQ,MAAM,qBAAqB;EACpC"}
|
|
1
|
+
{"version":3,"file":"service.mjs","names":[],"sources":["../../../src/modules/appCatalog/service.ts"],"sourcesContent":["import { getDbClient } from '../../db/client'\nimport type { Prisma } from '../../generated/prisma/client'\nimport type {\n AppApprovalMethod,\n AppCatalogData,\n AppCategory,\n GroupingTagDefinition,\n Resource,\n SourceCheck,\n} from '../../types/common/appCatalogTypes'\nimport type { ServiceConfig } from '../../types/common/approvalMethodTypes'\nimport type { Group, Person } from '../../types/common/personGroupTypes'\nimport { omit } from 'radashi'\nimport { parseSourceSlug } from '../../utils/parseSourceSlug'\nimport { computeFreshness } from './freshness'\nimport { computeSourcePulse } from './sourcePulse'\n\n/** Prisma query result for DbResource with sourceRefs included (used by rowToResource) */\ntype ResourceRowWithSourceRefs = Prisma.DbResourceGetPayload<{\n include: { sourceRefs: true }\n}>\n\nfunction capitalize(word: string): string {\n if (!word) return word\n return word.charAt(0).toUpperCase() + word.slice(1)\n}\n\nexport async function getGroupingTagDefinitionsFromPrisma(): Promise<\n GroupingTagDefinition[]\n> {\n const prisma = getDbClient()\n\n // Fetch all tag definitions\n const rows = await prisma.dbAppTagDefinition.findMany()\n\n // Fetch all resources to count tag usage\n const resources = await prisma.dbResource.findMany({\n select: { tags: true },\n })\n\n // Count tag values across all resources\n const tagCounts = new Map<string, Map<string, number>>()\n\n for (const resource of resources) {\n const tags = (resource.tags as unknown as string[] | null) ?? []\n for (const tag of tags) {\n const [prefix, value] = tag.split(':')\n if (prefix && value) {\n if (!tagCounts.has(prefix)) {\n tagCounts.set(prefix, new Map())\n }\n const prefixCounts = tagCounts.get(prefix)!\n prefixCounts.set(value, (prefixCounts.get(value) ?? 0) + 1)\n }\n }\n }\n\n // Sort values by count and return definitions\n return rows.map((row) => {\n const definition = omit(row, ['id', 'updatedAt', 'createdAt'])\n const counts = tagCounts.get(definition.prefix) ?? new Map()\n\n // Sort values by count (descending)\n const sortedValues = [...definition.values].sort((a, b) => {\n const countA = counts.get(a.value) ?? 0\n const countB = counts.get(b.value) ?? 0\n return countB - countA\n })\n\n return {\n ...definition,\n values: sortedValues,\n }\n })\n}\n\nexport async function getApprovalMethodsFromPrisma(): Promise<\n AppApprovalMethod[]\n> {\n const prisma = getDbClient()\n\n // Fetch all approval methods\n const rows = await prisma.dbApprovalMethod.findMany()\n\n return rows.map((row) => {\n // Handle discriminated union by explicitly narrowing based on type\n const baseFields = {\n slug: row.slug,\n displayName: row.displayName,\n }\n\n // Provide default empty config if null, as AppApprovalMethod discriminated union requires config\n const config = row.config ?? {}\n\n switch (row.type) {\n case 'service':\n return {\n ...baseFields,\n type: 'service' as const,\n config: config as ServiceConfig,\n }\n case 'custom':\n return {\n ...baseFields,\n type: 'custom' as const,\n config: config,\n }\n case 'noAccessRequired':\n return {\n ...baseFields,\n type: 'noAccessRequired' as const,\n config: config,\n }\n case 'unknown':\n return {\n ...baseFields,\n type: 'unknown' as const,\n config: config,\n }\n case 'personTeam':\n // Legacy: map personTeam to custom\n return {\n ...baseFields,\n type: 'custom' as const,\n config: config,\n }\n }\n })\n}\n\nfunction rowToResource(row: ResourceRowWithSourceRefs): Resource {\n const accessRequest =\n row.accessRequest as unknown as Resource['accessRequest']\n const teams = (row.teams as unknown as string[] | null) ?? []\n const tags = (row.tags as unknown as Resource['tags']) ?? []\n const screenshotIds =\n (row.screenshotIds as unknown as Resource['screenshotIds']) ?? []\n const sources = row.sourceRefs.map((ref) => ({\n sourceSlug: ref.sourceSlug,\n url: ref.url,\n parseDate: ref.parseDate ? ref.parseDate.toISOString() : null,\n // Derived here rather than shipped raw so the pulse mark can stay a dumb\n // renderer, the same way `freshness` is computed for the entry below.\n pulse: computeSourcePulse({\n lastCheckedAt: ref.lastCheckedAt?.toISOString() ?? null,\n nextCheckAfter: ref.nextCheckAfter?.toISOString() ?? null,\n lastContentChangeAt: ref.lastContentChangeAt?.toISOString() ?? null,\n checkIntervalHours: ref.checkIntervalHours,\n changeHistory: ref.changeHistory as SourceCheck[] | null,\n }),\n }))\n const notes = row.notes == null ? undefined : row.notes\n const background = row.background == null ? undefined : row.background\n const appUrl = row.appUrl == null ? undefined : row.appUrl\n const iconName = row.iconName == null ? undefined : row.iconName\n const abbreviation = row.abbreviation == null ? undefined : row.abbreviation\n const nicknames = (row.nicknames as unknown as string[] | null)?.length\n ? row.nicknames\n : undefined\n const deprecated =\n row.deprecated == null\n ? undefined\n : (row.deprecated as unknown as Resource['deprecated'])\n const aiPrompt = row.aiPrompt == null ? undefined : row.aiPrompt\n const aiMemory = row.aiMemory == null ? undefined : row.aiMemory\n const urlIssues = (row.urlIssues as unknown as string[] | null)?.length\n ? row.urlIssues\n : undefined\n const tiers =\n row.tiers == null ? undefined : (row.tiers as unknown as Resource['tiers'])\n const quickJumps =\n row.quickJumps == null\n ? undefined\n : (row.quickJumps as unknown as Resource['quickJumps'])\n const freshness = computeFreshness({\n lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null,\n nextCheckAfter: row.nextCheckAfter\n ? row.nextCheckAfter.toISOString()\n : null,\n lastContentChangeAt: row.lastContentChangeAt\n ? row.lastContentChangeAt.toISOString()\n : null,\n })\n\n return {\n id: row.id,\n slug: row.slug,\n type: row.type,\n displayName: row.displayName,\n abbreviation,\n nicknames,\n description: row.description,\n accessRequest,\n teams,\n notes,\n background,\n tags,\n appUrl,\n iconName,\n screenshotIds,\n sources,\n deprecated,\n aiPrompt,\n aiMemory,\n urlIssues,\n tiers,\n quickJumps,\n childrenLabel: row.childrenLabel ?? undefined,\n // Fields from former SubResource\n parentSlug: row.parentSlug ?? undefined,\n tier: row.tier ?? undefined,\n aliases: row.aliases.length ? row.aliases : undefined,\n ownerPersonSlug: row.ownerPersonSlug ?? undefined,\n approverSlugs: row.approverSlugs.length ? row.approverSlugs : undefined,\n accessComments: row.accessComments ?? undefined,\n extra: row.extra ? row.extra : undefined,\n // Only surface freshness once the app has actually been scanned.\n freshness: freshness.lastCheckedAt ? freshness : undefined,\n createdAt: row.createdAt.toISOString(),\n }\n}\n\nexport async function getResourcesFromPrisma(): Promise<Resource[]> {\n const prisma = getDbClient()\n\n const rows = await prisma.dbResource.findMany({\n include: {\n sourceRefs: true,\n },\n })\n\n return rows.map(rowToResource)\n}\n\n/** @deprecated Use getResourcesFromPrisma instead */\nexport const getAppsFromPrisma = getResourcesFromPrisma\n\nexport interface UpdateAppInput {\n id: string\n data: {\n displayName?: string\n abbreviation?: string | null\n slug?: string\n appUrl?: string\n description?: string\n sources?: string[]\n aiPrompt?: string | null\n }\n}\n\nexport async function updateApp(input: UpdateAppInput): Promise<Resource> {\n const prisma = getDbClient()\n const { id, data } = input\n\n const updatePayload: {\n displayName?: string\n abbreviation?: string | null\n slug?: string\n appUrl?: string\n description?: string\n aiPrompt?: string | null\n } = {}\n if (data.displayName !== undefined)\n updatePayload.displayName = data.displayName\n if (data.abbreviation !== undefined)\n updatePayload.abbreviation = data.abbreviation\n if (data.slug !== undefined) updatePayload.slug = data.slug\n if (data.appUrl !== undefined) updatePayload.appUrl = data.appUrl\n if (data.description !== undefined)\n updatePayload.description = data.description\n if (data.aiPrompt !== undefined) updatePayload.aiPrompt = data.aiPrompt\n\n const updated = await prisma.dbResource.update({\n where: { id },\n data: updatePayload,\n include: { sourceRefs: true },\n })\n\n if (data.sources !== undefined) {\n const urls = [...new Set(data.sources.map((u) => u.trim()).filter(Boolean))]\n const uniqueSourceSlugs = [...new Set(urls.map(parseSourceSlug))]\n\n for (const sourceSlug of uniqueSourceSlugs) {\n await prisma.source.upsert({\n where: { slug: sourceSlug },\n create: { slug: sourceSlug },\n update: {},\n })\n }\n\n await prisma.sourceReference.deleteMany({\n where: { resourceId: updated.id },\n })\n\n if (urls.length > 0) {\n await prisma.sourceReference.createMany({\n data: urls.map((url) => ({\n resourceId: updated.id,\n sourceSlug: parseSourceSlug(url),\n url: url.trim(),\n })),\n })\n }\n\n const refetched = await prisma.dbResource.findUnique({\n where: { id },\n include: { sourceRefs: true },\n })\n if (!refetched) throw new Error('Resource not found after update')\n return rowToResource(refetched)\n }\n\n return rowToResource(updated)\n}\n\nexport interface UpdateAiMemoryInput {\n /** Resource id (DB id, not slug) */\n id: string\n /** New value for aiMemory. Pass null to clear. */\n aiMemory: string | null\n}\n\n/**\n * Updates the AI-owned scratchpad for a resource.\n * This mutation is intentionally separate from updateApp so AI agents can\n * write knowledge without a full MR cycle. It does NOT touch aiPrompt.\n */\nexport async function updateAiMemory(\n input: UpdateAiMemoryInput,\n): Promise<Resource> {\n const prisma = getDbClient()\n const updated = await prisma.dbResource.update({\n where: { id: input.id },\n data: { aiMemory: input.aiMemory },\n include: { sourceRefs: true },\n })\n return rowToResource(updated)\n}\n\nexport function deriveCategories(resources: Resource[]): AppCategory[] {\n const tagSet = new Set<string>()\n for (const resource of resources) {\n for (const tag of resource.tags ?? []) {\n const normalized = tag.trim().toLowerCase()\n if (normalized) tagSet.add(normalized)\n }\n }\n const categories: AppCategory[] = [{ id: 'all', name: 'All' }]\n for (const tag of Array.from(tagSet).sort()) {\n categories.push({ id: tag, name: capitalize(tag) })\n }\n return categories\n}\n\nexport async function getPersonsFromPrisma(): Promise<Person[]> {\n const prisma = getDbClient()\n const rows = await prisma.dbPerson.findMany()\n return rows.map((row) => ({\n slug: row.slug,\n firstName: row.firstName,\n lastName: row.lastName,\n email: row.email ?? undefined,\n }))\n}\n\nexport async function getGroupsFromPrisma(): Promise<Group[]> {\n const prisma = getDbClient()\n const rows = await prisma.dbGroup.findMany({\n include: { memberships: true },\n })\n return rows.map((row) => ({\n slug: row.slug,\n displayName: row.displayName ?? undefined,\n email: row.email ?? undefined,\n memberSlugs: row.memberships.map((m) => m.personSlug),\n }))\n}\n\nexport async function getAppCatalogData(\n getResourcesOptional?: () => Promise<Resource[]>,\n): Promise<AppCatalogData> {\n const resources = getResourcesOptional\n ? await getResourcesOptional()\n : await getResourcesFromPrisma()\n\n return {\n resources,\n tagsDefinitions: await getGroupingTagDefinitionsFromPrisma(),\n approvalMethods: await getApprovalMethodsFromPrisma(),\n persons: await getPersonsFromPrisma(),\n groups: await getGroupsFromPrisma(),\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Router } from "express";
|
|
2
|
+
|
|
3
|
+
//#region src/modules/feedback/feedbackAttachmentController.d.ts
|
|
4
|
+
interface FeedbackAttachmentControllerConfig {
|
|
5
|
+
/** Base path for the endpoints (e.g. '/api/feedback-attachments'). */
|
|
6
|
+
basePath: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Upload and retrieval for the images on a correction request.
|
|
10
|
+
*
|
|
11
|
+
* These are deliberately NOT `DbAsset`: that table's `name` is unique and holds
|
|
12
|
+
* published catalog artwork, so letting a passing visitor write into it would mean
|
|
13
|
+
* untrusted uploads sharing a namespace with the icons the catalog serves.
|
|
14
|
+
*
|
|
15
|
+
* Endpoints:
|
|
16
|
+
* - POST {basePath}/upload - up to MAX_ATTACHMENTS images (multipart, field `image`)
|
|
17
|
+
* - GET {basePath}/:id - the stored image
|
|
18
|
+
*/
|
|
19
|
+
declare function registerFeedbackAttachmentController(router: Router, config: FeedbackAttachmentControllerConfig): void;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { FeedbackAttachmentControllerConfig, registerFeedbackAttachmentController };
|
|
22
|
+
//# sourceMappingURL=feedbackAttachmentController.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feedbackAttachmentController.d.mts","names":[],"sources":["../../../src/modules/feedback/feedbackAttachmentController.ts"],"mappings":";;;UA8GiB,kCAAA;;EAEf,QAAA;AAAA;;;;AAcF;;;;;;;;iBAAgB,oCAAA,CACd,MAAA,EAAQ,MAAA,EACR,MAAA,EAAQ,kCAAA"}
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { getDbClient } from "../../db/client.mjs";
|
|
2
|
+
import { MAX_ATTACHMENTS } from "./service.mjs";
|
|
3
|
+
import { generateChecksum } from "../assets/assetUtils.mjs";
|
|
4
|
+
import { resolveActor } from "../visitor/visitorIdentity.mjs";
|
|
5
|
+
import { isNotModified, setRevalidatingCacheHeaders } from "../assets/assetCache.mjs";
|
|
6
|
+
import sharp from "sharp";
|
|
7
|
+
import multer from "multer";
|
|
8
|
+
|
|
9
|
+
//#region src/modules/feedback/feedbackAttachmentController.ts
|
|
10
|
+
/** Long edge, in pixels. A correction request needs a readable screenshot, not a print. */
|
|
11
|
+
const MAX_EDGE = 2e3;
|
|
12
|
+
/** Per upload. Generous for a screenshot; multer rejects above it before we decode. */
|
|
13
|
+
const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
|
14
|
+
/** Uploads one browser may make per window, and the window. */
|
|
15
|
+
const RATE_LIMIT = 20;
|
|
16
|
+
const RATE_WINDOW_MS = 3600 * 1e3;
|
|
17
|
+
/** Orphans older than this are swept — see the sweep below. */
|
|
18
|
+
const ORPHAN_MAX_AGE_MS = 1440 * 60 * 1e3;
|
|
19
|
+
/**
|
|
20
|
+
* Everything is re-encoded to this one format, so the bytes we store are always
|
|
21
|
+
* produced by our own encoder from decoded pixels. That is the actual defence: it
|
|
22
|
+
* drops EXIF, colour profiles, trailing data and anything polyglot that a
|
|
23
|
+
* content-type header would have happily waved through.
|
|
24
|
+
*/
|
|
25
|
+
const STORED_MIME = "image/webp";
|
|
26
|
+
const upload = multer({
|
|
27
|
+
storage: multer.memoryStorage(),
|
|
28
|
+
limits: {
|
|
29
|
+
fileSize: MAX_UPLOAD_BYTES,
|
|
30
|
+
files: 3
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
/**
|
|
34
|
+
* Multer's own errors, answered as JSON.
|
|
35
|
+
*
|
|
36
|
+
* Handed straight to express they reach the default error page — an HTML stack trace
|
|
37
|
+
* carrying absolute server paths, from an endpoint any passer-by can POST to. The
|
|
38
|
+
* limits are also the first thing a person hits by accident, so they deserve a
|
|
39
|
+
* sentence rather than a 500.
|
|
40
|
+
*/
|
|
41
|
+
const acceptImages = (req, res, next) => {
|
|
42
|
+
upload.array("image", 3)(req, res, (err) => {
|
|
43
|
+
if (err instanceof multer.MulterError) {
|
|
44
|
+
const message = err.code === "LIMIT_FILE_COUNT" ? `Up to ${3} images` : err.code === "LIMIT_FILE_SIZE" ? `Each image must be under ${MAX_UPLOAD_BYTES / 1024 / 1024}MB` : err.code === "LIMIT_UNEXPECTED_FILE" ? "Unexpected upload field" : "Could not read the upload";
|
|
45
|
+
res.status(400).json({ error: message });
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (err) {
|
|
49
|
+
console.error("Error reading feedback attachment upload:", err);
|
|
50
|
+
res.status(500).json({ error: "Failed to upload image" });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
next();
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* ponytail: in-memory window, so the budget is per process rather than per browser
|
|
58
|
+
* across a replica set. Move to a shared store if this ever runs more than one
|
|
59
|
+
* replica and the abuse is real; for a form behind the office network it is enough
|
|
60
|
+
* to stop a script, which is all it is for.
|
|
61
|
+
*/
|
|
62
|
+
const recentUploads = /* @__PURE__ */ new Map();
|
|
63
|
+
function overRateLimit(actorHash) {
|
|
64
|
+
const now = Date.now();
|
|
65
|
+
const kept = (recentUploads.get(actorHash) ?? []).filter((at) => now - at < RATE_WINDOW_MS);
|
|
66
|
+
if (kept.length >= RATE_LIMIT) {
|
|
67
|
+
recentUploads.set(actorHash, kept);
|
|
68
|
+
return true;
|
|
69
|
+
}
|
|
70
|
+
kept.push(now);
|
|
71
|
+
recentUploads.set(actorHash, kept);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Delete images nobody ever submitted. Opportunistic rather than scheduled: an
|
|
76
|
+
* abandoned form is the only thing that creates them, so the cheapest place to notice
|
|
77
|
+
* is the next upload, and there is no cron to own.
|
|
78
|
+
*/
|
|
79
|
+
async function sweepOrphans() {
|
|
80
|
+
try {
|
|
81
|
+
await getDbClient().dbFeedbackAttachment.deleteMany({ where: {
|
|
82
|
+
feedbackId: null,
|
|
83
|
+
createdAt: { lt: new Date(Date.now() - ORPHAN_MAX_AGE_MS) }
|
|
84
|
+
} });
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error("Error sweeping orphaned feedback attachments:", error);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Upload and retrieval for the images on a correction request.
|
|
91
|
+
*
|
|
92
|
+
* These are deliberately NOT `DbAsset`: that table's `name` is unique and holds
|
|
93
|
+
* published catalog artwork, so letting a passing visitor write into it would mean
|
|
94
|
+
* untrusted uploads sharing a namespace with the icons the catalog serves.
|
|
95
|
+
*
|
|
96
|
+
* Endpoints:
|
|
97
|
+
* - POST {basePath}/upload - up to MAX_ATTACHMENTS images (multipart, field `image`)
|
|
98
|
+
* - GET {basePath}/:id - the stored image
|
|
99
|
+
*/
|
|
100
|
+
function registerFeedbackAttachmentController(router, config) {
|
|
101
|
+
const { basePath } = config;
|
|
102
|
+
router.post(`${basePath}/upload`, acceptImages, async (req, res) => {
|
|
103
|
+
try {
|
|
104
|
+
const actor = resolveActor(req, res);
|
|
105
|
+
if (!actor) {
|
|
106
|
+
res.status(412).json({ error: "Uploading needs cookies enabled" });
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const files = Array.isArray(req.files) ? req.files : [];
|
|
110
|
+
if (files.length === 0) {
|
|
111
|
+
res.status(400).json({ error: "No image uploaded" });
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (overRateLimit(actor.hash)) {
|
|
115
|
+
res.status(429).json({ error: "Too many uploads, try again later" });
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
const prisma = getDbClient();
|
|
119
|
+
const ids = [];
|
|
120
|
+
for (const file of files) {
|
|
121
|
+
let content;
|
|
122
|
+
let width;
|
|
123
|
+
let height;
|
|
124
|
+
try {
|
|
125
|
+
const out = await sharp(file.buffer, { failOn: "error" }).rotate().resize({
|
|
126
|
+
width: MAX_EDGE,
|
|
127
|
+
height: MAX_EDGE,
|
|
128
|
+
fit: "inside",
|
|
129
|
+
withoutEnlargement: true
|
|
130
|
+
}).webp({ quality: 82 }).toBuffer({ resolveWithObject: true });
|
|
131
|
+
content = out.data;
|
|
132
|
+
width = out.info.width;
|
|
133
|
+
height = out.info.height;
|
|
134
|
+
} catch {
|
|
135
|
+
res.status(400).json({ error: `Not a readable image: ${file.originalname}` });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const row = await prisma.dbFeedbackAttachment.create({
|
|
139
|
+
data: {
|
|
140
|
+
authorHash: actor.hash,
|
|
141
|
+
content: new Uint8Array(content),
|
|
142
|
+
mimeType: STORED_MIME,
|
|
143
|
+
fileSize: content.byteLength,
|
|
144
|
+
checksum: generateChecksum(content),
|
|
145
|
+
width,
|
|
146
|
+
height,
|
|
147
|
+
originalFilename: file.originalname.slice(0, 200)
|
|
148
|
+
},
|
|
149
|
+
select: { id: true }
|
|
150
|
+
});
|
|
151
|
+
ids.push(row.id);
|
|
152
|
+
}
|
|
153
|
+
sweepOrphans();
|
|
154
|
+
res.status(201).json({ ids });
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error("Error uploading feedback attachment:", error);
|
|
157
|
+
res.status(500).json({ error: "Failed to upload image" });
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
router.get(`${basePath}/:id`, async (req, res) => {
|
|
161
|
+
try {
|
|
162
|
+
const attachment = await getDbClient().dbFeedbackAttachment.findUnique({
|
|
163
|
+
where: { id: req.params["id"] ?? "" },
|
|
164
|
+
select: {
|
|
165
|
+
content: true,
|
|
166
|
+
mimeType: true,
|
|
167
|
+
checksum: true,
|
|
168
|
+
originalFilename: true
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
if (!attachment) {
|
|
172
|
+
res.status(404).json({ error: "Image not found" });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (isNotModified(req, setRevalidatingCacheHeaders(res, attachment.checksum))) {
|
|
176
|
+
res.status(304).end();
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.setHeader("Content-Type", attachment.mimeType);
|
|
180
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
181
|
+
res.setHeader("Content-Disposition", "inline");
|
|
182
|
+
res.send(Buffer.from(attachment.content));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
console.error("Error fetching feedback attachment:", error);
|
|
185
|
+
res.status(500).json({ error: "Failed to fetch image" });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
//#endregion
|
|
191
|
+
export { registerFeedbackAttachmentController };
|
|
192
|
+
//# sourceMappingURL=feedbackAttachmentController.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feedbackAttachmentController.mjs","names":[],"sources":["../../../src/modules/feedback/feedbackAttachmentController.ts"],"sourcesContent":["import type { Request, RequestHandler, Response, Router } from 'express'\nimport multer from 'multer'\nimport sharp from 'sharp'\nimport { getDbClient } from '../../db'\nimport {\n isNotModified,\n setRevalidatingCacheHeaders,\n} from '../assets/assetCache'\nimport { generateChecksum } from '../assets/assetUtils'\nimport { resolveActor } from '../visitor/visitorIdentity'\nimport { MAX_ATTACHMENTS } from './service'\n\n/** Long edge, in pixels. A correction request needs a readable screenshot, not a print. */\nconst MAX_EDGE = 2000\n\n/** Per upload. Generous for a screenshot; multer rejects above it before we decode. */\nconst MAX_UPLOAD_BYTES = 8 * 1024 * 1024\n\n/** Uploads one browser may make per window, and the window. */\nconst RATE_LIMIT = 20\nconst RATE_WINDOW_MS = 60 * 60 * 1000\n\n/** Orphans older than this are swept — see the sweep below. */\nconst ORPHAN_MAX_AGE_MS = 24 * 60 * 60 * 1000\n\n/**\n * Everything is re-encoded to this one format, so the bytes we store are always\n * produced by our own encoder from decoded pixels. That is the actual defence: it\n * drops EXIF, colour profiles, trailing data and anything polyglot that a\n * content-type header would have happily waved through.\n */\nconst STORED_MIME = 'image/webp'\n\nconst upload = multer({\n storage: multer.memoryStorage(),\n limits: { fileSize: MAX_UPLOAD_BYTES, files: MAX_ATTACHMENTS },\n})\n\n/**\n * Multer's own errors, answered as JSON.\n *\n * Handed straight to express they reach the default error page — an HTML stack trace\n * carrying absolute server paths, from an endpoint any passer-by can POST to. The\n * limits are also the first thing a person hits by accident, so they deserve a\n * sentence rather than a 500.\n */\nconst acceptImages: RequestHandler = (req, res, next) => {\n upload.array('image', MAX_ATTACHMENTS)(req, res, (err: unknown) => {\n if (err instanceof multer.MulterError) {\n const message =\n err.code === 'LIMIT_FILE_COUNT'\n ? `Up to ${MAX_ATTACHMENTS} images`\n : err.code === 'LIMIT_FILE_SIZE'\n ? `Each image must be under ${MAX_UPLOAD_BYTES / 1024 / 1024}MB`\n : err.code === 'LIMIT_UNEXPECTED_FILE'\n ? 'Unexpected upload field'\n : 'Could not read the upload'\n res.status(400).json({ error: message })\n return\n }\n if (err) {\n console.error('Error reading feedback attachment upload:', err)\n res.status(500).json({ error: 'Failed to upload image' })\n return\n }\n next()\n })\n}\n\n/**\n * ponytail: in-memory window, so the budget is per process rather than per browser\n * across a replica set. Move to a shared store if this ever runs more than one\n * replica and the abuse is real; for a form behind the office network it is enough\n * to stop a script, which is all it is for.\n */\nconst recentUploads = new Map<string, number[]>()\n\nfunction overRateLimit(actorHash: string): boolean {\n const now = Date.now()\n const kept = (recentUploads.get(actorHash) ?? []).filter(\n (at) => now - at < RATE_WINDOW_MS,\n )\n if (kept.length >= RATE_LIMIT) {\n recentUploads.set(actorHash, kept)\n return true\n }\n kept.push(now)\n recentUploads.set(actorHash, kept)\n return false\n}\n\n/**\n * Delete images nobody ever submitted. Opportunistic rather than scheduled: an\n * abandoned form is the only thing that creates them, so the cheapest place to notice\n * is the next upload, and there is no cron to own.\n */\nasync function sweepOrphans(): Promise<void> {\n try {\n await getDbClient().dbFeedbackAttachment.deleteMany({\n where: {\n feedbackId: null,\n createdAt: { lt: new Date(Date.now() - ORPHAN_MAX_AGE_MS) },\n },\n })\n } catch (error) {\n // A failed sweep must never fail the upload that triggered it.\n console.error('Error sweeping orphaned feedback attachments:', error)\n }\n}\n\nexport interface FeedbackAttachmentControllerConfig {\n /** Base path for the endpoints (e.g. '/api/feedback-attachments'). */\n basePath: string\n}\n\n/**\n * Upload and retrieval for the images on a correction request.\n *\n * These are deliberately NOT `DbAsset`: that table's `name` is unique and holds\n * published catalog artwork, so letting a passing visitor write into it would mean\n * untrusted uploads sharing a namespace with the icons the catalog serves.\n *\n * Endpoints:\n * - POST {basePath}/upload - up to MAX_ATTACHMENTS images (multipart, field `image`)\n * - GET {basePath}/:id - the stored image\n */\nexport function registerFeedbackAttachmentController(\n router: Router,\n config: FeedbackAttachmentControllerConfig,\n): void {\n const { basePath } = config\n\n router.post(\n `${basePath}/upload`,\n acceptImages,\n async (req: Request, res: Response) => {\n try {\n // Same identity the feedback row gets, and it issues the cookie if this is\n // the visitor's first request — so an upload can be the first thing they do.\n const actor = resolveActor(req, res)\n if (!actor) {\n res.status(412).json({ error: 'Uploading needs cookies enabled' })\n return\n }\n\n const files = Array.isArray(req.files) ? req.files : []\n if (files.length === 0) {\n res.status(400).json({ error: 'No image uploaded' })\n return\n }\n if (overRateLimit(actor.hash)) {\n res.status(429).json({ error: 'Too many uploads, try again later' })\n return\n }\n\n const prisma = getDbClient()\n const ids: string[] = []\n\n for (const file of files) {\n // The declared content type is never consulted. Decoding is the check:\n // if sharp cannot read it as an image, it is not one, whatever the\n // header claimed.\n let content: Buffer\n let width: number | undefined\n let height: number | undefined\n try {\n const out = await sharp(file.buffer, { failOn: 'error' })\n .rotate() // Apply EXIF orientation BEFORE the metadata is dropped.\n .resize({\n width: MAX_EDGE,\n height: MAX_EDGE,\n fit: 'inside',\n withoutEnlargement: true,\n })\n .webp({ quality: 82 })\n .toBuffer({ resolveWithObject: true })\n content = out.data\n width = out.info.width\n height = out.info.height\n } catch {\n res\n .status(400)\n .json({ error: `Not a readable image: ${file.originalname}` })\n return\n }\n\n const row = await prisma.dbFeedbackAttachment.create({\n data: {\n authorHash: actor.hash,\n // Prisma's Bytes wants a plain view; sharp hands back a Buffer whose\n // backing store it will not accept as-is.\n content: new Uint8Array(content),\n mimeType: STORED_MIME,\n fileSize: content.byteLength,\n checksum: generateChecksum(content),\n width,\n height,\n // Kept for a maintainer to read, never used as a path or a key.\n originalFilename: file.originalname.slice(0, 200),\n },\n select: { id: true },\n })\n ids.push(row.id)\n }\n\n void sweepOrphans()\n res.status(201).json({ ids })\n } catch (error) {\n console.error('Error uploading feedback attachment:', error)\n res.status(500).json({ error: 'Failed to upload image' })\n }\n },\n )\n\n router.get(`${basePath}/:id`, async (req: Request, res: Response) => {\n try {\n const attachment = await getDbClient().dbFeedbackAttachment.findUnique({\n where: { id: req.params['id'] ?? '' },\n select: {\n content: true,\n mimeType: true,\n checksum: true,\n originalFilename: true,\n },\n })\n if (!attachment) {\n res.status(404).json({ error: 'Image not found' })\n return\n }\n\n const etag = setRevalidatingCacheHeaders(res, attachment.checksum)\n if (isNotModified(req, etag)) {\n res.status(304).end()\n return\n }\n\n res.setHeader('Content-Type', attachment.mimeType)\n // Never inline-rendered as a document and never sniffed into one: this is the\n // one place the catalog serves bytes a stranger supplied.\n res.setHeader('X-Content-Type-Options', 'nosniff')\n res.setHeader('Content-Disposition', 'inline')\n res.send(Buffer.from(attachment.content))\n } catch (error) {\n console.error('Error fetching feedback attachment:', error)\n res.status(500).json({ error: 'Failed to fetch image' })\n }\n })\n}\n"],"mappings":";;;;;;;;;;AAaA,MAAM,WAAW;;AAGjB,MAAM,mBAAmB,IAAI,OAAO;;AAGpC,MAAM,aAAa;AACnB,MAAM,iBAAiB,OAAU;;AAGjC,MAAM,oBAAoB,OAAU,KAAK;;;;;;;AAQzC,MAAM,cAAc;AAEpB,MAAM,SAAS,OAAO;CACpB,SAAS,OAAO,eAAe;CAC/B,QAAQ;EAAE,UAAU;EAAkB;EAAwB;CAC/D,CAAC;;;;;;;;;AAUF,MAAM,gBAAgC,KAAK,KAAK,SAAS;AACvD,QAAO,MAAM,WAAyB,CAAC,KAAK,MAAM,QAAiB;AACjE,MAAI,eAAe,OAAO,aAAa;GACrC,MAAM,UACJ,IAAI,SAAS,qBACT,WAAyB,WACzB,IAAI,SAAS,oBACX,4BAA4B,mBAAmB,OAAO,KAAK,MAC3D,IAAI,SAAS,0BACX,4BACA;AACV,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,SAAS,CAAC;AACxC;;AAEF,MAAI,KAAK;AACP,WAAQ,MAAM,6CAA6C,IAAI;AAC/D,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;AACzD;;AAEF,QAAM;GACN;;;;;;;;AASJ,MAAM,gCAAgB,IAAI,KAAuB;AAEjD,SAAS,cAAc,WAA4B;CACjD,MAAM,MAAM,KAAK,KAAK;CACtB,MAAM,QAAQ,cAAc,IAAI,UAAU,IAAI,EAAE,EAAE,QAC/C,OAAO,MAAM,KAAK,eACpB;AACD,KAAI,KAAK,UAAU,YAAY;AAC7B,gBAAc,IAAI,WAAW,KAAK;AAClC,SAAO;;AAET,MAAK,KAAK,IAAI;AACd,eAAc,IAAI,WAAW,KAAK;AAClC,QAAO;;;;;;;AAQT,eAAe,eAA8B;AAC3C,KAAI;AACF,QAAM,aAAa,CAAC,qBAAqB,WAAW,EAClD,OAAO;GACL,YAAY;GACZ,WAAW,EAAE,IAAI,IAAI,KAAK,KAAK,KAAK,GAAG,kBAAkB,EAAE;GAC5D,EACF,CAAC;UACK,OAAO;AAEd,UAAQ,MAAM,iDAAiD,MAAM;;;;;;;;;;;;;;AAoBzE,SAAgB,qCACd,QACA,QACM;CACN,MAAM,EAAE,aAAa;AAErB,QAAO,KACL,GAAG,SAAS,UACZ,cACA,OAAO,KAAc,QAAkB;AACrC,MAAI;GAGF,MAAM,QAAQ,aAAa,KAAK,IAAI;AACpC,OAAI,CAAC,OAAO;AACV,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mCAAmC,CAAC;AAClE;;GAGF,MAAM,QAAQ,MAAM,QAAQ,IAAI,MAAM,GAAG,IAAI,QAAQ,EAAE;AACvD,OAAI,MAAM,WAAW,GAAG;AACtB,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,qBAAqB,CAAC;AACpD;;AAEF,OAAI,cAAc,MAAM,KAAK,EAAE;AAC7B,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,qCAAqC,CAAC;AACpE;;GAGF,MAAM,SAAS,aAAa;GAC5B,MAAM,MAAgB,EAAE;AAExB,QAAK,MAAM,QAAQ,OAAO;IAIxB,IAAI;IACJ,IAAI;IACJ,IAAI;AACJ,QAAI;KACF,MAAM,MAAM,MAAM,MAAM,KAAK,QAAQ,EAAE,QAAQ,SAAS,CAAC,CACtD,QAAQ,CACR,OAAO;MACN,OAAO;MACP,QAAQ;MACR,KAAK;MACL,oBAAoB;MACrB,CAAC,CACD,KAAK,EAAE,SAAS,IAAI,CAAC,CACrB,SAAS,EAAE,mBAAmB,MAAM,CAAC;AACxC,eAAU,IAAI;AACd,aAAQ,IAAI,KAAK;AACjB,cAAS,IAAI,KAAK;YACZ;AACN,SACG,OAAO,IAAI,CACX,KAAK,EAAE,OAAO,yBAAyB,KAAK,gBAAgB,CAAC;AAChE;;IAGF,MAAM,MAAM,MAAM,OAAO,qBAAqB,OAAO;KACnD,MAAM;MACJ,YAAY,MAAM;MAGlB,SAAS,IAAI,WAAW,QAAQ;MAChC,UAAU;MACV,UAAU,QAAQ;MAClB,UAAU,iBAAiB,QAAQ;MACnC;MACA;MAEA,kBAAkB,KAAK,aAAa,MAAM,GAAG,IAAI;MAClD;KACD,QAAQ,EAAE,IAAI,MAAM;KACrB,CAAC;AACF,QAAI,KAAK,IAAI,GAAG;;AAGlB,GAAK,cAAc;AACnB,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,KAAK,CAAC;WACtB,OAAO;AACd,WAAQ,MAAM,wCAAwC,MAAM;AAC5D,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,0BAA0B,CAAC;;GAG9D;AAED,QAAO,IAAI,GAAG,SAAS,OAAO,OAAO,KAAc,QAAkB;AACnE,MAAI;GACF,MAAM,aAAa,MAAM,aAAa,CAAC,qBAAqB,WAAW;IACrE,OAAO,EAAE,IAAI,IAAI,OAAO,SAAS,IAAI;IACrC,QAAQ;KACN,SAAS;KACT,UAAU;KACV,UAAU;KACV,kBAAkB;KACnB;IACF,CAAC;AACF,OAAI,CAAC,YAAY;AACf,QAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,mBAAmB,CAAC;AAClD;;AAIF,OAAI,cAAc,KADL,4BAA4B,KAAK,WAAW,SAAS,CACtC,EAAE;AAC5B,QAAI,OAAO,IAAI,CAAC,KAAK;AACrB;;AAGF,OAAI,UAAU,gBAAgB,WAAW,SAAS;AAGlD,OAAI,UAAU,0BAA0B,UAAU;AAClD,OAAI,UAAU,uBAAuB,SAAS;AAC9C,OAAI,KAAK,OAAO,KAAK,WAAW,QAAQ,CAAC;WAClC,OAAO;AACd,WAAQ,MAAM,uCAAuC,MAAM;AAC3D,OAAI,OAAO,IAAI,CAAC,KAAK,EAAE,OAAO,yBAAyB,CAAC;;GAE1D"}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { AcTrpcContext } from "../../server/acTrpcContext.mjs";
|
|
2
|
+
import { FeedbackList, FeedbackView } from "./service.mjs";
|
|
3
|
+
import * as _trpc_server0 from "@trpc/server";
|
|
4
|
+
|
|
5
|
+
//#region src/modules/feedback/feedbackRouter.d.ts
|
|
6
|
+
declare function createFeedbackRouter(): _trpc_server0.TRPCBuiltRouter<{
|
|
7
|
+
ctx: AcTrpcContext;
|
|
8
|
+
meta: object;
|
|
9
|
+
errorShape: _trpc_server0.TRPCDefaultErrorShape;
|
|
10
|
+
transformer: false;
|
|
11
|
+
}, _trpc_server0.TRPCDecorateCreateRouterOptions<{
|
|
12
|
+
/** One entry's thread, plus its unreviewed count so the badge needs no second call. */list: _trpc_server0.TRPCQueryProcedure<{
|
|
13
|
+
input: {
|
|
14
|
+
resourceSlug: string;
|
|
15
|
+
};
|
|
16
|
+
output: FeedbackList;
|
|
17
|
+
meta: object;
|
|
18
|
+
}>; /** Everything this browser filed, for the tracker. Empty means render no button. */
|
|
19
|
+
mine: _trpc_server0.TRPCQueryProcedure<{
|
|
20
|
+
input: void;
|
|
21
|
+
output: FeedbackView[];
|
|
22
|
+
meta: object;
|
|
23
|
+
}>;
|
|
24
|
+
add: _trpc_server0.TRPCMutationProcedure<{
|
|
25
|
+
input: {
|
|
26
|
+
resourceSlug?: string | undefined;
|
|
27
|
+
subject?: string | undefined;
|
|
28
|
+
body?: string | undefined;
|
|
29
|
+
attachmentIds?: string[] | undefined;
|
|
30
|
+
};
|
|
31
|
+
output: FeedbackView;
|
|
32
|
+
meta: object;
|
|
33
|
+
}>;
|
|
34
|
+
edit: _trpc_server0.TRPCMutationProcedure<{
|
|
35
|
+
input: {
|
|
36
|
+
id: string;
|
|
37
|
+
body: string;
|
|
38
|
+
};
|
|
39
|
+
output: FeedbackView;
|
|
40
|
+
meta: object;
|
|
41
|
+
}>; /** Withdraw your own, for as long as nobody has reviewed it. */
|
|
42
|
+
dismiss: _trpc_server0.TRPCMutationProcedure<{
|
|
43
|
+
input: {
|
|
44
|
+
id: string;
|
|
45
|
+
};
|
|
46
|
+
output: {
|
|
47
|
+
id: string;
|
|
48
|
+
};
|
|
49
|
+
meta: object;
|
|
50
|
+
}>;
|
|
51
|
+
}>>;
|
|
52
|
+
//#endregion
|
|
53
|
+
export { createFeedbackRouter };
|
|
54
|
+
//# sourceMappingURL=feedbackRouter.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feedbackRouter.d.mts","names":[],"sources":["../../../src/modules/feedback/feedbackRouter.ts"],"mappings":";;;;;iBAuDgB,oBAAA,CAAA,iBAAoB,eAAA;OAAA,aAAA;;;;;EAApB"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { publicProcedure, router } from "../../server/trpcSetup.mjs";
|
|
2
|
+
import { FeedbackError, MAX_ATTACHMENTS, MAX_BODY_LENGTH, MAX_SUBJECT_LENGTH, addFeedback, dismissFeedback, editFeedback, listFeedback, listMyFeedback } from "./service.mjs";
|
|
3
|
+
import { TRPCError } from "@trpc/server";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
//#region src/modules/feedback/feedbackRouter.ts
|
|
7
|
+
/**
|
|
8
|
+
* Writing needs a visitor token, and the token is issued on the request that reads the
|
|
9
|
+
* list — so by the time anyone can click Send, the cookie exists. A missing one here
|
|
10
|
+
* means cookies are blocked, which no error code describes well; PRECONDITION_FAILED at
|
|
11
|
+
* least separates it from a permission problem.
|
|
12
|
+
*/
|
|
13
|
+
function requireActor(actor) {
|
|
14
|
+
if (!actor) throw new TRPCError({
|
|
15
|
+
code: "PRECONDITION_FAILED",
|
|
16
|
+
message: "Sending this needs cookies enabled"
|
|
17
|
+
});
|
|
18
|
+
return actor;
|
|
19
|
+
}
|
|
20
|
+
const REASON_CODES = {
|
|
21
|
+
"not-found": "NOT_FOUND",
|
|
22
|
+
"not-author": "FORBIDDEN",
|
|
23
|
+
"window-closed": "FORBIDDEN",
|
|
24
|
+
"already-reviewed": "FORBIDDEN",
|
|
25
|
+
empty: "BAD_REQUEST",
|
|
26
|
+
"too-many-attachments": "BAD_REQUEST"
|
|
27
|
+
};
|
|
28
|
+
async function mapErrors(work) {
|
|
29
|
+
try {
|
|
30
|
+
return await work();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if (error instanceof FeedbackError) throw new TRPCError({
|
|
33
|
+
code: REASON_CODES[error.reason],
|
|
34
|
+
message: error.message
|
|
35
|
+
});
|
|
36
|
+
throw error;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function createFeedbackRouter() {
|
|
40
|
+
return router({
|
|
41
|
+
list: publicProcedure.input(z.object({ resourceSlug: z.string() })).query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),
|
|
42
|
+
mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),
|
|
43
|
+
add: publicProcedure.input(z.object({
|
|
44
|
+
resourceSlug: z.string().optional(),
|
|
45
|
+
subject: z.string().max(120).optional(),
|
|
46
|
+
body: z.string().max(MAX_BODY_LENGTH).optional(),
|
|
47
|
+
attachmentIds: z.array(z.string()).max(3).optional()
|
|
48
|
+
}).refine((v) => !!v.resourceSlug || !!v.subject?.trim(), { message: "Needs either an entry or a subject" })).mutation(({ input, ctx }) => mapErrors(() => addFeedback(input, requireActor(ctx.actor)))),
|
|
49
|
+
edit: publicProcedure.input(z.object({
|
|
50
|
+
id: z.string(),
|
|
51
|
+
body: z.string().max(MAX_BODY_LENGTH)
|
|
52
|
+
})).mutation(({ input, ctx }) => mapErrors(() => editFeedback(input.id, input.body, requireActor(ctx.actor)))),
|
|
53
|
+
dismiss: publicProcedure.input(z.object({ id: z.string() })).mutation(({ input, ctx }) => mapErrors(() => dismissFeedback(input.id, requireActor(ctx.actor))))
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
//#endregion
|
|
58
|
+
export { createFeedbackRouter };
|
|
59
|
+
//# sourceMappingURL=feedbackRouter.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"feedbackRouter.mjs","names":[],"sources":["../../../src/modules/feedback/feedbackRouter.ts"],"sourcesContent":["import { TRPCError } from '@trpc/server'\nimport { z } from 'zod'\nimport { publicProcedure, router } from '../../server/trpcSetup'\nimport type { Actor } from '../visitor/visitorIdentity'\nimport {\n FeedbackError,\n MAX_ATTACHMENTS,\n MAX_BODY_LENGTH,\n MAX_SUBJECT_LENGTH,\n addFeedback,\n dismissFeedback,\n editFeedback,\n listFeedback,\n listMyFeedback,\n} from './service'\n\n/**\n * Writing needs a visitor token, and the token is issued on the request that reads the\n * list — so by the time anyone can click Send, the cookie exists. A missing one here\n * means cookies are blocked, which no error code describes well; PRECONDITION_FAILED at\n * least separates it from a permission problem.\n */\nfunction requireActor(actor: Actor | null): Actor {\n if (!actor) {\n throw new TRPCError({\n code: 'PRECONDITION_FAILED',\n message: 'Sending this needs cookies enabled',\n })\n }\n return actor\n}\n\nconst REASON_CODES = {\n 'not-found': 'NOT_FOUND',\n 'not-author': 'FORBIDDEN',\n 'window-closed': 'FORBIDDEN',\n 'already-reviewed': 'FORBIDDEN',\n empty: 'BAD_REQUEST',\n 'too-many-attachments': 'BAD_REQUEST',\n} as const\n\nasync function mapErrors<T>(work: () => Promise<T>): Promise<T> {\n try {\n return await work()\n } catch (error) {\n if (error instanceof FeedbackError) {\n throw new TRPCError({\n code: REASON_CODES[error.reason],\n message: error.message,\n })\n }\n throw error\n }\n}\n\nexport function createFeedbackRouter() {\n return router({\n /** One entry's thread, plus its unreviewed count so the badge needs no second call. */\n list: publicProcedure\n .input(z.object({ resourceSlug: z.string() }))\n .query(({ input, ctx }) => listFeedback(input.resourceSlug, ctx.actor)),\n\n /** Everything this browser filed, for the tracker. Empty means render no button. */\n mine: publicProcedure.query(({ ctx }) => listMyFeedback(ctx.actor)),\n\n add: publicProcedure\n .input(\n z\n .object({\n resourceSlug: z.string().optional(),\n subject: z.string().max(MAX_SUBJECT_LENGTH).optional(),\n // Both optional on purpose: a bare flag with no words and no image is the\n // cheapest signal a passing visitor can send, and still worth having.\n body: z.string().max(MAX_BODY_LENGTH).optional(),\n attachmentIds: z.array(z.string()).max(MAX_ATTACHMENTS).optional(),\n })\n .refine((v) => !!v.resourceSlug || !!v.subject?.trim(), {\n message: 'Needs either an entry or a subject',\n }),\n )\n .mutation(({ input, ctx }) =>\n mapErrors(() => addFeedback(input, requireActor(ctx.actor))),\n ),\n\n edit: publicProcedure\n .input(\n z.object({ id: z.string(), body: z.string().max(MAX_BODY_LENGTH) }),\n )\n .mutation(({ input, ctx }) =>\n mapErrors(() =>\n editFeedback(input.id, input.body, requireActor(ctx.actor)),\n ),\n ),\n\n /** Withdraw your own, for as long as nobody has reviewed it. */\n dismiss: publicProcedure\n .input(z.object({ id: z.string() }))\n .mutation(({ input, ctx }) =>\n mapErrors(() => dismissFeedback(input.id, requireActor(ctx.actor))),\n ),\n })\n}\n"],"mappings":";;;;;;;;;;;;AAsBA,SAAS,aAAa,OAA4B;AAChD,KAAI,CAAC,MACH,OAAM,IAAI,UAAU;EAClB,MAAM;EACN,SAAS;EACV,CAAC;AAEJ,QAAO;;AAGT,MAAM,eAAe;CACnB,aAAa;CACb,cAAc;CACd,iBAAiB;CACjB,oBAAoB;CACpB,OAAO;CACP,wBAAwB;CACzB;AAED,eAAe,UAAa,MAAoC;AAC9D,KAAI;AACF,SAAO,MAAM,MAAM;UACZ,OAAO;AACd,MAAI,iBAAiB,cACnB,OAAM,IAAI,UAAU;GAClB,MAAM,aAAa,MAAM;GACzB,SAAS,MAAM;GAChB,CAAC;AAEJ,QAAM;;;AAIV,SAAgB,uBAAuB;AACrC,QAAO,OAAO;EAEZ,MAAM,gBACH,MAAM,EAAE,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,CAAC,CAAC,CAC7C,OAAO,EAAE,OAAO,UAAU,aAAa,MAAM,cAAc,IAAI,MAAM,CAAC;EAGzE,MAAM,gBAAgB,OAAO,EAAE,UAAU,eAAe,IAAI,MAAM,CAAC;EAEnE,KAAK,gBACF,MACC,EACG,OAAO;GACN,cAAc,EAAE,QAAQ,CAAC,UAAU;GACnC,SAAS,EAAE,QAAQ,CAAC,QAAuB,CAAC,UAAU;GAGtD,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB,CAAC,UAAU;GAChD,eAAe,EAAE,MAAM,EAAE,QAAQ,CAAC,CAAC,MAAoB,CAAC,UAAU;GACnE,CAAC,CACD,QAAQ,MAAM,CAAC,CAAC,EAAE,gBAAgB,CAAC,CAAC,EAAE,SAAS,MAAM,EAAE,EACtD,SAAS,sCACV,CAAC,CACL,CACA,UAAU,EAAE,OAAO,UAClB,gBAAgB,YAAY,OAAO,aAAa,IAAI,MAAM,CAAC,CAAC,CAC7D;EAEH,MAAM,gBACH,MACC,EAAE,OAAO;GAAE,IAAI,EAAE,QAAQ;GAAE,MAAM,EAAE,QAAQ,CAAC,IAAI,gBAAgB;GAAE,CAAC,CACpE,CACA,UAAU,EAAE,OAAO,UAClB,gBACE,aAAa,MAAM,IAAI,MAAM,MAAM,aAAa,IAAI,MAAM,CAAC,CAC5D,CACF;EAGH,SAAS,gBACN,MAAM,EAAE,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,CAAC,CAAC,CACnC,UAAU,EAAE,OAAO,UAClB,gBAAgB,gBAAgB,MAAM,IAAI,aAAa,IAAI,MAAM,CAAC,CAAC,CACpE;EACJ,CAAC"}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
//#region src/modules/feedback/service.d.ts
|
|
2
|
+
/** How long an author may edit their own wording. */
|
|
3
|
+
declare const EDIT_WINDOW_MS: number;
|
|
4
|
+
declare const MAX_BODY_LENGTH = 1000;
|
|
5
|
+
declare const MAX_SUBJECT_LENGTH = 120;
|
|
6
|
+
declare const MAX_ATTACHMENTS = 3;
|
|
7
|
+
/** One piece of feedback as a client sees it. Never carries an author hash. */
|
|
8
|
+
interface FeedbackView {
|
|
9
|
+
id: string;
|
|
10
|
+
authorName: string;
|
|
11
|
+
/** Null when the visitor sent a bare flag with no words. */
|
|
12
|
+
body: string | null;
|
|
13
|
+
/** What they were looking for, on a catalog-level ask. Null on entry feedback. */
|
|
14
|
+
subject: string | null;
|
|
15
|
+
/** Slug of the entry this is about, or null for a catalog-level ask. */
|
|
16
|
+
resourceSlug: string | null;
|
|
17
|
+
/** Display name of that entry, so the tracker can label a row without a second query. */
|
|
18
|
+
resourceName: string | null;
|
|
19
|
+
attachmentIds: string[];
|
|
20
|
+
status: 'applied' | 'acknowledged' | null;
|
|
21
|
+
reviewerReply: string | null;
|
|
22
|
+
/** When a maintainer last touched it — what "unread" is computed against. */
|
|
23
|
+
reviewedAt: string | null;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
editedAt: string | null;
|
|
26
|
+
/** Whether this browser wrote it — what makes Edit and Dismiss appear. */
|
|
27
|
+
isMine: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The absolute instant this browser's edit window closes, or null when it is not
|
|
30
|
+
* theirs. Absolute rather than a remaining duration so a client with a skewed clock
|
|
31
|
+
* hides the control at the same moment the server starts refusing the edit.
|
|
32
|
+
*/
|
|
33
|
+
canEditUntil: string | null;
|
|
34
|
+
}
|
|
35
|
+
interface FeedbackList {
|
|
36
|
+
items: FeedbackView[];
|
|
37
|
+
/** Unreviewed count — what the badge shows. `status IS NULL` is the whole definition. */
|
|
38
|
+
openCount: number;
|
|
39
|
+
}
|
|
40
|
+
//#endregion
|
|
41
|
+
export { EDIT_WINDOW_MS, FeedbackList, FeedbackView, MAX_ATTACHMENTS, MAX_BODY_LENGTH, MAX_SUBJECT_LENGTH };
|
|
42
|
+
//# sourceMappingURL=service.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.mts","names":[],"sources":["../../../src/modules/feedback/service.ts"],"mappings":";AAIA;AAAA,cAAa,cAAA;AAAA,cAEA,eAAA;AAAA,cACA,kBAAA;AAAA,cACA,eAAA;AAFb;AAAA,UAKiB,YAAA;EACf,EAAA;EACA,UAAA;EAP0B;EAS1B,IAAA;EAR6B;EAU7B,OAAA;EAV6B;EAY7B,YAAA;EAXW;EAaX,YAAA;EACA,aAAA;EACA,MAAA;EACA,aAAA;EAbe;EAef,UAAA;EACA,SAAA;EACA,QAAA;EAhBA;EAkBA,MAAA;EAfA;;;;;EAqBA,YAAA;AAAA;AAAA,UAqDe,YAAA;EACf,KAAA,EAAO,YAAA;EA9DP;EAgEA,SAAA;AAAA"}
|