@corgelabs/corge 0.0.1 → 1.0.0
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/dist/ai/index.d.ts +3 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +3 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/schema.d.ts +83 -0
- package/dist/ai/schema.d.ts.map +1 -0
- package/dist/ai/schema.js +19 -0
- package/dist/ai/schema.js.map +1 -0
- package/dist/ai/service.d.ts +14 -0
- package/dist/ai/service.d.ts.map +1 -0
- package/dist/ai/service.js +266 -0
- package/dist/ai/service.js.map +1 -0
- package/dist/ai/utils.d.ts +10 -0
- package/dist/ai/utils.d.ts.map +1 -0
- package/dist/ai/utils.js +27 -0
- package/dist/ai/utils.js.map +1 -0
- package/dist/attachments/index.d.ts +4 -0
- package/dist/attachments/index.d.ts.map +1 -0
- package/dist/attachments/index.js +4 -0
- package/dist/attachments/index.js.map +1 -0
- package/dist/attachments/routes.d.ts +75 -0
- package/dist/attachments/routes.d.ts.map +1 -0
- package/dist/attachments/routes.js +18 -0
- package/dist/attachments/routes.js.map +1 -0
- package/dist/attachments/schema.d.ts +61 -0
- package/dist/attachments/schema.d.ts.map +1 -0
- package/dist/attachments/schema.js +30 -0
- package/dist/attachments/schema.js.map +1 -0
- package/dist/attachments/service.d.ts +27 -0
- package/dist/attachments/service.d.ts.map +1 -0
- package/dist/attachments/service.js +224 -0
- package/dist/attachments/service.js.map +1 -0
- package/dist/attachments/utils.d.ts +9 -0
- package/dist/attachments/utils.d.ts.map +1 -0
- package/dist/attachments/utils.js +80 -0
- package/dist/attachments/utils.js.map +1 -0
- package/dist/auth/index.d.ts +4 -0
- package/dist/auth/index.d.ts.map +1 -0
- package/dist/auth/index.js +4 -0
- package/dist/auth/index.js.map +1 -0
- package/dist/auth/oidc.d.ts +25 -0
- package/dist/auth/oidc.d.ts.map +1 -0
- package/dist/auth/oidc.js +298 -0
- package/dist/auth/oidc.js.map +1 -0
- package/dist/auth/recovery.d.ts +41 -0
- package/dist/auth/recovery.d.ts.map +1 -0
- package/dist/auth/recovery.js +250 -0
- package/dist/auth/recovery.js.map +1 -0
- package/dist/auth/routes.d.ts +226 -0
- package/dist/auth/routes.d.ts.map +1 -0
- package/dist/auth/routes.js +96 -0
- package/dist/auth/routes.js.map +1 -0
- package/dist/auth/schema.d.ts +158 -0
- package/dist/auth/schema.d.ts.map +1 -0
- package/dist/auth/schema.js +82 -0
- package/dist/auth/schema.js.map +1 -0
- package/dist/auth/service.d.ts +96 -0
- package/dist/auth/service.d.ts.map +1 -0
- package/dist/auth/service.js +129 -0
- package/dist/auth/service.js.map +1 -0
- package/dist/auth/session.d.ts +51 -0
- package/dist/auth/session.d.ts.map +1 -0
- package/dist/auth/session.js +344 -0
- package/dist/auth/session.js.map +1 -0
- package/dist/auth/utils.d.ts +17 -0
- package/dist/auth/utils.d.ts.map +1 -0
- package/dist/auth/utils.js +96 -0
- package/dist/auth/utils.js.map +1 -0
- package/dist/bin/corge.js +547 -0
- package/dist/chat/context.d.ts +10 -0
- package/dist/chat/context.d.ts.map +1 -0
- package/dist/chat/context.js +221 -0
- package/dist/chat/context.js.map +1 -0
- package/dist/chat/index.d.ts +4 -0
- package/dist/chat/index.d.ts.map +1 -0
- package/dist/chat/index.js +4 -0
- package/dist/chat/index.js.map +1 -0
- package/dist/chat/routes.d.ts +160 -0
- package/dist/chat/routes.d.ts.map +1 -0
- package/dist/chat/routes.js +210 -0
- package/dist/chat/routes.js.map +1 -0
- package/dist/chat/schema.d.ts +285 -0
- package/dist/chat/schema.d.ts.map +1 -0
- package/dist/chat/schema.js +74 -0
- package/dist/chat/schema.js.map +1 -0
- package/dist/chat/service.d.ts +64 -0
- package/dist/chat/service.d.ts.map +1 -0
- package/dist/chat/service.js +279 -0
- package/dist/chat/service.js.map +1 -0
- package/dist/chat/stream.d.ts +53 -0
- package/dist/chat/stream.d.ts.map +1 -0
- package/dist/chat/stream.js +339 -0
- package/dist/chat/stream.js.map +1 -0
- package/dist/chat/utils.d.ts +61 -0
- package/dist/chat/utils.d.ts.map +1 -0
- package/dist/chat/utils.js +285 -0
- package/dist/chat/utils.js.map +1 -0
- package/dist/cli/builder.d.ts +6 -0
- package/dist/cli/builder.d.ts.map +1 -0
- package/dist/cli/builder.js +91 -0
- package/dist/cli/builder.js.map +1 -0
- package/dist/cli/deployer.d.ts +2 -0
- package/dist/cli/deployer.d.ts.map +1 -0
- package/dist/cli/deployer.js +152 -0
- package/dist/cli/deployer.js.map +1 -0
- package/dist/cli/dev-server.d.ts +2 -0
- package/dist/cli/dev-server.d.ts.map +1 -0
- package/dist/cli/dev-server.js +70 -0
- package/dist/cli/dev-server.js.map +1 -0
- package/dist/cli/env.d.ts +4 -0
- package/dist/cli/env.d.ts.map +1 -0
- package/dist/cli/env.js +68 -0
- package/dist/cli/env.js.map +1 -0
- package/dist/cli/init.d.ts +6 -0
- package/dist/cli/init.d.ts.map +1 -0
- package/dist/cli/init.js +103 -0
- package/dist/cli/init.js.map +1 -0
- package/dist/config.d.ts +262 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +212 -0
- package/dist/config.js.map +1 -0
- package/dist/db/index.d.ts +3 -0
- package/dist/db/index.d.ts.map +1 -0
- package/dist/db/index.js +3 -0
- package/dist/db/index.js.map +1 -0
- package/dist/db/schema.d.ts +410 -0
- package/dist/db/schema.d.ts.map +1 -0
- package/dist/db/schema.js +294 -0
- package/dist/db/schema.js.map +1 -0
- package/dist/db/service.d.ts +42 -0
- package/dist/db/service.d.ts.map +1 -0
- package/dist/db/service.js +372 -0
- package/dist/db/service.js.map +1 -0
- package/dist/db/utils.d.ts +25 -0
- package/dist/db/utils.d.ts.map +1 -0
- package/dist/db/utils.js +188 -0
- package/dist/db/utils.js.map +1 -0
- package/dist/emails/index.d.ts +4 -0
- package/dist/emails/index.d.ts.map +1 -0
- package/dist/emails/index.js +4 -0
- package/dist/emails/index.js.map +1 -0
- package/dist/emails/routes.d.ts +191 -0
- package/dist/emails/routes.d.ts.map +1 -0
- package/dist/emails/routes.js +39 -0
- package/dist/emails/routes.js.map +1 -0
- package/dist/emails/schema.d.ts +237 -0
- package/dist/emails/schema.d.ts.map +1 -0
- package/dist/emails/schema.js +98 -0
- package/dist/emails/schema.js.map +1 -0
- package/dist/emails/service.d.ts +22 -0
- package/dist/emails/service.d.ts.map +1 -0
- package/dist/emails/service.js +337 -0
- package/dist/emails/service.js.map +1 -0
- package/dist/emails/utils.d.ts +49 -0
- package/dist/emails/utils.d.ts.map +1 -0
- package/dist/emails/utils.js +341 -0
- package/dist/emails/utils.js.map +1 -0
- package/dist/files/index.d.ts +4 -0
- package/dist/files/index.d.ts.map +1 -0
- package/dist/files/index.js +4 -0
- package/dist/files/index.js.map +1 -0
- package/dist/files/routes.d.ts +72 -0
- package/dist/files/routes.d.ts.map +1 -0
- package/dist/files/routes.js +38 -0
- package/dist/files/routes.js.map +1 -0
- package/dist/files/schema.d.ts +57 -0
- package/dist/files/schema.d.ts.map +1 -0
- package/dist/files/schema.js +39 -0
- package/dist/files/schema.js.map +1 -0
- package/dist/files/service.d.ts +27 -0
- package/dist/files/service.d.ts.map +1 -0
- package/dist/files/service.js +175 -0
- package/dist/files/service.js.map +1 -0
- package/dist/files/utils.d.ts +3 -0
- package/dist/files/utils.d.ts.map +1 -0
- package/dist/files/utils.js +63 -0
- package/dist/files/utils.js.map +1 -0
- package/dist/index.d.ts +875 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +9919 -0
- package/dist/index.js.map +7 -0
- package/dist/payments/billing.d.ts +29 -0
- package/dist/payments/billing.d.ts.map +1 -0
- package/dist/payments/billing.js +378 -0
- package/dist/payments/billing.js.map +1 -0
- package/dist/payments/index.d.ts +4 -0
- package/dist/payments/index.d.ts.map +1 -0
- package/dist/payments/index.js +4 -0
- package/dist/payments/index.js.map +1 -0
- package/dist/payments/routes.d.ts +340 -0
- package/dist/payments/routes.d.ts.map +1 -0
- package/dist/payments/routes.js +56 -0
- package/dist/payments/routes.js.map +1 -0
- package/dist/payments/schema.d.ts +412 -0
- package/dist/payments/schema.d.ts.map +1 -0
- package/dist/payments/schema.js +260 -0
- package/dist/payments/schema.js.map +1 -0
- package/dist/payments/service.d.ts +31 -0
- package/dist/payments/service.d.ts.map +1 -0
- package/dist/payments/service.js +322 -0
- package/dist/payments/service.js.map +1 -0
- package/dist/payments/utils.d.ts +61 -0
- package/dist/payments/utils.d.ts.map +1 -0
- package/dist/payments/utils.js +369 -0
- package/dist/payments/utils.js.map +1 -0
- package/dist/router/guard.d.ts +19 -0
- package/dist/router/guard.d.ts.map +1 -0
- package/dist/router/guard.js +243 -0
- package/dist/router/guard.js.map +1 -0
- package/dist/router/index.d.ts +3 -0
- package/dist/router/index.d.ts.map +1 -0
- package/dist/router/index.js +3 -0
- package/dist/router/index.js.map +1 -0
- package/dist/router/schema.d.ts +103 -0
- package/dist/router/schema.d.ts.map +1 -0
- package/dist/router/schema.js +282 -0
- package/dist/router/schema.js.map +1 -0
- package/dist/router/service.d.ts +25 -0
- package/dist/router/service.d.ts.map +1 -0
- package/dist/router/service.js +339 -0
- package/dist/router/service.js.map +1 -0
- package/dist/tasks/index.d.ts +4 -0
- package/dist/tasks/index.d.ts.map +1 -0
- package/dist/tasks/index.js +4 -0
- package/dist/tasks/index.js.map +1 -0
- package/dist/tasks/routes.d.ts +54 -0
- package/dist/tasks/routes.d.ts.map +1 -0
- package/dist/tasks/routes.js +195 -0
- package/dist/tasks/routes.js.map +1 -0
- package/dist/tasks/schema.d.ts +261 -0
- package/dist/tasks/schema.d.ts.map +1 -0
- package/dist/tasks/schema.js +35 -0
- package/dist/tasks/schema.js.map +1 -0
- package/dist/tasks/service.d.ts +32 -0
- package/dist/tasks/service.d.ts.map +1 -0
- package/dist/tasks/service.js +201 -0
- package/dist/tasks/service.js.map +1 -0
- package/dist/tasks/steps.d.ts +18 -0
- package/dist/tasks/steps.d.ts.map +1 -0
- package/dist/tasks/steps.js +365 -0
- package/dist/tasks/steps.js.map +1 -0
- package/dist/tasks/utils.d.ts +75 -0
- package/dist/tasks/utils.d.ts.map +1 -0
- package/dist/tasks/utils.js +311 -0
- package/dist/tasks/utils.js.map +1 -0
- package/dist/tasks/watcher.d.ts +13 -0
- package/dist/tasks/watcher.d.ts.map +1 -0
- package/dist/tasks/watcher.js +255 -0
- package/dist/tasks/watcher.js.map +1 -0
- package/dist/tasks/worker.d.ts +28 -0
- package/dist/tasks/worker.d.ts.map +1 -0
- package/dist/tasks/worker.js +326 -0
- package/dist/tasks/worker.js.map +1 -0
- package/dist/utils/index.d.ts +3 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +3 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/schema.d.ts +79 -0
- package/dist/utils/schema.d.ts.map +1 -0
- package/dist/utils/schema.js +41 -0
- package/dist/utils/schema.js.map +1 -0
- package/dist/utils/service.d.ts +32 -0
- package/dist/utils/service.d.ts.map +1 -0
- package/dist/utils/service.js +384 -0
- package/dist/utils/service.js.map +1 -0
- package/docs/SDK_SPEC.md +1645 -0
- package/infra/firestore.base.indexes.json +138 -0
- package/infra/firestore.base.rules +152 -0
- package/infra/storage.base.rules +8 -0
- package/package.json +59 -13
- package/src/ai/index.ts +2 -0
- package/src/ai/schema.ts +64 -0
- package/src/ai/service.ts +317 -0
- package/src/ai/utils.ts +38 -0
- package/src/attachments/index.ts +3 -0
- package/src/attachments/routes.ts +28 -0
- package/src/attachments/schema.ts +45 -0
- package/src/attachments/service.ts +315 -0
- package/src/attachments/utils.ts +115 -0
- package/src/auth/index.ts +3 -0
- package/src/auth/oidc.ts +419 -0
- package/src/auth/recovery.ts +365 -0
- package/src/auth/routes.ts +142 -0
- package/src/auth/schema.ts +181 -0
- package/src/auth/service.ts +183 -0
- package/src/auth/session.ts +414 -0
- package/src/auth/utils.ts +106 -0
- package/src/chat/context.ts +310 -0
- package/src/chat/index.ts +3 -0
- package/src/chat/routes.ts +264 -0
- package/src/chat/schema.ts +295 -0
- package/src/chat/service.ts +360 -0
- package/src/chat/stream.ts +481 -0
- package/src/chat/utils.ts +370 -0
- package/src/cli/builder.ts +93 -0
- package/src/cli/deployer.ts +184 -0
- package/src/cli/dev-server.ts +77 -0
- package/src/cli/env.ts +89 -0
- package/src/cli/init.ts +125 -0
- package/src/config.ts +447 -0
- package/src/db/index.ts +2 -0
- package/src/db/schema.ts +409 -0
- package/src/db/service.ts +477 -0
- package/src/db/utils.ts +250 -0
- package/src/emails/index.ts +3 -0
- package/src/emails/routes.ts +64 -0
- package/src/emails/schema.ts +239 -0
- package/src/emails/service.ts +488 -0
- package/src/emails/utils.ts +449 -0
- package/src/files/index.ts +3 -0
- package/src/files/routes.ts +44 -0
- package/src/files/schema.ts +76 -0
- package/src/files/service.ts +238 -0
- package/src/files/utils.ts +77 -0
- package/src/index.ts +425 -0
- package/src/payments/billing.ts +488 -0
- package/src/payments/index.ts +3 -0
- package/src/payments/routes.ts +92 -0
- package/src/payments/schema.ts +401 -0
- package/src/payments/service.ts +445 -0
- package/src/payments/utils.ts +472 -0
- package/src/router/guard.ts +319 -0
- package/src/router/index.ts +2 -0
- package/src/router/schema.ts +458 -0
- package/src/router/service.ts +453 -0
- package/src/tasks/index.ts +11 -0
- package/src/tasks/routes.ts +230 -0
- package/src/tasks/schema.ts +310 -0
- package/src/tasks/service.ts +254 -0
- package/src/tasks/steps.ts +472 -0
- package/src/tasks/utils.ts +445 -0
- package/src/tasks/watcher.ts +307 -0
- package/src/tasks/worker.ts +461 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/schema.ts +69 -0
- package/src/utils/service.ts +461 -0
- package/tests/e2e.test.ts +2531 -0
- package/tsconfig.base.json +12 -0
- package/README.md +0 -2
- package/index.d.ts +0 -1
- package/index.js +0 -1
package/src/db/utils.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { FieldPath, Timestamp } from 'firebase-admin/firestore'
|
|
2
|
+
import type { DbAction, DbRole } from '../config'
|
|
3
|
+
import type { Core } from '../index'
|
|
4
|
+
import {
|
|
5
|
+
collectionRegistry,
|
|
6
|
+
getCollectionDef,
|
|
7
|
+
getEffectiveRbac,
|
|
8
|
+
type CollectionName,
|
|
9
|
+
type DbListOptions,
|
|
10
|
+
type PaginatedList,
|
|
11
|
+
type SelectModel,
|
|
12
|
+
type UserRelationConfig
|
|
13
|
+
} from './schema'
|
|
14
|
+
|
|
15
|
+
export const resolveJunction = (collection: string, j: NonNullable<UserRelationConfig['junction']>) => ({
|
|
16
|
+
collection: j.collection,
|
|
17
|
+
userField: j.userField ?? 'userId',
|
|
18
|
+
targetField: j.targetField ?? `${collection}Id`
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
export const hasAction = (actions: readonly DbAction[] | undefined, target: DbAction): boolean =>
|
|
22
|
+
Boolean(actions && (actions.includes('*') || actions.includes(target)))
|
|
23
|
+
|
|
24
|
+
export const resolveAllowedScope = (collection: string, role: DbRole, action: DbAction): 'any' | 'own' | 'none' => {
|
|
25
|
+
const rbac = getEffectiveRbac(collection)
|
|
26
|
+
const roleScopes = rbac[role]
|
|
27
|
+
if (!roleScopes) return 'none'
|
|
28
|
+
if (hasAction(roleScopes.any, action)) return 'any'
|
|
29
|
+
if (hasAction(roleScopes.own, action)) return 'own'
|
|
30
|
+
return 'none'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const normalizeDates = (val: unknown): unknown => {
|
|
34
|
+
if (val instanceof Timestamp) return val.toDate()
|
|
35
|
+
if (Array.isArray(val)) return val.map(normalizeDates)
|
|
36
|
+
if (val && typeof val === 'object' && !(val instanceof Date)) {
|
|
37
|
+
return Object.fromEntries(Object.entries(val).map(([k, v]) => [k, normalizeDates(v)]))
|
|
38
|
+
}
|
|
39
|
+
return val
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const fromFirestoreDoc = <T extends CollectionName>(
|
|
43
|
+
c: T,
|
|
44
|
+
id: string,
|
|
45
|
+
raw: Record<string, unknown>,
|
|
46
|
+
partial = false
|
|
47
|
+
): SelectModel<T> => {
|
|
48
|
+
const norm = normalizeDates({ ...raw, id }) as Record<string, unknown>
|
|
49
|
+
const schema = getCollectionDef(c).schema
|
|
50
|
+
return (partial ? schema.partial().parse(norm) : schema.parse(norm)) as SelectModel<T>
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export const attachNextCursor = <T extends { id: string }>(items: T[], limit: number): PaginatedList<T> => {
|
|
54
|
+
const nextCursor = items.length >= limit ? (items.at(-1)?.id ?? null) : null
|
|
55
|
+
Object.defineProperty(items, 'nextCursor', { value: nextCursor, enumerable: false, configurable: true })
|
|
56
|
+
return items as PaginatedList<T>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const stripImmutableUpdateFields = (
|
|
60
|
+
collection: string,
|
|
61
|
+
data: unknown,
|
|
62
|
+
allowSystemFields = false
|
|
63
|
+
): Record<string, unknown> => {
|
|
64
|
+
const def = getCollectionDef(collection)
|
|
65
|
+
const ownerField = def.userRelation?.userField ?? 'userId'
|
|
66
|
+
const raw = (data ?? {}) as Record<string, unknown>
|
|
67
|
+
const {
|
|
68
|
+
id: _id,
|
|
69
|
+
createdAt: _createdAt,
|
|
70
|
+
updatedAt: _updatedAt,
|
|
71
|
+
deleted: _deleted,
|
|
72
|
+
userId: _userId,
|
|
73
|
+
[ownerField]: _ownerField,
|
|
74
|
+
...mutableData
|
|
75
|
+
} = raw
|
|
76
|
+
if (allowSystemFields && raw.deleted !== undefined) {
|
|
77
|
+
mutableData.deleted = raw.deleted
|
|
78
|
+
}
|
|
79
|
+
const parsed = def.schema.partial().parse(mutableData) as Record<string, unknown>
|
|
80
|
+
return Object.fromEntries(
|
|
81
|
+
Object.keys(mutableData)
|
|
82
|
+
.filter((k) => mutableData[k] !== undefined && k in parsed)
|
|
83
|
+
.map((k) => [k, parsed[k]])
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export const drainQueryBatchDelete = async (
|
|
88
|
+
db: FirebaseFirestore.Firestore,
|
|
89
|
+
query: FirebaseFirestore.Query,
|
|
90
|
+
batchSize = 400
|
|
91
|
+
): Promise<number> => {
|
|
92
|
+
let deleted = 0
|
|
93
|
+
while (true) {
|
|
94
|
+
const snap = await query.limit(batchSize).get()
|
|
95
|
+
if (snap.empty) break
|
|
96
|
+
const batch = db.batch()
|
|
97
|
+
for (const doc of snap.docs) batch.delete(doc.ref)
|
|
98
|
+
await batch.commit()
|
|
99
|
+
deleted += snap.size
|
|
100
|
+
if (snap.size < batchSize) break
|
|
101
|
+
}
|
|
102
|
+
return deleted
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export const executeFilteredListQuery = async <N extends CollectionName>(
|
|
106
|
+
db: FirebaseFirestore.Firestore,
|
|
107
|
+
collection: N,
|
|
108
|
+
baseQuery: FirebaseFirestore.Query,
|
|
109
|
+
opts: DbListOptions<SelectModel<N>> | undefined,
|
|
110
|
+
maxDocs: number,
|
|
111
|
+
hasSelect: boolean,
|
|
112
|
+
defaultListLimit: number
|
|
113
|
+
): Promise<SelectModel<N>[]> => {
|
|
114
|
+
const def = getCollectionDef(collection)
|
|
115
|
+
const sortField = opts?.orderBy?.field ?? (def.sortByUpdated ? 'updatedAt' : undefined)
|
|
116
|
+
const sortDir = opts?.orderBy?.direction ?? 'desc'
|
|
117
|
+
const cursorSnap = opts?.cursor ? await db.collection(collection).doc(opts.cursor).get() : undefined
|
|
118
|
+
const filteredQuery = !opts?.includeDeleted ? baseQuery.where('deleted', '==', false) : baseQuery
|
|
119
|
+
|
|
120
|
+
let snap: FirebaseFirestore.QuerySnapshot
|
|
121
|
+
let sortedInDb = false
|
|
122
|
+
let appliedCursorInDb = false
|
|
123
|
+
try {
|
|
124
|
+
let q = sortField ? filteredQuery.orderBy(sortField, sortDir) : filteredQuery.orderBy(FieldPath.documentId(), 'asc')
|
|
125
|
+
if (cursorSnap?.exists) q = q.startAfter(cursorSnap)
|
|
126
|
+
snap = await q.limit(maxDocs).get()
|
|
127
|
+
sortedInDb = true
|
|
128
|
+
appliedCursorInDb = Boolean(cursorSnap?.exists)
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.warn(
|
|
131
|
+
`[db] composite index missing for "${collection}" (falling back to filtered query):`,
|
|
132
|
+
err instanceof Error ? err.message : err
|
|
133
|
+
)
|
|
134
|
+
const fallbackLimit = sortField || opts?.cursor ? Math.max(maxDocs * 5, defaultListLimit) : maxDocs
|
|
135
|
+
try {
|
|
136
|
+
snap = await filteredQuery.limit(fallbackLimit).get()
|
|
137
|
+
} catch {
|
|
138
|
+
snap = await baseQuery.limit(fallbackLimit).get()
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const docs = snap.docs
|
|
143
|
+
.map((s) => fromFirestoreDoc(collection, s.id, s.data() ?? {}, hasSelect))
|
|
144
|
+
.filter((d) => opts?.includeDeleted || !d.deleted)
|
|
145
|
+
|
|
146
|
+
return sortAndSliceFallbackDocs(docs, sortField, sortDir, opts?.cursor, sortedInDb, appliedCursorInDb)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const sortAndSliceFallbackDocs = <T extends Record<string, any> & { id: string }>(
|
|
150
|
+
docs: T[],
|
|
151
|
+
sortField: string | undefined,
|
|
152
|
+
sortDir: 'asc' | 'desc',
|
|
153
|
+
cursor: string | undefined,
|
|
154
|
+
sortedInDb: boolean,
|
|
155
|
+
appliedCursorInDb: boolean
|
|
156
|
+
): T[] => {
|
|
157
|
+
let result = docs
|
|
158
|
+
if (!sortedInDb) {
|
|
159
|
+
result.sort((a, b) => {
|
|
160
|
+
if (sortField) {
|
|
161
|
+
const va = a[sortField] instanceof Date ? a[sortField].getTime() : a[sortField]
|
|
162
|
+
const vb = b[sortField] instanceof Date ? b[sortField].getTime() : b[sortField]
|
|
163
|
+
if (va !== vb) return sortDir === 'asc' ? (va > vb ? 1 : -1) : va < vb ? 1 : -1
|
|
164
|
+
}
|
|
165
|
+
return a.id.localeCompare(b.id)
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
if (cursor && !appliedCursorInDb) {
|
|
169
|
+
const idx = result.findIndex((d) => d.id === cursor)
|
|
170
|
+
result = idx >= 0 ? result.slice(idx + 1) : []
|
|
171
|
+
}
|
|
172
|
+
return result
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export const runChunked = async <T>(
|
|
176
|
+
items: readonly T[],
|
|
177
|
+
fn: (item: T) => Promise<unknown>,
|
|
178
|
+
concurrency = 25
|
|
179
|
+
): Promise<void> => {
|
|
180
|
+
for (let i = 0; i < items.length; i += concurrency) {
|
|
181
|
+
await Promise.all(items.slice(i, i + concurrency).map(fn))
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export const isCreateOwnScopeAllowed = (
|
|
186
|
+
collection: string,
|
|
187
|
+
userId: string | undefined,
|
|
188
|
+
data: Record<string, any>
|
|
189
|
+
): boolean => {
|
|
190
|
+
if (!userId) return false
|
|
191
|
+
const def = getCollectionDef(collection)
|
|
192
|
+
const rel = def.userRelation?.type ?? '1:N'
|
|
193
|
+
if (rel === 'global') return false
|
|
194
|
+
if (rel === '1:1' || rel === '1:N') {
|
|
195
|
+
const uf = def.userRelation?.userField ?? (rel === '1:1' ? 'id' : 'userId')
|
|
196
|
+
const ownerVal = data[uf] ?? (uf === 'id' ? data.id : undefined)
|
|
197
|
+
if (ownerVal && ownerVal !== userId) return false
|
|
198
|
+
} else if (rel === 'M:N' && !def.userRelation?.junction) {
|
|
199
|
+
const uif = def.userRelation?.userIdsField ?? 'userIds'
|
|
200
|
+
const ownerIds = data[uif]
|
|
201
|
+
if (Array.isArray(ownerIds) && !ownerIds.includes(userId)) return false
|
|
202
|
+
}
|
|
203
|
+
return true
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export const buildScopedCollectionQuery = (
|
|
207
|
+
baseQuery: FirebaseFirestore.Query,
|
|
208
|
+
collection: string,
|
|
209
|
+
scopedUserId: string | undefined,
|
|
210
|
+
where: Record<string, unknown> | undefined,
|
|
211
|
+
select: readonly (string | symbol | number)[] | undefined
|
|
212
|
+
): FirebaseFirestore.Query => {
|
|
213
|
+
const def = getCollectionDef(collection)
|
|
214
|
+
const rel = def.userRelation?.type ?? '1:N'
|
|
215
|
+
let q = baseQuery
|
|
216
|
+
if (scopedUserId && rel !== 'global') {
|
|
217
|
+
q =
|
|
218
|
+
rel === '1:1' || rel === '1:N'
|
|
219
|
+
? q.where(def.userRelation?.userField ?? 'userId', '==', scopedUserId)
|
|
220
|
+
: q.where(def.userRelation?.userIdsField ?? 'userIds', 'array-contains', scopedUserId)
|
|
221
|
+
}
|
|
222
|
+
if (where) {
|
|
223
|
+
for (const [field, value] of Object.entries(where)) {
|
|
224
|
+
if (value === undefined) continue
|
|
225
|
+
q = Array.isArray(value) ? q.where(field, 'in', value.slice(0, 30)) : q.where(field, '==', value)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
if (select?.length) q = q.select(...(select as string[]))
|
|
229
|
+
return q
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export const wireCoreCollectionHooks = (): void => {
|
|
233
|
+
const chatDef = collectionRegistry.get('chat')
|
|
234
|
+
if (chatDef && !chatDef.onHardDelete) {
|
|
235
|
+
chatDef.onHardDelete = async (doc: { id?: string }, core: Core) => {
|
|
236
|
+
if (!doc.id) return
|
|
237
|
+
const db = core.db.firestore()
|
|
238
|
+
await core.internals.resilience.acquireLimit('firestore')
|
|
239
|
+
await drainQueryBatchDelete(db, db.collection('chat').doc(doc.id).collection('messages'))
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const fileDef = collectionRegistry.get('file')
|
|
243
|
+
if (fileDef && !fileDef.onHardDelete) {
|
|
244
|
+
fileDef.onHardDelete = async (doc: { id?: string }, core: Core) => {
|
|
245
|
+
if (!doc.id) return
|
|
246
|
+
await core.internals.resilience.acquireLimit('storage')
|
|
247
|
+
await core.files.bucket().file(doc.id).delete({ ignoreNotFound: true }).catch(() => {})
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { Context } from 'hono'
|
|
2
|
+
import type { AppEnv } from '../config'
|
|
3
|
+
import type { Core } from '../index'
|
|
4
|
+
import {
|
|
5
|
+
emailLogsQuerySchema,
|
|
6
|
+
emailWebhookParamsSchema,
|
|
7
|
+
sendBatchEmailInputSchema,
|
|
8
|
+
sendBatchEmailResultSchema,
|
|
9
|
+
sendEmailInputSchema,
|
|
10
|
+
sendEmailResultSchema,
|
|
11
|
+
type EmailLogsQuery,
|
|
12
|
+
type EmailWebhookParams,
|
|
13
|
+
type SendBatchEmailInput,
|
|
14
|
+
type SendEmailInput
|
|
15
|
+
} from './schema'
|
|
16
|
+
|
|
17
|
+
export const coreEmailRoutes = {
|
|
18
|
+
'emails.send': {
|
|
19
|
+
input: sendEmailInputSchema,
|
|
20
|
+
output: sendEmailResultSchema,
|
|
21
|
+
handler: async ({ core, input }: { core: Core; input: SendEmailInput }) => core.emails.send(input)
|
|
22
|
+
},
|
|
23
|
+
'emails.batch': {
|
|
24
|
+
input: sendBatchEmailInputSchema,
|
|
25
|
+
output: sendBatchEmailResultSchema,
|
|
26
|
+
handler: async ({ core, input }: { core: Core; input: SendBatchEmailInput }) => core.emails.sendBatch(input.emails)
|
|
27
|
+
},
|
|
28
|
+
'emails.logs': {
|
|
29
|
+
query: emailLogsQuerySchema,
|
|
30
|
+
handler: async ({ core, query }: { core: Core; query: EmailLogsQuery }) =>
|
|
31
|
+
core.emails.listLogs({
|
|
32
|
+
...(query.userId ? { userId: query.userId } : {}),
|
|
33
|
+
...(query.limit ? { limit: query.limit } : {}),
|
|
34
|
+
...(query.cursor ? { cursor: query.cursor } : {}),
|
|
35
|
+
where: {
|
|
36
|
+
...(query.to ? { to: query.to.trim().toLowerCase() } : {}),
|
|
37
|
+
...(query.template ? { template: query.template } : {}),
|
|
38
|
+
...(query.purpose ? { purpose: query.purpose } : {}),
|
|
39
|
+
...(query.status ? { status: query.status } : {})
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
},
|
|
43
|
+
'emails.webhook': {
|
|
44
|
+
params: emailWebhookParamsSchema,
|
|
45
|
+
handler: async ({
|
|
46
|
+
core,
|
|
47
|
+
c,
|
|
48
|
+
params,
|
|
49
|
+
rawBody
|
|
50
|
+
}: {
|
|
51
|
+
core: Core
|
|
52
|
+
c: Context<AppEnv>
|
|
53
|
+
params: EmailWebhookParams
|
|
54
|
+
rawBody?: string
|
|
55
|
+
}) => {
|
|
56
|
+
const headers: Record<string, string | undefined> = {}
|
|
57
|
+
c?.req.raw.headers.forEach((value, key) => {
|
|
58
|
+
headers[key.toLowerCase()] = value
|
|
59
|
+
})
|
|
60
|
+
const body = rawBody ?? (c ? await c.req.text() : '')
|
|
61
|
+
return core.internals.emails.verifyAndProcessWebhook(params.provider, body, headers)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import type {
|
|
3
|
+
AuthEmailPurpose,
|
|
4
|
+
AuthEmailVariables,
|
|
5
|
+
AuthFlowConfigSpec,
|
|
6
|
+
AuthFlowEmailHook,
|
|
7
|
+
EmailProvider,
|
|
8
|
+
EmailsConfig,
|
|
9
|
+
TransactionalEmailPayload
|
|
10
|
+
} from '../config'
|
|
11
|
+
import { entryId, fileRef, type EntryId } from '../db'
|
|
12
|
+
|
|
13
|
+
export type {
|
|
14
|
+
AuthEmailPurpose,
|
|
15
|
+
AuthEmailVariables,
|
|
16
|
+
AuthFlowConfigSpec,
|
|
17
|
+
AuthFlowEmailHook,
|
|
18
|
+
EmailProvider,
|
|
19
|
+
EmailsConfig,
|
|
20
|
+
TransactionalEmailPayload
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const emailLogStatusSchema = z.enum([
|
|
24
|
+
'queued',
|
|
25
|
+
'sent',
|
|
26
|
+
'delivered',
|
|
27
|
+
'opened',
|
|
28
|
+
'clicked',
|
|
29
|
+
'bounced',
|
|
30
|
+
'complained',
|
|
31
|
+
'failed',
|
|
32
|
+
'suppressed'
|
|
33
|
+
])
|
|
34
|
+
export type EmailLogStatus = z.infer<typeof emailLogStatusSchema>
|
|
35
|
+
|
|
36
|
+
export interface EmailTemplateProviderSpec<V = Record<string, unknown>> {
|
|
37
|
+
sendgrid?: {
|
|
38
|
+
templateId: string
|
|
39
|
+
}
|
|
40
|
+
resend?: {
|
|
41
|
+
templateId?: string
|
|
42
|
+
subject?: string | ((vars: V) => string)
|
|
43
|
+
html?: string | ((vars: V) => string)
|
|
44
|
+
text?: string | ((vars: V) => string)
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface EmailTemplateDefinition<S extends z.ZodTypeAny = z.ZodType<Record<string, unknown>>> {
|
|
49
|
+
id?: string
|
|
50
|
+
description?: string
|
|
51
|
+
subject?: string | ((vars: z.infer<S>) => string)
|
|
52
|
+
html?: string | ((vars: z.infer<S>) => string)
|
|
53
|
+
text?: string | ((vars: z.infer<S>) => string)
|
|
54
|
+
fromEmail?: string
|
|
55
|
+
fromName?: string
|
|
56
|
+
replyTo?: string
|
|
57
|
+
category?: string
|
|
58
|
+
variables?: S
|
|
59
|
+
providers?: EmailTemplateProviderSpec<z.infer<S>>
|
|
60
|
+
onBeforeSend?: (vars: z.infer<S>, recipient: string) => Promise<z.infer<S> | false>
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type EmailTemplatesMap = Record<string, EmailTemplateDefinition<any>>
|
|
64
|
+
|
|
65
|
+
const registeredEmailTemplates: EmailTemplatesMap = {}
|
|
66
|
+
|
|
67
|
+
export const registerEmailTemplates = (templates?: EmailTemplatesMap, reset = false): void => {
|
|
68
|
+
if (reset) {
|
|
69
|
+
for (const k of Object.keys(registeredEmailTemplates)) delete registeredEmailTemplates[k]
|
|
70
|
+
}
|
|
71
|
+
if (!templates) return
|
|
72
|
+
for (const [k, def] of Object.entries(templates)) {
|
|
73
|
+
registeredEmailTemplates[k] = { ...def, id: def.id ?? k }
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export const clearEmailTemplates = (): void => {
|
|
78
|
+
for (const k of Object.keys(registeredEmailTemplates)) delete registeredEmailTemplates[k]
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const defineEmailTemplates = <T extends EmailTemplatesMap>(templates: T): T => {
|
|
82
|
+
const normalized = Object.fromEntries(
|
|
83
|
+
Object.entries(templates).map(([k, def]) => [k, { ...def, id: def.id ?? k }])
|
|
84
|
+
) as T
|
|
85
|
+
registerEmailTemplates(normalized, false)
|
|
86
|
+
return normalized
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export const getEmailTemplate = (id: string): EmailTemplateDefinition<any> | undefined =>
|
|
90
|
+
registeredEmailTemplates[id]
|
|
91
|
+
|
|
92
|
+
export const getRegisteredEmailTemplates = (): EmailTemplatesMap => registeredEmailTemplates
|
|
93
|
+
|
|
94
|
+
export const emailAttachmentInputSchema = z
|
|
95
|
+
.object({
|
|
96
|
+
filename: z.string().min(1),
|
|
97
|
+
fileRef: fileRef.optional(),
|
|
98
|
+
contentBase64: z.string().optional(),
|
|
99
|
+
contentType: z.string().optional()
|
|
100
|
+
})
|
|
101
|
+
.refine((v) => Boolean(v.fileRef || v.contentBase64), {
|
|
102
|
+
message: 'Attachment requires either fileRef or contentBase64'
|
|
103
|
+
})
|
|
104
|
+
export type EmailAttachmentInput = z.infer<typeof emailAttachmentInputSchema>
|
|
105
|
+
|
|
106
|
+
export const sendEmailInputSchema = z
|
|
107
|
+
.object({
|
|
108
|
+
to: z.union([z.string().email(), z.array(z.string().email()).min(1).max(50)]),
|
|
109
|
+
template: z.string().optional(),
|
|
110
|
+
providerTemplateId: z.string().optional(),
|
|
111
|
+
variables: z.record(z.string(), z.unknown()).default({}),
|
|
112
|
+
subject: z.string().max(300).optional(),
|
|
113
|
+
html: z.string().optional(),
|
|
114
|
+
text: z.string().optional(),
|
|
115
|
+
fromEmail: z.string().email().optional(),
|
|
116
|
+
fromName: z.string().max(120).optional(),
|
|
117
|
+
replyTo: z.string().email().optional(),
|
|
118
|
+
cc: z.array(z.string().email()).optional(),
|
|
119
|
+
bcc: z.array(z.string().email()).optional(),
|
|
120
|
+
attachments: z.array(emailAttachmentInputSchema).max(10).optional(),
|
|
121
|
+
tags: z.record(z.string(), z.string()).optional(),
|
|
122
|
+
idempotencyKey: z.string().max(200).optional(),
|
|
123
|
+
purpose: z.string().optional()
|
|
124
|
+
})
|
|
125
|
+
.refine((v) => Boolean(v.template || v.providerTemplateId || v.html || v.text), {
|
|
126
|
+
message: 'Provide a template alias, providerTemplateId, or html/text body'
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
export type SendEmailInput<T = Record<string, unknown>> = Omit<z.input<typeof sendEmailInputSchema>, 'variables'> & {
|
|
130
|
+
variables?: T
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const sendBatchEmailInputSchema = z.object({
|
|
134
|
+
emails: z.array(sendEmailInputSchema).min(1).max(100)
|
|
135
|
+
})
|
|
136
|
+
export type SendBatchEmailInput = z.input<typeof sendBatchEmailInputSchema>
|
|
137
|
+
|
|
138
|
+
export const emailLogsQuerySchema = z.object({
|
|
139
|
+
to: z.string().optional(),
|
|
140
|
+
template: z.string().optional(),
|
|
141
|
+
purpose: z.string().optional(),
|
|
142
|
+
status: emailLogStatusSchema.optional(),
|
|
143
|
+
userId: entryId.optional(),
|
|
144
|
+
limit: z.coerce.number().int().min(1).max(500).optional(),
|
|
145
|
+
cursor: z.string().optional()
|
|
146
|
+
})
|
|
147
|
+
export type EmailLogsQuery = z.infer<typeof emailLogsQuerySchema>
|
|
148
|
+
|
|
149
|
+
export const emailWebhookParamsSchema = z.object({
|
|
150
|
+
provider: z.enum(['sendgrid', 'resend'])
|
|
151
|
+
})
|
|
152
|
+
export type EmailWebhookParams = z.infer<typeof emailWebhookParamsSchema>
|
|
153
|
+
|
|
154
|
+
export const sendEmailResultSchema = z.object({
|
|
155
|
+
emailId: entryId,
|
|
156
|
+
externalMessageId: z.string().optional(),
|
|
157
|
+
status: emailLogStatusSchema,
|
|
158
|
+
provider: z.enum(['sendgrid', 'resend', 'firebase', 'dev']),
|
|
159
|
+
deduplicated: z.boolean().optional()
|
|
160
|
+
})
|
|
161
|
+
export type SendEmailResult = z.infer<typeof sendEmailResultSchema>
|
|
162
|
+
|
|
163
|
+
export const sendBatchEmailResultSchema = z.object({
|
|
164
|
+
total: z.number().int().nonnegative(),
|
|
165
|
+
sent: z.number().int().nonnegative(),
|
|
166
|
+
suppressed: z.number().int().nonnegative(),
|
|
167
|
+
failed: z.number().int().nonnegative(),
|
|
168
|
+
results: z.array(sendEmailResultSchema)
|
|
169
|
+
})
|
|
170
|
+
export type SendBatchEmailResult = z.infer<typeof sendBatchEmailResultSchema>
|
|
171
|
+
|
|
172
|
+
export interface EmailCallOptions {
|
|
173
|
+
provider?: 'sendgrid' | 'resend'
|
|
174
|
+
userId?: EntryId
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export interface ResolvedAttachment {
|
|
178
|
+
filename: string
|
|
179
|
+
contentBase64: string
|
|
180
|
+
contentType: string
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface NormalizedEmailRequest {
|
|
184
|
+
emailId: EntryId
|
|
185
|
+
to: string[]
|
|
186
|
+
fromEmail: string
|
|
187
|
+
fromName: string
|
|
188
|
+
replyTo?: string
|
|
189
|
+
cc?: string[]
|
|
190
|
+
bcc?: string[]
|
|
191
|
+
subject?: string
|
|
192
|
+
html?: string
|
|
193
|
+
text?: string
|
|
194
|
+
template?: string
|
|
195
|
+
providerTemplateId?: string
|
|
196
|
+
variables: Record<string, unknown>
|
|
197
|
+
attachments: ResolvedAttachment[]
|
|
198
|
+
tags?: Record<string, string>
|
|
199
|
+
idempotencyKey?: string
|
|
200
|
+
purpose?: string
|
|
201
|
+
userId?: EntryId
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export interface ResendWebhookEvent {
|
|
205
|
+
type:
|
|
206
|
+
| 'email.sent'
|
|
207
|
+
| 'email.delivered'
|
|
208
|
+
| 'email.delivery_delayed'
|
|
209
|
+
| 'email.complained'
|
|
210
|
+
| 'email.bounced'
|
|
211
|
+
| 'email.opened'
|
|
212
|
+
| 'email.clicked'
|
|
213
|
+
| (string & {})
|
|
214
|
+
created_at?: string
|
|
215
|
+
data: {
|
|
216
|
+
email_id?: string
|
|
217
|
+
to?: string[] | string
|
|
218
|
+
from?: string
|
|
219
|
+
subject?: string
|
|
220
|
+
bounce?: { message?: string; type?: string }
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export interface SendGridWebhookEvent {
|
|
225
|
+
event:
|
|
226
|
+
| 'processed'
|
|
227
|
+
| 'delivered'
|
|
228
|
+
| 'open'
|
|
229
|
+
| 'click'
|
|
230
|
+
| 'bounce'
|
|
231
|
+
| 'dropped'
|
|
232
|
+
| 'spamreport'
|
|
233
|
+
| (string & {})
|
|
234
|
+
email?: string
|
|
235
|
+
sg_message_id?: string
|
|
236
|
+
timestamp?: number
|
|
237
|
+
reason?: string
|
|
238
|
+
type?: string
|
|
239
|
+
}
|