@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,196 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, {
|
|
3
|
+
createContext,
|
|
4
|
+
useCallback,
|
|
5
|
+
useContext,
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useState,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
THEME_ATTR,
|
|
12
|
+
THEME_CHANGE_EVENT,
|
|
13
|
+
THEME_STORAGE_KEY,
|
|
14
|
+
} from "./theme-constants.js";
|
|
15
|
+
|
|
16
|
+
const ThemeCtx = createContext({ theme: "dark", setTheme: () => {} });
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Wraps a subtree with a shared theme value.
|
|
20
|
+
*
|
|
21
|
+
* Behaviour:
|
|
22
|
+
* - If `defaultTheme` is provided it is used as the fallback when no stored
|
|
23
|
+
* preference exists (good for pages with a deliberate starting theme).
|
|
24
|
+
* - If `defaultTheme` is omitted the fallback is the OS `prefers-color-scheme`
|
|
25
|
+
* media query, evaluated after hydration in a layout effect.
|
|
26
|
+
* - The user's explicit toggle choice is persisted to
|
|
27
|
+
* `localStorage` under the key `quark-theme` and restored on subsequent
|
|
28
|
+
* visits so their preference is remembered across sessions.
|
|
29
|
+
*
|
|
30
|
+
* @param {{ defaultTheme?: 'light' | 'dark', children: React.ReactNode }} props
|
|
31
|
+
*/
|
|
32
|
+
export function ThemeProvider({ defaultTheme, children }) {
|
|
33
|
+
// Always initialize from the server-safe default. The lazy initialiser cannot
|
|
34
|
+
// run during React hydration (React reuses the server state), so we sync to
|
|
35
|
+
// the real user preference in a layout effect instead.
|
|
36
|
+
const [theme, setTheme] = useState(defaultTheme ?? "dark");
|
|
37
|
+
|
|
38
|
+
// useLayoutEffect fires synchronously after DOM mutations but BEFORE the
|
|
39
|
+
// browser paints, so the toggle snaps to the correct position with no
|
|
40
|
+
// visible flash. On the server it is a no-op (same as useEffect).
|
|
41
|
+
useLayoutEffect(() => {
|
|
42
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
43
|
+
const resolved =
|
|
44
|
+
stored === "light" || stored === "dark"
|
|
45
|
+
? stored
|
|
46
|
+
: (defaultTheme ??
|
|
47
|
+
(window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
48
|
+
? "dark"
|
|
49
|
+
: "light"));
|
|
50
|
+
document.documentElement.setAttribute(THEME_ATTR, resolved);
|
|
51
|
+
setTheme(resolved);
|
|
52
|
+
}, [defaultTheme]);
|
|
53
|
+
|
|
54
|
+
const persistSetTheme = useCallback((t) => {
|
|
55
|
+
localStorage.setItem(THEME_STORAGE_KEY, t);
|
|
56
|
+
document.documentElement.setAttribute(THEME_ATTR, t);
|
|
57
|
+
setTheme(t);
|
|
58
|
+
}, []);
|
|
59
|
+
|
|
60
|
+
// Keep state and DOM in sync when the OS colour scheme changes (only
|
|
61
|
+
// relevant when no defaultTheme was provided and the user has no stored
|
|
62
|
+
// preference). Must call persistSetTheme so data-theme and CSS vars update.
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
const stored = localStorage.getItem(THEME_STORAGE_KEY);
|
|
65
|
+
if (stored || defaultTheme != null) return;
|
|
66
|
+
const mq = window.matchMedia("(prefers-color-scheme: dark)");
|
|
67
|
+
const onChange = (e) => persistSetTheme(e.matches ? "dark" : "light");
|
|
68
|
+
mq.addEventListener("change", onChange);
|
|
69
|
+
return () => mq.removeEventListener("change", onChange);
|
|
70
|
+
}, [defaultTheme, persistSetTheme]);
|
|
71
|
+
|
|
72
|
+
// Listen for toggles fired by HomeThemeToggle (or any other out-of-tree
|
|
73
|
+
// component) so React context stays in sync without any import coupling.
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
function onExternalChange(e) {
|
|
76
|
+
const t = e.detail?.theme;
|
|
77
|
+
if (t === "light" || t === "dark") setTheme(t);
|
|
78
|
+
}
|
|
79
|
+
document.addEventListener(THEME_CHANGE_EVENT, onExternalChange);
|
|
80
|
+
return () =>
|
|
81
|
+
document.removeEventListener(THEME_CHANGE_EVENT, onExternalChange);
|
|
82
|
+
}, []);
|
|
83
|
+
|
|
84
|
+
return React.createElement(
|
|
85
|
+
ThemeCtx.Provider,
|
|
86
|
+
{ value: { theme, setTheme: persistSetTheme } },
|
|
87
|
+
children,
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the current theme and a setter from the nearest ThemeProvider.
|
|
93
|
+
* Falls back to `{ theme: 'dark' }` when used outside a provider.
|
|
94
|
+
*
|
|
95
|
+
* @returns {{ theme: 'light' | 'dark', setTheme: (t: string) => void }}
|
|
96
|
+
*/
|
|
97
|
+
export function useTheme() {
|
|
98
|
+
return useContext(ThemeCtx);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Labeled pill button that shows the current theme and switches on click.
|
|
103
|
+
* Displays "🌙 Dark Mode" when dark, "☀ Light Mode" when light.
|
|
104
|
+
* Must be rendered inside a ThemeProvider.
|
|
105
|
+
*/
|
|
106
|
+
export function ThemeToggle({ className = "" }) {
|
|
107
|
+
const { theme, setTheme } = useTheme();
|
|
108
|
+
const isDark = theme === "dark";
|
|
109
|
+
|
|
110
|
+
const icon = isDark
|
|
111
|
+
? React.createElement(
|
|
112
|
+
"svg",
|
|
113
|
+
{
|
|
114
|
+
"aria-hidden": "true",
|
|
115
|
+
width: "13",
|
|
116
|
+
height: "13",
|
|
117
|
+
viewBox: "0 0 24 24",
|
|
118
|
+
fill: "currentColor",
|
|
119
|
+
className: "shrink-0",
|
|
120
|
+
},
|
|
121
|
+
React.createElement("path", {
|
|
122
|
+
d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
: React.createElement(
|
|
126
|
+
"svg",
|
|
127
|
+
{
|
|
128
|
+
"aria-hidden": "true",
|
|
129
|
+
width: "13",
|
|
130
|
+
height: "13",
|
|
131
|
+
viewBox: "0 0 24 24",
|
|
132
|
+
fill: "none",
|
|
133
|
+
stroke: "currentColor",
|
|
134
|
+
strokeWidth: "2",
|
|
135
|
+
strokeLinecap: "round",
|
|
136
|
+
strokeLinejoin: "round",
|
|
137
|
+
className: "shrink-0",
|
|
138
|
+
},
|
|
139
|
+
React.createElement("circle", { cx: "12", cy: "12", r: "5" }),
|
|
140
|
+
React.createElement("line", { x1: "12", y1: "1", x2: "12", y2: "3" }),
|
|
141
|
+
React.createElement("line", { x1: "12", y1: "21", x2: "12", y2: "23" }),
|
|
142
|
+
React.createElement("line", {
|
|
143
|
+
x1: "4.22",
|
|
144
|
+
y1: "4.22",
|
|
145
|
+
x2: "5.64",
|
|
146
|
+
y2: "5.64",
|
|
147
|
+
}),
|
|
148
|
+
React.createElement("line", {
|
|
149
|
+
x1: "18.36",
|
|
150
|
+
y1: "18.36",
|
|
151
|
+
x2: "19.78",
|
|
152
|
+
y2: "19.78",
|
|
153
|
+
}),
|
|
154
|
+
React.createElement("line", { x1: "1", y1: "12", x2: "3", y2: "12" }),
|
|
155
|
+
React.createElement("line", { x1: "21", y1: "12", x2: "23", y2: "12" }),
|
|
156
|
+
React.createElement("line", {
|
|
157
|
+
x1: "4.22",
|
|
158
|
+
y1: "19.78",
|
|
159
|
+
x2: "5.64",
|
|
160
|
+
y2: "18.36",
|
|
161
|
+
}),
|
|
162
|
+
React.createElement("line", {
|
|
163
|
+
x1: "18.36",
|
|
164
|
+
y1: "5.64",
|
|
165
|
+
x2: "19.78",
|
|
166
|
+
y2: "4.22",
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
return React.createElement(
|
|
171
|
+
"button",
|
|
172
|
+
{
|
|
173
|
+
type: "button",
|
|
174
|
+
onClick: () => setTheme(isDark ? "light" : "dark"),
|
|
175
|
+
"aria-label": `Switch to ${isDark ? "light" : "dark"} theme`,
|
|
176
|
+
"aria-pressed": isDark,
|
|
177
|
+
className:
|
|
178
|
+
`inline-flex items-center cursor-pointer whitespace-nowrap shrink-0 outline-none transition-[background,border-color,color] duration-150 ease-in ${className || ""}`.trim(),
|
|
179
|
+
style: {
|
|
180
|
+
gap: "var(--toggle-gap, 6px)",
|
|
181
|
+
height: "var(--toggle-height, 28px)",
|
|
182
|
+
paddingLeft: "var(--toggle-padding-x, 10px)",
|
|
183
|
+
paddingRight: "var(--toggle-padding-x-right, 12px)",
|
|
184
|
+
borderRadius: "var(--toggle-radius, 14px)",
|
|
185
|
+
border: "1px solid var(--toggle-track-border)",
|
|
186
|
+
background: "var(--toggle-track-bg)",
|
|
187
|
+
color: "var(--toggle-text, var(--color-text-muted))",
|
|
188
|
+
fontSize: "var(--toggle-font-size, 12px)",
|
|
189
|
+
fontWeight: "500",
|
|
190
|
+
letterSpacing: "0.01em",
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
icon,
|
|
194
|
+
isDark ? "Dark Mode" : "Light Mode",
|
|
195
|
+
);
|
|
196
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
const RADIUS = 9;
|
|
5
|
+
const CIRC = 2 * Math.PI * RADIUS;
|
|
6
|
+
|
|
7
|
+
// Visual properties driven entirely by CSS variables — see globals.css
|
|
8
|
+
// [data-toast-variant] selectors override --toast-bg, --toast-border, --toast-text, --toast-shadow
|
|
9
|
+
|
|
10
|
+
export function Toast({
|
|
11
|
+
message,
|
|
12
|
+
variant = "default",
|
|
13
|
+
onClose,
|
|
14
|
+
visible = true,
|
|
15
|
+
duration = 4000,
|
|
16
|
+
_toastId,
|
|
17
|
+
}) {
|
|
18
|
+
const [progress, setProgress] = useState(100);
|
|
19
|
+
const [hovered, setHovered] = useState(false);
|
|
20
|
+
const [isAnimatingOut, setIsAnimatingOut] = useState(false);
|
|
21
|
+
const [shouldRender, setShouldRender] = useState(visible);
|
|
22
|
+
|
|
23
|
+
const variantCls =
|
|
24
|
+
"bg-[--toast-bg] border-[--toast-border] text-[--toast-text] shadow-[var(--toast-shadow)]";
|
|
25
|
+
|
|
26
|
+
const remainingRef = useRef(duration);
|
|
27
|
+
const startRef = useRef(null);
|
|
28
|
+
const intervalRef = useRef(null);
|
|
29
|
+
const isClosingRef = useRef(false);
|
|
30
|
+
|
|
31
|
+
const stop = useCallback(() => clearInterval(intervalRef.current), []);
|
|
32
|
+
|
|
33
|
+
const start = useCallback(() => {
|
|
34
|
+
stop();
|
|
35
|
+
startRef.current = Date.now();
|
|
36
|
+
intervalRef.current = setInterval(() => {
|
|
37
|
+
const elapsed = Date.now() - startRef.current;
|
|
38
|
+
const pct = Math.max(0, 100 - (elapsed / remainingRef.current) * 100);
|
|
39
|
+
setProgress(pct);
|
|
40
|
+
if (pct <= 0) {
|
|
41
|
+
stop();
|
|
42
|
+
onClose?.();
|
|
43
|
+
}
|
|
44
|
+
}, 16);
|
|
45
|
+
}, [stop, onClose]);
|
|
46
|
+
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
if (visible) {
|
|
49
|
+
setShouldRender(true);
|
|
50
|
+
setIsAnimatingOut(false);
|
|
51
|
+
isClosingRef.current = false;
|
|
52
|
+
remainingRef.current = duration;
|
|
53
|
+
setProgress(100);
|
|
54
|
+
setHovered(false);
|
|
55
|
+
start();
|
|
56
|
+
} else {
|
|
57
|
+
if (isClosingRef.current) return;
|
|
58
|
+
isClosingRef.current = true;
|
|
59
|
+
setIsAnimatingOut(true);
|
|
60
|
+
stop();
|
|
61
|
+
// Wait for animation to finish before removing from DOM
|
|
62
|
+
const t = setTimeout(() => {
|
|
63
|
+
setShouldRender(false);
|
|
64
|
+
setIsAnimatingOut(false);
|
|
65
|
+
isClosingRef.current = false;
|
|
66
|
+
}, 200);
|
|
67
|
+
return () => clearTimeout(t);
|
|
68
|
+
}
|
|
69
|
+
return stop;
|
|
70
|
+
}, [visible, duration, start, stop]);
|
|
71
|
+
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!visible || isAnimatingOut) return;
|
|
74
|
+
if (hovered) {
|
|
75
|
+
stop();
|
|
76
|
+
} else {
|
|
77
|
+
remainingRef.current = duration;
|
|
78
|
+
start();
|
|
79
|
+
}
|
|
80
|
+
}, [hovered, duration, start, stop, visible, isAnimatingOut]);
|
|
81
|
+
|
|
82
|
+
if (!shouldRender) return null;
|
|
83
|
+
|
|
84
|
+
const dashOffset = CIRC * (1 - progress / 100);
|
|
85
|
+
|
|
86
|
+
const animStyle = isAnimatingOut
|
|
87
|
+
? { animation: "quark-toast-out 0.2s ease-in forwards" }
|
|
88
|
+
: {
|
|
89
|
+
animation:
|
|
90
|
+
"quark-toast-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards",
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return React.createElement(
|
|
94
|
+
"div",
|
|
95
|
+
{
|
|
96
|
+
role: "alert",
|
|
97
|
+
"aria-live": "assertive",
|
|
98
|
+
"data-toast-variant": variant,
|
|
99
|
+
onMouseEnter: () => setHovered(true),
|
|
100
|
+
onMouseLeave: () => setHovered(false),
|
|
101
|
+
className: `fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-[--radius-default] px-4 py-3 text-sm transition-colors duration-200 ${variantCls}`,
|
|
102
|
+
style: animStyle,
|
|
103
|
+
},
|
|
104
|
+
React.createElement("span", null, message),
|
|
105
|
+
React.createElement(
|
|
106
|
+
"div",
|
|
107
|
+
{
|
|
108
|
+
className:
|
|
109
|
+
"relative ml-2 flex h-6 w-6 shrink-0 items-center justify-center",
|
|
110
|
+
},
|
|
111
|
+
hovered
|
|
112
|
+
? React.createElement(
|
|
113
|
+
"button",
|
|
114
|
+
{
|
|
115
|
+
type: "button",
|
|
116
|
+
"aria-label": "Dismiss notification",
|
|
117
|
+
onClick: onClose,
|
|
118
|
+
className:
|
|
119
|
+
"flex h-6 w-6 cursor-pointer items-center justify-center rounded-[--radius-default] text-base leading-none opacity-80 transition-all hover:bg-black/10 dark:hover:bg-white/10 hover:opacity-100 active:bg-black/20 dark:active:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
|
|
120
|
+
},
|
|
121
|
+
React.createElement(
|
|
122
|
+
"svg",
|
|
123
|
+
{
|
|
124
|
+
width: 10,
|
|
125
|
+
height: 10,
|
|
126
|
+
viewBox: "0 0 10 10",
|
|
127
|
+
"aria-hidden": "true",
|
|
128
|
+
fill: "none",
|
|
129
|
+
className: "shrink-0",
|
|
130
|
+
},
|
|
131
|
+
React.createElement("path", {
|
|
132
|
+
d: "M1 1L9 9",
|
|
133
|
+
stroke: "currentColor",
|
|
134
|
+
strokeWidth: 1.5,
|
|
135
|
+
strokeLinecap: "round",
|
|
136
|
+
}),
|
|
137
|
+
React.createElement("path", {
|
|
138
|
+
d: "M9 1L1 9",
|
|
139
|
+
stroke: "currentColor",
|
|
140
|
+
strokeWidth: 1.5,
|
|
141
|
+
strokeLinecap: "round",
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
)
|
|
145
|
+
: React.createElement(
|
|
146
|
+
"svg",
|
|
147
|
+
{
|
|
148
|
+
width: 24,
|
|
149
|
+
height: 24,
|
|
150
|
+
viewBox: "0 0 24 24",
|
|
151
|
+
"aria-hidden": "true",
|
|
152
|
+
},
|
|
153
|
+
React.createElement("circle", {
|
|
154
|
+
cx: 12,
|
|
155
|
+
cy: 12,
|
|
156
|
+
r: RADIUS,
|
|
157
|
+
fill: "none",
|
|
158
|
+
stroke: "currentColor",
|
|
159
|
+
strokeOpacity: 0.2,
|
|
160
|
+
strokeWidth: 2,
|
|
161
|
+
}),
|
|
162
|
+
React.createElement("circle", {
|
|
163
|
+
cx: 12,
|
|
164
|
+
cy: 12,
|
|
165
|
+
r: RADIUS,
|
|
166
|
+
fill: "none",
|
|
167
|
+
stroke: "currentColor",
|
|
168
|
+
strokeWidth: 2,
|
|
169
|
+
strokeLinecap: "round",
|
|
170
|
+
strokeDasharray: CIRC,
|
|
171
|
+
strokeDashoffset: dashOffset,
|
|
172
|
+
style: { transform: "rotate(-90deg)", transformOrigin: "center" },
|
|
173
|
+
}),
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function useToast() {
|
|
180
|
+
const [state, setState] = useState({
|
|
181
|
+
visible: false,
|
|
182
|
+
message: "",
|
|
183
|
+
variant: "default",
|
|
184
|
+
duration: 4000,
|
|
185
|
+
toastId: 0,
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
const show = useCallback(
|
|
189
|
+
(message, variant = "default", duration = 4000) =>
|
|
190
|
+
setState((s) => ({
|
|
191
|
+
visible: true,
|
|
192
|
+
message,
|
|
193
|
+
variant,
|
|
194
|
+
duration,
|
|
195
|
+
toastId: s.toastId + 1,
|
|
196
|
+
})),
|
|
197
|
+
[],
|
|
198
|
+
);
|
|
199
|
+
|
|
200
|
+
const hide = useCallback(
|
|
201
|
+
() => setState((s) => ({ ...s, visible: false })),
|
|
202
|
+
[],
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
return { show, hide, toastProps: { ...state, onClose: hide } };
|
|
206
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
FROM node:22-alpine@sha256:9385cd9f3001dfc3431e8ead12c43e9e1f87cc1b9b5c6cfd0f73865d405b27c4 AS builder
|
|
2
|
+
|
|
3
|
+
WORKDIR /app
|
|
4
|
+
|
|
5
|
+
RUN corepack enable
|
|
6
|
+
|
|
7
|
+
COPY . .
|
|
8
|
+
|
|
9
|
+
RUN pnpm install --frozen-lockfile --filter ./apps/worker... --filter ./packages/db \
|
|
10
|
+
&& pnpm --filter ./packages/db db:generate \
|
|
11
|
+
&& pnpm --filter ./apps/worker --prod deploy --legacy /app/deploy
|
|
12
|
+
|
|
13
|
+
# `pnpm deploy --prod` correctly drops `prisma` (a devDependency of
|
|
14
|
+
# packages/db), but it keeps the peer subtrees pnpm auto-installed *for* that
|
|
15
|
+
# devDependency. `prisma` declares `typescript` as an optional peer, so the
|
|
16
|
+
# hoisted store ends up with typescript@7 - the native Go build, which ships
|
|
17
|
+
# @typescript/typescript-<platform> (a ~100 MB Go `tsc` with 10 HIGH CVEs).
|
|
18
|
+
#
|
|
19
|
+
# Nothing at runtime needs it: the worker runs compiled JS and talks to
|
|
20
|
+
# Postgres through @prisma/client. Remove the auto-installed peer subtrees from
|
|
21
|
+
# the deploy output so the runtime image stays free of dev-only binaries.
|
|
22
|
+
RUN rm -rf \
|
|
23
|
+
/app/deploy/node_modules/.pnpm/typescript@* \
|
|
24
|
+
/app/deploy/node_modules/.pnpm/@typescript+typescript-* \
|
|
25
|
+
/app/deploy/node_modules/.pnpm/node_modules/typescript \
|
|
26
|
+
/app/deploy/node_modules/.pnpm/node_modules/@typescript \
|
|
27
|
+
/app/deploy/node_modules/.pnpm/node_modules/.bin/tsc \
|
|
28
|
+
/app/deploy/node_modules/typescript \
|
|
29
|
+
/app/deploy/node_modules/@typescript
|
|
30
|
+
|
|
31
|
+
# `pnpm deploy` rewrites peer-suffixed directory names (e.g.
|
|
32
|
+
# valibot@1.4.2_typescript@7.0.2) and nested `.bin/tsc` shims that link back
|
|
33
|
+
# into the removed store, so drop those too.
|
|
34
|
+
RUN find /app/deploy/node_modules -type d -name '*_typescript@*' -prune \
|
|
35
|
+
-exec rm -rf {} + \
|
|
36
|
+
&& find /app/deploy/node_modules -path '*/node_modules/.bin/tsc*' -delete
|
|
37
|
+
|
|
38
|
+
# Fail the build rather than ship silently if a future dependency reintroduces
|
|
39
|
+
# a dev-only native binary through the same path.
|
|
40
|
+
RUN if find /app/deploy -name 'tsc' -type f 2>/dev/null | grep -q .; then \
|
|
41
|
+
echo 'error: dev-only TypeScript binary leaked into the deploy output' >&2; \
|
|
42
|
+
find /app/deploy -name 'tsc' -type f >&2; \
|
|
43
|
+
exit 1; \
|
|
44
|
+
fi
|
|
45
|
+
|
|
46
|
+
FROM node:22-alpine@sha256:9385cd9f3001dfc3431e8ead12c43e9e1f87cc1b9b5c6cfd0f73865d405b27c4 AS runtime
|
|
47
|
+
|
|
48
|
+
RUN apk upgrade --no-cache
|
|
49
|
+
|
|
50
|
+
ENV NODE_ENV=production
|
|
51
|
+
|
|
52
|
+
WORKDIR /app
|
|
53
|
+
|
|
54
|
+
RUN rm -rf \
|
|
55
|
+
/usr/local/lib/node_modules/npm \
|
|
56
|
+
/usr/local/bin/npm \
|
|
57
|
+
/usr/local/bin/npx \
|
|
58
|
+
/opt/yarn-v1.22.22 \
|
|
59
|
+
/usr/local/bin/yarn \
|
|
60
|
+
/usr/local/bin/yarnpkg
|
|
61
|
+
|
|
62
|
+
COPY --from=builder /app/deploy/ ./
|
|
63
|
+
|
|
64
|
+
CMD ["node", "src/index.js"]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# Quark Worker Service
|
|
2
|
+
|
|
3
|
+
Background job processor using **BullMQ** and **Redis**.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
docker compose up -d # Redis required
|
|
9
|
+
pnpm install
|
|
10
|
+
pnpm dev # Starts worker with file watching
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Adding a Job Handler
|
|
14
|
+
|
|
15
|
+
1. Define the job name in `packages/jobs/src/definitions.js`
|
|
16
|
+
2. Create a handler in `apps/worker/src/handlers/<job-name>.js`:
|
|
17
|
+
|
|
18
|
+
```javascript
|
|
19
|
+
import { createLogger } from "@techstream/quark-core";
|
|
20
|
+
|
|
21
|
+
const log = createLogger("job:my-task");
|
|
22
|
+
|
|
23
|
+
export async function handleMyTask({ data }) {
|
|
24
|
+
log.info("processing", data);
|
|
25
|
+
// your logic here
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
3. Register it in `apps/worker/src/handlers/index.js`:
|
|
30
|
+
|
|
31
|
+
```javascript
|
|
32
|
+
import { handleMyTask } from "./my-task.js";
|
|
33
|
+
|
|
34
|
+
const jobHandlers = {
|
|
35
|
+
[JOB_NAMES.MY_TASK]: handleMyTask,
|
|
36
|
+
};
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
## Dispatching Jobs
|
|
40
|
+
|
|
41
|
+
```javascript
|
|
42
|
+
import { createQueue, addJob } from "@techstream/quark-core";
|
|
43
|
+
import { JOB_NAMES } from "@yourscope/jobs";
|
|
44
|
+
|
|
45
|
+
const queue = createQueue("default");
|
|
46
|
+
await addJob(queue, JOB_NAMES.MY_TASK, { userId: "123" });
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Key Files
|
|
50
|
+
|
|
51
|
+
| File | Purpose |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `src/index.js` | Worker entry point, queue registration |
|
|
54
|
+
| `src/handlers/` | Job handler functions |
|
|
55
|
+
| `src/lib/` | Shared utilities (tokens, Railway helpers) |
|
|
56
|
+
| `railway.json` | Railway deployment config *(deprecated - use `.railway/railway.ts`)* |
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@myquark/worker",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"description": "",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./tools": "./src/lib/tools/index.js"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"clean": "rm -rf dist",
|
|
14
|
+
"test": "node ../../scripts/run-tests.mjs src --exclude=integration.test.js",
|
|
15
|
+
"dev": "tsx watch src/index.js",
|
|
16
|
+
"start:deploy": "node src/index.js",
|
|
17
|
+
"preflight": "node src/index.js --preflight",
|
|
18
|
+
"lint": "biome format --write && biome check --write"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [],
|
|
21
|
+
"author": "",
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"packageManager": "pnpm@10.12.1",
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@techstream/quark-config": "workspace:*",
|
|
26
|
+
"@techstream/quark-core": "^2.0.0",
|
|
27
|
+
"@techstream/quark-db": "workspace:*",
|
|
28
|
+
"@techstream/quark-jobs": "workspace:*",
|
|
29
|
+
"bullmq": "^6.3.8",
|
|
30
|
+
"jose": "^6.2.12"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@types/node": "^26.6.2",
|
|
34
|
+
"tsx": "^4.23.15"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Default model pricing (USD per 1M tokens).
|
|
3
|
+
*
|
|
4
|
+
* Override or extend at runtime via OPENROUTER_PRICING_OVERRIDE (JSON string).
|
|
5
|
+
* Example:
|
|
6
|
+
* OPENROUTER_PRICING_OVERRIDE='{"openai/gpt-4o":{"input":2.0,"output":8.0}}'
|
|
7
|
+
*
|
|
8
|
+
* Override entries are shallow-merged on top of these defaults.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @type {Record<string, { input: number, output: number }>} */
|
|
12
|
+
export const DEFAULT_PRICING = {
|
|
13
|
+
"deepseek/deepseek-v4-flash": { input: 0.09, output: 0.18 },
|
|
14
|
+
"anthropic/claude-3.5-sonnet": { input: 3, output: 15 },
|
|
15
|
+
"anthropic/claude-3-opus": { input: 15, output: 75 },
|
|
16
|
+
"anthropic/claude-4-sonnet": { input: 15, output: 75 },
|
|
17
|
+
"openai/gpt-4o": { input: 2.5, output: 10 },
|
|
18
|
+
"openai/gpt-4o-mini": { input: 0.15, output: 0.6 },
|
|
19
|
+
"google/gemini-2.0-flash": { input: 0.1, output: 0.4 },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Resolve pricing table: defaults merged with optional OPENROUTER_PRICING_OVERRIDE.
|
|
24
|
+
* @returns {Record<string, { input: number, output: number }>}
|
|
25
|
+
*/
|
|
26
|
+
export function getPricing() {
|
|
27
|
+
const pricing = { ...DEFAULT_PRICING };
|
|
28
|
+
const override = process.env.OPENROUTER_PRICING_OVERRIDE;
|
|
29
|
+
|
|
30
|
+
if (!override) {
|
|
31
|
+
return pricing;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(override);
|
|
36
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
37
|
+
return { ...pricing, ...parsed };
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// Invalid JSON — fall back to defaults
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return pricing;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export default getPricing;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { AppError, ValidationError } from "@techstream/quark-core/errors";
|
|
2
|
+
|
|
3
|
+
export function requireWelcomeEmailUserId(data) {
|
|
4
|
+
const userId = data?.userId;
|
|
5
|
+
|
|
6
|
+
if (!userId) {
|
|
7
|
+
throw new ValidationError("userId is required for SEND_WELCOME_EMAIL job");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
return userId;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function requireResetPasswordEmailData(data) {
|
|
14
|
+
const userId = data?.userId;
|
|
15
|
+
const resetUrl = data?.resetUrl;
|
|
16
|
+
|
|
17
|
+
if (!userId || !resetUrl) {
|
|
18
|
+
throw new ValidationError(
|
|
19
|
+
"userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return { userId, resetUrl };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function requireUserEmailRecord(userId, userRecord) {
|
|
27
|
+
if (!userRecord?.email) {
|
|
28
|
+
throw new AppError(
|
|
29
|
+
`User ${userId} not found or has no email`,
|
|
30
|
+
404,
|
|
31
|
+
"USER_EMAIL_NOT_FOUND",
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return userRecord;
|
|
36
|
+
}
|