create-m5kdev 0.11.0 → 0.13.0
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 +1 -1
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +8 -0
- package/templates/minimal-app/apps/server/package.json.tpl +1 -0
- package/templates/minimal-app/apps/server/src/db.ts.tpl +4 -0
- package/templates/minimal-app/apps/server/src/index.ts.tpl +62 -4
- package/templates/minimal-app/apps/server/src/repository.ts.tpl +4 -0
- package/templates/minimal-app/apps/server/src/service.ts.tpl +14 -8
- package/templates/minimal-app/apps/server/src/trpc.ts.tpl +6 -1
- package/templates/minimal-app/apps/server/src/workflow.ts.tpl +21 -0
- package/templates/minimal-app/apps/shared/.env.example.tpl +18 -0
- package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -0
- package/templates/minimal-app/apps/webapp/public/push-sw.js +34 -0
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +3 -0
- package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +93 -0
- package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +14 -0
package/package.json
CHANGED
|
@@ -28,3 +28,11 @@ apps/server/src/modules/<module>/
|
|
|
28
28
|
- Services own business rules, orchestration, and context-aware defaults.
|
|
29
29
|
- tRPC files own transport only and must delegate to services.
|
|
30
30
|
- Keep composition explicit in `db.ts`, `repository.ts`, `service.ts`, and `trpc.ts`.
|
|
31
|
+
|
|
32
|
+
## Workflow, Redis, and push notifications
|
|
33
|
+
|
|
34
|
+
- `workflow.ts` wires BullMQ via **Redis** (`REDIS_URL`). Start Redis locally before `pnpm dev` on the server, or jobs will not run.
|
|
35
|
+
- `index.ts` calls `workflowRegistry.registerService(notificationService)` and `await workflowRegistry.start()` before listening.
|
|
36
|
+
- After changing Drizzle tables (including `notification_*` tables merged in `db.ts`), run your project’s **Drizzle generate / migrate** command — do not hand-edit SQL migrations in this repo.
|
|
37
|
+
|
|
38
|
+
Push-related server env vars are documented in `apps/shared/.env.example`.
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import * as auth from "@m5kdev/backend/modules/auth/auth.db";
|
|
2
|
+
import * as notification from "@m5kdev/backend/modules/notification/notification.db";
|
|
3
|
+
import * as workflow from "@m5kdev/backend/modules/workflow/workflow.db";
|
|
2
4
|
import { drizzle } from "drizzle-orm/libsql";
|
|
3
5
|
import * as posts from "./modules/posts/posts.db";
|
|
4
6
|
|
|
5
7
|
export const schema = {
|
|
6
8
|
...auth,
|
|
9
|
+
...workflow,
|
|
10
|
+
...notification,
|
|
7
11
|
...posts,
|
|
8
12
|
};
|
|
9
13
|
|
|
@@ -3,12 +3,19 @@ import * as trpcExpress from "@trpc/server/adapters/express";
|
|
|
3
3
|
import { toNodeHandler } from "better-auth/node";
|
|
4
4
|
import cors from "cors";
|
|
5
5
|
import express from "express";
|
|
6
|
+
import type { Server } from "node:http";
|
|
6
7
|
import { auth } from "./lib/auth";
|
|
8
|
+
import { notificationService } from "./service";
|
|
7
9
|
import { appRouter } from "./trpc";
|
|
10
|
+
import { workflowRegistry, workflowService } from "./workflow";
|
|
11
|
+
|
|
12
|
+
workflowRegistry.registerService(notificationService);
|
|
8
13
|
|
|
9
14
|
const app = express();
|
|
10
15
|
const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8080;
|
|
11
16
|
|
|
17
|
+
let httpServer: Server | undefined;
|
|
18
|
+
|
|
12
19
|
app.use(express.json());
|
|
13
20
|
app.use(
|
|
14
21
|
cors({
|
|
@@ -16,7 +23,7 @@ app.use(
|
|
|
16
23
|
credentials: true,
|
|
17
24
|
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
18
25
|
allowedHeaders: ["Content-Type", "Authorization"],
|
|
19
|
-
})
|
|
26
|
+
}),
|
|
20
27
|
);
|
|
21
28
|
|
|
22
29
|
app.use(
|
|
@@ -24,11 +31,62 @@ app.use(
|
|
|
24
31
|
trpcExpress.createExpressMiddleware({
|
|
25
32
|
router: appRouter,
|
|
26
33
|
createContext: createAuthContext(auth as never),
|
|
27
|
-
})
|
|
34
|
+
}),
|
|
28
35
|
);
|
|
29
36
|
|
|
30
37
|
app.all("/api/auth/*", toNodeHandler(auth));
|
|
31
38
|
|
|
32
|
-
|
|
33
|
-
console.
|
|
39
|
+
function logError(context: string, error: unknown): void {
|
|
40
|
+
console.error(`[server] ${context}`, error);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function shutdown(): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
await workflowRegistry.stop();
|
|
46
|
+
} catch (e) {
|
|
47
|
+
logError("workflowRegistry.stop() failed", e);
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
await workflowService.close();
|
|
51
|
+
} catch (e) {
|
|
52
|
+
logError("workflowService.close() failed", e);
|
|
53
|
+
}
|
|
54
|
+
if (httpServer) {
|
|
55
|
+
await new Promise<void>((resolve) => {
|
|
56
|
+
httpServer?.close((err) => {
|
|
57
|
+
if (err) {
|
|
58
|
+
logError("HTTP server close failed", err);
|
|
59
|
+
}
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
process.exit(0);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
process.once("SIGINT", () => {
|
|
68
|
+
void shutdown();
|
|
34
69
|
});
|
|
70
|
+
process.once("SIGTERM", () => {
|
|
71
|
+
void shutdown();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
async function start(): Promise<void> {
|
|
75
|
+
await workflowRegistry.start();
|
|
76
|
+
await new Promise<void>((resolve, reject) => {
|
|
77
|
+
httpServer = app.listen(port, () => {
|
|
78
|
+
console.info(`Server running at ${process.env.VITE_SERVER_URL ?? `http://localhost:${port}`}`);
|
|
79
|
+
resolve();
|
|
80
|
+
});
|
|
81
|
+
httpServer.on("error", reject);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
void (async () => {
|
|
86
|
+
try {
|
|
87
|
+
await start();
|
|
88
|
+
} catch (e) {
|
|
89
|
+
logError("Fatal: workflowRegistry.start() or HTTP listen failed", e);
|
|
90
|
+
process.exit(1);
|
|
91
|
+
}
|
|
92
|
+
})();
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { AuthRepository } from "@m5kdev/backend/modules/auth/auth.repository";
|
|
2
|
+
import { NotificationRepository } from "@m5kdev/backend/modules/notification/notification.repository";
|
|
3
|
+
import { WorkflowRepository } from "@m5kdev/backend/modules/workflow/workflow.repository";
|
|
2
4
|
import { orm, schema } from "./db";
|
|
3
5
|
import { PostsRepository } from "./modules/posts/posts.repository";
|
|
4
6
|
|
|
5
7
|
export const authRepository = new AuthRepository({ orm, schema });
|
|
8
|
+
export const workflowRepository = new WorkflowRepository({ orm, schema });
|
|
9
|
+
export const notificationRepository = new NotificationRepository({ orm, schema });
|
|
6
10
|
export const postsRepository = new PostsRepository({ orm, schema, table: schema.posts });
|
|
@@ -1,13 +1,19 @@
|
|
|
1
|
-
import { AuthService } from "@m5kdev/backend/modules/auth/auth.service";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
1
|
+
import { AuthService } from "@m5kdev/backend/modules/auth/auth.service";
|
|
2
|
+
import { NotificationService } from "@m5kdev/backend/modules/notification/notification.service";
|
|
3
|
+
import { LocalEmailService } from "./lib/localEmailService";
|
|
4
|
+
import { postsGrants } from "./modules/posts/posts.grants";
|
|
5
|
+
import { PostsService } from "./modules/posts/posts.service";
|
|
6
|
+
import { authRepository, notificationRepository, postsRepository } from "./repository";
|
|
7
|
+
import { workflowService } from "./workflow";
|
|
6
8
|
|
|
7
9
|
export const emailService = new LocalEmailService({
|
|
8
10
|
appName: "{{APP_NAME}}",
|
|
9
11
|
appUrl: process.env.VITE_APP_URL ?? "http://localhost:5173",
|
|
10
12
|
});
|
|
11
|
-
|
|
12
|
-
export const authService = new AuthService({ auth: authRepository }, { email: emailService });
|
|
13
|
-
export const postsService = new PostsService({ posts: postsRepository }, {}, postsGrants);
|
|
13
|
+
|
|
14
|
+
export const authService = new AuthService({ auth: authRepository }, { email: emailService });
|
|
15
|
+
export const postsService = new PostsService({ posts: postsRepository }, {}, postsGrants);
|
|
16
|
+
export const notificationService = new NotificationService(
|
|
17
|
+
{ notification: notificationRepository },
|
|
18
|
+
{ workflow: workflowService },
|
|
19
|
+
);
|
|
@@ -1,9 +1,14 @@
|
|
|
1
|
+
import { createNotificationTRPC } from "@m5kdev/backend/modules/notification/notification.trpc";
|
|
1
2
|
import type { inferRouterInputs, inferRouterOutputs } from "@trpc/server";
|
|
3
|
+
import { notificationService } from "./service";
|
|
2
4
|
import { postsRouter as posts } from "./modules/posts/posts.trpc";
|
|
3
|
-
import { router } from "./utils/trpc";
|
|
5
|
+
import { router, trpcObject } from "./utils/trpc";
|
|
6
|
+
|
|
7
|
+
const notification = createNotificationTRPC(trpcObject, notificationService);
|
|
4
8
|
|
|
5
9
|
export const appRouter = router({
|
|
6
10
|
posts,
|
|
11
|
+
notification,
|
|
7
12
|
});
|
|
8
13
|
|
|
9
14
|
export type AppRouter = typeof appRouter;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { WorkflowRegistry } from "@m5kdev/backend/modules/workflow/workflow.registry";
|
|
2
|
+
import { WorkflowService } from "@m5kdev/backend/modules/workflow/workflow.service";
|
|
3
|
+
import IORedis from "ioredis";
|
|
4
|
+
import { workflowRepository } from "./repository";
|
|
5
|
+
|
|
6
|
+
const redisUrl = process.env.REDIS_URL ?? "redis://127.0.0.1:6379";
|
|
7
|
+
const connection = new IORedis(redisUrl, { maxRetriesPerRequest: null });
|
|
8
|
+
|
|
9
|
+
export const workflowService = new WorkflowService(workflowRepository, {
|
|
10
|
+
connection,
|
|
11
|
+
queues: {
|
|
12
|
+
fast: { concurrency: 5 },
|
|
13
|
+
},
|
|
14
|
+
defaultQueue: "fast",
|
|
15
|
+
defaults: {
|
|
16
|
+
timeout: 60_000,
|
|
17
|
+
jobOptions: { removeOnComplete: { age: 3600 } },
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export const workflowRegistry = new WorkflowRegistry(workflowService);
|
|
@@ -11,6 +11,24 @@ DATABASE_URL=file:./local.db
|
|
|
11
11
|
TURSO_DATABASE_URL=
|
|
12
12
|
TURSO_AUTH_TOKEN=
|
|
13
13
|
|
|
14
|
+
# Redis (BullMQ / workflow workers)
|
|
15
|
+
REDIS_URL=redis://127.0.0.1:6379
|
|
16
|
+
|
|
17
|
+
# Web Push (VAPID) — generate with: npx web-push generate-vapid-keys
|
|
18
|
+
VAPID_PUBLIC_KEY=
|
|
19
|
+
VAPID_PRIVATE_KEY=
|
|
20
|
+
VAPID_SUBJECT=mailto:you@example.com
|
|
21
|
+
|
|
22
|
+
# Optional: APNs (iOS) — path to AuthKey .p8
|
|
23
|
+
# APNS_KEY_PATH=
|
|
24
|
+
# APNS_KEY_ID=
|
|
25
|
+
# APNS_TEAM_ID=
|
|
26
|
+
# APNS_BUNDLE_ID=
|
|
27
|
+
# APNS_PRODUCTION=false
|
|
28
|
+
|
|
29
|
+
# Optional: FCM (Android) — service account JSON path
|
|
30
|
+
# FIREBASE_SERVICE_ACCOUNT_PATH=
|
|
31
|
+
|
|
14
32
|
# Optional email and analytics providers
|
|
15
33
|
RESEND_API_KEY=
|
|
16
34
|
VITE_PUBLIC_POSTHOG_KEY=demo
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/* global self */
|
|
2
|
+
self.addEventListener("push", (event) => {
|
|
3
|
+
let payload = { title: "Notification", body: "" };
|
|
4
|
+
try {
|
|
5
|
+
if (event.data) {
|
|
6
|
+
payload = { ...payload, ...event.data.json() };
|
|
7
|
+
}
|
|
8
|
+
} catch {
|
|
9
|
+
const text = event.data?.text();
|
|
10
|
+
if (text) payload = { title: "Notification", body: text };
|
|
11
|
+
}
|
|
12
|
+
event.waitUntil(
|
|
13
|
+
self.registration.showNotification(payload.title, {
|
|
14
|
+
body: payload.body,
|
|
15
|
+
data: payload.data ?? {},
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
self.addEventListener("notificationclick", (event) => {
|
|
21
|
+
event.notification.close();
|
|
22
|
+
event.waitUntil(
|
|
23
|
+
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => {
|
|
24
|
+
if (clientList.length > 0) {
|
|
25
|
+
const client = clientList[0];
|
|
26
|
+
if ("focus" in client) return client.focus();
|
|
27
|
+
}
|
|
28
|
+
if (self.clients.openWindow) {
|
|
29
|
+
return self.clients.openWindow("/");
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "lucide-react";
|
|
13
13
|
import { useTranslation } from "react-i18next";
|
|
14
14
|
import { NavLink, Outlet } from "react-router";
|
|
15
|
+
import { PushNotificationsPanel } from "./modules/notification/PushNotificationsPanel";
|
|
15
16
|
|
|
16
17
|
export function Layout() {
|
|
17
18
|
const { data: session } = useSession();
|
|
@@ -58,6 +59,8 @@ export function Layout() {
|
|
|
58
59
|
</div>
|
|
59
60
|
</div>
|
|
60
61
|
|
|
62
|
+
<PushNotificationsPanel />
|
|
63
|
+
|
|
61
64
|
<nav className="mt-8 grid gap-2">
|
|
62
65
|
<NavLink
|
|
63
66
|
to="/posts"
|
package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Button, Chip } from "@heroui/react";
|
|
2
|
+
import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
|
|
3
|
+
import { useWebPush } from "@m5kdev/web-ui/hooks/useWebPush";
|
|
4
|
+
import { BellIcon } from "lucide-react";
|
|
5
|
+
import { useMemo } from "react";
|
|
6
|
+
import { useTranslation } from "react-i18next";
|
|
7
|
+
import { useTRPC } from "../../utils/trpc";
|
|
8
|
+
|
|
9
|
+
export function PushNotificationsPanel() {
|
|
10
|
+
const { data: session } = useSession();
|
|
11
|
+
const trpc = useTRPC();
|
|
12
|
+
const { t } = useTranslation("blog-app");
|
|
13
|
+
|
|
14
|
+
const messages = useMemo(
|
|
15
|
+
() => ({
|
|
16
|
+
unsupported: t("layout.push.unsupported"),
|
|
17
|
+
denied: t("layout.push.denied"),
|
|
18
|
+
noVapid: t("layout.push.noVapid"),
|
|
19
|
+
badSubscription: t("layout.push.badSubscription"),
|
|
20
|
+
failed: t("layout.push.failed"),
|
|
21
|
+
enabled: t("layout.push.enabled"),
|
|
22
|
+
}),
|
|
23
|
+
[t],
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
const {
|
|
27
|
+
permission,
|
|
28
|
+
isSupported,
|
|
29
|
+
subscribe,
|
|
30
|
+
flowStatus,
|
|
31
|
+
feedback,
|
|
32
|
+
isWorking,
|
|
33
|
+
canSubscribe,
|
|
34
|
+
vapidError,
|
|
35
|
+
isVapidLoading,
|
|
36
|
+
} = useWebPush({
|
|
37
|
+
enabled: Boolean(session),
|
|
38
|
+
messages,
|
|
39
|
+
vapidPublicKeyQuery: trpc.notification.vapidPublicKey.queryOptions(),
|
|
40
|
+
registerDeviceMutation: trpc.notification.registerDevice.mutationOptions(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
if (!session) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const disabled = isVapidLoading || vapidError || isWorking || !canSubscribe;
|
|
48
|
+
const blocked = permission === "denied";
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div className="mt-6 rounded-[28px] border border-sky-200/80 bg-sky-50/90 p-4">
|
|
52
|
+
<div className="flex items-start gap-3">
|
|
53
|
+
<div className="rounded-2xl border border-sky-200 bg-white/90 p-2 text-sky-700">
|
|
54
|
+
<BellIcon className="h-5 w-5" />
|
|
55
|
+
</div>
|
|
56
|
+
<div className="min-w-0 flex-1">
|
|
57
|
+
<p className="text-[0.68rem] font-semibold uppercase tracking-[0.28em] text-sky-800/80">
|
|
58
|
+
{t("layout.push.eyebrow")}
|
|
59
|
+
</p>
|
|
60
|
+
<p className="mt-2 text-sm leading-6 text-ink/80">{t("layout.push.body")}</p>
|
|
61
|
+
{!isSupported ? (
|
|
62
|
+
<p className="mt-2 text-xs text-rose-700">{t("layout.push.unsupported")}</p>
|
|
63
|
+
) : null}
|
|
64
|
+
{blocked ? (
|
|
65
|
+
<p className="mt-2 text-xs text-rose-700">{t("layout.push.blockedHint")}</p>
|
|
66
|
+
) : null}
|
|
67
|
+
{vapidError ? (
|
|
68
|
+
<p className="mt-2 text-xs text-rose-700">{t("layout.push.vapidMissing")}</p>
|
|
69
|
+
) : null}
|
|
70
|
+
{permission === "granted" && flowStatus === "idle" && !vapidError ? (
|
|
71
|
+
<p className="mt-2 text-xs text-emerald-800/90">{t("layout.push.permissionGranted")}</p>
|
|
72
|
+
) : null}
|
|
73
|
+
{feedback ? (
|
|
74
|
+
<Chip className="mt-3" color={flowStatus === "error" ? "danger" : "success"} variant="flat">
|
|
75
|
+
{feedback}
|
|
76
|
+
</Chip>
|
|
77
|
+
) : null}
|
|
78
|
+
<Button
|
|
79
|
+
className="mt-3"
|
|
80
|
+
color="primary"
|
|
81
|
+
isDisabled={disabled}
|
|
82
|
+
radius="full"
|
|
83
|
+
size="sm"
|
|
84
|
+
variant="flat"
|
|
85
|
+
onPress={() => void subscribe()}
|
|
86
|
+
>
|
|
87
|
+
{t("layout.push.cta")}
|
|
88
|
+
</Button>
|
|
89
|
+
</div>
|
|
90
|
+
</div>
|
|
91
|
+
</div>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -14,6 +14,20 @@
|
|
|
14
14
|
"navigation": {
|
|
15
15
|
"posts": "Posts"
|
|
16
16
|
},
|
|
17
|
+
"push": {
|
|
18
|
+
"eyebrow": "Browser push",
|
|
19
|
+
"body": "Enable notifications for this browser profile. Requires HTTPS in production, VAPID keys on the server, Redis for the worker, and a running server process.",
|
|
20
|
+
"cta": "Enable push for this device",
|
|
21
|
+
"enabled": "This browser is registered for push.",
|
|
22
|
+
"denied": "Notification permission was denied.",
|
|
23
|
+
"unsupported": "Push messaging is not supported in this browser.",
|
|
24
|
+
"noVapid": "Server is missing VAPID public key configuration.",
|
|
25
|
+
"badSubscription": "Could not read push subscription from the browser.",
|
|
26
|
+
"failed": "Could not enable push. Check the console and server logs.",
|
|
27
|
+
"vapidMissing": "Server did not return a VAPID key — set VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY, then restart.",
|
|
28
|
+
"blockedHint": "Notifications are blocked for this site. Enable them in the browser address bar or site settings, then try again.",
|
|
29
|
+
"permissionGranted": "Permission granted — tap below to register this browser with the server."
|
|
30
|
+
},
|
|
17
31
|
"account": {
|
|
18
32
|
"eyebrow": "Signed In As",
|
|
19
33
|
"light": "Light mode",
|