@usequark/quark-create-app 1.23.11
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/README.md +210 -0
- package/package.json +56 -0
- package/src/dependabot-config.test.js +128 -0
- package/src/deploy/adapters/iac.js +203 -0
- package/src/deploy/adapters/index.js +27 -0
- package/src/deploy/adapters/railway.js +508 -0
- package/src/deploy/adapters/railway.test.js +318 -0
- package/src/deploy/contract.js +59 -0
- package/src/deploy/deploy.integration.test.js +183 -0
- package/src/deploy/deploy.js +430 -0
- package/src/deploy/deploy.test.js +105 -0
- package/src/deploy/discovery.js +115 -0
- package/src/deploy/discovery.test.js +141 -0
- package/src/deploy/index.js +38 -0
- package/src/deploy/inspect.js +130 -0
- package/src/index.js +2920 -0
- package/src/mobile-template.test.js +232 -0
- package/src/prepare-hooks.test.js +535 -0
- package/src/scaffold-guards.test.js +140 -0
- package/src/scaffold-output.test.js +228 -0
- package/src/template-scripts.test.js +160 -0
- package/src/utils.js +77 -0
- package/src/utils.test.js +95 -0
- package/src/worker-dockerfile.test.js +85 -0
- package/templates/base-project/.cursor/rules/quark.mdc +172 -0
- package/templates/base-project/.dockerignore +14 -0
- package/templates/base-project/.env.railway.example +87 -0
- package/templates/base-project/.github/copilot-instructions.md +65 -0
- package/templates/base-project/.github/dependabot.yml +31 -0
- package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
- package/templates/base-project/.github/workflows/ci.yml +116 -0
- package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
- package/templates/base-project/.github/workflows/install-windows.yml +27 -0
- package/templates/base-project/.github/workflows/release.yml +67 -0
- package/templates/base-project/.railway/railway.ts +40 -0
- package/templates/base-project/CLAUDE.md +163 -0
- package/templates/base-project/MAIN.md +21 -0
- package/templates/base-project/README.md +164 -0
- package/templates/base-project/apps/web/.env.example +99 -0
- package/templates/base-project/apps/web/Dockerfile +38 -0
- package/templates/base-project/apps/web/biome.json +8 -0
- package/templates/base-project/apps/web/jsconfig.json +9 -0
- package/templates/base-project/apps/web/next.config.js +154 -0
- package/templates/base-project/apps/web/package.json +42 -0
- package/templates/base-project/apps/web/postcss.config.mjs +7 -0
- package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
- package/templates/base-project/apps/web/public/quark.svg +47 -0
- package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
- package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
- package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
- package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
- package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
- package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
- package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
- package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
- package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
- package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
- package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
- package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
- package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
- package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
- package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
- package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
- package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
- package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
- package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
- package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
- package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
- package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
- package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
- package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
- package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
- package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
- package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
- package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
- package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
- package/templates/base-project/apps/web/src/app/global-error.js +58 -0
- package/templates/base-project/apps/web/src/app/globals.css +1166 -0
- package/templates/base-project/apps/web/src/app/icon.svg +47 -0
- package/templates/base-project/apps/web/src/app/layout.js +57 -0
- package/templates/base-project/apps/web/src/app/manifest.js +12 -0
- package/templates/base-project/apps/web/src/app/not-found.js +39 -0
- package/templates/base-project/apps/web/src/app/page.js +262 -0
- package/templates/base-project/apps/web/src/app/robots.js +23 -0
- package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
- package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
- package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
- package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
- package/templates/base-project/apps/web/src/lib/auth.js +191 -0
- package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
- package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
- package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
- package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
- package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
- package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
- package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
- package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
- package/templates/base-project/apps/web/src/preflight.js +55 -0
- package/templates/base-project/apps/web/src/proxy.js +328 -0
- package/templates/base-project/biome.json +52 -0
- package/templates/base-project/docker-compose.override.yml +17 -0
- package/templates/base-project/docker-compose.yml +37 -0
- package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
- package/templates/base-project/docs/START_HERE.md +70 -0
- package/templates/base-project/docs/adr/README.md +42 -0
- package/templates/base-project/package.json +58 -0
- package/templates/base-project/packages/db/package.json +38 -0
- package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
- package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
- package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
- package/templates/base-project/packages/db/prisma/seed.js +152 -0
- package/templates/base-project/packages/db/prisma.config.js +27 -0
- package/templates/base-project/packages/db/src/client.js +75 -0
- package/templates/base-project/packages/db/src/connection.js +44 -0
- package/templates/base-project/packages/db/src/context.js +275 -0
- package/templates/base-project/packages/db/src/index.js +5 -0
- package/templates/base-project/packages/db/src/ping.js +66 -0
- package/templates/base-project/packages/db/src/queries.js +292 -0
- package/templates/base-project/packages/db/src/schemas.js +30 -0
- package/templates/base-project/packages/db/src/umami.js +299 -0
- package/templates/base-project/pnpm-workspace.yaml +20 -0
- package/templates/base-project/scripts/check-loading.mjs +49 -0
- package/templates/base-project/scripts/check-standards.mjs +186 -0
- package/templates/base-project/scripts/clean-workspace.mjs +479 -0
- package/templates/base-project/scripts/dev-preflight.mjs +48 -0
- package/templates/base-project/scripts/doctor.js +408 -0
- package/templates/base-project/scripts/prepare.js +490 -0
- package/templates/base-project/scripts/run-tests.mjs +71 -0
- package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
- package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
- package/templates/base-project/skills/add-model/SKILL.md +28 -0
- package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
- package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
- package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
- package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
- package/templates/base-project/skills/i18n/SKILL.md +211 -0
- package/templates/base-project/skills/payment/SKILL.md +280 -0
- package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
- package/templates/base-project/turbo.json +143 -0
- package/templates/config/package.json +20 -0
- package/templates/config/src/app-url.js +134 -0
- package/templates/config/src/environment.js +284 -0
- package/templates/config/src/index.js +16 -0
- package/templates/config/src/load-config.js +157 -0
- package/templates/config/src/rate-limit.js +136 -0
- package/templates/config/src/redis.js +43 -0
- package/templates/config/src/validate-env.js +532 -0
- package/templates/jobs/README.md +52 -0
- package/templates/jobs/package.json +9 -0
- package/templates/jobs/src/definitions.js +13 -0
- package/templates/jobs/src/index.js +1 -0
- package/templates/mobile/.env.example +16 -0
- package/templates/mobile/CHECKLIST.md +40 -0
- package/templates/mobile/README.md +130 -0
- package/templates/mobile/app/(app)/_layout.tsx +83 -0
- package/templates/mobile/app/(app)/index.tsx +147 -0
- package/templates/mobile/app/(app)/profile.tsx +204 -0
- package/templates/mobile/app/(app)/settings.tsx +80 -0
- package/templates/mobile/app/(auth)/_layout.tsx +10 -0
- package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
- package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
- package/templates/mobile/app/+not-found.tsx +32 -0
- package/templates/mobile/app/_layout.tsx +18 -0
- package/templates/mobile/app.json +47 -0
- package/templates/mobile/assets/adaptive-icon.png +0 -0
- package/templates/mobile/assets/icon.png +0 -0
- package/templates/mobile/assets/notification-icon.png +0 -0
- package/templates/mobile/assets/splash.png +0 -0
- package/templates/mobile/biome.json +27 -0
- package/templates/mobile/components/AuthGate.tsx +19 -0
- package/templates/mobile/components/ThemeProvider.tsx +23 -0
- package/templates/mobile/eas.json +29 -0
- package/templates/mobile/hooks/use-auth.ts +127 -0
- package/templates/mobile/lib/api-client.ts +138 -0
- package/templates/mobile/lib/api.ts +83 -0
- package/templates/mobile/lib/auth-providers-google.ts +74 -0
- package/templates/mobile/lib/auth-providers.ts +74 -0
- package/templates/mobile/lib/auth.ts +100 -0
- package/templates/mobile/lib/config.ts +14 -0
- package/templates/mobile/lib/errors.ts +46 -0
- package/templates/mobile/lib/notifications.ts +77 -0
- package/templates/mobile/lib/storage.ts +25 -0
- package/templates/mobile/lib/theme-tokens.ts +16 -0
- package/templates/mobile/package.json +37 -0
- package/templates/mobile/tsconfig.json +10 -0
- package/templates/pwa/app/_components/PWARegister.js +15 -0
- package/templates/pwa/app/manifest.js +32 -0
- package/templates/pwa/public/sw.js +72 -0
- package/templates/ui/README.md +44 -0
- package/templates/ui/package.json +21 -0
- package/templates/ui/src/badge.js +23 -0
- package/templates/ui/src/button.js +111 -0
- package/templates/ui/src/card.js +128 -0
- package/templates/ui/src/checkbox.js +20 -0
- package/templates/ui/src/container.js +13 -0
- package/templates/ui/src/dialog.js +137 -0
- package/templates/ui/src/error-banner.js +15 -0
- package/templates/ui/src/footer.js +239 -0
- package/templates/ui/src/form-field.integration-test.js +142 -0
- package/templates/ui/src/form-field.js +50 -0
- package/templates/ui/src/index.js +24 -0
- package/templates/ui/src/input.js +11 -0
- package/templates/ui/src/label.js +14 -0
- package/templates/ui/src/lightbox.integration-test.js +167 -0
- package/templates/ui/src/lightbox.js +243 -0
- package/templates/ui/src/logo.js +52 -0
- package/templates/ui/src/navbar.js +602 -0
- package/templates/ui/src/password-input.js +31 -0
- package/templates/ui/src/rich-text.js +514 -0
- package/templates/ui/src/select.js +317 -0
- package/templates/ui/src/skeleton.js +10 -0
- package/templates/ui/src/spinner.js +31 -0
- package/templates/ui/src/table.js +169 -0
- package/templates/ui/src/textarea.js +11 -0
- package/templates/ui/src/theme-constants.js +24 -0
- package/templates/ui/src/theme.js +196 -0
- package/templates/ui/src/toast.js +206 -0
- package/templates/worker/Dockerfile +64 -0
- package/templates/worker/README.md +56 -0
- package/templates/worker/package.json +36 -0
- package/templates/worker/src/config/pricing.js +46 -0
- package/templates/worker/src/handlers/email-job-validation.js +36 -0
- package/templates/worker/src/handlers/email.js +98 -0
- package/templates/worker/src/handlers/files.js +59 -0
- package/templates/worker/src/handlers/index.js +20 -0
- package/templates/worker/src/handlers/push-notification.js +234 -0
- package/templates/worker/src/index.js +505 -0
- package/templates/worker/src/lib/railway.js +438 -0
- package/templates/worker/src/lib/tokens.js +81 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared connection string builder for PostgreSQL.
|
|
3
|
+
* Used by both the Prisma client (client.js) and Prisma CLI (prisma.config.js).
|
|
4
|
+
*
|
|
5
|
+
* Priority: DATABASE_URL env var → assembled from POSTGRES_* env vars.
|
|
6
|
+
*
|
|
7
|
+
* @param {{ throwOnMissing?: boolean }} [options]
|
|
8
|
+
* - throwOnMissing=true (default) - runtime: throws if credentials are missing.
|
|
9
|
+
* - throwOnMissing=false - CLI/CI: returns a placeholder URL so `prisma generate` works.
|
|
10
|
+
* @returns {string} PostgreSQL connection string
|
|
11
|
+
*/
|
|
12
|
+
export function getConnectionString({ throwOnMissing = true } = {}) {
|
|
13
|
+
// 1. Prefer an explicit DATABASE_URL if set
|
|
14
|
+
if (process.env.DATABASE_URL) {
|
|
15
|
+
return process.env.DATABASE_URL;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// 2. Assemble from individual POSTGRES_* vars
|
|
19
|
+
const user = process.env.POSTGRES_USER;
|
|
20
|
+
const password = process.env.POSTGRES_PASSWORD;
|
|
21
|
+
const host = process.env.POSTGRES_HOST || "localhost";
|
|
22
|
+
const port = process.env.POSTGRES_PORT || "5432";
|
|
23
|
+
const db = process.env.POSTGRES_DB;
|
|
24
|
+
|
|
25
|
+
const hasCredentials = user && password && db;
|
|
26
|
+
|
|
27
|
+
if (!hasCredentials) {
|
|
28
|
+
if (throwOnMissing) {
|
|
29
|
+
const missing = [];
|
|
30
|
+
if (!user) missing.push("POSTGRES_USER");
|
|
31
|
+
if (!password) missing.push("POSTGRES_PASSWORD");
|
|
32
|
+
if (!db) missing.push("POSTGRES_DB");
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Missing required database environment variables: ${missing.join(", ")}. ` +
|
|
35
|
+
"Set DATABASE_URL or the individual POSTGRES_* variables.",
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Placeholder for prisma generate / CI - will fail at connect time, not at config time.
|
|
40
|
+
return "postgresql://placeholder:placeholder@localhost:5432/placeholder?schema=public";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return `postgresql://${encodeURIComponent(user)}:${encodeURIComponent(password)}@${host}:${port}/${encodeURIComponent(db)}?schema=public`;
|
|
44
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context Retrieval Utility
|
|
3
|
+
*
|
|
4
|
+
* Generic, project-agnostic utility for retrieving relevant business context.
|
|
5
|
+
* Designed to be portable - can be copied to any Quark project with minimal changes.
|
|
6
|
+
*
|
|
7
|
+
* Features:
|
|
8
|
+
* - getRelevantContext(question, records) - filters context by keyword relevance
|
|
9
|
+
* - summarizeContext(records) - brief category overview
|
|
10
|
+
* - shouldProcess(input, previousHash) - dedup check for extraction
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ── Default Keyword Map ──────────────────────────────────────────────────────
|
|
14
|
+
// Maps question keywords to BusinessContext categories.
|
|
15
|
+
// Generic enough for any project - customize by extending or replacing.
|
|
16
|
+
const DEFAULT_KEYWORD_MAP = {
|
|
17
|
+
billing: [
|
|
18
|
+
"billing",
|
|
19
|
+
"invoice",
|
|
20
|
+
"pay",
|
|
21
|
+
"payment",
|
|
22
|
+
"money",
|
|
23
|
+
"cost",
|
|
24
|
+
"revenue",
|
|
25
|
+
"price",
|
|
26
|
+
"$",
|
|
27
|
+
"rate",
|
|
28
|
+
"fee",
|
|
29
|
+
"subscription",
|
|
30
|
+
"plan",
|
|
31
|
+
"charge",
|
|
32
|
+
],
|
|
33
|
+
client: [
|
|
34
|
+
"client",
|
|
35
|
+
"customer",
|
|
36
|
+
"company",
|
|
37
|
+
"business",
|
|
38
|
+
"organization",
|
|
39
|
+
"project",
|
|
40
|
+
"account",
|
|
41
|
+
"partner",
|
|
42
|
+
"vendor",
|
|
43
|
+
],
|
|
44
|
+
task: [
|
|
45
|
+
"task",
|
|
46
|
+
"todo",
|
|
47
|
+
"overdue",
|
|
48
|
+
"blocked",
|
|
49
|
+
"progress",
|
|
50
|
+
"status",
|
|
51
|
+
"assign",
|
|
52
|
+
"priority",
|
|
53
|
+
"deadline",
|
|
54
|
+
"due",
|
|
55
|
+
"complete",
|
|
56
|
+
"pipeline",
|
|
57
|
+
],
|
|
58
|
+
tech_note: [
|
|
59
|
+
"tech",
|
|
60
|
+
"config",
|
|
61
|
+
"deploy",
|
|
62
|
+
"server",
|
|
63
|
+
"integration",
|
|
64
|
+
"api",
|
|
65
|
+
"code",
|
|
66
|
+
"bug",
|
|
67
|
+
"fix",
|
|
68
|
+
"setup",
|
|
69
|
+
"migration",
|
|
70
|
+
"database",
|
|
71
|
+
],
|
|
72
|
+
process: [
|
|
73
|
+
"process",
|
|
74
|
+
"policy",
|
|
75
|
+
"rule",
|
|
76
|
+
"workflow",
|
|
77
|
+
"how",
|
|
78
|
+
"procedure",
|
|
79
|
+
"guideline",
|
|
80
|
+
"step",
|
|
81
|
+
"method",
|
|
82
|
+
"standard",
|
|
83
|
+
],
|
|
84
|
+
preference: [
|
|
85
|
+
"prefer",
|
|
86
|
+
"like",
|
|
87
|
+
"want",
|
|
88
|
+
"style",
|
|
89
|
+
"format",
|
|
90
|
+
"communication",
|
|
91
|
+
"contact",
|
|
92
|
+
"schedule",
|
|
93
|
+
"frequency",
|
|
94
|
+
],
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
// ── Relevance Scoring ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Scores a context record's relevance to a question.
|
|
101
|
+
* @param {Object} record - { key, value, category }
|
|
102
|
+
* @param {string} question - The user's question (lowercased)
|
|
103
|
+
* @param {Object} keywordMap - Custom keyword map (optional)
|
|
104
|
+
* @returns {number} Relevance score (0 = irrelevant, higher = more relevant)
|
|
105
|
+
*/
|
|
106
|
+
function scoreRelevance(record, question, keywordMap = DEFAULT_KEYWORD_MAP) {
|
|
107
|
+
let score = 0;
|
|
108
|
+
const lowerQuestion = question;
|
|
109
|
+
|
|
110
|
+
// Score 1: Category matches question keywords
|
|
111
|
+
const categoryKeywords = keywordMap[record.category];
|
|
112
|
+
if (categoryKeywords) {
|
|
113
|
+
for (const kw of categoryKeywords) {
|
|
114
|
+
if (lowerQuestion.includes(kw)) {
|
|
115
|
+
score += 2;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Score 2: Key words match question
|
|
121
|
+
const keyWords = record.key.toLowerCase().split(/[.\s_-]+/);
|
|
122
|
+
for (const word of keyWords) {
|
|
123
|
+
if (word.length > 2 && lowerQuestion.includes(word)) {
|
|
124
|
+
score += 3;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Score 3: Value contains question keywords (partial match)
|
|
129
|
+
const valueLower = (record.value || "").toLowerCase();
|
|
130
|
+
const questionWords = lowerQuestion.split(/\s+/).filter((w) => w.length > 3);
|
|
131
|
+
for (const word of questionWords) {
|
|
132
|
+
if (valueLower.includes(word)) {
|
|
133
|
+
score += 1;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return score;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── Public API ───────────────────────────────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Filters context records to only those relevant to a question.
|
|
144
|
+
* Returns records with score > 0, sorted by relevance (highest first).
|
|
145
|
+
* If no records score > 0, returns a summary instead.
|
|
146
|
+
*
|
|
147
|
+
* @param {string} question - The user's question
|
|
148
|
+
* @param {Array<Object>} records - BusinessContext records [{ key, value, category, source }]
|
|
149
|
+
* @param {Object} [options]
|
|
150
|
+
* @param {number} [options.maxRecords=15] - Max records to return
|
|
151
|
+
* @param {number} [options.minScore=1] - Minimum relevance score
|
|
152
|
+
* @param {Object} [options.keywordMap] - Custom keyword map
|
|
153
|
+
* @returns {Object} { records: Array, summary: string|null }
|
|
154
|
+
*/
|
|
155
|
+
export function getRelevantContext(question, records, options = {}) {
|
|
156
|
+
const {
|
|
157
|
+
maxRecords = 15,
|
|
158
|
+
minScore = 1,
|
|
159
|
+
keywordMap = DEFAULT_KEYWORD_MAP,
|
|
160
|
+
} = options;
|
|
161
|
+
|
|
162
|
+
if (!question || !records || records.length === 0) {
|
|
163
|
+
return { records: [], summary: "No business context available." };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const lowerQuestion = question.toLowerCase();
|
|
167
|
+
|
|
168
|
+
// Score all records
|
|
169
|
+
const scored = records.map((r) => ({
|
|
170
|
+
...r,
|
|
171
|
+
_score: scoreRelevance(r, lowerQuestion, keywordMap),
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// Filter by minimum score
|
|
175
|
+
const relevant = scored
|
|
176
|
+
.filter((r) => r._score >= minScore)
|
|
177
|
+
.sort((a, b) => b._score - a._score)
|
|
178
|
+
.slice(0, maxRecords);
|
|
179
|
+
|
|
180
|
+
// Clean up internal score field
|
|
181
|
+
const cleanRecords = relevant.map(({ _score, ...r }) => r);
|
|
182
|
+
|
|
183
|
+
if (cleanRecords.length > 0) {
|
|
184
|
+
return { records: cleanRecords, summary: null };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// No relevant records found - return a summary instead
|
|
188
|
+
return {
|
|
189
|
+
records: [],
|
|
190
|
+
summary: summarizeContext(records),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Creates a brief category summary of all context records.
|
|
196
|
+
* Tells the AI what's available without overwhelming it with details.
|
|
197
|
+
*
|
|
198
|
+
* @param {Array<Object>} records
|
|
199
|
+
* @returns {string}
|
|
200
|
+
*/
|
|
201
|
+
export function summarizeContext(records) {
|
|
202
|
+
if (!records || records.length === 0) {
|
|
203
|
+
return "No business context available.";
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const byCategory = {};
|
|
207
|
+
for (const r of records) {
|
|
208
|
+
if (!byCategory[r.category]) byCategory[r.category] = [];
|
|
209
|
+
byCategory[r.category].push(r);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const totalManual = records.filter((r) => r.source === "manual").length;
|
|
213
|
+
const totalSeed = records.filter((r) => r.source === "seed").length;
|
|
214
|
+
|
|
215
|
+
let summary = `Available business context (${records.length} records, ${totalSeed} curated + ${totalManual} manual):\n`;
|
|
216
|
+
|
|
217
|
+
for (const [category, entries] of Object.entries(byCategory)) {
|
|
218
|
+
const label = category.charAt(0).toUpperCase() + category.slice(1);
|
|
219
|
+
const manual = entries.filter((e) => e.source === "manual").length;
|
|
220
|
+
const seed = entries.filter((e) => e.source === "seed").length;
|
|
221
|
+
summary += `- **${label}**: ${entries.length} records`;
|
|
222
|
+
if (seed > 0) summary += ` (${seed} curated, ${manual} manual)`;
|
|
223
|
+
summary += "\n";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
summary +=
|
|
227
|
+
"\nUse `getRelevantContext` with a specific question to retrieve detailed context.";
|
|
228
|
+
|
|
229
|
+
return summary;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Creates a deterministic hash of input data for dedup checking.
|
|
234
|
+
* Returns null if input is empty.
|
|
235
|
+
*
|
|
236
|
+
* @param {string} input - The data to hash (e.g., conversation text, task data)
|
|
237
|
+
* @returns {string|null} Hex hash string, or null if empty
|
|
238
|
+
*/
|
|
239
|
+
export function hashInput(input) {
|
|
240
|
+
if (!input || typeof input !== "string" || input.trim().length === 0) {
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Simple hash function (no crypto dependency needed)
|
|
245
|
+
let hash = 0;
|
|
246
|
+
const str = input.trim();
|
|
247
|
+
for (let i = 0; i < str.length; i++) {
|
|
248
|
+
const char = str.charCodeAt(i);
|
|
249
|
+
hash = (hash << 5) - hash + char;
|
|
250
|
+
hash |= 0; // Convert to 32bit integer
|
|
251
|
+
}
|
|
252
|
+
return Math.abs(hash).toString(16);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Checks whether an input should be processed, based on a previous hash.
|
|
257
|
+
* Useful for skipping extraction when nothing has changed.
|
|
258
|
+
*
|
|
259
|
+
* @param {string} input - The current input data
|
|
260
|
+
* @param {string|null} previousHash - The hash from the last successful processing
|
|
261
|
+
* @returns {Object} { shouldProcess: boolean, hash: string|null }
|
|
262
|
+
*/
|
|
263
|
+
export function shouldProcess(input, previousHash) {
|
|
264
|
+
const currentHash = hashInput(input);
|
|
265
|
+
|
|
266
|
+
if (!currentHash) {
|
|
267
|
+
return { shouldProcess: false, hash: null };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (currentHash === previousHash) {
|
|
271
|
+
return { shouldProcess: false, hash: currentHash };
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
return { shouldProcess: true, hash: currentHash };
|
|
275
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { getConnectionString } from "./connection.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pings PostgreSQL to verify connectivity.
|
|
5
|
+
* Uses a raw `pg.Client` directly so the check is immune to Prisma
|
|
6
|
+
* adapter quirks - in particular, Prisma 7 + @prisma/adapter-pg surfaces
|
|
7
|
+
* connection failures as a misleading "Invalid invocation" error rather
|
|
8
|
+
* than a proper ECONNREFUSED.
|
|
9
|
+
*
|
|
10
|
+
* @param {object} [options]
|
|
11
|
+
* @param {number} [options.timeout=3000] - Connection/query timeout in milliseconds.
|
|
12
|
+
* @returns {Promise<{ status: "ok", latencyMs: number } | { status: "error", message: string }>}
|
|
13
|
+
*/
|
|
14
|
+
export async function pingDatabase({ timeout = 3000 } = {}) {
|
|
15
|
+
/** @type {import("pg").Client | null} */
|
|
16
|
+
let client = null;
|
|
17
|
+
let connectionString;
|
|
18
|
+
|
|
19
|
+
try {
|
|
20
|
+
const { default: pg } = await import("pg");
|
|
21
|
+
connectionString = getConnectionString();
|
|
22
|
+
|
|
23
|
+
client = new pg.Client({
|
|
24
|
+
connectionString,
|
|
25
|
+
connectionTimeoutMillis: timeout,
|
|
26
|
+
query_timeout: timeout,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
await client.connect();
|
|
30
|
+
|
|
31
|
+
const start = performance.now();
|
|
32
|
+
await client.query("SELECT 1");
|
|
33
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
34
|
+
|
|
35
|
+
return { status: "ok", latencyMs };
|
|
36
|
+
} catch (/** @type {any} */ error) {
|
|
37
|
+
let message;
|
|
38
|
+
if (
|
|
39
|
+
error?.code === "MODULE_NOT_FOUND" ||
|
|
40
|
+
error?.code === "ERR_MODULE_NOT_FOUND"
|
|
41
|
+
) {
|
|
42
|
+
message = "pg is not installed";
|
|
43
|
+
} else if (
|
|
44
|
+
error?.code === "ECONNREFUSED" ||
|
|
45
|
+
/ECONNREFUSED/i.test(error?.message ?? "")
|
|
46
|
+
) {
|
|
47
|
+
try {
|
|
48
|
+
const { hostname, port } = new URL(connectionString);
|
|
49
|
+
message = `PostgreSQL unreachable at ${hostname}:${port || "5432"}`;
|
|
50
|
+
} catch {
|
|
51
|
+
message = "PostgreSQL unreachable";
|
|
52
|
+
}
|
|
53
|
+
} else if (error?.code === "ENOTFOUND") {
|
|
54
|
+
message = `PostgreSQL host not found: ${error.hostname ?? "unknown"}`;
|
|
55
|
+
} else {
|
|
56
|
+
message = error?.message ?? String(error);
|
|
57
|
+
}
|
|
58
|
+
return { status: "error", message };
|
|
59
|
+
} finally {
|
|
60
|
+
try {
|
|
61
|
+
await client?.end();
|
|
62
|
+
} catch {
|
|
63
|
+
// ignore disconnect errors
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { prisma } from "./client.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Safe select for user queries - excludes sensitive fields (password).
|
|
5
|
+
* Use this on any query whose result is returned to the client.
|
|
6
|
+
*/
|
|
7
|
+
const USER_SAFE_SELECT = {
|
|
8
|
+
id: true,
|
|
9
|
+
email: true,
|
|
10
|
+
emailVerified: true,
|
|
11
|
+
name: true,
|
|
12
|
+
image: true,
|
|
13
|
+
role: true,
|
|
14
|
+
createdAt: true,
|
|
15
|
+
updatedAt: true,
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { USER_SAFE_SELECT };
|
|
19
|
+
|
|
20
|
+
// User queries
|
|
21
|
+
export const user = {
|
|
22
|
+
findById: (id) => {
|
|
23
|
+
return prisma.user.findUnique({
|
|
24
|
+
where: { id },
|
|
25
|
+
select: USER_SAFE_SELECT,
|
|
26
|
+
});
|
|
27
|
+
},
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* findByEmail returns ALL fields including password.
|
|
31
|
+
* Only use for internal auth - never expose the result directly to clients.
|
|
32
|
+
*/
|
|
33
|
+
findByEmail: (email) => {
|
|
34
|
+
return prisma.user.findUnique({
|
|
35
|
+
where: { email },
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
findAll: (options = {}) => {
|
|
39
|
+
const { skip = 0, take = 10 } = options;
|
|
40
|
+
return prisma.user.findMany({
|
|
41
|
+
skip,
|
|
42
|
+
take,
|
|
43
|
+
select: USER_SAFE_SELECT,
|
|
44
|
+
orderBy: { createdAt: "desc" },
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
create: (data) => {
|
|
48
|
+
return prisma.user.create({
|
|
49
|
+
data,
|
|
50
|
+
select: USER_SAFE_SELECT,
|
|
51
|
+
});
|
|
52
|
+
},
|
|
53
|
+
update: (id, data) => {
|
|
54
|
+
return prisma.user.update({
|
|
55
|
+
where: { id },
|
|
56
|
+
data,
|
|
57
|
+
select: USER_SAFE_SELECT,
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
delete: (id) => {
|
|
61
|
+
return prisma.user.delete({
|
|
62
|
+
where: { id },
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
count: () => {
|
|
66
|
+
return prisma.user.count();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Job queries
|
|
71
|
+
export const job = {
|
|
72
|
+
findAll: (options = {}) => {
|
|
73
|
+
const { skip = 0, take = 50, where = {} } = options;
|
|
74
|
+
return prisma.job.findMany({
|
|
75
|
+
skip,
|
|
76
|
+
take,
|
|
77
|
+
where,
|
|
78
|
+
orderBy: { createdAt: "desc" },
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
findById: (id) => {
|
|
83
|
+
return prisma.job.findUnique({ where: { id } });
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
upsert: (id, data) => {
|
|
87
|
+
return prisma.job.upsert({
|
|
88
|
+
where: { id },
|
|
89
|
+
create: { id, ...data },
|
|
90
|
+
update: data,
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Account queries (NextAuth)
|
|
96
|
+
export const account = {
|
|
97
|
+
findById: (id) => {
|
|
98
|
+
return prisma.account.findUnique({
|
|
99
|
+
where: { id },
|
|
100
|
+
});
|
|
101
|
+
},
|
|
102
|
+
findByUserIdAndProvider: (userId, provider) => {
|
|
103
|
+
return prisma.account.findFirst({
|
|
104
|
+
where: { userId, provider },
|
|
105
|
+
});
|
|
106
|
+
},
|
|
107
|
+
findByUser: (userId) => {
|
|
108
|
+
return prisma.account.findMany({
|
|
109
|
+
where: { userId },
|
|
110
|
+
});
|
|
111
|
+
},
|
|
112
|
+
create: (data) => {
|
|
113
|
+
return prisma.account.create({
|
|
114
|
+
data,
|
|
115
|
+
});
|
|
116
|
+
},
|
|
117
|
+
delete: (id) => {
|
|
118
|
+
return prisma.account.delete({
|
|
119
|
+
where: { id },
|
|
120
|
+
});
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
// Session queries (NextAuth)
|
|
125
|
+
export const session = {
|
|
126
|
+
findByToken: (sessionToken) => {
|
|
127
|
+
return prisma.session.findUnique({
|
|
128
|
+
where: { sessionToken },
|
|
129
|
+
include: { user: { select: USER_SAFE_SELECT } },
|
|
130
|
+
});
|
|
131
|
+
},
|
|
132
|
+
findByUserId: (userId) => {
|
|
133
|
+
return prisma.session.findMany({
|
|
134
|
+
where: { userId },
|
|
135
|
+
});
|
|
136
|
+
},
|
|
137
|
+
create: (data) => {
|
|
138
|
+
return prisma.session.create({
|
|
139
|
+
data,
|
|
140
|
+
include: { user: { select: USER_SAFE_SELECT } },
|
|
141
|
+
});
|
|
142
|
+
},
|
|
143
|
+
update: (sessionToken, data) => {
|
|
144
|
+
return prisma.session.update({
|
|
145
|
+
where: { sessionToken },
|
|
146
|
+
data,
|
|
147
|
+
});
|
|
148
|
+
},
|
|
149
|
+
delete: (sessionToken) => {
|
|
150
|
+
return prisma.session.delete({
|
|
151
|
+
where: { sessionToken },
|
|
152
|
+
});
|
|
153
|
+
},
|
|
154
|
+
deleteByUserId: (userId) => {
|
|
155
|
+
return prisma.session.deleteMany({
|
|
156
|
+
where: { userId },
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Verification Token queries (NextAuth)
|
|
162
|
+
export const verificationToken = {
|
|
163
|
+
findByToken: (token) => {
|
|
164
|
+
return prisma.verificationToken.findUnique({
|
|
165
|
+
where: { token },
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
findByIdentifierAndToken: (identifier, token) => {
|
|
169
|
+
return prisma.verificationToken.findUnique({
|
|
170
|
+
where: { identifier_token: { identifier, token } },
|
|
171
|
+
});
|
|
172
|
+
},
|
|
173
|
+
create: (data) => {
|
|
174
|
+
return prisma.verificationToken.create({
|
|
175
|
+
data,
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
delete: (identifier, token) => {
|
|
179
|
+
return prisma.verificationToken.deleteMany({
|
|
180
|
+
where: { identifier, token },
|
|
181
|
+
});
|
|
182
|
+
},
|
|
183
|
+
deleteExpired: () => {
|
|
184
|
+
return prisma.verificationToken.deleteMany({
|
|
185
|
+
where: { expires: { lt: new Date() } },
|
|
186
|
+
});
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
// AuditLog queries
|
|
191
|
+
export const auditLog = {
|
|
192
|
+
findAll: (options = {}) => {
|
|
193
|
+
const { skip = 0, take = 50 } = options;
|
|
194
|
+
return prisma.auditLog.findMany({
|
|
195
|
+
skip,
|
|
196
|
+
take,
|
|
197
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
198
|
+
orderBy: { createdAt: "desc" },
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
findByUserId: (userId, options = {}) => {
|
|
202
|
+
const { skip = 0, take = 50 } = options;
|
|
203
|
+
return prisma.auditLog.findMany({
|
|
204
|
+
where: { userId },
|
|
205
|
+
skip,
|
|
206
|
+
take,
|
|
207
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
208
|
+
orderBy: { createdAt: "desc" },
|
|
209
|
+
});
|
|
210
|
+
},
|
|
211
|
+
findByEntity: (entity, options = {}) => {
|
|
212
|
+
const { skip = 0, take = 50 } = options;
|
|
213
|
+
return prisma.auditLog.findMany({
|
|
214
|
+
where: { entity },
|
|
215
|
+
skip,
|
|
216
|
+
take,
|
|
217
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
218
|
+
orderBy: { createdAt: "desc" },
|
|
219
|
+
});
|
|
220
|
+
},
|
|
221
|
+
findByAction: (action, options = {}) => {
|
|
222
|
+
const { skip = 0, take = 50 } = options;
|
|
223
|
+
return prisma.auditLog.findMany({
|
|
224
|
+
where: { action },
|
|
225
|
+
skip,
|
|
226
|
+
take,
|
|
227
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
228
|
+
orderBy: { createdAt: "desc" },
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
create: (data) => {
|
|
232
|
+
return prisma.auditLog.create({
|
|
233
|
+
data,
|
|
234
|
+
include: { user: { select: { id: true, email: true, name: true } } },
|
|
235
|
+
});
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// File queries
|
|
240
|
+
export const file = {
|
|
241
|
+
create: (data) => {
|
|
242
|
+
return prisma.file.create({ data });
|
|
243
|
+
},
|
|
244
|
+
findById: (id) => {
|
|
245
|
+
return prisma.file.findUnique({
|
|
246
|
+
where: { id },
|
|
247
|
+
include: {
|
|
248
|
+
uploadedBy: { select: { id: true, email: true, name: true } },
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
},
|
|
252
|
+
findByStorageKey: (storageKey) => {
|
|
253
|
+
return prisma.file.findUnique({ where: { storageKey } });
|
|
254
|
+
},
|
|
255
|
+
findByUploader: (uploadedById, options = {}) => {
|
|
256
|
+
const { skip = 0, take = 50 } = options;
|
|
257
|
+
return prisma.file.findMany({
|
|
258
|
+
where: { uploadedById },
|
|
259
|
+
skip,
|
|
260
|
+
take,
|
|
261
|
+
orderBy: { createdAt: "desc" },
|
|
262
|
+
});
|
|
263
|
+
},
|
|
264
|
+
findOrphaned: (options = {}) => {
|
|
265
|
+
const { take = 100 } = options;
|
|
266
|
+
return prisma.file.findMany({
|
|
267
|
+
where: { uploadedById: null },
|
|
268
|
+
take,
|
|
269
|
+
orderBy: { createdAt: "asc" },
|
|
270
|
+
});
|
|
271
|
+
},
|
|
272
|
+
findOlderThan: (date, options = {}) => {
|
|
273
|
+
const { take = 100 } = options;
|
|
274
|
+
return prisma.file.findMany({
|
|
275
|
+
where: {
|
|
276
|
+
uploadedById: null,
|
|
277
|
+
createdAt: { lt: date },
|
|
278
|
+
},
|
|
279
|
+
take,
|
|
280
|
+
orderBy: { createdAt: "asc" },
|
|
281
|
+
});
|
|
282
|
+
},
|
|
283
|
+
delete: (id) => {
|
|
284
|
+
return prisma.file.delete({ where: { id } });
|
|
285
|
+
},
|
|
286
|
+
deleteMany: (ids) => {
|
|
287
|
+
return prisma.file.deleteMany({ where: { id: { in: ids } } });
|
|
288
|
+
},
|
|
289
|
+
count: (where = {}) => {
|
|
290
|
+
return prisma.file.count({ where });
|
|
291
|
+
},
|
|
292
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
export const userCreateSchema = z.object({
|
|
4
|
+
email: z.string().email(),
|
|
5
|
+
name: z.string().optional(),
|
|
6
|
+
image: z.string().url().optional(),
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const userRegisterSchema = z.object({
|
|
10
|
+
email: z.string().email("Invalid email address"),
|
|
11
|
+
password: z
|
|
12
|
+
.string()
|
|
13
|
+
.min(8, "Password must be at least 8 characters")
|
|
14
|
+
.regex(/[A-Z]/, "Password must contain at least one uppercase letter")
|
|
15
|
+
.regex(/[a-z]/, "Password must contain at least one lowercase letter")
|
|
16
|
+
.regex(/[0-9]/, "Password must contain at least one number"),
|
|
17
|
+
name: z.string().min(2, "Name must be at least 2 characters").optional(),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
export const userUpdateSchema = z.object({
|
|
21
|
+
email: z.string().email().optional(),
|
|
22
|
+
name: z.string().optional(),
|
|
23
|
+
image: z.string().url().optional(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const fileUploadSchema = z.object({
|
|
27
|
+
filename: z.string().min(1, "Filename is required"),
|
|
28
|
+
mimeType: z.string().min(1, "MIME type is required"),
|
|
29
|
+
size: z.number().int().positive("File size must be positive"),
|
|
30
|
+
});
|