create-m5kdev 0.34.0 → 0.36.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.
Files changed (65) hide show
  1. package/package.json +1 -1
  2. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +1 -0
  3. package/templates/minimal-app/.cursor/rules/server-event-emit.mdc +41 -0
  4. package/templates/minimal-app/AGENTS.md.tpl +1 -0
  5. package/templates/minimal-app/CLAUDE.md.tpl +1 -0
  6. package/templates/minimal-app/README.md.tpl +2 -1
  7. package/templates/minimal-app/apps/e2e/servers.ts.tpl +3 -0
  8. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  9. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  10. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  11. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  12. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  13. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  14. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  15. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  16. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  17. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  18. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  19. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  20. package/templates/minimal-app/apps/server/AGENTS.md.tpl +1 -0
  21. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +5 -40
  22. package/templates/minimal-app/apps/server/db.ts.tpl +5 -42
  23. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  24. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  25. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  26. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  27. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  28. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  29. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  30. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  31. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  32. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  33. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  34. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  35. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +23 -9
  36. package/templates/minimal-app/apps/server/src/app.ts.tpl +4 -1
  37. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.module.ts.tpl +7 -3
  38. package/templates/minimal-app/apps/server/src/modules/demo-workflow/demo-workflow.service.ts.tpl +12 -4
  39. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  40. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +7 -4
  41. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +2 -2
  42. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  43. package/templates/minimal-app/apps/server/src/schema.ts.tpl +3 -2
  44. package/templates/minimal-app/apps/shared/package.json.tpl +2 -0
  45. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  46. package/templates/minimal-app/apps/shared/src/modules/demo-workflow/demo-workflow.constants.ts.tpl +1 -0
  47. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  48. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  49. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +8 -5
  50. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  51. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  52. package/templates/minimal-app/apps/webapp/src/StarterServerEventProvider.tsx.tpl +38 -0
  53. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  54. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  55. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  56. package/templates/minimal-app/package.json.tpl +5 -4
  57. package/templates/minimal-app/pnpm-workspace.yaml.tpl +12 -11
  58. package/templates/minimal-app/template.manifest.json +1 -0
  59. package/templates/minimal-app/apps/landing/scripts/fly-deploy.mjs.tpl +0 -75
  60. package/templates/minimal-app/apps/landing/scripts/fly-secrets.mjs.tpl +0 -31
  61. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  62. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  63. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
  64. package/templates/minimal-app/apps/shared/scripts/fly-deploy.mjs.tpl +0 -75
  65. package/templates/minimal-app/apps/shared/scripts/fly-secrets.mjs.tpl +0 -31
@@ -5,30 +5,44 @@
5
5
  {
6
6
  "idx": 0,
7
7
  "version": "6",
8
- "when": 1783931793066,
9
- "tag": "0000_wealthy_absorbing_man",
8
+ "when": 1787759631723,
9
+ "tag": "0000_whole_echo",
10
10
  "breakpoints": true
11
11
  },
12
12
  {
13
13
  "idx": 1,
14
14
  "version": "6",
15
- "when": 1785171166863,
16
- "tag": "0001_third_ink",
15
+ "when": 1788197370969,
16
+ "tag": "0001_yummy_spectrum",
17
17
  "breakpoints": true
18
18
  },
19
19
  {
20
20
  "idx": 2,
21
21
  "version": "6",
22
- "when": 1786951360347,
23
- "tag": "0002_concerned_proudstar",
22
+ "when": 1788197859737,
23
+ "tag": "0002_crazy_lilith",
24
24
  "breakpoints": true
25
25
  },
26
26
  {
27
27
  "idx": 3,
28
28
  "version": "6",
29
- "when": 1787145865079,
30
- "tag": "0003_silly_the_liberteens",
29
+ "when": 1788198203749,
30
+ "tag": "0003_freezing_fantastic_four",
31
+ "breakpoints": true
32
+ },
33
+ {
34
+ "idx": 4,
35
+ "version": "6",
36
+ "when": 1788198833389,
37
+ "tag": "0004_slow_ezekiel_stane",
38
+ "breakpoints": true
39
+ },
40
+ {
41
+ "idx": 5,
42
+ "version": "6",
43
+ "when": 1788253467922,
44
+ "tag": "0005_wonderful_the_professor",
31
45
  "breakpoints": true
32
46
  }
33
47
  ]
34
- }
48
+ }
@@ -23,6 +23,9 @@ import {
23
23
  APP_NAME,
24
24
  APP_ROLES_CONFIG,
25
25
  } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
26
+ // m5k:notifications:start
27
+ import { NOTIFICATION_KINDS } from "{{PACKAGE_SCOPE}}/shared/modules/notification/notification.constants";
28
+ // m5k:notifications:end
26
29
  // m5k:ai:start
27
30
  import { mastra, openrouter } from "./modules/conversation/conversation.mastra";
28
31
  // m5k:ai:end
@@ -138,7 +141,7 @@ export const builtBackendApp = createBackendApp(
138
141
  new DemoWorkflowModule(),
139
142
  // m5k:workflows:end
140
143
  // m5k:notifications:start
141
- new NotificationModule(),
144
+ new NotificationModule({ kinds: NOTIFICATION_KINDS }),
142
145
  // m5k:notifications:end
143
146
  new PostsModule(),
144
147
  new EmailPreviewModule({ allowDelete: process.env.NODE_ENV !== "production" }),
@@ -1,4 +1,5 @@
1
1
  import { createBackendRouterMap } from "@m5kdev/backend/app";
2
+ import type { AuthModule } from "@m5kdev/backend/modules/auth/auth.module";
2
3
  import {
3
4
  BaseModule,
4
5
  type ModuleServicesContext,
@@ -9,7 +10,7 @@ import type { WorkflowModule } from "@m5kdev/backend/modules/workflow/workflow.m
9
10
  import { DemoWorkflowService } from "./demo-workflow.service";
10
11
  import { createDemoWorkflowTRPC } from "./demo-workflow.trpc";
11
12
 
12
- type DemoWorkflowModuleDeps = { workflow: WorkflowModule };
13
+ type DemoWorkflowModuleDeps = { workflow: WorkflowModule; auth: AuthModule };
13
14
  type DemoWorkflowModuleServices = {
14
15
  demoWorkflow: DemoWorkflowService;
15
16
  };
@@ -25,13 +26,16 @@ export class DemoWorkflowModule extends BaseModule<
25
26
  DemoWorkflowModuleRouters
26
27
  > {
27
28
  readonly id = "demo-workflow";
28
- override readonly dependsOn = ["workflow"] as const;
29
+ override readonly dependsOn = ["workflow", "auth"] as const;
29
30
 
30
31
  override services({
31
32
  deps,
32
33
  }: ModuleServicesContext<DemoWorkflowModuleDeps, Record<string, never>>) {
33
34
  return {
34
- demoWorkflow: new DemoWorkflowService({ workflow: deps.workflow.services.workflow }),
35
+ demoWorkflow: new DemoWorkflowService({
36
+ workflow: deps.workflow.services.workflow,
37
+ auth: deps.auth.services.auth,
38
+ }),
35
39
  };
36
40
  }
37
41
 
@@ -1,9 +1,11 @@
1
+ import type { AuthService } from "@m5kdev/backend/modules/auth/auth.service";
1
2
  import type { ServerResultAsync } from "@m5kdev/backend/modules/base/base.dto";
2
3
  import { BaseService } from "@m5kdev/backend/modules/base/base.service";
3
4
  import type { WorkflowService } from "@m5kdev/backend/modules/workflow/workflow.service";
4
5
  import type { FireAndForgetJobDefinition } from "@m5kdev/backend/modules/workflow/workflow.types";
5
6
  import type { Context } from "@m5kdev/backend/utils/trpc";
6
7
  import { workflowTriggerOutputSchema } from "@m5kdev/commons/modules/workflow/workflow.schema";
8
+ import { DEMO_WORKFLOW_SERVER_EVENT_RESOURCE } from "{{PACKAGE_SCOPE}}/shared/modules/demo-workflow/demo-workflow.constants";
7
9
  import { ok } from "neverthrow";
8
10
 
9
11
  const DEMO_PING_JOB_NAME = "demo.ping";
@@ -14,12 +16,12 @@ interface DemoPingJobPayload {
14
16
 
15
17
  export class DemoWorkflowService extends BaseService<
16
18
  Record<string, never>,
17
- { workflow: WorkflowService },
19
+ { workflow: WorkflowService; auth: AuthService },
18
20
  Context
19
21
  > {
20
22
  readonly demoPingJob: FireAndForgetJobDefinition<DemoPingJobPayload>;
21
23
 
22
- constructor(services: { workflow: WorkflowService }) {
24
+ constructor(services: { workflow: WorkflowService; auth: AuthService }) {
23
25
  super({}, services);
24
26
 
25
27
  this.demoPingJob = this.service.workflow
@@ -28,8 +30,14 @@ export class DemoWorkflowService extends BaseService<
28
30
  queue: "fast",
29
31
  meta: (payload) => ({ userId: payload.userId }),
30
32
  })
31
- .handle(async () => {
32
- // Completes immediately so the Starter webapp can observe a finished run.
33
+ .handle(async (payload) => {
34
+ this.service.auth.userEmit({
35
+ userId: payload.userId,
36
+ resource: DEMO_WORKFLOW_SERVER_EVENT_RESOURCE,
37
+ id: DEMO_PING_JOB_NAME,
38
+ change: "updated",
39
+ organizationId: null,
40
+ });
33
41
  });
34
42
  }
35
43
 
@@ -1,4 +1,4 @@
1
- import { members, organizations, teams, users } from "@m5kdev/backend/modules/auth/auth.db";
1
+ import { members, organizations, users } from "@m5kdev/backend/modules/auth/auth.db";
2
2
  import { integer, sqliteTable as table, text } from "drizzle-orm/sqlite-core";
3
3
  import { v4 as uuidv4 } from "uuid";
4
4
 
@@ -13,7 +13,7 @@ export const posts = table("posts", {
13
13
  organizationId: text("organization_id").references(() => organizations.id, {
14
14
  onDelete: "set null",
15
15
  }),
16
- teamId: text("team_id").references(() => teams.id, { onDelete: "set null" }),
16
+ teamId: text("team_id"),
17
17
  title: text("title").notNull(),
18
18
  slug: text("slug").notNull().unique(),
19
19
  excerpt: text("excerpt"),
@@ -11,6 +11,7 @@ import { PostsRepository } from "./posts.repository";
11
11
  import { PostsService } from "./posts.service";
12
12
  import { createPostsTRPC } from "./posts.trpc";
13
13
 
14
+ type PostsModuleDeps = Record<string, never>;
14
15
  type PostsModuleTables = typeof postsTables;
15
16
  type PostsModuleRepositories = {
16
17
  posts: PostsRepository;
@@ -23,7 +24,7 @@ type PostsModuleRouters = {
23
24
  };
24
25
 
25
26
  export class PostsModule extends BaseModule<
26
- never,
27
+ PostsModuleDeps,
27
28
  PostsModuleTables,
28
29
  PostsModuleRepositories,
29
30
  PostsModuleServices,
@@ -32,7 +33,7 @@ export class PostsModule extends BaseModule<
32
33
  readonly id = "posts";
33
34
  override readonly dbDependsOn = ["auth"] as const;
34
35
 
35
- override repositories({ db }: ModuleRepositoriesContext<never, PostsModuleTables>) {
36
+ override repositories({ db }: ModuleRepositoriesContext<PostsModuleDeps, PostsModuleTables>) {
36
37
  return {
37
38
  posts: new PostsRepository({
38
39
  orm: db.orm,
@@ -42,13 +43,15 @@ export class PostsModule extends BaseModule<
42
43
  };
43
44
  }
44
45
 
45
- override services({ repositories }: ModuleServicesContext<never, PostsModuleRepositories>) {
46
+ override services({
47
+ repositories,
48
+ }: ModuleServicesContext<PostsModuleDeps, PostsModuleRepositories>) {
46
49
  return {
47
50
  posts: new PostsService({ posts: repositories.posts }, {}, postsGrants),
48
51
  };
49
52
  }
50
53
 
51
- override trpc({ trpc, services }: ModuleTRPCContext<never, PostsModuleServices>) {
54
+ override trpc({ trpc, services }: ModuleTRPCContext<PostsModuleDeps, PostsModuleServices>) {
52
55
  return createBackendRouterMap("posts", createPostsTRPC(trpc, services.posts));
53
56
  }
54
57
  }
@@ -50,7 +50,7 @@ export class PostsService extends BasePermissionService<
50
50
  authorUserId: ctx.user.id,
51
51
  memberId: ctx.actor.memberId ?? null,
52
52
  organizationId: ctx.session.activeOrganizationId ?? null,
53
- teamId: ctx.session.activeTeamId ?? null,
53
+ teamId: null,
54
54
  title: input.title.trim(),
55
55
  slug: uniqueSlug.value,
56
56
  excerpt: createExcerpt(input.excerpt, input.content),
@@ -88,7 +88,7 @@ export class PostsService extends BasePermissionService<
88
88
  action: "publish",
89
89
  entityStep: "post",
90
90
  })
91
- .handle(({ input, state }) => {
91
+ .handle(async ({ input, state }) => {
92
92
  return this.repository.posts.update({
93
93
  id: input.id,
94
94
  status: "published",
@@ -3,7 +3,7 @@ import {
3
3
  type ModuleExpressContext,
4
4
  type TableMap,
5
5
  } from "@m5kdev/backend/modules/base/base.module";
6
- import { and, desc, eq, isNotNull } from "drizzle-orm";
6
+ import { and, desc, eq, isNotNull, isNull } from "drizzle-orm";
7
7
  import type { Request, Response } from "express";
8
8
  import { v4 as uuidv4 } from "uuid";
9
9
 
@@ -77,13 +77,14 @@ export class TestHarnessModule extends BaseModule<
77
77
  type: db.schema.organizations.type,
78
78
  parentId: db.schema.organizations.parentId,
79
79
  role: db.schema.members.role,
80
+ memberId: db.schema.members.id,
80
81
  })
81
82
  .from(db.schema.members)
82
83
  .innerJoin(
83
84
  db.schema.organizations,
84
85
  eq(db.schema.members.organizationId, db.schema.organizations.id)
85
86
  )
86
- .where(eq(db.schema.members.userId, user.id));
87
+ .where(and(eq(db.schema.members.userId, user.id), isNull(db.schema.members.deletedAt)));
87
88
 
88
89
  const [credentialAccount] = await db.orm
89
90
  .select({ id: db.schema.accounts.id })
@@ -99,21 +100,16 @@ export class TestHarnessModule extends BaseModule<
99
100
 
100
101
  const accountClaims = await db.orm
101
102
  .select({
102
- id: db.schema.waitlist.id,
103
- status: db.schema.waitlist.status,
104
- claimUserId: db.schema.waitlist.claimUserId,
105
- claimedEmail: db.schema.waitlist.claimedEmail,
106
- claimedAt: db.schema.waitlist.claimedAt,
107
- expiresAt: db.schema.waitlist.expiresAt,
103
+ id: db.schema.accountClaims.id,
104
+ status: db.schema.accountClaims.status,
105
+ claimUserId: db.schema.accountClaims.claimUserId,
106
+ claimedEmail: db.schema.accountClaims.claimedEmail,
107
+ claimedAt: db.schema.accountClaims.claimedAt,
108
+ expiresAt: db.schema.accountClaims.expiresAt,
108
109
  })
109
- .from(db.schema.waitlist)
110
- .where(
111
- and(
112
- eq(db.schema.waitlist.type, "ACCOUNT_CLAIM"),
113
- eq(db.schema.waitlist.claimUserId, user.id)
114
- )
115
- )
116
- .orderBy(desc(db.schema.waitlist.createdAt));
110
+ .from(db.schema.accountClaims)
111
+ .where(eq(db.schema.accountClaims.claimUserId, user.id))
112
+ .orderBy(desc(db.schema.accountClaims.createdAt));
117
113
 
118
114
  const [latestSession] = await db.orm
119
115
  .select({
@@ -123,7 +119,6 @@ export class TestHarnessModule extends BaseModule<
123
119
  activeOrganizationRole: db.schema.sessions.activeOrganizationRole,
124
120
  activeOrganizationMemberId: db.schema.sessions.activeOrganizationMemberId,
125
121
  activeOrganizationType: db.schema.sessions.activeOrganizationType,
126
- activeTeamId: db.schema.sessions.activeTeamId,
127
122
  impersonatedBy: db.schema.sessions.impersonatedBy,
128
123
  createdAt: db.schema.sessions.createdAt,
129
124
  })
@@ -172,7 +167,6 @@ export class TestHarnessModule extends BaseModule<
172
167
  const now = new Date();
173
168
  const userId = uuidv4();
174
169
  const organizationId = uuidv4();
175
- const teamId = uuidv4();
176
170
  const claimId = createClaim ? uuidv4() : null;
177
171
 
178
172
  await db.orm.transaction(async (tx) => {
@@ -202,26 +196,9 @@ export class TestHarnessModule extends BaseModule<
202
196
  createdAt: now,
203
197
  });
204
198
 
205
- await tx.insert(db.schema.teams).values({
206
- id: teamId,
207
- name: "Editorial",
208
- organizationId,
209
- createdAt: now,
210
- updatedAt: now,
211
- });
212
-
213
- await tx.insert(db.schema.teamMembers).values({
214
- id: uuidv4(),
215
- teamId,
216
- userId,
217
- role: "owner",
218
- createdAt: now,
219
- });
220
-
221
199
  if (claimId) {
222
- await tx.insert(db.schema.waitlist).values({
200
+ await tx.insert(db.schema.accountClaims).values({
223
201
  id: claimId,
224
- type: "ACCOUNT_CLAIM",
225
202
  status: "INVITED",
226
203
  claimUserId: userId,
227
204
  code: uuidv4(),
@@ -1,14 +1,13 @@
1
1
  // biome-ignore-all assist/source/organizeImports: feature-gated table exports stay in marker blocks
2
2
  export {
3
3
  accountClaimMagicLinks,
4
+ accountClaims,
4
5
  accounts,
5
6
  apikeys,
6
7
  invitations,
7
8
  members,
8
9
  organizations,
9
10
  sessions,
10
- teamMembers,
11
- teams,
12
11
  users,
13
12
  verifications,
14
13
  waitlist,
@@ -22,7 +21,9 @@ export { files } from "@m5kdev/backend/modules/file/file.db";
22
21
  // m5k:notifications:start
23
22
  export {
24
23
  notificationDevices,
24
+ notificationPreferences,
25
25
  notificationSendLogs,
26
+ notifications,
26
27
  } from "@m5kdev/backend/modules/notification/notification.db";
27
28
  // m5k:notifications:end
28
29
  // m5k:workflows:start
@@ -20,6 +20,8 @@
20
20
  "exports": {
21
21
  "./modules/app/*": "./src/modules/app/*.ts",
22
22
  "./modules/conversation/*": "./src/modules/conversation/*.ts",
23
+ "./modules/demo-workflow/*": "./src/modules/demo-workflow/*.ts",
24
+ "./modules/notification/*": "./src/modules/notification/*.ts",
23
25
  "./modules/posts/*": "./src/modules/posts/*.ts"
24
26
  }
25
27
  }
@@ -18,7 +18,7 @@ export const APP_ROLES_CONFIG = defineAuthRoles({
18
18
  organization: {
19
19
  roles: ["member", "admin", "owner"],
20
20
  managerRoles: ["admin", "owner"],
21
- assignableRoles: ["member", "admin", "owner"],
21
+ assignableRoles: ["member", "admin"],
22
22
  defaultRole: "member",
23
23
  },
24
24
  team: {
@@ -0,0 +1 @@
1
+ export const DEMO_WORKFLOW_SERVER_EVENT_RESOURCE = "workflow";
@@ -0,0 +1,5 @@
1
+ import type { NotificationKind } from "@m5kdev/commons/modules/notification/notification.constants";
2
+
3
+ export const NOTIFICATION_KINDS = [
4
+ { id: "notification.test", defaultChannels: ["in-app"] },
5
+ ] as const satisfies readonly NotificationKind[];
@@ -1,3 +1,3 @@
1
- /** Re-export shared push-notification contracts from commons. */
1
+ /** Re-export shared Notification contracts from commons. */
2
2
  export * from "@m5kdev/commons/modules/notification/notification.constants";
3
3
  export * from "@m5kdev/commons/modules/notification/notification.schema";
@@ -14,6 +14,7 @@ import { NuqsAdapter } from "nuqs/adapters/react-router/v7";
14
14
  import { BrowserRouter } from "react-router";
15
15
  import { Toaster } from "sonner";
16
16
  import { Router } from "./Router";
17
+ import { StarterServerEventProvider } from "./StarterServerEventProvider";
17
18
 
18
19
  export function App() {
19
20
  return (
@@ -31,11 +32,13 @@ export function App() {
31
32
  <ThemeProvider defaultTheme="light" storageKey="m5kdev-theme">
32
33
  <AuthProvider loader={<AppLoader />}>
33
34
  <AppTrpcQueryProvider>
34
- <DialogProvider>
35
- <Router />
36
- </DialogProvider>
37
- <Toaster richColors closeButton />
38
- <Toast.Provider placement="bottom end" />
35
+ <StarterServerEventProvider>
36
+ <DialogProvider>
37
+ <Router />
38
+ </DialogProvider>
39
+ <Toaster richColors closeButton />
40
+ <Toast.Provider placement="bottom end" />
41
+ </StarterServerEventProvider>
39
42
  </AppTrpcQueryProvider>
40
43
  </AuthProvider>
41
44
  </ThemeProvider>
@@ -7,8 +7,11 @@ import { AppSidebarHeader } from "@m5kdev/web-ui/modules/app/components/AppSideb
7
7
  import { AppSidebarUser } from "@m5kdev/web-ui/modules/app/components/AppSidebarUser";
8
8
  import { AuthOrganizationSelect } from "@m5kdev/web-ui/modules/auth/components/AuthOrganizationSelect";
9
9
  import { AuthUtilityImpersonationBanner } from "@m5kdev/web-ui/modules/auth/components/AuthUtilityImpersonationBanner";
10
+ // m5k:notifications:start
11
+ import { NotificationSidebarInbox } from "@m5kdev/web-ui/modules/notification/components/NotificationSidebarInbox";
12
+ // m5k:notifications:end
10
13
  import { APP_NAME } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
11
- import { FileIcon, UsersIcon } from "lucide-react";
14
+ import { BellIcon, FileIcon, UsersIcon } from "lucide-react";
12
15
  // m5k:ai:start
13
16
  import { SparklesIcon } from "lucide-react";
14
17
  // m5k:ai:end
@@ -21,6 +24,9 @@ import { WorkflowIcon } from "lucide-react";
21
24
  import { useState } from "react";
22
25
  import { useTranslation } from "react-i18next";
23
26
  import { useNavigate } from "react-router";
27
+ // m5k:notifications:start
28
+ import { StarterWebPush } from "./modules/notification/StarterWebPush";
29
+ // m5k:notifications:end
24
30
 
25
31
  export function Layout() {
26
32
  const { data: session } = useSession();
@@ -42,6 +48,9 @@ export function Layout() {
42
48
  header: <AppSidebarHeader logo={{ src: "/logo.svg", alt: APP_NAME }} title={APP_NAME} />,
43
49
  content: (
44
50
  <>
51
+ {/* m5k:notifications:start */}
52
+ <NotificationSidebarInbox enablePush={<StarterWebPush />} />
53
+ {/* m5k:notifications:end */}
45
54
  <div className="px-2 group-data-[collapsible=icon]:hidden">
46
55
  <AuthOrganizationSelect />
47
56
  </div>
@@ -73,6 +82,13 @@ export function Layout() {
73
82
  link: "/workflows",
74
83
  },
75
84
  // m5k:workflows:end
85
+ // m5k:notifications:start
86
+ {
87
+ label: t("layout.navigation.notifications"),
88
+ icon: <BellIcon />,
89
+ link: "/notifications",
90
+ },
91
+ // m5k:notifications:end
76
92
  {
77
93
  label: t("layout.navigation.members"),
78
94
  icon: <UsersIcon />,
@@ -1,7 +1,6 @@
1
1
  // biome-ignore-all assist/source/organizeImports: feature-gated route imports stay in marker blocks
2
2
  import { syncI18nLocale } from "@m5kdev/frontend/modules/app/utils/locale";
3
3
  import { AuthAdminRouter } from "@m5kdev/web-ui/modules/auth/components/AuthAdminRouter";
4
- import { AuthOrganizationAcceptInvitationRoute } from "@m5kdev/web-ui/modules/auth/components/AuthOrganizationAcceptInvitationRoute";
5
4
  import { AuthOrganizationRouter } from "@m5kdev/web-ui/modules/auth/components/AuthOrganizationRouter";
6
5
  import { AuthPublicRouter } from "@m5kdev/web-ui/modules/auth/components/AuthPublicRouter";
7
6
  import { AuthUserRouter } from "@m5kdev/web-ui/modules/auth/components/AuthUserRouter";
@@ -20,6 +19,9 @@ import { FilesRoute } from "@/modules/files/FilesRoute";
20
19
  // m5k:workflows:start
21
20
  import { WorkflowsRoute } from "@/modules/workflows/WorkflowsRoute";
22
21
  // m5k:workflows:end
22
+ // m5k:notifications:start
23
+ import { NotificationPreferencesRoute } from "@/modules/notification/NotificationPreferencesRoute";
24
+ // m5k:notifications:end
23
25
  import { Layout } from "./Layout";
24
26
 
25
27
  const preferenceSchema = z.object({
@@ -68,13 +70,6 @@ export function Router() {
68
70
  onLocaleChange: syncI18nLocale,
69
71
  })}
70
72
 
71
- {/* public: a logged-out invitee must reach this route so it can send
72
- them to signup — inside ProtectedRoutes they'd bounce to /login */}
73
- <Route
74
- path="/organization/accept-invitation"
75
- element={<AuthOrganizationAcceptInvitationRoute />}
76
- />
77
-
78
73
  <Route
79
74
  element={
80
75
  <ProtectedRoutes>
@@ -93,6 +88,13 @@ export function Router() {
93
88
  // m5k:workflows:start
94
89
  <Route key="workflows" path="workflows" element={<WorkflowsRoute />} />,
95
90
  // m5k:workflows:end
91
+ // m5k:notifications:start
92
+ <Route
93
+ key="notifications"
94
+ path="notifications"
95
+ element={<NotificationPreferencesRoute />}
96
+ />,
97
+ // m5k:notifications:end
96
98
  ]}
97
99
 
98
100
  {AuthUserRouter({
@@ -0,0 +1,38 @@
1
+ // biome-ignore-all assist/source/organizeImports: feature-gated Server event imports stay in marker blocks
2
+ import { ServerEventProvider } from "@m5kdev/frontend/modules/app/components/ServerEventProvider";
3
+ // m5k:workflows:start
4
+ import type { QueryClient } from "@tanstack/react-query";
5
+ import { DEMO_WORKFLOW_SERVER_EVENT_RESOURCE } from "{{PACKAGE_SCOPE}}/shared/modules/demo-workflow/demo-workflow.constants";
6
+ // m5k:workflows:end
7
+ import type { ReactNode } from "react";
8
+ // m5k:workflows:start
9
+ import { useTRPC } from "./utils/trpc";
10
+ // m5k:workflows:end
11
+
12
+ type Props = {
13
+ children: ReactNode;
14
+ };
15
+
16
+ /** Kernel SSE for the authenticated session. Workflow handlers are feature-gated. */
17
+ export function StarterServerEventProvider({ children }: Props): ReactNode {
18
+ // m5k:workflows:start
19
+ const trpc = useTRPC();
20
+ const invalidateWorkflowList = (queryClient: QueryClient): void => {
21
+ void queryClient.invalidateQueries(trpc.workflow.list.queryFilter());
22
+ };
23
+ // m5k:workflows:end
24
+ return (
25
+ <ServerEventProvider
26
+ // m5k:workflows:start
27
+ handlers={{
28
+ [DEMO_WORKFLOW_SERVER_EVENT_RESOURCE]: (_event, queryClient) => {
29
+ invalidateWorkflowList(queryClient);
30
+ },
31
+ }}
32
+ onReconnect={invalidateWorkflowList}
33
+ // m5k:workflows:end
34
+ >
35
+ {children}
36
+ </ServerEventProvider>
37
+ );
38
+ }
@@ -0,0 +1,29 @@
1
+ import { Card } from "@heroui/react";
2
+ import { NotificationPreferences } from "@m5kdev/web-ui/modules/notification/components/NotificationPreferences";
3
+ import { NOTIFICATION_KINDS } from "{{PACKAGE_SCOPE}}/shared/modules/notification/notification.constants";
4
+ import { useTranslation } from "react-i18next";
5
+ import { StarterWebPush } from "./StarterWebPush";
6
+
7
+ export function NotificationPreferencesRoute() {
8
+ const { t } = useTranslation("{{APP_SLUG}}");
9
+
10
+ return (
11
+ <div className="grid gap-6 p-4" data-testid="notification-preferences-route">
12
+ <div className="justify-self-start">
13
+ <StarterWebPush />
14
+ </div>
15
+ <Card>
16
+ <Card.Header className="flex flex-col gap-1">
17
+ <p className="text-xs font-medium uppercase tracking-wide text-muted">
18
+ {t("notifications.hero.eyebrow")}
19
+ </p>
20
+ <Card.Title>{t("notifications.hero.title")}</Card.Title>
21
+ <Card.Description>{t("notifications.hero.body")}</Card.Description>
22
+ </Card.Header>
23
+ <Card.Content>
24
+ <NotificationPreferences kinds={NOTIFICATION_KINDS} offerEmail />
25
+ </Card.Content>
26
+ </Card>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,44 @@
1
+ import { Button } from "@heroui/react";
2
+ import { useSession } from "@m5kdev/frontend/modules/auth/hooks/useSession";
3
+ import { useWebPush } from "@m5kdev/web-ui/hooks/useWebPush";
4
+ import { BellRingIcon } from "lucide-react";
5
+ import type { ReactNode } from "react";
6
+ import { useTranslation } from "react-i18next";
7
+ import { useTRPC } from "../../utils/trpc";
8
+
9
+ export function StarterWebPush(): ReactNode {
10
+ const { t } = useTranslation("{{APP_SLUG}}");
11
+ const { data: session } = useSession();
12
+ const trpc = useTRPC();
13
+ const webPush = useWebPush({
14
+ enabled: Boolean(session?.user?.id),
15
+ messages: {
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
+ vapidPublicKeyQuery: trpc.notification.vapidPublicKey.queryOptions(),
24
+ registerDeviceMutation: trpc.notification.registerDevice.mutationOptions(),
25
+ });
26
+
27
+ if (!session?.user?.id || webPush.flowStatus === "done") return null;
28
+
29
+ const label = webPush.permission === "denied" ? t("layout.push.denied") : t("layout.push.cta");
30
+
31
+ return (
32
+ <Button
33
+ size="sm"
34
+ variant="secondary"
35
+ isDisabled={!webPush.canSubscribe || webPush.isWorking}
36
+ onPress={() => {
37
+ void webPush.subscribe();
38
+ }}
39
+ >
40
+ <BellRingIcon className="size-4" />
41
+ {label}
42
+ </Button>
43
+ );
44
+ }
@@ -19,6 +19,7 @@
19
19
  "organization": "Organization",
20
20
  "account": "Account",
21
21
  "members": "Members",
22
+ "notifications": "Notifications",
22
23
  "childOrgs": "Child orgs",
23
24
  "orgPreferences": "Org preferences",
24
25
  "profile": "Profile",
@@ -184,6 +185,13 @@
184
185
  "failed": "Upload failed."
185
186
  }
186
187
  },
188
+ "notifications": {
189
+ "hero": {
190
+ "eyebrow": "This membership",
191
+ "title": "Notification preferences",
192
+ "body": "Mute channels for this organization. Other organizations keep their own settings."
193
+ }
194
+ },
187
195
  "auth": {
188
196
  "header": {
189
197
  "eyebrow": "Framework Starter",