create-m5kdev 0.35.0 → 0.37.2

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 (61) hide show
  1. package/package.json +3 -3
  2. package/templates/minimal-app/.dockerignore.tpl +6 -0
  3. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +3 -0
  4. package/templates/minimal-app/apps/e2e/servers.ts.tpl +4 -0
  5. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +2 -34
  6. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +225 -5
  7. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +47 -1
  8. package/templates/minimal-app/apps/e2e/tests/mcp-helpers.ts.tpl +406 -0
  9. package/templates/minimal-app/apps/e2e/tests/mcp.spec.ts.tpl +108 -0
  10. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +290 -8
  11. package/templates/minimal-app/apps/expo/app.json.tpl +1 -0
  12. package/templates/minimal-app/apps/expo/package.json.tpl +4 -0
  13. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +4 -1
  14. package/templates/minimal-app/apps/expo/src/app/notifications.tsx.tpl +70 -0
  15. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +8 -16
  16. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +4 -1
  17. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +11 -2
  18. package/templates/minimal-app/apps/landing/vite.config.ts.tpl +2 -3
  19. package/templates/minimal-app/apps/server/AGENTS.md.tpl +10 -0
  20. package/templates/minimal-app/apps/server/db.e2e.ts.tpl +6 -40
  21. package/templates/minimal-app/apps/server/db.ts.tpl +6 -42
  22. package/templates/minimal-app/apps/server/drizzle/{0000_wealthy_absorbing_man.sql → 0000_whole_echo.sql} +102 -3
  23. package/templates/minimal-app/apps/server/drizzle/0001_yummy_spectrum.sql +26 -0
  24. package/templates/minimal-app/apps/server/drizzle/0002_crazy_lilith.sql +47 -0
  25. package/templates/minimal-app/apps/server/drizzle/0003_freezing_fantastic_four.sql +4 -0
  26. package/templates/minimal-app/apps/server/drizzle/0004_slow_ezekiel_stane.sql +99 -0
  27. package/templates/minimal-app/apps/server/drizzle/0005_wonderful_the_professor.sql +2 -0
  28. package/templates/minimal-app/apps/server/drizzle/0006_lazy_blue_marvel.sql +159 -0
  29. package/templates/minimal-app/apps/server/drizzle/0007_abnormal_ultimo.sql +2 -0
  30. package/templates/minimal-app/apps/server/drizzle/0008_steady_nekra.sql +87 -0
  31. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +2625 -1780
  32. package/templates/minimal-app/apps/server/drizzle/meta/0001_snapshot.json.tpl +2671 -1796
  33. package/templates/minimal-app/apps/server/drizzle/meta/0002_snapshot.json.tpl +2716 -1818
  34. package/templates/minimal-app/apps/server/drizzle/meta/0003_snapshot.json.tpl +715 -164
  35. package/templates/minimal-app/apps/server/drizzle/meta/0004_snapshot.json.tpl +2498 -0
  36. package/templates/minimal-app/apps/server/drizzle/meta/0005_snapshot.json.tpl +2484 -0
  37. package/templates/minimal-app/apps/server/drizzle/meta/0006_snapshot.json.tpl +3603 -0
  38. package/templates/minimal-app/apps/server/drizzle/meta/0007_snapshot.json.tpl +3620 -0
  39. package/templates/minimal-app/apps/server/drizzle/meta/0008_snapshot.json.tpl +3620 -0
  40. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +44 -9
  41. package/templates/minimal-app/apps/server/src/app.ts.tpl +12 -2
  42. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +2 -2
  43. package/templates/minimal-app/apps/server/src/modules/posts/posts.mcp.ts.tpl +17 -0
  44. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +11 -0
  45. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +14 -5
  46. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +13 -36
  47. package/templates/minimal-app/apps/server/src/schema.ts.tpl +16 -2
  48. package/templates/minimal-app/apps/shared/package.json.tpl +1 -0
  49. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +1 -1
  50. package/templates/minimal-app/apps/shared/src/modules/notification/notification.constants.ts.tpl +5 -0
  51. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +1 -1
  52. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +17 -1
  53. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +10 -8
  54. package/templates/minimal-app/apps/webapp/src/modules/notification/NotificationPreferencesRoute.tsx.tpl +29 -0
  55. package/templates/minimal-app/apps/webapp/src/modules/notification/StarterWebPush.tsx.tpl +44 -0
  56. package/templates/minimal-app/apps/webapp/translations/en/{{APP_SLUG}}.json.tpl +8 -0
  57. package/templates/minimal-app/pnpm-workspace.yaml.tpl +14 -13
  58. package/templates/minimal-app/template.manifest.json +9 -0
  59. package/templates/minimal-app/apps/server/drizzle/0001_third_ink.sql +0 -1
  60. package/templates/minimal-app/apps/server/drizzle/0002_concerned_proudstar.sql +0 -3
  61. package/templates/minimal-app/apps/server/drizzle/0003_silly_the_liberteens.sql +0 -27
@@ -5,30 +5,65 @@
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",
45
+ "breakpoints": true
46
+ },
47
+ {
48
+ "idx": 6,
49
+ "version": "6",
50
+ "when": 1788787832546,
51
+ "tag": "0006_lazy_blue_marvel",
52
+ "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "6",
57
+ "when": 1788788533642,
58
+ "tag": "0007_abnormal_ultimo",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "6",
64
+ "when": 1788789159968,
65
+ "tag": "0008_steady_nekra",
31
66
  "breakpoints": true
32
67
  }
33
68
  ]
34
- }
69
+ }
@@ -10,6 +10,9 @@ import { EmailPreviewModule } from "@m5kdev/backend/modules/email/email.preview.
10
10
  // m5k:files:start
11
11
  import { FileModule } from "@m5kdev/backend/modules/file/file.module";
12
12
  // m5k:files:end
13
+ // m5k:mcp:start
14
+ import { McpModule } from "@m5kdev/backend/modules/mcp/mcp.module";
15
+ // m5k:mcp:end
13
16
  // m5k:notifications:start
14
17
  import { NotificationModule } from "@m5kdev/backend/modules/notification/notification.module";
15
18
  // m5k:notifications:end
@@ -23,6 +26,9 @@ import {
23
26
  APP_NAME,
24
27
  APP_ROLES_CONFIG,
25
28
  } from "{{PACKAGE_SCOPE}}/shared/modules/app/app.constants";
29
+ // m5k:notifications:start
30
+ import { NOTIFICATION_KINDS } from "{{PACKAGE_SCOPE}}/shared/modules/notification/notification.constants";
31
+ // m5k:notifications:end
26
32
  // m5k:ai:start
27
33
  import { mastra, openrouter } from "./modules/conversation/conversation.mastra";
28
34
  // m5k:ai:end
@@ -91,7 +97,7 @@ export const builtBackendApp = createBackendApp(
91
97
  outputDirectory: process.env.EMAIL_OUTPUT_DIRECTORY ?? ".emails",
92
98
  },
93
99
  auth: {
94
- factory({ db, services, appConfig, i18n }) {
100
+ factory({ db, services, appConfig, i18n, mcp }) {
95
101
  return createBetterAuth({
96
102
  orm: db.orm,
97
103
  schema: db.schema,
@@ -100,6 +106,7 @@ export const builtBackendApp = createBackendApp(
100
106
  },
101
107
  app: appConfig,
102
108
  i18n,
109
+ mcp,
103
110
  config: {
104
111
  waitlist: enableWaitlist,
105
112
  provisionedAccountEmailDomain:
@@ -115,6 +122,9 @@ export const builtBackendApp = createBackendApp(
115
122
  [
116
123
  new EmailModule(templates as never),
117
124
  new AuthModule(),
125
+ // m5k:mcp:start
126
+ new McpModule(),
127
+ // m5k:mcp:end
118
128
  // m5k:ai:start
119
129
  new AIModule({
120
130
  libs: { mastra, openrouter },
@@ -138,7 +148,7 @@ export const builtBackendApp = createBackendApp(
138
148
  new DemoWorkflowModule(),
139
149
  // m5k:workflows:end
140
150
  // m5k:notifications:start
141
- new NotificationModule(),
151
+ new NotificationModule({ kinds: NOTIFICATION_KINDS }),
142
152
  // m5k:notifications:end
143
153
  new PostsModule(),
144
154
  new EmailPreviewModule({ allowDelete: process.env.NODE_ENV !== "production" }),
@@ -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"),
@@ -0,0 +1,17 @@
1
+ import { defineMcpCall } from "@m5kdev/backend/modules/mcp/mcp.define";
2
+ import type { McpOrganizationCallDefinition } from "@m5kdev/backend/modules/mcp/mcp.types";
3
+ import { postSchemas } from "./posts.dto";
4
+ import type { PostsService } from "./posts.service";
5
+
6
+ export function createPostsMcp(posts: PostsService): Record<string, McpOrganizationCallDefinition> {
7
+ return {
8
+ "list-posts": defineMcpCall()
9
+ .description("List posts in the selected Organization")
10
+ .input(postSchemas.input.list)
11
+ .handle(async (input, actor) => posts.list(input, { actor, user: { id: actor.userId } })),
12
+ "create-post": defineMcpCall()
13
+ .description("Create a draft post in the selected Organization")
14
+ .input(postSchemas.input.create)
15
+ .handle(async (input, actor) => posts.create(input, { actor, user: { id: actor.userId } })),
16
+ };
17
+ }
@@ -1,3 +1,4 @@
1
+ // biome-ignore-all assist/source/organizeImports: feature-gated posts.mcp import stays in marker blocks
1
2
  import { createBackendRouterMap } from "@m5kdev/backend/app";
2
3
  import {
3
4
  BaseModule,
@@ -10,6 +11,10 @@ import { postsGrants } from "./posts.grants";
10
11
  import { PostsRepository } from "./posts.repository";
11
12
  import { PostsService } from "./posts.service";
12
13
  import { createPostsTRPC } from "./posts.trpc";
14
+ // m5k:mcp:start
15
+ import type { ModuleMcpContext } from "@m5kdev/backend/modules/base/base.module";
16
+ import { createPostsMcp } from "./posts.mcp";
17
+ // m5k:mcp:end
13
18
 
14
19
  type PostsModuleDeps = Record<string, never>;
15
20
  type PostsModuleTables = typeof postsTables;
@@ -54,4 +59,10 @@ export class PostsModule extends BaseModule<
54
59
  override trpc({ trpc, services }: ModuleTRPCContext<PostsModuleDeps, PostsModuleServices>) {
55
60
  return createBackendRouterMap("posts", createPostsTRPC(trpc, services.posts));
56
61
  }
62
+
63
+ // m5k:mcp:start
64
+ override mcp({ services }: ModuleMcpContext<PostsModuleDeps, PostsModuleServices>) {
65
+ return createPostsMcp(services.posts);
66
+ }
67
+ // m5k:mcp:end
57
68
  }
@@ -1,15 +1,21 @@
1
+ import type { AuthenticatedActor } from "@m5kdev/backend/base/base.actor";
2
+ import type { ServiceProcedureContext } from "@m5kdev/backend/base/base.procedure";
1
3
  import { BasePermissionService } from "@m5kdev/backend/modules/base/base.service";
2
4
  import { serializeSpanValue, withSpan } from "@m5kdev/backend/utils/telemetry";
3
- import type { Context } from "@m5kdev/backend/utils/trpc";
4
5
  import { err, ok } from "neverthrow";
5
6
  import { postSchemas } from "./posts.dto";
6
7
  import type { PostsRepository } from "./posts.repository";
7
8
  import { createExcerpt, slugify } from "./posts.utils";
8
9
 
10
+ export interface PostsProcedureContext extends ServiceProcedureContext {
11
+ actor: AuthenticatedActor;
12
+ user: { id: string };
13
+ }
14
+
9
15
  export class PostsService extends BasePermissionService<
10
16
  { posts: PostsRepository },
11
17
  Record<string, never>,
12
- Context
18
+ PostsProcedureContext
13
19
  > {
14
20
  readonly list = this.procedure("list")
15
21
  .input(postSchemas.input.list)
@@ -37,6 +43,9 @@ export class PostsService extends BasePermissionService<
37
43
  .output(postSchemas.output.single)
38
44
  .access({
39
45
  action: "write",
46
+ entities: ({ ctx }) => ({
47
+ organizationId: ctx.actor.organizationId,
48
+ }),
40
49
  })
41
50
  .handle(async ({ input, ctx }) => {
42
51
  const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
@@ -47,10 +56,10 @@ export class PostsService extends BasePermissionService<
47
56
  }
48
57
 
49
58
  return this.repository.posts.create({
50
- authorUserId: ctx.user.id,
59
+ authorUserId: ctx.actor.userId,
51
60
  memberId: ctx.actor.memberId ?? null,
52
- organizationId: ctx.session.activeOrganizationId ?? null,
53
- teamId: ctx.session.activeTeamId ?? null,
61
+ organizationId: ctx.actor.organizationId,
62
+ teamId: null,
54
63
  title: input.title.trim(),
55
64
  slug: uniqueSlug.value,
56
65
  excerpt: createExcerpt(input.excerpt, input.content),
@@ -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,18 +1,30 @@
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,
15
14
  } from "@m5kdev/backend/modules/auth/auth.db";
15
+ // m5k:mcp:start
16
+ export {
17
+ jwks,
18
+ oauthAccessTokens,
19
+ oauthClientAssertions,
20
+ oauthClientResources,
21
+ oauthClients,
22
+ oauthConsents,
23
+ oauthRefreshTokens,
24
+ oauthResources,
25
+ } from "@m5kdev/backend/modules/auth/auth.oauth.db";
26
+ export { mcpAllowlistEntries } from "@m5kdev/backend/modules/mcp/mcp.db";
27
+ // m5k:mcp:end
16
28
  // m5k:ai:start
17
29
  export { aiUsage, chats } from "@m5kdev/backend/modules/ai/ai.db";
18
30
  // m5k:ai:end
@@ -22,7 +34,9 @@ export { files } from "@m5kdev/backend/modules/file/file.db";
22
34
  // m5k:notifications:start
23
35
  export {
24
36
  notificationDevices,
37
+ notificationPreferences,
25
38
  notificationSendLogs,
39
+ notifications,
26
40
  } from "@m5kdev/backend/modules/notification/notification.db";
27
41
  // m5k:notifications:end
28
42
  // m5k:workflows:start
@@ -21,6 +21,7 @@
21
21
  "./modules/app/*": "./src/modules/app/*.ts",
22
22
  "./modules/conversation/*": "./src/modules/conversation/*.ts",
23
23
  "./modules/demo-workflow/*": "./src/modules/demo-workflow/*.ts",
24
+ "./modules/notification/*": "./src/modules/notification/*.ts",
24
25
  "./modules/posts/*": "./src/modules/posts/*.ts"
25
26
  }
26
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,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";
@@ -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,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",
@@ -7,17 +7,18 @@ catalogs:
7
7
  "@heroui/react": 3.0.3
8
8
  "@heroui/styles": 3.0.3
9
9
  "@libsql/client": 0.17.4
10
- "@m5kdev/backend": 0.35.0
11
- "@m5kdev/commons": 0.35.0
12
- "@m5kdev/config": 0.35.0
13
- "@m5kdev/email": 0.35.0
14
- "@m5kdev/frontend": 0.35.0
15
- "@m5kdev/module-clay": 0.35.0
16
- "@m5kdev/module-docx": 0.35.0
17
- "@m5kdev/module-pdf": 0.35.0
18
- "@m5kdev/module-social": 0.35.0
19
- "@m5kdev/module-video": 0.35.0
20
- "@m5kdev/web-ui": 0.35.0
10
+ "@m5kdev/backend": 0.37.2
11
+ "@m5kdev/commons": 0.37.2
12
+ "@m5kdev/config": 0.37.2
13
+ "@m5kdev/email": 0.37.2
14
+ "@m5kdev/expo": 0.37.2
15
+ "@m5kdev/frontend": 0.37.2
16
+ "@m5kdev/module-clay": 0.37.2
17
+ "@m5kdev/module-docx": 0.37.2
18
+ "@m5kdev/module-pdf": 0.37.2
19
+ "@m5kdev/module-social": 0.37.2
20
+ "@m5kdev/module-video": 0.37.2
21
+ "@m5kdev/web-ui": 0.37.2
21
22
  "@mastra/core": 1.51.0
22
23
  "@openrouter/ai-sdk-provider": 3.0.0
23
24
  "@opentelemetry/api": 1.9.0
@@ -34,7 +35,7 @@ catalogs:
34
35
  "@types/react-dom": 19.2.3
35
36
  "@vitejs/plugin-react": 4.2.0
36
37
  babel-plugin-react-compiler: 1.0.0
37
- better-auth: 1.4.18
38
+ better-auth: 1.7.2
38
39
  dotenv: 16.6.1
39
40
  drizzle-kit: 0.31.10
40
41
  drizzle-orm: 0.45.2
@@ -66,5 +67,5 @@ catalogs:
66
67
  vite: 7.0.4
67
68
  vite-plugin-i18next-loader: 3.1.2
68
69
  vite-tsconfig-paths: 5.1.4
69
- zod: 4.2.1
70
+ zod: 4.4.3
70
71
  catalogMode: strict