@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.
- package/LICENSE +21 -0
- package/dist/index.d.ts +1934 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2539 -0
- package/dist/index.js.map +1 -0
- package/package.json +84 -0
- package/prisma/migrations/20250526183023_init/migration.sql +71 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +149 -0
- package/src/__tests__/dummy.test.ts +7 -0
- package/src/db/client.ts +42 -0
- package/src/db/index.ts +21 -0
- package/src/db/syncAppCatalog.ts +312 -0
- package/src/db/tableSyncMagazine.ts +32 -0
- package/src/db/tableSyncPrismaAdapter.ts +203 -0
- package/src/index.ts +126 -0
- package/src/middleware/backendResolver.ts +42 -0
- package/src/middleware/createEhMiddleware.ts +171 -0
- package/src/middleware/database.ts +62 -0
- package/src/middleware/featureRegistry.ts +173 -0
- package/src/middleware/index.ts +43 -0
- package/src/middleware/types.ts +202 -0
- package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
- package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
- package/src/modules/appCatalog/service.ts +130 -0
- package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +187 -0
- package/src/modules/appCatalogAdmin/catalogBackupController.ts +213 -0
- package/src/modules/approvalMethod/approvalMethodRouter.ts +169 -0
- package/src/modules/approvalMethod/slugUtils.ts +17 -0
- package/src/modules/approvalMethod/syncApprovalMethods.ts +38 -0
- package/src/modules/assets/assetRestController.ts +271 -0
- package/src/modules/assets/assetUtils.ts +114 -0
- package/src/modules/assets/screenshotRestController.ts +195 -0
- package/src/modules/assets/screenshotRouter.ts +112 -0
- package/src/modules/assets/syncAssets.ts +277 -0
- package/src/modules/assets/upsertAsset.ts +46 -0
- package/src/modules/auth/auth.ts +51 -0
- package/src/modules/auth/authProviders.ts +40 -0
- package/src/modules/auth/authRouter.ts +75 -0
- package/src/modules/auth/authorizationUtils.ts +132 -0
- package/src/modules/auth/devMockUserUtils.ts +49 -0
- package/src/modules/auth/registerAuthRoutes.ts +33 -0
- package/src/modules/icons/iconRestController.ts +171 -0
- package/src/modules/icons/iconRouter.ts +180 -0
- package/src/modules/icons/iconService.ts +73 -0
- package/src/modules/icons/iconUtils.ts +46 -0
- package/src/prisma-json-types.d.ts +34 -0
- package/src/server/controller.ts +47 -0
- package/src/server/ehStaticControllerContract.ts +19 -0
- package/src/server/ehTrpcContext.ts +26 -0
- package/src/server/trpcSetup.ts +89 -0
- package/src/types/backend/api.ts +73 -0
- package/src/types/backend/common.ts +10 -0
- package/src/types/backend/companySpecificBackend.ts +5 -0
- package/src/types/backend/dataSources.ts +25 -0
- package/src/types/backend/deployments.ts +40 -0
- package/src/types/common/app/appTypes.ts +13 -0
- package/src/types/common/app/ui/appUiTypes.ts +12 -0
- package/src/types/common/appCatalogTypes.ts +65 -0
- package/src/types/common/approvalMethodTypes.ts +149 -0
- package/src/types/common/env/envTypes.ts +7 -0
- package/src/types/common/resourceTypes.ts +8 -0
- package/src/types/common/sharedTypes.ts +5 -0
- 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
|
+
}
|