@env-hopper/backend-core 2.0.1-alpha → 2.0.1-alpha.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 (66) hide show
  1. package/dist/index.d.ts +1584 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +1806 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +26 -11
  6. package/prisma/migrations/20250526183023_init/migration.sql +71 -0
  7. package/prisma/migrations/migration_lock.toml +3 -0
  8. package/prisma/schema.prisma +121 -0
  9. package/src/db/client.ts +34 -0
  10. package/src/db/index.ts +17 -0
  11. package/src/db/syncAppCatalog.ts +67 -0
  12. package/src/db/tableSyncMagazine.ts +22 -0
  13. package/src/db/tableSyncPrismaAdapter.ts +202 -0
  14. package/src/index.ts +96 -3
  15. package/src/modules/admin/chat/createAdminChatHandler.ts +152 -0
  16. package/src/modules/admin/chat/createDatabaseTools.ts +261 -0
  17. package/src/modules/appCatalog/service.ts +79 -0
  18. package/src/modules/appCatalogAdmin/appCatalogAdminRouter.ts +113 -0
  19. package/src/modules/assets/assetRestController.ts +309 -0
  20. package/src/modules/assets/assetUtils.ts +81 -0
  21. package/src/modules/assets/screenshotRestController.ts +195 -0
  22. package/src/modules/assets/screenshotRouter.ts +116 -0
  23. package/src/modules/assets/syncAssets.ts +261 -0
  24. package/src/modules/auth/auth.ts +51 -0
  25. package/src/modules/auth/authProviders.ts +108 -0
  26. package/src/modules/auth/authRouter.ts +77 -0
  27. package/src/modules/auth/authorizationUtils.ts +114 -0
  28. package/src/modules/auth/registerAuthRoutes.ts +33 -0
  29. package/src/modules/icons/iconRestController.ts +190 -0
  30. package/src/modules/icons/iconRouter.ts +157 -0
  31. package/src/modules/icons/iconService.ts +73 -0
  32. package/src/server/controller.ts +102 -29
  33. package/src/server/ehStaticControllerContract.ts +8 -1
  34. package/src/server/ehTrpcContext.ts +0 -6
  35. package/src/types/backend/api.ts +1 -14
  36. package/src/types/backend/companySpecificBackend.ts +17 -0
  37. package/src/types/common/appCatalogTypes.ts +167 -0
  38. package/src/types/common/dataRootTypes.ts +72 -10
  39. package/src/types/index.ts +2 -0
  40. package/dist/esm/__tests__/dummy.test.d.ts +0 -1
  41. package/dist/esm/index.d.ts +0 -7
  42. package/dist/esm/index.js +0 -9
  43. package/dist/esm/index.js.map +0 -1
  44. package/dist/esm/server/controller.d.ts +0 -32
  45. package/dist/esm/server/controller.js +0 -35
  46. package/dist/esm/server/controller.js.map +0 -1
  47. package/dist/esm/server/db.d.ts +0 -2
  48. package/dist/esm/server/ehStaticControllerContract.d.ts +0 -9
  49. package/dist/esm/server/ehStaticControllerContract.js +0 -12
  50. package/dist/esm/server/ehStaticControllerContract.js.map +0 -1
  51. package/dist/esm/server/ehTrpcContext.d.ts +0 -8
  52. package/dist/esm/server/ehTrpcContext.js +0 -11
  53. package/dist/esm/server/ehTrpcContext.js.map +0 -1
  54. package/dist/esm/types/backend/api.d.ts +0 -71
  55. package/dist/esm/types/backend/common.d.ts +0 -9
  56. package/dist/esm/types/backend/dataSources.d.ts +0 -20
  57. package/dist/esm/types/backend/deployments.d.ts +0 -34
  58. package/dist/esm/types/common/app/appTypes.d.ts +0 -12
  59. package/dist/esm/types/common/app/ui/appUiTypes.d.ts +0 -10
  60. package/dist/esm/types/common/appCatalogTypes.d.ts +0 -16
  61. package/dist/esm/types/common/dataRootTypes.d.ts +0 -32
  62. package/dist/esm/types/common/env/envTypes.d.ts +0 -6
  63. package/dist/esm/types/common/resourceTypes.d.ts +0 -8
  64. package/dist/esm/types/common/sharedTypes.d.ts +0 -4
  65. package/dist/esm/types/index.d.ts +0 -11
  66. package/src/server/db.ts +0 -4
@@ -0,0 +1,309 @@
1
+ import type { AssetType } from '@prisma/client'
2
+ import type { Request, Response, Router } from 'express'
3
+ import multer from 'multer'
4
+ import sharp from 'sharp'
5
+ import { getDbClient } from '../../db'
6
+ import {
7
+ generateChecksum,
8
+ getImageDimensions,
9
+ getImageFormat,
10
+ isRasterImage,
11
+ resizeImage,
12
+ } from './assetUtils'
13
+
14
+ // Configure multer for memory storage
15
+ const upload = multer({
16
+ storage: multer.memoryStorage(),
17
+ limits: {
18
+ fileSize: 10 * 1024 * 1024, // 10MB limit
19
+ },
20
+ fileFilter: (_req, file, cb) => {
21
+ // Accept images only
22
+ if (!file.mimetype.startsWith('image/')) {
23
+ cb(new Error('Only image files are allowed'))
24
+ return
25
+ }
26
+ cb(null, true)
27
+ },
28
+ })
29
+
30
+ export interface AssetRestControllerConfig {
31
+ /**
32
+ * Base path for asset endpoints (e.g., '/api/assets')
33
+ */
34
+ basePath: string
35
+ }
36
+
37
+ /**
38
+ * Registers REST endpoints for universal asset upload and retrieval
39
+ *
40
+ * Endpoints:
41
+ * - POST {basePath}/upload - Upload a new asset (multipart/form-data)
42
+ * - GET {basePath}/:id - Get asset binary by ID
43
+ * - GET {basePath}/:id/metadata - Get asset metadata only
44
+ * - GET {basePath}/by-name/:name - Get asset binary by name
45
+ */
46
+ export function registerAssetRestController(
47
+ router: Router,
48
+ config: AssetRestControllerConfig,
49
+ ): void {
50
+ const { basePath } = config
51
+
52
+ // Upload endpoint - accepts multipart/form-data
53
+ router.post(
54
+ `${basePath}/upload`,
55
+ upload.single('asset'),
56
+ async (req: Request, res: Response) => {
57
+ try {
58
+ if (!req.file) {
59
+ res.status(400).json({ error: 'No file uploaded' })
60
+ return
61
+ }
62
+
63
+ const name = req.body['name'] as string
64
+ const assetTypeInput = req.body['assetType']
65
+ const assetType = (assetTypeInput as AssetType | undefined) ?? 'icon'
66
+
67
+ if (!name) {
68
+ res.status(400).json({ error: 'Name is required' })
69
+ return
70
+ }
71
+
72
+ const prisma = getDbClient()
73
+
74
+ // Compute checksum of the binary for content-based deduplication.
75
+ const checksum = generateChecksum(req.file.buffer)
76
+
77
+ // If an asset with the same checksum already exists, reuse it instead of storing duplicate binary.
78
+ const existing = await prisma.dbAsset.findUnique({
79
+ where: { checksum },
80
+ select: {
81
+ id: true,
82
+ name: true,
83
+ assetType: true,
84
+ checksum: true,
85
+ mimeType: true,
86
+ fileSize: true,
87
+ width: true,
88
+ height: true,
89
+ createdAt: true,
90
+ },
91
+ })
92
+
93
+ if (existing) {
94
+ res.status(200).json(existing)
95
+ return
96
+ }
97
+
98
+ // Get image dimensions using our utility
99
+ const { width, height } = await getImageDimensions(req.file.buffer)
100
+
101
+ const asset = await prisma.dbAsset.create({
102
+ data: {
103
+ name,
104
+ checksum,
105
+ assetType,
106
+ content: new Uint8Array(req.file.buffer),
107
+ mimeType: req.file.mimetype,
108
+ fileSize: req.file.size,
109
+ width,
110
+ height,
111
+ },
112
+ })
113
+
114
+ res.status(201).json({
115
+ id: asset.id,
116
+ name: asset.name,
117
+ assetType: asset.assetType,
118
+ mimeType: asset.mimeType,
119
+ fileSize: asset.fileSize,
120
+ width: asset.width,
121
+ height: asset.height,
122
+ createdAt: asset.createdAt,
123
+ })
124
+ } catch (error) {
125
+ console.error('Error uploading asset:', error)
126
+ res.status(500).json({ error: 'Failed to upload asset' })
127
+ }
128
+ },
129
+ )
130
+
131
+ // Get asset binary by ID
132
+ router.get(`${basePath}/:id`, async (req: Request, res: Response) => {
133
+ try {
134
+ const { id } = req.params
135
+
136
+ const prisma = getDbClient()
137
+ const asset = await prisma.dbAsset.findUnique({
138
+ where: { id },
139
+ select: {
140
+ content: true,
141
+ mimeType: true,
142
+ name: true,
143
+ width: true,
144
+ height: true,
145
+ },
146
+ })
147
+
148
+ if (!asset) {
149
+ res.status(404).json({ error: 'Asset not found' })
150
+ return
151
+ }
152
+
153
+ const resizeEnabled =
154
+ String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'
155
+ const wParam = req.query['w'] as string | undefined
156
+ const width = wParam ? Number.parseInt(wParam, 10) : undefined
157
+
158
+ let outBuffer: Uint8Array = asset.content
159
+ let outMime = asset.mimeType
160
+
161
+ const shouldResize =
162
+ resizeEnabled &&
163
+ isRasterImage(asset.mimeType) &&
164
+ !!width &&
165
+ Number.isFinite(width) &&
166
+ width > 0
167
+
168
+ if (shouldResize) {
169
+ const fmt = getImageFormat(asset.mimeType) || 'jpeg'
170
+ const buf = await resizeImage(
171
+ Buffer.from(asset.content),
172
+ width,
173
+ undefined,
174
+ fmt,
175
+ )
176
+ outBuffer = new Uint8Array(buf)
177
+ outMime = `image/${fmt}`
178
+ }
179
+
180
+ // Set appropriate headers
181
+ res.setHeader('Content-Type', outMime)
182
+ res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
183
+ res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
184
+
185
+ // Send binary content (resized if requested)
186
+ res.send(outBuffer)
187
+ } catch (error) {
188
+ console.error('Error fetching asset:', error)
189
+ res.status(500).json({ error: 'Failed to fetch asset' })
190
+ }
191
+ })
192
+
193
+ // Get asset metadata only (no binary content)
194
+ router.get(
195
+ `${basePath}/:id/metadata`,
196
+ async (req: Request, res: Response) => {
197
+ try {
198
+ const { id } = req.params
199
+
200
+ const prisma = getDbClient()
201
+ const asset = await prisma.dbAsset.findUnique({
202
+ where: { id },
203
+ select: {
204
+ id: true,
205
+ name: true,
206
+ assetType: true,
207
+ mimeType: true,
208
+ fileSize: true,
209
+ width: true,
210
+ height: true,
211
+ createdAt: true,
212
+ updatedAt: true,
213
+ },
214
+ })
215
+
216
+ if (!asset) {
217
+ res.status(404).json({ error: 'Asset not found' })
218
+ return
219
+ }
220
+
221
+ res.json(asset)
222
+ } catch (error) {
223
+ console.error('Error fetching asset metadata:', error)
224
+ res.status(500).json({ error: 'Failed to fetch asset metadata' })
225
+ }
226
+ },
227
+ )
228
+
229
+ // Get asset binary by name
230
+ router.get(
231
+ `${basePath}/by-name/:name`,
232
+ async (req: Request, res: Response) => {
233
+ try {
234
+ const { name } = req.params
235
+
236
+ const prisma = getDbClient()
237
+ const asset = await prisma.dbAsset.findUnique({
238
+ where: { name },
239
+ select: {
240
+ content: true,
241
+ mimeType: true,
242
+ name: true,
243
+ width: true,
244
+ height: true,
245
+ },
246
+ })
247
+
248
+ if (!asset) {
249
+ res.status(404).json({ error: 'Asset not found' })
250
+ return
251
+ }
252
+
253
+ const resizeEnabled =
254
+ String(process.env.EH_ASSETS_RESIZE_ENABLED || 'true') === 'true'
255
+ const wParam = req.query['w'] as string | undefined
256
+ const width = wParam ? Number.parseInt(wParam, 10) : undefined
257
+
258
+ let outBuffer: Uint8Array = asset.content
259
+ let outMime = asset.mimeType
260
+
261
+ const isRaster =
262
+ asset.mimeType.startsWith('image/') && !asset.mimeType.includes('svg')
263
+ const shouldResize =
264
+ resizeEnabled &&
265
+ isRaster &&
266
+ !!width &&
267
+ Number.isFinite(width) &&
268
+ width > 0
269
+
270
+ if (shouldResize) {
271
+ const fmt = asset.mimeType.includes('png')
272
+ ? 'png'
273
+ : asset.mimeType.includes('webp')
274
+ ? 'webp'
275
+ : 'jpeg'
276
+
277
+ let buf: Buffer
278
+ const pipeline = sharp(Buffer.from(asset.content)).resize({
279
+ width,
280
+ fit: 'inside',
281
+ withoutEnlargement: true,
282
+ })
283
+ if (fmt === 'png') {
284
+ buf = await pipeline.png().toBuffer()
285
+ outMime = 'image/png'
286
+ } else if (fmt === 'webp') {
287
+ buf = await pipeline.webp().toBuffer()
288
+ outMime = 'image/webp'
289
+ } else {
290
+ buf = await pipeline.jpeg().toBuffer()
291
+ outMime = 'image/jpeg'
292
+ }
293
+ outBuffer = new Uint8Array(buf)
294
+ }
295
+
296
+ // Set appropriate headers
297
+ res.setHeader('Content-Type', outMime)
298
+ res.setHeader('Content-Disposition', `inline; filename="${asset.name}"`)
299
+ res.setHeader('Cache-Control', 'public, max-age=86400') // Cache for 1 day
300
+
301
+ // Send binary content (resized if requested)
302
+ res.send(outBuffer)
303
+ } catch (error) {
304
+ console.error('Error fetching asset by name:', error)
305
+ res.status(500).json({ error: 'Failed to fetch asset' })
306
+ }
307
+ },
308
+ )
309
+ }
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import sharp from 'sharp';
3
+
4
+ /**
5
+ * Extract image dimensions from a buffer using sharp
6
+ */
7
+ export async function getImageDimensions(
8
+ buffer: Buffer,
9
+ ): Promise<{ width: number | null; height: number | null }> {
10
+ try {
11
+ const metadata = await sharp(buffer).metadata()
12
+ return {
13
+ width: metadata.width ?? null,
14
+ height: metadata.height ?? null,
15
+ }
16
+ } catch (error) {
17
+ console.error('Error extracting image dimensions:', error)
18
+ return { width: null, height: null }
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Resize an image buffer to the specified dimensions
24
+ * @param buffer - The image buffer to resize
25
+ * @param width - Target width (optional)
26
+ * @param height - Target height (optional)
27
+ * @param format - Output format ('png', 'jpeg', 'webp'), auto-detected if not provided
28
+ */
29
+ export async function resizeImage(
30
+ buffer: Buffer,
31
+ width?: number,
32
+ height?: number,
33
+ format?: 'png' | 'jpeg' | 'webp',
34
+ ): Promise<Buffer> {
35
+ let pipeline = sharp(buffer)
36
+
37
+ // Apply resize if dimensions provided
38
+ if (width || height) {
39
+ pipeline = pipeline.resize({
40
+ width,
41
+ height,
42
+ fit: 'inside',
43
+ withoutEnlargement: true,
44
+ })
45
+ }
46
+
47
+ // Apply format conversion if specified
48
+ if (format === 'png') {
49
+ pipeline = pipeline.png()
50
+ } else if (format === 'webp') {
51
+ pipeline = pipeline.webp()
52
+ } else if (format === 'jpeg') {
53
+ pipeline = pipeline.jpeg()
54
+ }
55
+
56
+ return pipeline.toBuffer()
57
+ }
58
+
59
+ /**
60
+ * Generate SHA-256 checksum for a buffer
61
+ */
62
+ export function generateChecksum(buffer: Buffer): string {
63
+ return createHash('sha256').update(buffer).digest('hex')
64
+ }
65
+
66
+ /**
67
+ * Detect image format from mime type
68
+ */
69
+ export function getImageFormat(mimeType: string): 'png' | 'webp' | 'jpeg' | null {
70
+ if (mimeType.includes('png')) return 'png'
71
+ if (mimeType.includes('webp')) return 'webp'
72
+ if (mimeType.includes('jpeg') || mimeType.includes('jpg')) return 'jpeg'
73
+ return null
74
+ }
75
+
76
+ /**
77
+ * Check if a mime type represents a raster image (not SVG)
78
+ */
79
+ export function isRasterImage(mimeType: string): boolean {
80
+ return mimeType.startsWith('image/') && !mimeType.includes('svg')
81
+ }
@@ -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,116 @@
1
+ import type { TRPCRootObject } from '@trpc/server'
2
+ import { z } from 'zod'
3
+ import { getDbClient } from '../../db'
4
+ import type { EhTrpcContext } from '../../server/ehTrpcContext'
5
+
6
+ export function createScreenshotRouter(t: TRPCRootObject<EhTrpcContext, {}, {}>) {
7
+ const router = t.router
8
+ const publicProcedure = t.procedure
9
+
10
+ return router({
11
+ list: publicProcedure.query(async () => {
12
+ const prisma = getDbClient()
13
+ return prisma.dbAsset.findMany({
14
+ where: { assetType: 'screenshot' },
15
+ select: {
16
+ id: true,
17
+ name: true,
18
+ mimeType: true,
19
+ fileSize: true,
20
+ width: true,
21
+ height: true,
22
+ createdAt: true,
23
+ updatedAt: true,
24
+ },
25
+ orderBy: { createdAt: 'desc' },
26
+ })
27
+ }),
28
+
29
+ getOne: publicProcedure
30
+ .input(z.object({ id: z.string() }))
31
+ .query(async ({ input }) => {
32
+ const prisma = getDbClient()
33
+ return prisma.dbAsset.findFirst({
34
+ where: {
35
+ id: input.id,
36
+ assetType: 'screenshot',
37
+ },
38
+ select: {
39
+ id: true,
40
+ name: true,
41
+ mimeType: true,
42
+ fileSize: true,
43
+ width: true,
44
+ height: true,
45
+ createdAt: true,
46
+ updatedAt: true,
47
+ },
48
+ })
49
+ }),
50
+
51
+ getByAppSlug: publicProcedure
52
+ .input(z.object({ appSlug: z.string() }))
53
+ .query(async ({ input }) => {
54
+ const prisma = getDbClient()
55
+
56
+ // Find app by slug
57
+ const app = await prisma.dbAppForCatalog.findUnique({
58
+ where: { slug: input.appSlug },
59
+ select: { screenshotIds: true },
60
+ })
61
+
62
+ if (!app) {
63
+ return []
64
+ }
65
+
66
+ // Fetch all screenshots for the app
67
+ return prisma.dbAsset.findMany({
68
+ where: {
69
+ id: { in: app.screenshotIds },
70
+ assetType: 'screenshot',
71
+ },
72
+ select: {
73
+ id: true,
74
+ name: true,
75
+ mimeType: true,
76
+ fileSize: true,
77
+ width: true,
78
+ height: true,
79
+ createdAt: true,
80
+ updatedAt: true,
81
+ },
82
+ })
83
+ }),
84
+
85
+ getFirstByAppSlug: publicProcedure
86
+ .input(z.object({ appSlug: z.string() }))
87
+ .query(async ({ input }) => {
88
+ const prisma = getDbClient()
89
+
90
+ // Find app by slug
91
+ const app = await prisma.dbAppForCatalog.findUnique({
92
+ where: { slug: input.appSlug },
93
+ select: { screenshotIds: true },
94
+ })
95
+
96
+ if (!app || app.screenshotIds.length === 0) {
97
+ return null
98
+ }
99
+
100
+ // Fetch first screenshot
101
+ return prisma.dbAsset.findUnique({
102
+ where: { id: app.screenshotIds[0] },
103
+ select: {
104
+ id: true,
105
+ name: true,
106
+ mimeType: true,
107
+ fileSize: true,
108
+ width: true,
109
+ height: true,
110
+ createdAt: true,
111
+ updatedAt: true,
112
+ },
113
+ })
114
+ }),
115
+ })
116
+ }