@igstack/app-catalog-backend-core 0.0.1

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/dist/index.d.ts +1934 -0
  3. package/dist/index.d.ts.map +1 -0
  4. package/dist/index.js +2539 -0
  5. package/dist/index.js.map +1 -0
  6. package/package.json +84 -0
  7. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  8. package/prisma/migrations/migration_lock.toml +3 -0
  9. package/prisma/schema.prisma +149 -0
  10. package/src/__tests__/dummy.test.ts +7 -0
  11. package/src/db/client.ts +42 -0
  12. package/src/db/index.ts +21 -0
  13. package/src/db/syncAppCatalog.ts +312 -0
  14. package/src/db/tableSyncMagazine.ts +32 -0
  15. package/src/db/tableSyncPrismaAdapter.ts +203 -0
  16. package/src/index.ts +126 -0
  17. package/src/middleware/backendResolver.ts +42 -0
  18. package/src/middleware/createEhMiddleware.ts +171 -0
  19. package/src/middleware/database.ts +62 -0
  20. package/src/middleware/featureRegistry.ts +173 -0
  21. package/src/middleware/index.ts +43 -0
  22. package/src/middleware/types.ts +202 -0
  23. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  24. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  25. package/src/modules/appCatalog/service.ts +130 -0
  26. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
  27. package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
  28. package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
  29. package/src/modules/approvalMethod/slugUtils.ts +17 -0
  30. package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
  31. package/src/modules/assets/assetRestController.ts +271 -0
  32. package/src/modules/assets/assetUtils.ts +114 -0
  33. package/src/modules/assets/screenshotRestController.ts +195 -0
  34. package/src/modules/assets/screenshotRouter.ts +112 -0
  35. package/src/modules/assets/syncAssets.ts +277 -0
  36. package/src/modules/assets/upsertAsset.ts +46 -0
  37. package/src/modules/auth/auth.ts +51 -0
  38. package/src/modules/auth/authProviders.ts +40 -0
  39. package/src/modules/auth/authRouter.ts +75 -0
  40. package/src/modules/auth/authorizationUtils.ts +132 -0
  41. package/src/modules/auth/devMockUserUtils.ts +49 -0
  42. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  43. package/src/modules/icons/iconRestController.ts +171 -0
  44. package/src/modules/icons/iconRouter.ts +180 -0
  45. package/src/modules/icons/iconService.ts +73 -0
  46. package/src/modules/icons/iconUtils.ts +46 -0
  47. package/src/prisma-json-types.d.ts +34 -0
  48. package/src/server/controller.ts +47 -0
  49. package/src/server/ehStaticControllerContract.ts +19 -0
  50. package/src/server/ehTrpcContext.ts +26 -0
  51. package/src/server/trpcSetup.ts +89 -0
  52. package/src/types/backend/api.ts +73 -0
  53. package/src/types/backend/common.ts +10 -0
  54. package/src/types/backend/companySpecificBackend.ts +5 -0
  55. package/src/types/backend/dataSources.ts +25 -0
  56. package/src/types/backend/deployments.ts +40 -0
  57. package/src/types/common/app/appTypes.ts +13 -0
  58. package/src/types/common/app/ui/appUiTypes.ts +12 -0
  59. package/src/types/common/appCatalogTypes.ts +65 -0
  60. package/src/types/common/approvalMethodTypes.ts +149 -0
  61. package/src/types/common/env/envTypes.ts +7 -0
  62. package/src/types/common/resourceTypes.ts +8 -0
  63. package/src/types/common/sharedTypes.ts +5 -0
  64. package/src/types/index.ts +21 -0
@@ -0,0 +1,213 @@
1
+ import type { Request, Response } from 'express'
2
+ import { getDbClient } from '../../db'
3
+
4
+ /**
5
+ * Export the complete app catalog as JSON
6
+ * Includes all fields from DbAppForCatalog and DbApprovalMethod
7
+ */
8
+ export async function exportCatalog(
9
+ _req: Request,
10
+ res: Response,
11
+ ): Promise<void> {
12
+ try {
13
+ const prisma = getDbClient()
14
+
15
+ // Fetch all catalog entries
16
+ const apps = await prisma.dbAppForCatalog.findMany({
17
+ orderBy: { slug: 'asc' },
18
+ })
19
+
20
+ // Fetch all approval methods
21
+ const approvalMethods = await prisma.dbApprovalMethod.findMany({
22
+ orderBy: { displayName: 'asc' },
23
+ })
24
+
25
+ res.json({
26
+ version: '2.0',
27
+ exportDate: new Date().toISOString(),
28
+ apps,
29
+ approvalMethods,
30
+ })
31
+ } catch (error) {
32
+ console.error('Error exporting catalog:', error)
33
+ res.status(500).json({ error: 'Failed to export catalog' })
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Import/restore the complete app catalog from JSON
39
+ * Overwrites existing data
40
+ */
41
+ export async function importCatalog(
42
+ req: Request,
43
+ res: Response,
44
+ ): Promise<void> {
45
+ try {
46
+ const prisma = getDbClient()
47
+ const { apps, approvalMethods } = req.body
48
+
49
+ if (!Array.isArray(apps)) {
50
+ res
51
+ .status(400)
52
+ .json({ error: 'Invalid data format: apps must be an array' })
53
+ return
54
+ }
55
+
56
+ // Use transaction to ensure atomicity
57
+ await prisma.$transaction(async (tx) => {
58
+ // Delete all existing approval methods first (due to potential FK references)
59
+ if (Array.isArray(approvalMethods)) {
60
+ await tx.dbApprovalMethod.deleteMany({})
61
+ }
62
+
63
+ // Delete all existing catalog entries
64
+ await tx.dbAppForCatalog.deleteMany({})
65
+
66
+ // Insert approval methods first
67
+ if (Array.isArray(approvalMethods)) {
68
+ for (const method of approvalMethods) {
69
+ // Remove id, createdAt, updatedAt to let Prisma generate new ones
70
+ const { id, createdAt, updatedAt, ...methodData } = method
71
+
72
+ await tx.dbApprovalMethod.create({
73
+ data: methodData,
74
+ })
75
+ }
76
+ }
77
+
78
+ // Insert app catalog entries
79
+ for (const app of apps) {
80
+ // Remove id, createdAt, updatedAt to let Prisma generate new ones
81
+ const { id, createdAt, updatedAt, ...appData } = app
82
+
83
+ await tx.dbAppForCatalog.create({
84
+ data: appData,
85
+ })
86
+ }
87
+ })
88
+
89
+ res.json({
90
+ success: true,
91
+ imported: {
92
+ apps: apps.length,
93
+ approvalMethods: Array.isArray(approvalMethods)
94
+ ? approvalMethods.length
95
+ : 0,
96
+ },
97
+ })
98
+ } catch (error) {
99
+ console.error('Error importing catalog:', error)
100
+ res.status(500).json({ error: 'Failed to import catalog' })
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Export an asset (icon or screenshot) by name
106
+ */
107
+ export async function exportAsset(req: Request, res: Response): Promise<void> {
108
+ try {
109
+ const { name } = req.params
110
+ const prisma = getDbClient()
111
+
112
+ const asset = await prisma.dbAsset.findUnique({
113
+ where: { name },
114
+ })
115
+
116
+ if (!asset) {
117
+ res.status(404).json({ error: 'Asset not found' })
118
+ return
119
+ }
120
+
121
+ // Set appropriate content type and send binary data
122
+ res.set('Content-Type', asset.mimeType)
123
+ res.set('Content-Disposition', `attachment; filename="${name}"`)
124
+ res.send(Buffer.from(asset.content))
125
+ } catch (error) {
126
+ console.error('Error exporting asset:', error)
127
+ res.status(500).json({ error: 'Failed to export asset' })
128
+ }
129
+ }
130
+
131
+ /**
132
+ * List all assets with metadata
133
+ */
134
+ export async function listAssets(_req: Request, res: Response): Promise<void> {
135
+ try {
136
+ const prisma = getDbClient()
137
+
138
+ const assets = await prisma.dbAsset.findMany({
139
+ select: {
140
+ id: true,
141
+ name: true,
142
+ assetType: true,
143
+ mimeType: true,
144
+ fileSize: true,
145
+ width: true,
146
+ height: true,
147
+ checksum: true,
148
+ },
149
+ orderBy: { name: 'asc' },
150
+ })
151
+
152
+ res.json({ assets })
153
+ } catch (error) {
154
+ console.error('Error listing assets:', error)
155
+ res.status(500).json({ error: 'Failed to list assets' })
156
+ }
157
+ }
158
+
159
+ /**
160
+ * Import an asset (icon or screenshot)
161
+ */
162
+ export async function importAsset(req: Request, res: Response): Promise<void> {
163
+ try {
164
+ const file = req.file
165
+ const { name, assetType, mimeType, width, height } = req.body
166
+
167
+ if (!file) {
168
+ res.status(400).json({ error: 'No file uploaded' })
169
+ return
170
+ }
171
+
172
+ const prisma = getDbClient()
173
+ const crypto = await import('node:crypto')
174
+
175
+ // Calculate checksum
176
+ const checksum = crypto
177
+ .createHash('sha256')
178
+ .update(file.buffer)
179
+ .digest('hex')
180
+
181
+ // Convert Buffer to Uint8Array for Prisma Bytes type
182
+ const content = new Uint8Array(file.buffer)
183
+
184
+ // Upsert asset (update if exists, create if not)
185
+ await prisma.dbAsset.upsert({
186
+ where: { name },
187
+ update: {
188
+ content,
189
+ checksum,
190
+ mimeType: mimeType || file.mimetype,
191
+ fileSize: file.size,
192
+ width: width ? parseInt(width) : null,
193
+ height: height ? parseInt(height) : null,
194
+ assetType: assetType || 'icon',
195
+ },
196
+ create: {
197
+ name,
198
+ content,
199
+ checksum,
200
+ mimeType: mimeType || file.mimetype,
201
+ fileSize: file.size,
202
+ width: width ? parseInt(width) : null,
203
+ height: height ? parseInt(height) : null,
204
+ assetType: assetType || 'icon',
205
+ },
206
+ })
207
+
208
+ res.json({ success: true, name, size: file.size })
209
+ } catch (error) {
210
+ console.error('Error importing asset:', error)
211
+ res.status(500).json({ error: 'Failed to import asset' })
212
+ }
213
+ }
@@ -0,0 +1,169 @@
1
+ import { z } from 'zod'
2
+ import { Prisma } from '@prisma/client'
3
+ import { getDbClient } from '../../db'
4
+ import { adminProcedure, publicProcedure, router } from '../../server/trpcSetup'
5
+ import type {
6
+ ApprovalMethod,
7
+ ApprovalMethodConfig,
8
+ CustomConfig,
9
+ PersonTeamConfig,
10
+ ServiceConfig,
11
+ } from '../../types'
12
+ import { generateSlugFromDisplayName } from './slugUtils'
13
+
14
+ // Zod schemas
15
+ const ReachOutContactSchema = z.object({
16
+ displayName: z.string(),
17
+ contact: z.string(),
18
+ })
19
+
20
+ const ServiceConfigSchema = z.object({
21
+ url: z.url().optional(),
22
+ icon: z.string().optional(),
23
+ })
24
+
25
+ const PersonTeamConfigSchema = z.object({
26
+ reachOutContacts: z.array(ReachOutContactSchema).optional(),
27
+ })
28
+
29
+ const CustomConfigSchema = z.object({})
30
+
31
+ const ApprovalMethodConfigSchema = z.union([
32
+ ServiceConfigSchema,
33
+ PersonTeamConfigSchema,
34
+ CustomConfigSchema,
35
+ ])
36
+
37
+ const CreateApprovalMethodSchema = z.object({
38
+ type: z.enum(['service', 'personTeam', 'custom']),
39
+ displayName: z.string().min(1),
40
+ config: ApprovalMethodConfigSchema.optional(),
41
+ })
42
+
43
+ const UpdateApprovalMethodSchema = z.object({
44
+ slug: z.string(),
45
+ type: z.enum(['service', 'personTeam', 'custom']).optional(),
46
+ displayName: z.string().min(1).optional(),
47
+ config: ApprovalMethodConfigSchema.optional(),
48
+ })
49
+
50
+ /**
51
+ * Convert Prisma DbApprovalMethod to our ApprovalMethod type.
52
+ * This ensures tRPC infers proper types for frontend consumers.
53
+ */
54
+ function toApprovalMethod(db: {
55
+ slug: string
56
+ type: ApprovalMethod['type']
57
+ displayName: string
58
+ config: ApprovalMethodConfig | null
59
+ createdAt: Date
60
+ updatedAt: Date
61
+ }): ApprovalMethod {
62
+ // Handle discriminated union by explicitly narrowing based on type
63
+ const baseFields = {
64
+ slug: db.slug,
65
+ displayName: db.displayName,
66
+ createdAt: db.createdAt,
67
+ updatedAt: db.updatedAt,
68
+ }
69
+
70
+ // Provide default empty config if null, as ApprovalMethod discriminated union requires config
71
+ const config = db.config ?? {}
72
+
73
+ switch (db.type) {
74
+ case 'service':
75
+ return { ...baseFields, type: 'service', config: config as ServiceConfig }
76
+ case 'personTeam':
77
+ return {
78
+ ...baseFields,
79
+ type: 'personTeam',
80
+ config: config as PersonTeamConfig,
81
+ }
82
+ case 'custom':
83
+ return { ...baseFields, type: 'custom', config: config as CustomConfig }
84
+ }
85
+ }
86
+
87
+ export function createApprovalMethodRouter() {
88
+ return router({
89
+ // Public: list for selection in app admin
90
+ list: publicProcedure.query(async (): Promise<Array<ApprovalMethod>> => {
91
+ const prisma = getDbClient()
92
+ const results = await prisma.dbApprovalMethod.findMany({
93
+ orderBy: { displayName: 'asc' },
94
+ })
95
+ return results.map(toApprovalMethod)
96
+ }),
97
+
98
+ // Public: get by ID
99
+ getById: publicProcedure
100
+ .input(z.object({ slug: z.string() }))
101
+ .query(async ({ input }): Promise<ApprovalMethod | null> => {
102
+ const prisma = getDbClient()
103
+ const result = await prisma.dbApprovalMethod.findUnique({
104
+ where: { slug: input.slug },
105
+ })
106
+ return result ? toApprovalMethod(result) : null
107
+ }),
108
+
109
+ // Admin: create
110
+ create: adminProcedure
111
+ .input(CreateApprovalMethodSchema)
112
+ .mutation(async ({ input }): Promise<ApprovalMethod> => {
113
+ const prisma = getDbClient()
114
+ const result = await prisma.dbApprovalMethod.create({
115
+ data: {
116
+ slug: generateSlugFromDisplayName(input.displayName),
117
+ type: input.type,
118
+ displayName: input.displayName,
119
+ config: input.config ?? Prisma.JsonNull,
120
+ },
121
+ })
122
+ return toApprovalMethod(result)
123
+ }),
124
+
125
+ // Admin: update
126
+ update: adminProcedure
127
+ .input(UpdateApprovalMethodSchema)
128
+ .mutation(async ({ input }): Promise<ApprovalMethod> => {
129
+ const prisma = getDbClient()
130
+ const { slug, ...updateData } = input
131
+ const result = await prisma.dbApprovalMethod.update({
132
+ where: { slug },
133
+ data: {
134
+ ...(updateData.type !== undefined && { type: updateData.type }),
135
+ ...(updateData.displayName !== undefined && {
136
+ displayName: updateData.displayName,
137
+ }),
138
+ ...(updateData.config !== undefined && {
139
+ config: updateData.config ?? Prisma.JsonNull,
140
+ }),
141
+ },
142
+ })
143
+ return toApprovalMethod(result)
144
+ }),
145
+
146
+ // Admin: delete
147
+ delete: adminProcedure
148
+ .input(z.object({ slug: z.string() }))
149
+ .mutation(async ({ input }): Promise<ApprovalMethod> => {
150
+ const prisma = getDbClient()
151
+ const result = await prisma.dbApprovalMethod.delete({
152
+ where: { slug: input.slug },
153
+ })
154
+ return toApprovalMethod(result)
155
+ }),
156
+
157
+ // Admin: search by type
158
+ listByType: publicProcedure
159
+ .input(z.object({ type: z.enum(['service', 'personTeam', 'custom']) }))
160
+ .query(async ({ input }): Promise<Array<ApprovalMethod>> => {
161
+ const prisma = getDbClient()
162
+ const results = await prisma.dbApprovalMethod.findMany({
163
+ where: { type: input.type },
164
+ orderBy: { displayName: 'asc' },
165
+ })
166
+ return results.map(toApprovalMethod)
167
+ }),
168
+ })
169
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Generates a URL-friendly slug from a display name.
3
+ * Converts to lowercase and replaces non-alphanumeric characters with hyphens.
4
+ *
5
+ * @param displayName - The display name to convert
6
+ * @returns A slug suitable for use as a primary key
7
+ *
8
+ * @example
9
+ * generateSlugFromDisplayName("My Service") // "my-service"
10
+ * generateSlugFromDisplayName("John's Team") // "john-s-team"
11
+ */
12
+ export function generateSlugFromDisplayName(displayName: string): string {
13
+ return displayName
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9]+/g, '-')
16
+ .replace(/^-+|-+$/g, '')
17
+ }
@@ -0,0 +1,38 @@
1
+ import type { PrismaClient as CorePrismaClient } from '@prisma/client'
2
+
3
+ export interface ApprovalMethodSyncInput {
4
+ slug: string
5
+ type: 'service' | 'personTeam' | 'custom'
6
+ displayName: string
7
+ }
8
+
9
+ /**
10
+ * Syncs approval methods to the database using upsert logic based on type + displayName.
11
+ *
12
+ * @param prisma - The PrismaClient instance from the backend-core database
13
+ * @param methods - Array of approval methods to sync
14
+ */
15
+ export async function syncApprovalMethods(
16
+ prisma: CorePrismaClient,
17
+ methods: Array<ApprovalMethodSyncInput>,
18
+ ): Promise<void> {
19
+ // Use transaction for atomicity
20
+ await prisma.$transaction(
21
+ methods.map((method) =>
22
+ prisma.dbApprovalMethod.upsert({
23
+ where: {
24
+ slug: method.slug,
25
+ },
26
+ update: {
27
+ displayName: method.displayName,
28
+ type: method.type,
29
+ },
30
+ create: {
31
+ slug: method.slug,
32
+ type: method.type,
33
+ displayName: method.displayName,
34
+ },
35
+ }),
36
+ ),
37
+ )
38
+ }
@@ -0,0 +1,271 @@
1
+ import type { Request, Response, Router } from 'express'
2
+ import multer from 'multer'
3
+ import sharp from 'sharp'
4
+ import { getDbClient } from '../../db'
5
+ import { getImageFormat, isRasterImage, resizeImage } from './assetUtils'
6
+ import { upsertAsset } from './upsertAsset'
7
+
8
+ // Configure multer for memory storage
9
+ const upload = multer({
10
+ storage: multer.memoryStorage(),
11
+ limits: {
12
+ fileSize: 10 * 1024 * 1024, // 10MB limit
13
+ },
14
+ fileFilter: (_req, file, cb) => {
15
+ // Accept images only
16
+ if (!file.mimetype.startsWith('image/')) {
17
+ cb(new Error('Only image files are allowed'))
18
+ return
19
+ }
20
+ cb(null, true)
21
+ },
22
+ })
23
+
24
+ export interface AssetRestControllerConfig {
25
+ /**
26
+ * Base path for asset endpoints (e.g., '/api/assets')
27
+ */
28
+ basePath: string
29
+ }
30
+
31
+ export interface ParseAssetParams {
32
+ buffer: Buffer
33
+ originalFilename: string
34
+ fileSize?: number
35
+ }
36
+
37
+ export interface ParseAssetReturn {
38
+ checksum: string
39
+ fileSize: number
40
+ mimeType: string
41
+ width?: number
42
+ height?: number
43
+ }
44
+
45
+ /**
46
+ * Registers REST endpoints for universal asset upload and retrieval
47
+ *
48
+ * Endpoints:
49
+ * - POST {basePath}/upload - Upload a new asset (multipart/form-data)
50
+ * - GET {basePath}/:id - Get asset binary by ID
51
+ * - GET {basePath}/:id/metadata - Get asset metadata only
52
+ * - GET {basePath}/by-name/:name - Get asset binary by name
53
+ */
54
+ export function registerAssetRestController(
55
+ router: Router,
56
+ config: AssetRestControllerConfig,
57
+ ): void {
58
+ const { basePath } = config
59
+ const prisma = getDbClient()
60
+
61
+ // Upload endpoint - accepts multipart/form-data
62
+ router.post(
63
+ `${basePath}/upload`,
64
+ upload.single('asset'),
65
+ async (req: Request, res: Response) => {
66
+ try {
67
+ if (!req.file) {
68
+ res.status(400).json({ error: 'No file uploaded' })
69
+ return
70
+ }
71
+
72
+ const name = req.body['name'] as string
73
+ const assetType = req.body['assetType']
74
+
75
+ if (!name) {
76
+ res.status(400).json({ error: 'Name is required' })
77
+ return
78
+ }
79
+
80
+ const id = await upsertAsset({
81
+ prisma,
82
+ buffer: req.file.buffer,
83
+ name,
84
+ originalFilename: req.file.filename,
85
+ assetType,
86
+ })
87
+
88
+ res.status(201).json({ id })
89
+ } catch (error) {
90
+ console.error('Error uploading asset:', error)
91
+ res.status(500).json({ error: 'Failed to upload asset' })
92
+ }
93
+ },
94
+ )
95
+
96
+ // Get asset binary by ID
97
+ router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
98
+ try {
99
+ const { id } = req.params
100
+
101
+ const asset = await prisma.dbAsset.findUnique({
102
+ where: { id },
103
+ select: {
104
+ content: true,
105
+ mimeType: true,
106
+ name: true,
107
+ width: true,
108
+ height: true,
109
+ },
110
+ })
111
+
112
+ if (!asset) {
113
+ res.status(404).json({ error: 'Asset not found' })
114
+ return
115
+ }
116
+
117
+ const resizeEnabled =
118
+ String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'
119
+ const wParam = req.query['w'] as string | undefined
120
+ const width = wParam ? Number.parseInt(wParam, 10) : undefined
121
+
122
+ let outBuffer: Uint8Array = asset.content
123
+ let outMime = asset.mimeType
124
+
125
+ const shouldResize =
126
+ resizeEnabled &&
127
+ isRasterImage(asset.mimeType) &&
128
+ !!width &&
129
+ Number.isFinite(width) &&
130
+ width > 0
131
+
132
+ if (shouldResize) {
133
+ const fmt = getImageFormat(asset.mimeType) || 'jpeg'
134
+ const buf = await resizeImage(
135
+ Buffer.from(asset.content),
136
+ width,
137
+ undefined,
138
+ fmt,
139
+ )
140
+ outBuffer = new Uint8Array(buf)
141
+ outMime = `image/${fmt}`
142
+ }
143
+
144
+ // Set appropriate headers
145
+ res.setHeader('Content-Type', outMime)
146
+ res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
147
+ res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
148
+
149
+ // Send binary content (resized if requested)
150
+ res.send(outBuffer)
151
+ } catch (error) {
152
+ console.error('Error fetching asset:', error)
153
+ res.status(500).json({ error: 'Failed to fetch asset' })
154
+ }
155
+ })
156
+
157
+ // Get asset metadata only (no binary content)
158
+ router.get(
159
+ `${basePath}/:id/metadata`,
160
+ async (req: Request, res: Response) => {
161
+ try {
162
+ const { id } = req.params
163
+
164
+ const asset = await prisma.dbAsset.findUnique({
165
+ where: { id },
166
+ select: {
167
+ id: true,
168
+ name: true,
169
+ assetType: true,
170
+ mimeType: true,
171
+ fileSize: true,
172
+ width: true,
173
+ height: true,
174
+ createdAt: true,
175
+ updatedAt: true,
176
+ },
177
+ })
178
+
179
+ if (!asset) {
180
+ res.status(404).json({ error: 'Asset not found' })
181
+ return
182
+ }
183
+
184
+ res.json(asset)
185
+ } catch (error) {
186
+ console.error('Error fetching asset metadata:', error)
187
+ res.status(500).json({ error: 'Failed to fetch asset metadata' })
188
+ }
189
+ },
190
+ )
191
+
192
+ // Get asset binary by name
193
+ router.get(
194
+ `${basePath}/by-name/:name`,
195
+ async (req: Request, res: Response) => {
196
+ try {
197
+ const { name } = req.params
198
+
199
+ const asset = await prisma.dbAsset.findUnique({
200
+ where: { name },
201
+ select: {
202
+ content: true,
203
+ mimeType: true,
204
+ name: true,
205
+ width: true,
206
+ height: true,
207
+ },
208
+ })
209
+
210
+ if (!asset) {
211
+ res.status(404).json({ error: 'Asset not found' })
212
+ return
213
+ }
214
+
215
+ const resizeEnabled =
216
+ String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'
217
+ const wParam = req.query['w'] as string | undefined
218
+ const width = wParam ? Number.parseInt(wParam, 10) : undefined
219
+
220
+ let outBuffer: Uint8Array = asset.content
221
+ let outMime = asset.mimeType
222
+
223
+ const isRaster =
224
+ asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')
225
+ const shouldResize =
226
+ resizeEnabled &&
227
+ isRaster &&
228
+ !!width &&
229
+ Number.isFinite(width) &&
230
+ width > 0
231
+
232
+ if (shouldResize) {
233
+ const fmt = asset.mimeType.includes('png')
234
+ ? 'png'
235
+ : asset.mimeType.includes('webp')
236
+ ? 'webp'
237
+ : 'jpeg'
238
+
239
+ let buf: Buffer
240
+ const pipeline = sharp(Buffer.from(asset.content)).resize({
241
+ width,
242
+ fit: 'inside',
243
+ withoutEnlargement: true,
244
+ })
245
+ if (fmt === 'png') {
246
+ buf = await pipeline.png().toBuffer()
247
+ outMime = 'image/png'
248
+ } else if (fmt === 'webp') {
249
+ buf = await pipeline.webp().toBuffer()
250
+ outMime = 'image/webp'
251
+ } else {
252
+ buf = await pipeline.jpeg().toBuffer()
253
+ outMime = 'image/jpeg'
254
+ }
255
+ outBuffer = new Uint8Array(buf)
256
+ }
257
+
258
+ // Set appropriate headers
259
+ res.setHeader('Content-Type', outMime)
260
+ res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
261
+ res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
262
+
263
+ // Send binary content (resized if requested)
264
+ res.send(outBuffer)
265
+ } catch (error) {
266
+ console.error('Error fetching asset by name:', error)
267
+ res.status(500).json({ error: 'Failed to fetch asset' })
268
+ }
269
+ },
270
+ )
271
+ }