@usequark/quark-create-app 1.25.12 → 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 +2 -2
- package/src/index.js +84 -89
- package/src/scaffold-warning.js +5 -0
- package/templates/base-project/apps/web/src/lib/enqueue-welcome-email.js +14 -3
- package/templates/jobs/src/definitions.js +0 -2
- package/templates/mobile/app/(app)/_layout.tsx +2 -3
- package/templates/mobile/hooks/use-auth.ts +0 -21
- package/templates/mobile/package.json +0 -2
- package/templates/worker/package.json +1 -2
- package/templates/worker/src/handlers/index.js +0 -2
- package/templates/base-project/apps/web/src/app/api/device/register/route.js +0 -57
- package/templates/mobile/components/AuthGate.tsx +0 -19
- package/templates/mobile/lib/notifications.ts +0 -77
- package/templates/worker/src/handlers/push-notification.js +0 -234
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@usequark/quark-create-app",
|
|
3
|
-
"version": "1.25.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
2798
|
-
|
|
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) {
|
package/src/scaffold-warning.js
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
53
|
+
const queue = createQueue(EMAIL_QUEUE);
|
|
43
54
|
|
|
44
55
|
try {
|
|
45
|
-
await queue.add(
|
|
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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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",
|
|
@@ -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
|
-
}
|