@usequark/quark-create-app 1.25.11 → 1.25.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@usequark/quark-create-app",
3
- "version": "1.25.11",
3
+ "version": "1.25.13",
4
4
  "description": "CLI to scaffold new Quark projects with Next.js, Prisma, BullMQ, and Railway deployment",
5
5
  "keywords": [
6
6
  "quark",
@@ -59,7 +59,7 @@
59
59
  "sync-templates:check": "node scripts/sync-templates.js --check",
60
60
  "regen-migration": "node scripts/regenerate-template-migration.js",
61
61
  "smoke:published": "node scripts/smoke-published.js",
62
- "test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-output.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js'",
62
+ "test": "node test-cli.js && node --test src/utils.test.js && node --test src/prepare-hooks.test.js && node --test src/scaffold-warning.test.js && node --test src/scaffold-output.test.js && node --test src/scaffold-drift.test.js && node --test src/mobile-template.test.js && node --test src/template-scripts.test.js && node --test src/scaffold-guards.test.js && node --test src/template-config.test.js && node --test src/worker-dockerfile.test.js && node --test src/dependabot-config.test.js && node --test 'src/deploy/**/*.test.js' && node --test --test-concurrency=1 'src/deploy/**/*.integration.test.js'",
63
63
  "test:build": "node test-build.js",
64
64
  "test:e2e": "node test-e2e.js",
65
65
  "test:e2e:full": "node test-e2e-full.js",
package/src/index.js CHANGED
@@ -1219,37 +1219,6 @@ program
1219
1219
  );
1220
1220
  console.log(chalk.green(` ✓ next.config.js patched`));
1221
1221
 
1222
- // Step 7d: Strip jobs-related code from register route when jobs not selected
1223
- if (!features.includes("jobs")) {
1224
- const registerPath = path.join(
1225
- targetDir,
1226
- "apps",
1227
- "web",
1228
- "src",
1229
- "app",
1230
- "api",
1231
- "auth",
1232
- "register",
1233
- "route.js",
1234
- );
1235
- if (await fs.pathExists(registerPath)) {
1236
- let content = await fs.readFile(registerPath, "utf-8");
1237
- // Remove the jobs import line
1238
- content = content.replace(
1239
- /import\s*\{[^}]*\}\s*from\s*["']@[^/]+\/jobs["'];?\n/,
1240
- "",
1241
- );
1242
- // Remove createQueue from the core import if present
1243
- content = content.replace(/\tcreateQueue,\n/, "");
1244
- // Remove the @quark:start:jobs ... @quark:end:jobs block (inclusive)
1245
- content = content.replace(
1246
- /[ \t]*\/\/ @quark:start:jobs[\s\S]*?\/\/ @quark:end:jobs\n?/,
1247
- "",
1248
- );
1249
- await fs.writeFile(registerPath, content);
1250
- }
1251
- }
1252
-
1253
1222
  // Step 8: Create .env.example file
1254
1223
  console.log(chalk.cyan("\n 📋 Creating environment configuration..."));
1255
1224
  const envExampleTemplate = `# ⚠️ IMPORTANT: Copy this file to .env and fill in the values for your environment.
@@ -1867,6 +1836,34 @@ async function detectInstalledFeatures(projectDir) {
1867
1836
  return installed;
1868
1837
  }
1869
1838
 
1839
+ /**
1840
+ * Detect which AI-harness skill directory a project uses.
1841
+ *
1842
+ * The scaffold writes embedded skills to exactly one of HARNESS_SKILL_DIRS and
1843
+ * points the AI-context docs at that path. The drift check rebuilds a reference
1844
+ * scaffold to compare against, so it must pass the same `--harness` the project
1845
+ * was built with — otherwise the reference lands skills in .opencode/skills for
1846
+ * a claude/copilot project and reports those files as drift forever.
1847
+ *
1848
+ * `.github/skills` is checked last on purpose: every scaffold carries
1849
+ * `.github/skills/project-context/SKILL.md` regardless of harness, so its
1850
+ * presence alone says nothing about the selection.
1851
+ *
1852
+ * @param {string} projectDir
1853
+ * @returns {Promise<string>} A key of HARNESS_SKILL_DIRS, defaulting to
1854
+ * DEFAULT_HARNESS when no skill directory exists yet.
1855
+ */
1856
+ async function detectConfiguredHarness(projectDir) {
1857
+ for (const harness of ["opencode", "claude", "copilot"]) {
1858
+ if (
1859
+ await fs.pathExists(path.join(projectDir, HARNESS_SKILL_DIRS[harness]))
1860
+ ) {
1861
+ return harness;
1862
+ }
1863
+ }
1864
+ return DEFAULT_HARNESS;
1865
+ }
1866
+
1870
1867
  async function collectRelativeFilePaths(
1871
1868
  rootDir,
1872
1869
  currentDir = rootDir,
@@ -1944,6 +1941,16 @@ async function createReferenceScaffold(projectDir, quarkLink) {
1944
1941
  ...linkedFeatures,
1945
1942
  ...detectedFeatures,
1946
1943
  ]);
1944
+ // `mobile` is a post-creation-only feature: `create` rejects it ("run quark
1945
+ // add mobile after scaffolding") while `add mobile` records it in
1946
+ // .quark-link.json like any other package. Forwarding it below therefore
1947
+ // made the reference-scaffold subprocess exit non-zero, which crashed
1948
+ // `update --scaffold-check` with "Could not generate the reference
1949
+ // scaffold" on every project that had ever run `add mobile` — the check
1950
+ // meant to keep projects healthy took the whole command down instead.
1951
+ const scaffoldFeatures = comparisonFeatures.filter(
1952
+ (feature) => feature !== "mobile",
1953
+ );
1947
1954
  const allowSignup =
1948
1955
  typeof quarkLink.authAllowSignup === "boolean"
1949
1956
  ? quarkLink.authAllowSignup
@@ -1957,11 +1964,28 @@ async function createReferenceScaffold(projectDir, quarkLink) {
1957
1964
  "--no-prompts",
1958
1965
  "--skip-install",
1959
1966
  "--skip-docker",
1967
+ // A hand-edited or legacy .quark-link.json can leave the optional set
1968
+ // empty (only mobile recorded). `--packages ""` parses as one invalid
1969
+ // feature and the subprocess rejects it, so fall back to the minimum
1970
+ // scaffoldable set rather than failing the check on a technicality.
1960
1971
  "--packages",
1961
- comparisonFeatures.join(","),
1972
+ scaffoldFeatures.length > 0 ? scaffoldFeatures.join(",") : "ui",
1962
1973
  "--signup",
1963
1974
  allowSignup ? "enabled" : "disabled",
1964
1975
  ];
1976
+ // Forward the project's brief and harness so the reference scaffold is
1977
+ // byte-comparable. Without them the reference is built with the default
1978
+ // brief and the default harness directory, so every project with a custom
1979
+ // `--prompt` reports false drift on the brief-seeded doc files, and every
1980
+ // `--harness claude|copilot` project reports false-missing skill files for
1981
+ // the default .opencode/skills directory it never had.
1982
+ if (
1983
+ typeof quarkLink.brief === "string" &&
1984
+ quarkLink.brief.trim().length > 0
1985
+ ) {
1986
+ args.push("--prompt", quarkLink.brief);
1987
+ }
1988
+ args.push("--harness", await detectConfiguredHarness(projectDir));
1965
1989
 
1966
1990
  try {
1967
1991
  await execa(process.execPath, args, {
@@ -1975,7 +1999,15 @@ async function createReferenceScaffold(projectDir, quarkLink) {
1975
1999
  stdio: "pipe",
1976
2000
  });
1977
2001
  } catch (error) {
1978
- const details = error.stderr || error.stdout || error.shortMessage;
2002
+ // Keep the subprocess's own error output, drop its stack trace: the
2003
+ // reference scaffold is a CLI invocation whose failure reason is the
2004
+ // line it printed before it exited, and a raw "at ..." dump only makes
2005
+ // a failed check look like a CLI crash.
2006
+ const details = (error.stderr || error.stdout || error.shortMessage || "")
2007
+ .split("\n")
2008
+ .filter((line) => !/^\s*at\s/.test(line))
2009
+ .join("\n")
2010
+ .trim();
1979
2011
  await fs.remove(tempRoot);
1980
2012
  throw new Error(
1981
2013
  details
@@ -2381,60 +2413,15 @@ program
2381
2413
  );
2382
2414
  }
2383
2415
 
2384
- // Add DEVICE model to Prisma schema
2385
- const schemaPath = path.join(
2386
- projectDir,
2387
- "packages/db/prisma/schema.prisma",
2388
- );
2389
- if (await fs.pathExists(schemaPath)) {
2390
- let schema = await fs.readFile(schemaPath, "utf-8");
2391
- if (!schema.includes("model Device")) {
2392
- // Insert Device model after AuditLog
2393
- const deviceModel = `
2394
-
2395
- // ─── Device Model (Mobile Push Notifications) ────────────────────────────────
2396
-
2397
- model Device {
2398
- id String @id @default(cuid())
2399
- userId String
2400
- user User @relation(fields: [userId], references: [id], onDelete: Cascade)
2401
- platform String // "ios" | "android"
2402
- pushToken String
2403
- deviceId String
2404
- createdAt DateTime @default(now())
2405
- updatedAt DateTime @updatedAt
2406
-
2407
- @@unique([userId, deviceId])
2408
- @@index([userId])
2409
- @@index([pushToken])
2410
- @@index([createdAt])
2411
- }
2412
- `;
2413
- schema = schema.replace(
2414
- /(\/\/ Audit Log Model[\s\S]*?^}\s)/m,
2415
- `$1${deviceModel}`,
2416
- );
2417
- // Add devices relation to User model
2418
- schema = schema.replace(
2419
- /(files\s+File\[\])/,
2420
- "$1\n devices Device[]",
2421
- );
2422
- await fs.writeFile(schemaPath, schema);
2423
- }
2424
- }
2425
-
2426
2416
  console.log(
2427
2417
  chalk.green(" ✓ Mobile app scaffolded (apps/mobile)"),
2428
2418
  );
2429
- console.log(
2430
- chalk.green(" ✓ DEVICE model added to Prisma schema"),
2431
- );
2432
2419
  console.log(
2433
2420
  chalk.green(" ✓ EXPO_PUBLIC_API_URL added to .env.example"),
2434
2421
  );
2435
2422
  console.log(
2436
2423
  chalk.dim(
2437
- " · API routes (token, refresh, device/register) already in base template",
2424
+ " · API routes (token, refresh) already in base template",
2438
2425
  ),
2439
2426
  );
2440
2427
  }
@@ -2604,12 +2591,7 @@ model Device {
2604
2591
  console.log(chalk.white(" 3. pnpm dev (starts web + worker)"));
2605
2592
  console.log(
2606
2593
  chalk.white(
2607
- " 4. pnpm dev:mobile (starts Expo in a second terminal)",
2608
- ),
2609
- );
2610
- console.log(
2611
- chalk.white(
2612
- " 5. Configure APNs/FCM env vars for push notifications\n",
2594
+ " 4. pnpm dev:mobile (starts Expo in a second terminal)\n",
2613
2595
  ),
2614
2596
  );
2615
2597
  }
@@ -2794,10 +2776,23 @@ program
2794
2776
  }
2795
2777
 
2796
2778
  if (options.scaffoldCheck) {
2797
- const scaffoldReport = await reportScaffoldDrift(
2798
- process.cwd(),
2799
- quarkLink,
2800
- );
2779
+ let scaffoldReport;
2780
+ try {
2781
+ scaffoldReport = await reportScaffoldDrift(process.cwd(), quarkLink);
2782
+ } catch (error) {
2783
+ // A drift check that cannot build its reference scaffold must
2784
+ // fail loudly and cleanly. The old behaviour rethrew into the
2785
+ // command handler, so a subprocess rejection surfaced as a raw
2786
+ // stack trace — indistinguishable from a CLI crash, when what
2787
+ // actually happened is that the check could not run.
2788
+ console.error(
2789
+ chalk.red(
2790
+ `✗ Scaffold check could not run: ${error?.message || error}`,
2791
+ ),
2792
+ );
2793
+ process.exitCode = 1;
2794
+ return;
2795
+ }
2801
2796
  const hasDrift =
2802
2797
  scaffoldReport.changed.length > 0 || scaffoldReport.missing.length > 0;
2803
2798
  if (options.failOnDrift && hasDrift) {
@@ -30,6 +30,11 @@ export function warnIfJobsOmitted(features, log = console.log) {
30
30
  ` Deleting a user leaves their uploaded files orphaned in storage — the schema\n keeps the rows rather than removing them — and without a worker nothing sweeps them.`,
31
31
  ),
32
32
  );
33
+ log(
34
+ chalk.dim(
35
+ ` Signup welcome emails are also skipped: nothing processes the queue.`,
36
+ ),
37
+ );
33
38
  log(
34
39
  chalk.dim(
35
40
  ` Add it later with: npx @usequark/quark-create-app add jobs\n`,
@@ -1,4 +1,15 @@
1
- import { JOB_NAMES, JOB_QUEUES } from "@usequark/quark-jobs";
1
+ /**
2
+ * Queue and job name for the signup welcome email.
3
+ *
4
+ * These mirror `JOB_QUEUES.EMAIL` and `JOB_NAMES.SEND_WELCOME_EMAIL` in the
5
+ * `@<scope>/jobs` package — an *optional* scaffold package. Importing them from
6
+ * there puts `@<scope>/jobs` in the web module graph of every project, and a
7
+ * project that declines `jobs` has no such dependency to resolve: the web app
8
+ * fails to build before any test can run. The strings are duplicated here on
9
+ * purpose; a `packages/cli` test fails when the two copies diverge.
10
+ */
11
+ export const EMAIL_QUEUE = "email-queue";
12
+ export const SEND_WELCOME_EMAIL = "send-welcome-email";
2
13
 
3
14
  /**
4
15
  * Loads the queue module on demand.
@@ -39,10 +50,10 @@ function loadQueueModule() {
39
50
  */
40
51
  export async function enqueueWelcomeEmail(userId, loadQueue = loadQueueModule) {
41
52
  const { createQueue } = await loadQueue();
42
- const queue = createQueue(JOB_QUEUES.EMAIL);
53
+ const queue = createQueue(EMAIL_QUEUE);
43
54
 
44
55
  try {
45
- await queue.add(JOB_NAMES.SEND_WELCOME_EMAIL, { userId });
56
+ await queue.add(SEND_WELCOME_EMAIL, { userId });
46
57
  } finally {
47
58
  // Never let a failing close mask the enqueue outcome — the caller only
48
59
  // needs to know whether the job reached the queue.
@@ -2,9 +2,14 @@ import { getAuthSecret } from "@usequark/quark-core/auth";
2
2
  import { getToken } from "next-auth/jwt";
3
3
 
4
4
  const STRICT_AUTH_RATE_LIMIT_ROUTES = new Set([
5
+ // `callback/credentials` is where the next-auth client POSTs a credentials
6
+ // sign-in (next-auth/react posts to `callback/<provider>` for credentials
7
+ // providers), so this is the credential-stuffing surface and stays strict.
8
+ // `signin/credentials` is deliberately NOT listed: no client POSTs there,
9
+ // and @auth/core's POST `signin` action for a credentials provider just
10
+ // redirects to the sign-in page — nothing to brute force.
5
11
  "/api/auth/callback/credentials",
6
12
  "/api/auth/register",
7
- "/api/auth/signin/credentials",
8
13
  ]);
9
14
 
10
15
  /**
@@ -1,7 +1,6 @@
1
1
  export const JOB_QUEUES = {
2
2
  EMAIL: "email-queue",
3
3
  FILES: "files-queue",
4
- PUSH: "push-queue",
5
4
  DEFAULT: "default-queue",
6
5
  };
7
6
 
@@ -9,5 +8,4 @@ export const JOB_NAMES = {
9
8
  SEND_WELCOME_EMAIL: "send-welcome-email",
10
9
  SEND_RESET_PASSWORD_EMAIL: "send-reset-password-email",
11
10
  CLEANUP_ORPHANED_FILES: "cleanup-orphaned-files",
12
- SEND_PUSH_NOTIFICATION: "send-push-notification",
13
11
  };
@@ -2,7 +2,6 @@ import { Ionicons } from "@expo/vector-icons";
2
2
  import { Redirect, Tabs } from "expo-router";
3
3
  import { Platform } from "react-native";
4
4
  import { useSafeAreaInsets } from "react-native-safe-area-context";
5
- import { AuthGate } from "../../components/AuthGate";
6
5
  import { useTheme } from "../../components/ThemeProvider";
7
6
  import { useAuth } from "../../hooks/use-auth";
8
7
 
@@ -21,7 +20,7 @@ export default function AppLayout() {
21
20
  }
22
21
 
23
22
  return (
24
- <AuthGate>
23
+ <>
25
24
  <Tabs
26
25
  screenOptions={{
27
26
  headerShown: false,
@@ -78,6 +77,6 @@ export default function AppLayout() {
78
77
  }}
79
78
  />
80
79
  </Tabs>
81
- </AuthGate>
80
+ </>
82
81
  );
83
82
  }
@@ -10,7 +10,6 @@ interface AuthState {
10
10
  isLoading: boolean;
11
11
  signIn: (email: string, password: string) => Promise<void>;
12
12
  signOut: () => Promise<void>;
13
- registerPushToken: () => Promise<void>;
14
13
  }
15
14
 
16
15
  let authStateListeners: Array<() => void> = [];
@@ -19,7 +18,6 @@ let globalAuthState: AuthState = {
19
18
  isLoading: true,
20
19
  signIn: async () => {},
21
20
  signOut: async () => {},
22
- registerPushToken: async () => {},
23
21
  };
24
22
 
25
23
  function notifyListeners() {
@@ -35,10 +33,6 @@ export function useAuthInit() {
35
33
  const [, setState] = useState<AuthState>(globalAuthState);
36
34
 
37
35
  useEffect(() => {
38
- import("../lib/notifications")
39
- .then((m) => m.configureNotificationHandler())
40
- .catch(() => {});
41
-
42
36
  (async () => {
43
37
  try {
44
38
  const authenticated = await checkAuth();
@@ -104,24 +98,9 @@ export function useAuth(): AuthState {
104
98
  notifyListeners();
105
99
  }, []);
106
100
 
107
- const registerPushToken = useCallback(async () => {
108
- try {
109
- const { registerForPushNotifications, registerDevice } = await import(
110
- "../lib/notifications"
111
- );
112
- const token = await registerForPushNotifications();
113
- if (token) {
114
- await registerDevice(token);
115
- }
116
- } catch {
117
- // Non-critical - don't block auth flow
118
- }
119
- }, []);
120
-
121
101
  return {
122
102
  ...state,
123
103
  signIn,
124
104
  signOut,
125
- registerPushToken,
126
105
  };
127
106
  }
@@ -18,8 +18,6 @@
18
18
  "expo-auth-session": "~57.0.13",
19
19
  "expo-constants": "~57.0.19",
20
20
  "expo-crypto": "~57.0.3",
21
- "expo-device": "~57.0.2",
22
- "expo-notifications": "~57.0.21",
23
21
  "expo-router": "~57.0.23",
24
22
  "expo-secure-store": "~57.0.4",
25
23
  "expo-status-bar": "~57.0.1",
@@ -26,8 +26,7 @@
26
26
  "@usequark/quark-core": "^2.0.0",
27
27
  "@usequark/quark-db": "workspace:*",
28
28
  "@usequark/quark-jobs": "workspace:*",
29
- "bullmq": "^6.3.8",
30
- "jose": "^6.2.12"
29
+ "bullmq": "^6.3.8"
31
30
  },
32
31
  "devDependencies": {
33
32
  "@types/node": "^26.6.2",
@@ -10,11 +10,9 @@ import {
10
10
  handleSendWelcomeEmail,
11
11
  } from "./email.js";
12
12
  import { handleCleanupOrphanedFiles } from "./files.js";
13
- import { handleSendPushNotification } from "./push-notification.js";
14
13
 
15
14
  export const jobHandlers = {
16
15
  [JOB_NAMES.SEND_WELCOME_EMAIL]: handleSendWelcomeEmail,
17
16
  [JOB_NAMES.SEND_RESET_PASSWORD_EMAIL]: handleSendResetPasswordEmail,
18
17
  [JOB_NAMES.CLEANUP_ORPHANED_FILES]: handleCleanupOrphanedFiles,
19
- [JOB_NAMES.SEND_PUSH_NOTIFICATION]: handleSendPushNotification,
20
18
  };
@@ -1,57 +0,0 @@
1
- import { validateBody } from "@usequark/quark-core/core";
2
- import { prisma } from "@usequark/quark-db";
3
- import { NextResponse } from "next/server";
4
- import { z } from "zod";
5
- import { extractBearerPayload } from "../../../../lib/jwt";
6
- import { handleError } from "../../error-handler";
7
-
8
- const deviceRegisterSchema = z.object({
9
- platform: z.enum(["ios", "android"]),
10
- pushToken: z.string().min(1),
11
- deviceId: z.string().min(1),
12
- });
13
-
14
- /**
15
- * POST /api/device/register
16
- * Register a mobile device for push notifications.
17
- * Requires authentication via Bearer token.
18
- */
19
- export async function POST(request) {
20
- try {
21
- const payload = await extractBearerPayload(request);
22
- if (!payload?.sub) {
23
- return NextResponse.json(
24
- { message: "Authentication required" },
25
- { status: 401 },
26
- );
27
- }
28
-
29
- const userId = payload.sub;
30
-
31
- const { platform, pushToken, deviceId } = await validateBody(
32
- request,
33
- deviceRegisterSchema,
34
- );
35
-
36
- // Upsert device record
37
- await prisma.device.upsert({
38
- where: {
39
- userId_deviceId: { userId, deviceId },
40
- },
41
- update: {
42
- pushToken,
43
- platform,
44
- },
45
- create: {
46
- userId,
47
- platform,
48
- pushToken,
49
- deviceId,
50
- },
51
- });
52
-
53
- return NextResponse.json({ success: true });
54
- } catch (error) {
55
- return handleError(error);
56
- }
57
- }
@@ -1,19 +0,0 @@
1
- import { type ReactNode, useEffect } from "react";
2
- import { useAuth } from "../hooks/use-auth";
3
-
4
- interface AuthGateProps {
5
- children: ReactNode;
6
- }
7
-
8
- /**
9
- * Wraps authenticated screens and ensures push token is registered.
10
- */
11
- export function AuthGate({ children }: AuthGateProps) {
12
- const { registerPushToken } = useAuth();
13
-
14
- useEffect(() => {
15
- registerPushToken();
16
- }, [registerPushToken]);
17
-
18
- return <>{children}</>;
19
- }
@@ -1,77 +0,0 @@
1
- import { Platform } from "react-native";
2
- import { apiClient } from "./api-client";
3
-
4
- /**
5
- * Register the device push token with the backend.
6
- * Must be called after authentication.
7
- */
8
- export async function registerForPushNotifications(): Promise<string | null> {
9
- const Device = await import("expo-device");
10
- if (!Device.isDevice) {
11
- return null;
12
- }
13
-
14
- const Notifications = await import("expo-notifications");
15
-
16
- const { status: existingStatus } = await Notifications.getPermissionsAsync();
17
- let finalStatus = existingStatus;
18
-
19
- if (existingStatus !== "granted") {
20
- const { status } = await Notifications.requestPermissionsAsync();
21
- finalStatus = status;
22
- }
23
-
24
- if (finalStatus !== "granted") {
25
- return null;
26
- }
27
-
28
- const tokenData = await Notifications.getExpoPushTokenAsync();
29
- const pushToken = tokenData.data;
30
-
31
- if (Platform.OS === "android") {
32
- Notifications.setNotificationChannelAsync("default", {
33
- name: "default",
34
- importance: Notifications.AndroidImportance.MAX,
35
- vibrationPattern: [0, 250, 250, 250],
36
- lightColor: "#FF231F7C",
37
- });
38
- }
39
-
40
- return pushToken;
41
- }
42
-
43
- /**
44
- * Send the push token to the backend for storage.
45
- */
46
- export async function registerDevice(pushToken: string): Promise<void> {
47
- const { Platform: PlatformModule } = await import("react-native");
48
- const Constants = await import("expo-constants");
49
- const deviceId =
50
- Constants.default?.installationId ||
51
- Constants.default?.expoConfig?.extra?.eas?.projectId ||
52
- "unknown";
53
-
54
- await apiClient("/api/device/register", {
55
- method: "POST",
56
- body: JSON.stringify({
57
- platform: PlatformModule.OS,
58
- pushToken,
59
- deviceId,
60
- }),
61
- });
62
- }
63
-
64
- /**
65
- * Configure foreground notification handling.
66
- */
67
- export async function configureNotificationHandler() {
68
- const Notifications = await import("expo-notifications");
69
- Notifications.setNotificationHandler({
70
- handleNotification: async () => ({
71
- shouldShowBanner: true,
72
- shouldShowList: true,
73
- shouldPlaySound: true,
74
- shouldSetBadge: false,
75
- }),
76
- });
77
- }
@@ -1,234 +0,0 @@
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 "@usequark/quark-core/errors";
8
- import { prisma } from "@usequark/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("@usequark/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
- }