@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,114 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { FormatEnum } from 'sharp'
3
+ import sharp from 'sharp'
4
+ import type { ParseAssetParams, ParseAssetReturn } from './assetRestController'
5
+
6
+ /**
7
+ * Extract image dimensions from a buffer using sharp
8
+ */
9
+ export async function getImageDimensions(
10
+ buffer: Buffer,
11
+ ): Promise<{ width?: number; height?: number }> {
12
+ try {
13
+ const metadata = await sharp(buffer).metadata()
14
+ return {
15
+ width: metadata.width,
16
+ height: metadata.height,
17
+ }
18
+ } catch (error) {
19
+ console.error('Error extracting image dimensions:', error)
20
+ return { width: undefined, height: undefined }
21
+ }
22
+ }
23
+
24
+ /**
25
+ * Resize an image buffer to the specified dimensions
26
+ * @param buffer - The image buffer to resize
27
+ * @param width - Target width (optional)
28
+ * @param height - Target height (optional)
29
+ * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
30
+ */
31
+ export async function resizeImage(
32
+ buffer: Buffer,
33
+ width?: number,
34
+ height?: number,
35
+ format?: 'png' | 'jpeg' | 'webp',
36
+ ): Promise<Buffer> {
37
+ let pipeline = sharp(buffer)
38
+
39
+ // Apply resize if dimensions provided
40
+ if (width || height) {
41
+ pipeline = pipeline.resize({
42
+ width,
43
+ height,
44
+ fit: 'inside',
45
+ withoutEnlargement: true,
46
+ })
47
+ }
48
+
49
+ // Apply format conversion if specified
50
+ if (format === 'png') {
51
+ pipeline = pipeline.png()
52
+ } else if (format === 'webp') {
53
+ pipeline = pipeline.webp()
54
+ } else if (format === 'jpeg') {
55
+ pipeline = pipeline.jpeg()
56
+ }
57
+
58
+ return pipeline.toBuffer()
59
+ }
60
+
61
+ /**
62
+ * Generate SHA-256 checksum for a buffer
63
+ */
64
+ export function generateChecksum(buffer: Buffer): string {
65
+ return createHash('sha256').update(buffer).digest('hex')
66
+ }
67
+
68
+ /**
69
+ * Detect image format from mime type
70
+ */
71
+ export function getImageFormat(
72
+ mimeType: string,
73
+ ): 'png' | 'webp' | 'jpeg' | null {
74
+ if (mimeType.includes('png')) return 'png'
75
+ if (mimeType.includes('webp')) return 'webp'
76
+ if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'
77
+ return null
78
+ }
79
+
80
+ /**
81
+ * Check if a mime type represents a raster image (not SVG)
82
+ */
83
+ export function isRasterImage(mimeType: string): boolean {
84
+ return mimeType.startsWith('image/') && !mimeType.includes('svg')
85
+ }
86
+
87
+ export async function parseAssetMeta(
88
+ p: ParseAssetParams,
89
+ ): Promise<ParseAssetReturn> {
90
+ // Get image dimensions using our utility
91
+ const { width, height, format, size } = await sharp(p.buffer).metadata()
92
+
93
+ const formatToMime: Partial<Record<keyof FormatEnum, string>> = {
94
+ jpeg: 'image/jpeg',
95
+ jpg: 'image/jpeg',
96
+ png: 'image/png',
97
+ webp: 'image/webp',
98
+ avif: 'image/avif',
99
+ tiff: 'image/tiff',
100
+ gif: 'image/gif',
101
+ heif: 'image/heif',
102
+ raw: 'application/octet-stream',
103
+ }
104
+
105
+ return {
106
+ checksum: generateChecksum(p.buffer),
107
+ width,
108
+ height,
109
+ mimeType: format
110
+ ? (formatToMime[format] ?? `image/${format}`)
111
+ : 'application/octet-stream',
112
+ fileSize: size || 0,
113
+ }
114
+ }
@@ -0,0 +1,195 @@
1
+ import type { Request, Response, Router } from 'express'
2
+ import sharp from 'sharp'
3
+ import { getDbClient } from '../../db'
4
+
5
+ export interface ScreenshotRestControllerConfig {
6
+ /**
7
+ * Base path for screenshot endpoints (e.g., '/api/screenshots')
8
+ */
9
+ basePath: string
10
+ }
11
+
12
+ /**
13
+ * Registers REST endpoints for screenshot retrieval
14
+ *
15
+ * Endpoints:
16
+ * - GET {basePath}/app/:appId - Get all screenshots for an app
17
+ * - GET {basePath}/:id - Get screenshot binary by ID
18
+ * - GET {basePath}/:id/metadata - Get screenshot metadata only
19
+ */
20
+ export function registerScreenshotRestController(
21
+ router: Router,
22
+ config: ScreenshotRestControllerConfig,
23
+ ): void {
24
+ const { basePath } = config
25
+
26
+ // Get all screenshots for an app
27
+ router.get(`${basePath}/app/:appSlug`, async (req: Request, res: Response) => {
28
+ try {
29
+ const { appSlug } = req.params
30
+
31
+ const prisma = getDbClient()
32
+
33
+ // Find app by slug
34
+ const app = await prisma.dbAppForCatalog.findUnique({
35
+ where: { slug: appSlug },
36
+ select: { screenshotIds: true },
37
+ })
38
+
39
+ if (!app) {
40
+ res.status(404).json({ error: 'App not found' })
41
+ return
42
+ }
43
+
44
+ // Fetch all screenshots for the app
45
+ const screenshots = await prisma.dbAsset.findMany({
46
+ where: {
47
+ id: { in: app.screenshotIds },
48
+ assetType: 'screenshot',
49
+ },
50
+ select: {
51
+ id: true,
52
+ name: true,
53
+ mimeType: true,
54
+ fileSize: true,
55
+ width: true,
56
+ height: true,
57
+ createdAt: true,
58
+ },
59
+ })
60
+
61
+ res.json(screenshots)
62
+ } catch (error) {
63
+ console.error('Error fetching app screenshots:', error)
64
+ res.status(500).json({ error: 'Failed to fetch screenshots' })
65
+ }
66
+ })
67
+
68
+ // Get first screenshot for an app (convenience endpoint)
69
+ router.get(`${basePath}/app/:appSlug/first`, async (req: Request, res: Response) => {
70
+ try {
71
+ const { appSlug } = req.params
72
+
73
+ const prisma = getDbClient()
74
+
75
+ // Find app by slug
76
+ const app = await prisma.dbAppForCatalog.findUnique({
77
+ where: { slug: appSlug },
78
+ select: { screenshotIds: true },
79
+ })
80
+
81
+ if (!app || app.screenshotIds.length === 0) {
82
+ res.status(404).json({ error: 'No screenshots found' })
83
+ return
84
+ }
85
+
86
+ // Fetch first screenshot
87
+ const screenshot = await prisma.dbAsset.findUnique({
88
+ where: { id: app.screenshotIds[0] },
89
+ select: {
90
+ id: true,
91
+ name: true,
92
+ mimeType: true,
93
+ fileSize: true,
94
+ width: true,
95
+ height: true,
96
+ createdAt: true,
97
+ },
98
+ })
99
+
100
+ if (!screenshot) {
101
+ res.status(404).json({ error: 'Screenshot not found' })
102
+ return
103
+ }
104
+
105
+ res.json(screenshot)
106
+ } catch (error) {
107
+ console.error('Error fetching first screenshot:', error)
108
+ res.status(500).json({ error: 'Failed to fetch screenshot' })
109
+ }
110
+ })
111
+
112
+ // Get screenshot binary by ID
113
+ router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
114
+ try {
115
+ const { id } = req.params
116
+ const sizeParam = req.query.size as string | undefined
117
+ const targetSize = sizeParam ? parseInt(sizeParam, 10) : undefined
118
+
119
+ const prisma = getDbClient()
120
+ const screenshot = await prisma.dbAsset.findUnique({
121
+ where: { id },
122
+ select: {
123
+ content: true,
124
+ mimeType: true,
125
+ name: true,
126
+ },
127
+ })
128
+
129
+ if (!screenshot) {
130
+ res.status(404).json({ error: 'Screenshot not found' })
131
+ return
132
+ }
133
+
134
+ let content: Uint8Array | Buffer = screenshot.content
135
+
136
+ // Resize if size parameter provided
137
+ if (targetSize && targetSize > 0) {
138
+ try {
139
+ content = await sharp(screenshot.content)
140
+ .resize(targetSize, targetSize, {
141
+ fit: 'inside',
142
+ withoutEnlargement: true,
143
+ })
144
+ .toBuffer()
145
+ } catch (resizeError) {
146
+ console.error('Error resizing screenshot:', resizeError)
147
+ // Fall back to original if resize fails
148
+ }
149
+ }
150
+
151
+ // Set appropriate headers
152
+ res.setHeader('Content-Type', screenshot.mimeType)
153
+ res.setHeader('Content-Disposition', `inline; filename="${screenshot.name}"`)
154
+ res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
155
+
156
+ // Send binary content
157
+ res.send(content)
158
+ } catch (error) {
159
+ console.error('Error fetching screenshot:', error)
160
+ res.status(500).json({ error: 'Failed to fetch screenshot' })
161
+ }
162
+ })
163
+
164
+ // Get screenshot metadata only (no binary content)
165
+ router.get(`${basePath}/:id/metadata`, async (req: Request, res: Response) => {
166
+ try {
167
+ const { id } = req.params
168
+
169
+ const prisma = getDbClient()
170
+ const screenshot = await prisma.dbAsset.findUnique({
171
+ where: { id },
172
+ select: {
173
+ id: true,
174
+ name: true,
175
+ mimeType: true,
176
+ fileSize: true,
177
+ width: true,
178
+ height: true,
179
+ createdAt: true,
180
+ updatedAt: true,
181
+ },
182
+ })
183
+
184
+ if (!screenshot) {
185
+ res.status(404).json({ error: 'Screenshot not found' })
186
+ return
187
+ }
188
+
189
+ res.json(screenshot)
190
+ } catch (error) {
191
+ console.error('Error fetching screenshot metadata:', error)
192
+ res.status(500).json({ error: 'Failed to fetch screenshot metadata' })
193
+ }
194
+ })
195
+ }
@@ -0,0 +1,112 @@
1
+ import { z } from 'zod'
2
+ import { getDbClient } from '../../db'
3
+ import { publicProcedure, router } from '../../server/trpcSetup'
4
+
5
+ export function createScreenshotRouter() {
6
+ return router({
7
+ list: publicProcedure.query(async () => {
8
+ const prisma = getDbClient()
9
+ return prisma.dbAsset.findMany({
10
+ where: { assetType: 'screenshot' },
11
+ select: {
12
+ id: true,
13
+ name: true,
14
+ mimeType: true,
15
+ fileSize: true,
16
+ width: true,
17
+ height: true,
18
+ createdAt: true,
19
+ updatedAt: true,
20
+ },
21
+ orderBy: { createdAt: 'desc' },
22
+ })
23
+ }),
24
+
25
+ getOne: publicProcedure
26
+ .input(z.object({ id: z.string() }))
27
+ .query(async ({ input }) => {
28
+ const prisma = getDbClient()
29
+ return prisma.dbAsset.findFirst({
30
+ where: {
31
+ id: input.id,
32
+ assetType: 'screenshot',
33
+ },
34
+ select: {
35
+ id: true,
36
+ name: true,
37
+ mimeType: true,
38
+ fileSize: true,
39
+ width: true,
40
+ height: true,
41
+ createdAt: true,
42
+ updatedAt: true,
43
+ },
44
+ })
45
+ }),
46
+
47
+ getByAppSlug: publicProcedure
48
+ .input(z.object({ appSlug: z.string() }))
49
+ .query(async ({ input }) => {
50
+ const prisma = getDbClient()
51
+
52
+ // Find app by slug
53
+ const app = await prisma.dbAppForCatalog.findUnique({
54
+ where: { slug: input.appSlug },
55
+ select: { screenshotIds: true },
56
+ })
57
+
58
+ if (!app) {
59
+ return []
60
+ }
61
+
62
+ // Fetch all screenshots for the app
63
+ return prisma.dbAsset.findMany({
64
+ where: {
65
+ id: { in: app.screenshotIds },
66
+ assetType: 'screenshot',
67
+ },
68
+ select: {
69
+ id: true,
70
+ name: true,
71
+ mimeType: true,
72
+ fileSize: true,
73
+ width: true,
74
+ height: true,
75
+ createdAt: true,
76
+ updatedAt: true,
77
+ },
78
+ })
79
+ }),
80
+
81
+ getFirstByAppSlug: publicProcedure
82
+ .input(z.object({ appSlug: z.string() }))
83
+ .query(async ({ input }) => {
84
+ const prisma = getDbClient()
85
+
86
+ // Find app by slug
87
+ const app = await prisma.dbAppForCatalog.findUnique({
88
+ where: { slug: input.appSlug },
89
+ select: { screenshotIds: true },
90
+ })
91
+
92
+ if (!app || app.screenshotIds.length === 0) {
93
+ return null
94
+ }
95
+
96
+ // Fetch first screenshot
97
+ return prisma.dbAsset.findUnique({
98
+ where: { id: app.screenshotIds[0] },
99
+ select: {
100
+ id: true,
101
+ name: true,
102
+ mimeType: true,
103
+ fileSize: true,
104
+ width: true,
105
+ height: true,
106
+ createdAt: true,
107
+ updatedAt: true,
108
+ },
109
+ })
110
+ }),
111
+ })
112
+ }
@@ -0,0 +1,277 @@
1
+ import { readFileSync, readdirSync } from 'node:fs'
2
+ import { extname, join } from 'node:path'
3
+ import { getDbClient } from '../../db'
4
+ import { generateChecksum, getImageDimensions } from './assetUtils'
5
+
6
+ export interface SyncAssetsConfig {
7
+ /**
8
+ * Directory containing icon files to sync
9
+ */
10
+ iconsDir?: string
11
+
12
+ /**
13
+ * Directory containing screenshot files to sync
14
+ */
15
+ screenshotsDir?: string
16
+ }
17
+
18
+ /**
19
+ * Sync local asset files (icons and screenshots) from directories into the database.
20
+ *
21
+ * This function allows consuming applications to sync asset files without directly
22
+ * exposing the Prisma client. It handles:
23
+ * - Icon files: Assigned to apps by matching filename to icon name patterns
24
+ * - Screenshot files: Assigned to apps by matching filename to app ID (format: <app-id>_screenshot_<no>.<ext>)
25
+ *
26
+ * @param config Configuration with paths to icon and screenshot directories
27
+ */
28
+ export async function syncAssets(config: SyncAssetsConfig): Promise<{
29
+ iconsUpserted: number
30
+ screenshotsUpserted: number
31
+ }> {
32
+ const prisma = getDbClient()
33
+ let iconsUpserted = 0
34
+ let screenshotsUpserted = 0
35
+
36
+ // Sync icons from local/icons directory
37
+ if (config.iconsDir) {
38
+ console.log(`📁 Syncing icons from ${config.iconsDir}...`)
39
+ iconsUpserted = await syncIconsFromDirectory(prisma, config.iconsDir)
40
+ console.log(` ✓ Upserted ${iconsUpserted} icons`)
41
+ }
42
+
43
+ // Sync screenshots from local/screenshots directory
44
+ if (config.screenshotsDir) {
45
+ console.log(`📷 Syncing screenshots from ${config.screenshotsDir}...`)
46
+ screenshotsUpserted = await syncScreenshotsFromDirectory(
47
+ prisma,
48
+ config.screenshotsDir,
49
+ )
50
+ console.log(
51
+ ` ✓ Upserted ${screenshotsUpserted} screenshots and assigned to apps`,
52
+ )
53
+ }
54
+
55
+ return {
56
+ iconsUpserted,
57
+ screenshotsUpserted,
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Sync icon files from a directory
63
+ */
64
+ async function syncIconsFromDirectory(
65
+ prisma: ReturnType<typeof getDbClient>,
66
+ iconsDir: string,
67
+ ): Promise<number> {
68
+ let count = 0
69
+
70
+ try {
71
+ const files = readdirSync(iconsDir)
72
+
73
+ for (const file of files) {
74
+ const filePath = join(iconsDir, file)
75
+ const ext = extname(file).toLowerCase().slice(1) // Remove leading dot
76
+
77
+ // Skip non-image files
78
+ if (!['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'].includes(ext)) {
79
+ continue
80
+ }
81
+
82
+ try {
83
+ const content = readFileSync(filePath)
84
+ const buffer = Buffer.from(content)
85
+ const checksum = generateChecksum(buffer)
86
+ const iconName = file.replace(/\.[^/.]+$/, '') // Remove extension
87
+
88
+ // Check if asset with same checksum already exists
89
+ const existing = await prisma.dbAsset.findFirst({
90
+ where: { checksum, assetType: 'icon' },
91
+ })
92
+
93
+ if (existing) {
94
+ continue // Already synced
95
+ }
96
+
97
+ // Extract dimensions for raster images
98
+ let width: number | null = null
99
+ let height: number | null = null
100
+ if (!ext.includes('svg')) {
101
+ const { width: w, height: h } = await getImageDimensions(buffer)
102
+ width = w ?? null
103
+ height = h ?? null
104
+ }
105
+
106
+ // Determine MIME type
107
+ const mimeType =
108
+ {
109
+ png: 'image/png',
110
+ jpg: 'image/jpeg',
111
+ jpeg: 'image/jpeg',
112
+ gif: 'image/gif',
113
+ webp: 'image/webp',
114
+ svg: 'image/svg+xml',
115
+ }[ext] || 'application/octet-stream'
116
+
117
+ await prisma.dbAsset.create({
118
+ data: {
119
+ name: iconName,
120
+ assetType: 'icon',
121
+ content: new Uint8Array(buffer),
122
+ checksum,
123
+ mimeType,
124
+ fileSize: buffer.length,
125
+ width,
126
+ height,
127
+ },
128
+ })
129
+
130
+ count++
131
+ } catch (error) {
132
+ console.warn(` ⚠ Failed to sync icon ${file}:`, error)
133
+ }
134
+ }
135
+ } catch (error) {
136
+ console.error(` ❌ Error reading icons directory:`, error)
137
+ }
138
+
139
+ return count
140
+ }
141
+
142
+ /**
143
+ * Sync screenshot files from a directory and assign to apps
144
+ */
145
+ async function syncScreenshotsFromDirectory(
146
+ prisma: ReturnType<typeof getDbClient>,
147
+ screenshotsDir: string,
148
+ ): Promise<number> {
149
+ let count = 0
150
+
151
+ try {
152
+ const files = readdirSync(screenshotsDir)
153
+
154
+ // Group screenshots by app ID
155
+ const screenshotsByApp = new Map<
156
+ string,
157
+ Array<{ path: string; ext: string }>
158
+ >()
159
+
160
+ for (const file of files) {
161
+ // Parse filename: <app-id>_screenshot_<no>.<ext>
162
+ const match = file.match(/^(.+?)_screenshot_(\d+)\.([^.]+)$/)
163
+ if (!match || !match[1] || !match[3]) {
164
+ continue
165
+ }
166
+
167
+ const appId = match[1]
168
+ const ext = match[3]
169
+ if (!screenshotsByApp.has(appId)) {
170
+ screenshotsByApp.set(appId, [])
171
+ }
172
+ screenshotsByApp.get(appId)!.push({
173
+ path: join(screenshotsDir, file),
174
+ ext,
175
+ })
176
+ }
177
+
178
+ // Process each app's screenshots
179
+ for (const [appId, screenshots] of screenshotsByApp) {
180
+ try {
181
+ // Check if app exists
182
+ const app = await prisma.dbAppForCatalog.findUnique({
183
+ where: { slug: appId },
184
+ select: { id: true },
185
+ })
186
+
187
+ if (!app) {
188
+ console.warn(` ⚠ App not found: ${appId}`)
189
+ continue
190
+ }
191
+
192
+ // Sync screenshots for this app
193
+ for (const screenshot of screenshots) {
194
+ try {
195
+ const content = readFileSync(screenshot.path)
196
+ const buffer = Buffer.from(content)
197
+ const checksum = generateChecksum(buffer)
198
+
199
+ // Check if screenshot with same checksum already exists
200
+ const existing = await prisma.dbAsset.findFirst({
201
+ where: { checksum, assetType: 'screenshot' },
202
+ })
203
+
204
+ if (existing) {
205
+ // Link to app via screenshotIds array if not already linked
206
+ const existingApp = await prisma.dbAppForCatalog.findUnique({
207
+ where: { slug: appId },
208
+ })
209
+ if (
210
+ existingApp &&
211
+ !existingApp.screenshotIds.includes(existing.id)
212
+ ) {
213
+ await prisma.dbAppForCatalog.update({
214
+ where: { slug: appId },
215
+ data: {
216
+ screenshotIds: [...existingApp.screenshotIds, existing.id],
217
+ },
218
+ })
219
+ }
220
+ continue
221
+ }
222
+
223
+ // Extract dimensions
224
+ const { width, height } = await getImageDimensions(buffer)
225
+
226
+ // Determine MIME type
227
+ const mimeType =
228
+ {
229
+ png: 'image/png',
230
+ jpg: 'image/jpeg',
231
+ jpeg: 'image/jpeg',
232
+ gif: 'image/gif',
233
+ webp: 'image/webp',
234
+ }[screenshot.ext.toLowerCase()] || 'application/octet-stream'
235
+
236
+ // Create screenshot asset
237
+ const asset = await prisma.dbAsset.create({
238
+ data: {
239
+ name: `${appId}-screenshot-${Date.now()}`,
240
+ assetType: 'screenshot',
241
+ content: new Uint8Array(buffer),
242
+ checksum,
243
+ mimeType,
244
+ fileSize: buffer.length,
245
+ width: width ?? null,
246
+ height: height ?? null,
247
+ },
248
+ })
249
+
250
+ // Link screenshot to app via screenshotIds array
251
+ await prisma.dbAppForCatalog.update({
252
+ where: { slug: appId },
253
+ data: {
254
+ screenshotIds: {
255
+ push: asset.id,
256
+ },
257
+ },
258
+ })
259
+
260
+ count++
261
+ } catch (error) {
262
+ console.warn(
263
+ ` ⚠ Failed to sync screenshot ${screenshot.path}:`,
264
+ error,
265
+ )
266
+ }
267
+ }
268
+ } catch (error) {
269
+ console.warn(` ⚠ Failed to process app ${appId}:`, error)
270
+ }
271
+ }
272
+ } catch (error) {
273
+ console.error(` ❌ Error reading screenshots directory:`, error)
274
+ }
275
+
276
+ return count
277
+ }