@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,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email Job Handlers
|
|
3
|
+
* Processes email-related background jobs
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createEmailService,
|
|
8
|
+
passwordResetEmail,
|
|
9
|
+
welcomeEmail,
|
|
10
|
+
} from "@techstream/quark-core";
|
|
11
|
+
import { prisma } from "@techstream/quark-db";
|
|
12
|
+
import { JOB_NAMES } from "@techstream/quark-jobs";
|
|
13
|
+
import {
|
|
14
|
+
requireResetPasswordEmailData,
|
|
15
|
+
requireUserEmailRecord,
|
|
16
|
+
requireWelcomeEmailUserId,
|
|
17
|
+
} from "./email-job-validation.js";
|
|
18
|
+
|
|
19
|
+
const emailService = createEmailService();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Job handler for SEND_WELCOME_EMAIL
|
|
23
|
+
* @param {import("bullmq").Job} bullJob
|
|
24
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
25
|
+
*/
|
|
26
|
+
export async function handleSendWelcomeEmail(bullJob, logger) {
|
|
27
|
+
const userId = requireWelcomeEmailUserId(bullJob.data);
|
|
28
|
+
|
|
29
|
+
logger.info(`Sending welcome email for user ${userId}`, {
|
|
30
|
+
job: JOB_NAMES.SEND_WELCOME_EMAIL,
|
|
31
|
+
userId,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const userRecord = requireUserEmailRecord(
|
|
35
|
+
userId,
|
|
36
|
+
await prisma.user.findUnique({
|
|
37
|
+
where: { id: userId },
|
|
38
|
+
select: { email: true, name: true },
|
|
39
|
+
}),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
const template = welcomeEmail({
|
|
43
|
+
name: userRecord.name,
|
|
44
|
+
appName: process.env.APP_NAME,
|
|
45
|
+
loginUrl: process.env.APP_URL
|
|
46
|
+
? `${process.env.APP_URL}/api/auth/signin`
|
|
47
|
+
: undefined,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
await emailService.sendEmail(
|
|
51
|
+
userRecord.email,
|
|
52
|
+
template.subject,
|
|
53
|
+
template.html,
|
|
54
|
+
template.text,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
return { success: true, userId, email: userRecord.email };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Job handler for SEND_RESET_PASSWORD_EMAIL
|
|
62
|
+
* @param {import("bullmq").Job} bullJob
|
|
63
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
64
|
+
*/
|
|
65
|
+
export async function handleSendResetPasswordEmail(bullJob, logger) {
|
|
66
|
+
const { userId, resetUrl } = requireResetPasswordEmailData(bullJob.data);
|
|
67
|
+
|
|
68
|
+
logger.info(`Sending password reset email for user ${userId}`, {
|
|
69
|
+
job: JOB_NAMES.SEND_RESET_PASSWORD_EMAIL,
|
|
70
|
+
userId,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const userRecord = requireUserEmailRecord(
|
|
74
|
+
userId,
|
|
75
|
+
await prisma.user.findUnique({
|
|
76
|
+
where: { id: userId },
|
|
77
|
+
select: { email: true, name: true },
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const template = passwordResetEmail({
|
|
82
|
+
name: userRecord.name,
|
|
83
|
+
appName: process.env.APP_NAME,
|
|
84
|
+
resetUrl,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await emailService.sendEmail(
|
|
88
|
+
userRecord.email,
|
|
89
|
+
template.subject,
|
|
90
|
+
template.html,
|
|
91
|
+
template.text,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return { success: true, userId, email: userRecord.email };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Governance alerts are sent inline by the governance-check handler.
|
|
98
|
+
// No standalone handler needed in Quark.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File Job Handlers
|
|
3
|
+
* Processes file-related background jobs (cleanup, etc.)
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createStorage } from "@techstream/quark-core";
|
|
7
|
+
import { file } from "@techstream/quark-db";
|
|
8
|
+
import { JOB_NAMES } from "@techstream/quark-jobs";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Job handler for CLEANUP_ORPHANED_FILES
|
|
12
|
+
* Deletes files with no owner that are older than a retention period.
|
|
13
|
+
*
|
|
14
|
+
* @param {import("bullmq").Job} bullJob
|
|
15
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
16
|
+
*/
|
|
17
|
+
export async function handleCleanupOrphanedFiles(bullJob, logger) {
|
|
18
|
+
const retentionHours = bullJob.data?.retentionHours || 24;
|
|
19
|
+
const cutoff = new Date(Date.now() - retentionHours * 60 * 60 * 1000);
|
|
20
|
+
|
|
21
|
+
logger.info("Starting orphaned file cleanup", {
|
|
22
|
+
job: JOB_NAMES.CLEANUP_ORPHANED_FILES,
|
|
23
|
+
retentionHours,
|
|
24
|
+
cutoff: cutoff.toISOString(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const orphaned = await file.findOlderThan(cutoff);
|
|
28
|
+
|
|
29
|
+
if (orphaned.length === 0) {
|
|
30
|
+
logger.info("No orphaned files to clean up");
|
|
31
|
+
return { success: true, deleted: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const storage = createStorage();
|
|
35
|
+
let deleted = 0;
|
|
36
|
+
const errors = [];
|
|
37
|
+
|
|
38
|
+
for (const record of orphaned) {
|
|
39
|
+
try {
|
|
40
|
+
await storage.delete(record.storageKey);
|
|
41
|
+
await file.delete(record.id);
|
|
42
|
+
deleted++;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
errors.push({ id: record.id, error: err.message });
|
|
45
|
+
logger.warn(`Failed to delete file ${record.id}: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
logger.info(
|
|
50
|
+
`Orphaned file cleanup complete: ${deleted}/${orphaned.length} deleted`,
|
|
51
|
+
{
|
|
52
|
+
deleted,
|
|
53
|
+
total: orphaned.length,
|
|
54
|
+
errors: errors.length,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
return { success: true, deleted, total: orphaned.length, errors };
|
|
59
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job Handler Registry
|
|
3
|
+
* Maps job names to their handler functions.
|
|
4
|
+
* Each handler receives (bullJob, logger) and returns a result object.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { JOB_NAMES } from "@techstream/quark-jobs";
|
|
8
|
+
import {
|
|
9
|
+
handleSendResetPasswordEmail,
|
|
10
|
+
handleSendWelcomeEmail,
|
|
11
|
+
} from "./email.js";
|
|
12
|
+
import { handleCleanupOrphanedFiles } from "./files.js";
|
|
13
|
+
import { handleSendPushNotification } from "./push-notification.js";
|
|
14
|
+
|
|
15
|
+
export const jobHandlers = {
|
|
16
|
+
[JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
|
|
17
|
+
[JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
|
|
18
|
+
[JOB_NAMES.CLEANUP_ORPHANED_FILES]: handleCleanupOrphanedFiles,
|
|
19
|
+
[JOB_NAMES.SEND_PUSH_NOTIFICATION]: handleSendPushNotification,
|
|
20
|
+
};
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push Notification Job Handler
|
|
3
|
+
* Sends push notifications via APNs (iOS) and FCM (Android) directly.
|
|
4
|
+
* No Firebase SDK — uses HTTP APIs with service account credentials from env.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { AppError, ValidationError } from "@techstream/quark-core/errors";
|
|
8
|
+
import { prisma } from "@techstream/quark-db";
|
|
9
|
+
import { importPKCS8, SignJWT } from "jose";
|
|
10
|
+
|
|
11
|
+
const APNS_KEY = process.env.APNS_KEY;
|
|
12
|
+
const APNS_KEY_ID = process.env.APNS_KEY_ID;
|
|
13
|
+
const APNS_TEAM_ID = process.env.APNS_TEAM_ID;
|
|
14
|
+
const APNS_BUNDLE_ID = process.env.APNS_BUNDLE_ID;
|
|
15
|
+
const APNS_USE_SANDBOX = process.env.APNS_USE_SANDBOX !== "false";
|
|
16
|
+
|
|
17
|
+
const GCP_SERVICE_ACCOUNT = process.env.GCP_SERVICE_ACCOUNT;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Job handler for SEND_PUSH_NOTIFICATION
|
|
21
|
+
* @param {import("bullmq").Job} bullJob
|
|
22
|
+
* @param {import("@techstream/quark-core").Logger} logger
|
|
23
|
+
*/
|
|
24
|
+
export async function handleSendPushNotification(bullJob, logger) {
|
|
25
|
+
const { userId, title, body: notificationBody, data } = bullJob.data;
|
|
26
|
+
|
|
27
|
+
if (!userId || !title || !notificationBody) {
|
|
28
|
+
throw new ValidationError("Missing required fields: userId, title, body");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Fetch all devices for the user
|
|
32
|
+
const devices = await prisma.device.findMany({
|
|
33
|
+
where: { userId },
|
|
34
|
+
select: { pushToken: true, platform: true },
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
if (devices.length === 0) {
|
|
38
|
+
logger.warn("No devices found for user", { userId });
|
|
39
|
+
return { success: true, sent: 0, failed: 0 };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let sent = 0;
|
|
43
|
+
let failed = 0;
|
|
44
|
+
|
|
45
|
+
for (const device of devices) {
|
|
46
|
+
try {
|
|
47
|
+
if (device.platform === "ios") {
|
|
48
|
+
await sendAPNs(device.pushToken, title, notificationBody, data);
|
|
49
|
+
sent++;
|
|
50
|
+
} else if (device.platform === "android") {
|
|
51
|
+
await sendFCM(device.pushToken, title, notificationBody, data);
|
|
52
|
+
sent++;
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
logger.error("Failed to send push notification", {
|
|
56
|
+
error: error.message,
|
|
57
|
+
platform: device.platform,
|
|
58
|
+
token: device.pushToken.slice(0, 10),
|
|
59
|
+
});
|
|
60
|
+
failed++;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
logger.info("Push notification batch complete", {
|
|
65
|
+
userId,
|
|
66
|
+
sent,
|
|
67
|
+
failed,
|
|
68
|
+
total: devices.length,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
return { success: true, sent, failed };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Generate APNs JWT token for authentication.
|
|
76
|
+
*/
|
|
77
|
+
async function generateAPNsToken() {
|
|
78
|
+
if (!APNS_KEY || !APNS_KEY_ID || !APNS_TEAM_ID) {
|
|
79
|
+
throw new AppError(
|
|
80
|
+
"APNs credentials not configured (APNS_KEY, APNS_KEY_ID, APNS_TEAM_ID)",
|
|
81
|
+
500,
|
|
82
|
+
"APNS_NOT_CONFIGURED",
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const privateKey = await importPKCS8(APNS_KEY, "ES256");
|
|
87
|
+
const now = Math.floor(Date.now() / 1000);
|
|
88
|
+
|
|
89
|
+
return new SignJWT({})
|
|
90
|
+
.setProtectedHeader({ alg: "ES256", kid: APNS_KEY_ID })
|
|
91
|
+
.setIssuedAt(now)
|
|
92
|
+
.setExpirationTime("1h")
|
|
93
|
+
.setIssuer(APNS_TEAM_ID)
|
|
94
|
+
.sign(privateKey);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Send push notification via Apple Push Notification service (HTTP/2).
|
|
99
|
+
*/
|
|
100
|
+
async function sendAPNs(token, title, body, data = {}) {
|
|
101
|
+
if (!APNS_KEY) {
|
|
102
|
+
throw new AppError("APNs not configured", 500, "APNS_NOT_CONFIGURED");
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const jwt = await generateAPNsToken();
|
|
106
|
+
const host = APNS_USE_SANDBOX
|
|
107
|
+
? "api.sandbox.push.apple.com"
|
|
108
|
+
: "api.push.apple.com";
|
|
109
|
+
|
|
110
|
+
const payload = {
|
|
111
|
+
aps: {
|
|
112
|
+
alert: { title, body },
|
|
113
|
+
sound: "default",
|
|
114
|
+
"content-available": 1,
|
|
115
|
+
},
|
|
116
|
+
...data,
|
|
117
|
+
};
|
|
118
|
+
|
|
119
|
+
const response = await fetch(`https://${host}/3/device/${token}`, {
|
|
120
|
+
method: "POST",
|
|
121
|
+
headers: {
|
|
122
|
+
authorization: `bearer ${jwt}`,
|
|
123
|
+
"apns-topic": APNS_BUNDLE_ID || "",
|
|
124
|
+
"apns-push-type": "alert",
|
|
125
|
+
"apns-priority": "10",
|
|
126
|
+
"content-type": "application/json",
|
|
127
|
+
},
|
|
128
|
+
body: JSON.stringify(payload),
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
if (!response.ok) {
|
|
132
|
+
const errorBody = await response.text();
|
|
133
|
+
throw new AppError(
|
|
134
|
+
`APNs error ${response.status}: ${errorBody}`,
|
|
135
|
+
502,
|
|
136
|
+
"APNS_ERROR",
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Get a short-lived OAuth2 access token for FCM from a GCP service account.
|
|
143
|
+
*/
|
|
144
|
+
async function getFCMAccessToken() {
|
|
145
|
+
if (!GCP_SERVICE_ACCOUNT) {
|
|
146
|
+
throw new AppError(
|
|
147
|
+
"GCP_SERVICE_ACCOUNT not configured",
|
|
148
|
+
500,
|
|
149
|
+
"FCM_NOT_CONFIGURED",
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const serviceAccount = JSON.parse(GCP_SERVICE_ACCOUNT);
|
|
154
|
+
const now = Math.floor(Date.now() / 1000);
|
|
155
|
+
|
|
156
|
+
const privateKey = await importPKCS8(serviceAccount.private_key, "RS256");
|
|
157
|
+
|
|
158
|
+
const jwt = await new SignJWT({
|
|
159
|
+
scope: "https://www.googleapis.com/auth/firebase.messaging",
|
|
160
|
+
})
|
|
161
|
+
.setProtectedHeader({ alg: "RS256", kid: serviceAccount.private_key_id })
|
|
162
|
+
.setIssuedAt(now)
|
|
163
|
+
.setExpirationTime("1h")
|
|
164
|
+
.setIssuer(serviceAccount.client_email)
|
|
165
|
+
.setSubject(serviceAccount.client_email)
|
|
166
|
+
.audience("https://oauth2.googleapis.com/token")
|
|
167
|
+
.sign(privateKey);
|
|
168
|
+
|
|
169
|
+
const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
172
|
+
body: new URLSearchParams({
|
|
173
|
+
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
|
174
|
+
assertion: jwt,
|
|
175
|
+
}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!tokenResponse.ok) {
|
|
179
|
+
const error = await tokenResponse.text();
|
|
180
|
+
throw new AppError(
|
|
181
|
+
`FCM token exchange failed: ${error}`,
|
|
182
|
+
502,
|
|
183
|
+
"FCM_TOKEN_EXCHANGE_FAILED",
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const { access_token } = await tokenResponse.json();
|
|
188
|
+
return access_token;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Send push notification via Firebase Cloud Messaging HTTP v1 API.
|
|
193
|
+
*/
|
|
194
|
+
async function sendFCM(token, title, body, data = {}) {
|
|
195
|
+
const accessToken = await getFCMAccessToken();
|
|
196
|
+
|
|
197
|
+
const projectId = JSON.parse(GCP_SERVICE_ACCOUNT || "{}").project_id;
|
|
198
|
+
if (!projectId) {
|
|
199
|
+
throw new AppError(
|
|
200
|
+
"GCP project_id not found in service account",
|
|
201
|
+
500,
|
|
202
|
+
"FCM_NOT_CONFIGURED",
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const message = {
|
|
207
|
+
token,
|
|
208
|
+
notification: { title, body },
|
|
209
|
+
data: Object.fromEntries(
|
|
210
|
+
Object.entries(data).map(([k, v]) => [k, String(v)]),
|
|
211
|
+
),
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const response = await fetch(
|
|
215
|
+
`https://fcm.googleapis.com/v1/projects/${projectId}/messages:send`,
|
|
216
|
+
{
|
|
217
|
+
method: "POST",
|
|
218
|
+
headers: {
|
|
219
|
+
authorization: `Bearer ${accessToken}`,
|
|
220
|
+
"content-type": "application/json",
|
|
221
|
+
},
|
|
222
|
+
body: JSON.stringify({ message }),
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (!response.ok) {
|
|
227
|
+
const errorBody = await response.text();
|
|
228
|
+
throw new AppError(
|
|
229
|
+
`FCM error ${response.status}: ${errorBody}`,
|
|
230
|
+
502,
|
|
231
|
+
"FCM_ERROR",
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
}
|