@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,149 @@
1
+ // Prisma schema for backend-core library
2
+ // Uses AC_CORE_DATABASE_URL environment variable
3
+
4
+ datasource db {
5
+ provider = "postgresql"
6
+ url = env("AC_CORE_DATABASE_URL")
7
+ }
8
+
9
+ generator client {
10
+ provider = "prisma-client-js"
11
+ }
12
+
13
+ generator json {
14
+ provider = "prisma-json-types-generator"
15
+ }
16
+
17
+ // ========== Better Auth Models ==========
18
+ model user {
19
+ id String @id
20
+ name String?
21
+ email String? @unique
22
+ emailVerified Boolean @default(false)
23
+ image String?
24
+ role String @default("user") // 'user' | 'admin'
25
+ createdAt DateTime @default(now())
26
+ updatedAt DateTime @updatedAt
27
+ accounts account[]
28
+ sessions session[]
29
+ }
30
+
31
+ model session {
32
+ id String @id
33
+ token String @unique
34
+ userId String
35
+ user user @relation(fields: [userId], references: [id], onDelete: Cascade)
36
+ expiresAt DateTime
37
+ ipAddress String?
38
+ userAgent String?
39
+ createdAt DateTime @default(now())
40
+ updatedAt DateTime @updatedAt
41
+ }
42
+
43
+ model account {
44
+ id String @id
45
+ userId String
46
+ user user @relation(fields: [userId], references: [id], onDelete: Cascade)
47
+ accountId String
48
+ providerId String
49
+ refreshToken String?
50
+ accessToken String?
51
+ accessTokenExpiresAt DateTime?
52
+ refreshTokenExpiresAt DateTime?
53
+ idToken String?
54
+ scope String?
55
+ password String?
56
+ createdAt DateTime @default(now())
57
+ updatedAt DateTime @updatedAt
58
+
59
+ @@unique([providerId, accountId])
60
+ }
61
+
62
+ model verification {
63
+ id String @id
64
+ identifier String
65
+ value String
66
+ expiresAt DateTime
67
+ createdAt DateTime @default(now())
68
+ updatedAt DateTime @updatedAt
69
+
70
+ @@unique([identifier, value])
71
+ }
72
+
73
+ // ========== EnvHopper Models ==========
74
+
75
+ enum ApprovalMethodType {
76
+ service
77
+ personTeam
78
+ custom
79
+ }
80
+
81
+ model DbApprovalMethod {
82
+ slug String @id
83
+ type ApprovalMethodType
84
+ displayName String
85
+ /// [ApprovalMethodConfig] - Type-specific configuration
86
+ config Json?
87
+ createdAt DateTime @default(now())
88
+ updatedAt DateTime @updatedAt
89
+
90
+ @@unique([type, displayName])
91
+ }
92
+
93
+ model DbAppForCatalog {
94
+ id String @id @default(cuid())
95
+ slug String @unique // URL-friendly identifier for navigation
96
+ displayName String
97
+ description String
98
+ /// [AccessMethod]
99
+ access Json?
100
+ teams String[] @default([])
101
+ /// [AppAccessRequest] - Per-app approval configuration linking to ApprovalMethod
102
+ accessRequest Json?
103
+ notes String?
104
+ tags String[] @default([])
105
+ appUrl String?
106
+ /// [AppLink[]]
107
+ links Json? // Array of {displayName?: string, url: string}
108
+ iconName String? // Optional icon identifier matching DbAsset.name
109
+ screenshotIds String[] @default([]) // Ordered array of DbAsset IDs
110
+ createdAt DateTime @default(now())
111
+ updatedAt DateTime @updatedAt
112
+
113
+ @@index([displayName])
114
+ @@index([slug])
115
+ @@index([tags])
116
+ }
117
+
118
+ model DbAppTagDefinition {
119
+ id String @id @default(cuid())
120
+ prefix String @unique
121
+ displayName String
122
+ description String
123
+ /// [GroupingTagValue[]]
124
+ values Json
125
+ createdAt DateTime @default(now())
126
+ updatedAt DateTime @updatedAt
127
+ }
128
+
129
+ enum AssetType {
130
+ icon
131
+ screenshot
132
+ }
133
+
134
+ model DbAsset {
135
+ id String @id @default(cuid())
136
+ name String @unique // Name with extension (e.g., "jira.svg", "github.png")
137
+ assetType AssetType @default(icon)
138
+ content Bytes // Binary storage for all asset types (SVG, PNG, etc)
139
+ checksum String // SHA-256 of content for deduplication
140
+ mimeType String // e.g., "image/svg+xml", "image/png", "image/jpeg"
141
+ fileSize Int // Size in bytes
142
+ width Int? // Image width in pixels
143
+ height Int? // Image height in pixels
144
+ createdAt DateTime @default(now())
145
+ updatedAt DateTime @updatedAt
146
+
147
+ @@index([name])
148
+ @@index([assetType])
149
+ }
@@ -0,0 +1,7 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ describe('dummy', () => {
4
+ it('always passes', () => {
5
+ expect(true).toBe(true)
6
+ })
7
+ })
@@ -0,0 +1,42 @@
1
+ import { PrismaClient } from '@prisma/client'
2
+
3
+ let prismaClient: PrismaClient | null = null
4
+
5
+ /**
6
+ * Gets the internal Prisma client instance.
7
+ * Creates one if it doesn't exist.
8
+ */
9
+ export function getDbClient(): PrismaClient {
10
+ if (!prismaClient) {
11
+ prismaClient = new PrismaClient()
12
+ }
13
+ return prismaClient
14
+ }
15
+
16
+ /**
17
+ * Sets the internal Prisma client instance.
18
+ * Used by middleware to bridge with existing getDbClient() usage.
19
+ */
20
+ export function setDbClient(client: PrismaClient): void {
21
+ prismaClient = client
22
+ }
23
+
24
+ /**
25
+ * Connects to the database.
26
+ * Call this before performing database operations.
27
+ */
28
+ export async function connectDb(): Promise<void> {
29
+ const client = getDbClient()
30
+ await client.$connect()
31
+ }
32
+
33
+ /**
34
+ * Disconnects from the database.
35
+ * Call this when done with database operations (e.g., in scripts).
36
+ */
37
+ export async function disconnectDb(): Promise<void> {
38
+ if (prismaClient) {
39
+ await prismaClient.$disconnect()
40
+ prismaClient = null
41
+ }
42
+ }
@@ -0,0 +1,21 @@
1
+ // Database connection
2
+ export { connectDb, disconnectDb, getDbClient, setDbClient } from './client'
3
+
4
+ // Table sync utilities
5
+ export {
6
+ tableSyncPrisma,
7
+ type MakeTFromPrismaModel,
8
+ type ObjectKeys,
9
+ type ScalarFilter,
10
+ type ScalarKeys,
11
+ type TableSyncParamsPrisma,
12
+ } from './tableSyncPrismaAdapter'
13
+
14
+ export {
15
+ TABLE_SYNC_MAGAZINE,
16
+ type TableSyncMagazine,
17
+ type TableSyncMagazineModelNameKey,
18
+ } from './tableSyncMagazine'
19
+
20
+ // App catalog sync
21
+ export { syncAppCatalog, type SyncAppCatalogResult } from './syncAppCatalog'
@@ -0,0 +1,312 @@
1
+ import type {
2
+ AppForCatalog,
3
+ GroupingTagDefinition,
4
+ } from '../types/common/appCatalogTypes'
5
+ import { getDbClient } from './client'
6
+ import { TABLE_SYNC_MAGAZINE } from './tableSyncMagazine'
7
+ import { tableSyncPrisma } from './tableSyncPrismaAdapter'
8
+ import { readFile, readdir, stat } from 'node:fs/promises'
9
+ import { group } from 'radashi'
10
+ import { upsertAsset } from '../modules/assets/upsertAsset'
11
+ import type { ApprovalMethod } from '../types'
12
+ import type { PrismaClient } from '@prisma/client'
13
+
14
+ export interface SyncAppCatalogResult {
15
+ created: number
16
+ updated: number
17
+ deleted: number
18
+ total: number
19
+ }
20
+
21
+ interface AssetSyncResult {
22
+ screenshotIds: Array<string>
23
+ iconName: string | null
24
+ }
25
+
26
+ function isFileNotFoundError(error: unknown): boolean {
27
+ return (
28
+ error instanceof Error &&
29
+ 'code' in error &&
30
+ (error as NodeJS.ErrnoException).code === 'ENOENT'
31
+ )
32
+ }
33
+
34
+ async function processAssetDirectory(
35
+ dirPath: string,
36
+ appSlug: string,
37
+ assetType: 'screenshot' | 'icon',
38
+ prisma: PrismaClient,
39
+ ): Promise<Array<string>> {
40
+ try {
41
+ const files = await readdir(dirPath)
42
+ const assetIds: Array<string> = []
43
+
44
+ for (let i = 0; i < files.length; i++) {
45
+ const fileName = files[i]
46
+ if (!fileName) continue
47
+
48
+ const assetName =
49
+ assetType === 'screenshot'
50
+ ? `${appSlug}-screenshot-${i + 1}`
51
+ : `${appSlug}-icon`
52
+
53
+ const id = await upsertAsset({
54
+ prisma,
55
+ buffer: await readFile(`${dirPath}/${fileName}`),
56
+ originalFilename: fileName,
57
+ name: assetName,
58
+ assetType,
59
+ })
60
+ assetIds.push(id)
61
+
62
+ // For icons, only process the first file
63
+ if (assetType === 'icon') {
64
+ break
65
+ }
66
+ }
67
+
68
+ return assetIds
69
+ } catch (error: unknown) {
70
+ if (isFileNotFoundError(error)) {
71
+ return []
72
+ }
73
+ throw error
74
+ }
75
+ }
76
+
77
+ async function syncAppAssets(
78
+ appSlug: string,
79
+ appPath: string,
80
+ prisma: PrismaClient,
81
+ ): Promise<AssetSyncResult> {
82
+ const screenshotIds = await processAssetDirectory(
83
+ `${appPath}/screenshots`,
84
+ appSlug,
85
+ 'screenshot',
86
+ prisma,
87
+ )
88
+
89
+ const iconIds = await processAssetDirectory(
90
+ `${appPath}/icons`,
91
+ appSlug,
92
+ 'icon',
93
+ prisma,
94
+ )
95
+
96
+ return {
97
+ screenshotIds,
98
+ iconName: iconIds.length > 0 ? `${appSlug}-icon` : null,
99
+ }
100
+ }
101
+
102
+ async function syncAssetsFromFileSystem(
103
+ apps: Array<AppForCatalog>,
104
+ allAppsAssetsPath: string,
105
+ ) {
106
+ const appDirectories = await readdir(allAppsAssetsPath)
107
+ const prisma = getDbClient()
108
+ const bySlug = group(apps, (a) => a.slug)
109
+
110
+ for (const appDirName of appDirectories) {
111
+ try {
112
+ const stats = await stat(`${allAppsAssetsPath}/${appDirName}`)
113
+ if (!stats.isDirectory()) {
114
+ continue
115
+ }
116
+ } catch (error: unknown) {
117
+ if (isFileNotFoundError(error)) {
118
+ continue
119
+ }
120
+ throw error
121
+ }
122
+
123
+ const appSlug = appDirName
124
+ if (!bySlug[appSlug]) {
125
+ throw new Error(
126
+ `App '${appSlug}' does not exist in the app catalog. Existing apps: ${Object.keys(bySlug).join(', ')}`,
127
+ )
128
+ }
129
+
130
+ try {
131
+ const { screenshotIds, iconName } = await syncAppAssets(
132
+ appSlug,
133
+ `${allAppsAssetsPath}/${appDirName}`,
134
+ prisma,
135
+ )
136
+
137
+ const updateData: {
138
+ screenshotIds?: Array<string>
139
+ iconName?: string | null
140
+ } = {}
141
+
142
+ if (screenshotIds.length > 0) {
143
+ updateData.screenshotIds = screenshotIds
144
+ }
145
+ if (iconName !== null) {
146
+ updateData.iconName = iconName
147
+ }
148
+
149
+ if (Object.keys(updateData).length > 0) {
150
+ await prisma.dbAppForCatalog.update({
151
+ where: { slug: appSlug },
152
+ data: updateData,
153
+ })
154
+ }
155
+ } catch (error: unknown) {
156
+ const errorMessage =
157
+ error instanceof Error ? error.message : String(error)
158
+ throw new Error(
159
+ `Error while upserting assets for app '${appSlug}': ${errorMessage}`,
160
+ )
161
+ }
162
+ }
163
+ // private async syncScreenshots(apps: AppForCatalog[], screenshotsPath: string): Promise<void> {
164
+ // const db = getDbClient();
165
+ //
166
+ // for (const app of apps) {
167
+ // const dirPath = join(screenshotsPath, `${app.slug}-screenshots`);
168
+ //
169
+ // try {
170
+ // const files = await readdir(dirPath);
171
+ // const imageFiles = files.filter(f => /\.(png|jpg|jpeg|gif|webp)$/i.test(f)).sort();
172
+ //
173
+ // if (imageFiles.length === 0) continue;
174
+ //
175
+ // const screenshotIds: string[] = [];
176
+ //
177
+ // for (const filename of imageFiles) {
178
+ // const filePath = join(dirPath, filename);
179
+ // const content = await readFile(filePath);
180
+ // const buffer = Buffer.from(content);
181
+ // const checksum = generateChecksum(buffer);
182
+ // const { width, height } = await getImageDimensions(buffer);
183
+ //
184
+ // const ext = filename.split('.').pop()?.toLowerCase();
185
+ // const mimeType = {
186
+ // png: 'image/png',
187
+ // jpg: 'image/jpeg',
188
+ // jpeg: 'image/jpeg',
189
+ // gif: 'image/gif',
190
+ // webp: 'image/webp',
191
+ // }[ext || ''] || 'application/octet-stream';
192
+ //
193
+ // const asset = await db.dbAsset.upsert({
194
+ // where: { checksum },
195
+ // create: {
196
+ // name: `${app.slug}/${filename}`,
197
+ // assetType: 'screenshot',
198
+ // content: new Uint8Array(buffer),
199
+ // checksum,
200
+ // mimeType,
201
+ // fileSize: buffer.length,
202
+ // width,
203
+ // height,
204
+ // },
205
+ // update: {
206
+ // name: `${app.slug}/${filename}`,
207
+ // content: new Uint8Array(buffer),
208
+ // mimeType,
209
+ // fileSize: buffer.length,
210
+ // width,
211
+ // height,
212
+ // },
213
+ // });
214
+ //
215
+ // screenshotIds.push(asset.id);
216
+ // }
217
+ //
218
+ // await db.dbAppForCatalog.update({
219
+ // where: { slug: app.slug },
220
+ // data: { screenshotIds },
221
+ // });
222
+ //
223
+ // logger.info(`Synced ${screenshotIds.length} screenshots for ${app.slug}`);
224
+ // } catch (error: any) {
225
+ // if (error.code === 'ENOENT') continue; // Skip if directory doesn't exist
226
+ // throw error;
227
+ // }
228
+ // }
229
+ // }
230
+ }
231
+
232
+ /**
233
+ * Syncs app catalog data to the database using table sync.
234
+ * This will create new apps, update existing ones, and delete any that are no longer in the input.
235
+ *
236
+ * Note: Call connectDb() before and disconnectDb() after if running in a script.
237
+ */
238
+ export async function syncAppCatalog(
239
+ apps: Array<AppForCatalog>,
240
+ tagsDefinitions: Array<GroupingTagDefinition>,
241
+ approvalMethods: Array<ApprovalMethod>,
242
+ sreenshotsPath?: string,
243
+ ): Promise<SyncAppCatalogResult> {
244
+ try {
245
+ const prisma = getDbClient()
246
+
247
+ await tableSyncPrisma({
248
+ prisma,
249
+ ...TABLE_SYNC_MAGAZINE.DbApprovalMethod,
250
+ }).sync(approvalMethods)
251
+
252
+ const sync = tableSyncPrisma({
253
+ prisma,
254
+ ...TABLE_SYNC_MAGAZINE.DbAppForCatalog,
255
+ })
256
+
257
+ await tableSyncPrisma({
258
+ prisma,
259
+ ...TABLE_SYNC_MAGAZINE.DbAppTagDefinition,
260
+ }).sync(tagsDefinitions)
261
+
262
+ // Transform AppForCatalog to DbAppForCatalog format
263
+ const dbApps = apps.map((app) => {
264
+ const slug =
265
+ app.slug ||
266
+ app.displayName
267
+ .toLowerCase()
268
+ .replace(/[^a-z0-9]+/g, '-')
269
+ .replace(/^-+|-+$/g, '')
270
+
271
+ return {
272
+ slug,
273
+ displayName: app.displayName,
274
+ description: app.description,
275
+ teams: app.teams ?? [],
276
+ accessRequest: app.accessRequest ?? null,
277
+ notes: app.notes ?? null,
278
+ tags: app.tags ?? [],
279
+ appUrl: app.appUrl ?? null,
280
+ links: app.links ?? null,
281
+ iconName: app.iconName ?? null,
282
+ screenshotIds: app.screenshotIds ?? [],
283
+ }
284
+ })
285
+
286
+ const result = await sync.sync(dbApps)
287
+
288
+ // Get actual synced data to calculate stats
289
+ const actual = result.getActual()
290
+
291
+ if (sreenshotsPath) {
292
+ await syncAssetsFromFileSystem(apps, sreenshotsPath)
293
+ } else {
294
+ console.warn('DO not sync screenhots')
295
+ }
296
+
297
+ return {
298
+ created: actual.length - apps.length + (apps.length - actual.length),
299
+ updated: 0, // TableSync doesn't expose this directly
300
+ deleted: 0, // TableSync doesn't expose this directly
301
+ total: actual.length,
302
+ }
303
+ } catch (error) {
304
+ // Wrap error with context
305
+ const errorMessage = error instanceof Error ? error.message : String(error)
306
+ const errorStack = error instanceof Error ? error.stack : undefined
307
+
308
+ throw new Error(
309
+ `Error syncing app catalog: ${errorMessage}\n\nDetails:\n${errorStack || 'No stack trace available'}`,
310
+ )
311
+ }
312
+ }
@@ -0,0 +1,32 @@
1
+ import type { Prisma } from '@prisma/client'
2
+ import type { ObjectKeys, ScalarKeys } from './tableSyncPrismaAdapter'
3
+
4
+ interface CommonSyncTableInfo<TPrismaModelName extends Prisma.ModelName> {
5
+ prismaModelName: TPrismaModelName
6
+ id?: ScalarKeys<TPrismaModelName>
7
+ uniqColumns: Array<ScalarKeys<TPrismaModelName>>
8
+ relationColumns?: Array<ObjectKeys<TPrismaModelName>>
9
+ }
10
+
11
+ type TableSyncMagazineType = Partial<{
12
+ [key in Prisma.ModelName]: CommonSyncTableInfo<key>
13
+ }>
14
+
15
+ export const TABLE_SYNC_MAGAZINE = {
16
+ DbAppForCatalog: {
17
+ prismaModelName: 'DbAppForCatalog',
18
+ uniqColumns: ['slug'],
19
+ },
20
+ DbAppTagDefinition: {
21
+ prismaModelName: 'DbAppTagDefinition',
22
+ uniqColumns: ['prefix'],
23
+ },
24
+ DbApprovalMethod: {
25
+ id: 'slug',
26
+ prismaModelName: 'DbApprovalMethod',
27
+ uniqColumns: ['slug'],
28
+ },
29
+ } as const satisfies TableSyncMagazineType
30
+
31
+ export type TableSyncMagazine = typeof TABLE_SYNC_MAGAZINE
32
+ export type TableSyncMagazineModelNameKey = keyof TableSyncMagazine