create-m5kdev 0.25.2 → 0.26.1

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 (143) hide show
  1. package/dist/src/create.cjs +18 -1
  2. package/dist/src/create.cjs.map +1 -1
  3. package/dist/src/fs.cjs +65 -5
  4. package/dist/src/fs.cjs.map +1 -1
  5. package/dist/src/fs.d.cts +13 -2
  6. package/dist/src/index.cjs +0 -0
  7. package/dist/src/paths.cjs +2 -2
  8. package/dist/src/prompts.cjs +22 -0
  9. package/dist/src/prompts.cjs.map +1 -1
  10. package/dist/src/runCli.cjs +13 -2
  11. package/dist/src/runCli.cjs.map +1 -1
  12. package/dist/src/types.d.cts +9 -1
  13. package/package.json +8 -6
  14. package/templates/minimal-app/.cursor/rules/frontend-data-guide.mdc +46 -3
  15. package/templates/minimal-app/.cursor/rules/module-db-guide.mdc +15 -1
  16. package/templates/minimal-app/.cursor/rules/module-dto-guide.mdc +60 -0
  17. package/templates/minimal-app/.cursor/rules/module-repository-guide.mdc +98 -193
  18. package/templates/minimal-app/.cursor/rules/module-schema-guide.mdc +53 -173
  19. package/templates/minimal-app/.cursor/rules/module-service-guide.mdc +133 -240
  20. package/templates/minimal-app/.cursor/rules/module-trpc-guide.mdc +71 -113
  21. package/templates/minimal-app/README.md.tpl +3 -3
  22. package/templates/minimal-app/apps/.gitignore.tpl +13 -0
  23. package/templates/minimal-app/apps/e2e/package.json.tpl +23 -0
  24. package/templates/minimal-app/apps/e2e/playwright.config.ts.tpl +39 -0
  25. package/templates/minimal-app/apps/e2e/playwright.expo.config.ts.tpl +38 -0
  26. package/templates/minimal-app/apps/e2e/servers.ts.tpl +100 -0
  27. package/templates/minimal-app/apps/e2e/tests/email.spec.ts.tpl +47 -0
  28. package/templates/minimal-app/apps/e2e/tests/expo-helpers.ts.tpl +73 -0
  29. package/templates/minimal-app/apps/e2e/tests/expo-standard.spec.ts.tpl +517 -0
  30. package/templates/minimal-app/apps/e2e/tests/expo-waitlist.spec.ts.tpl +94 -0
  31. package/templates/minimal-app/apps/e2e/tests/helpers.ts.tpl +289 -0
  32. package/templates/minimal-app/apps/e2e/tests/standard.spec.ts.tpl +516 -0
  33. package/templates/minimal-app/apps/e2e/tests/waitlist.spec.ts.tpl +107 -0
  34. package/templates/minimal-app/apps/e2e/tsconfig.json.tpl +10 -0
  35. package/templates/minimal-app/apps/email/package.json.tpl +34 -34
  36. package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +35 -34
  37. package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +34 -33
  38. package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +35 -34
  39. package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +37 -36
  40. package/templates/minimal-app/apps/email/src/emails/waitlistEmails.tsx.tpl +87 -0
  41. package/templates/minimal-app/apps/email/src/index.ts.tpl +72 -32
  42. package/templates/minimal-app/apps/email/src/resources.ts.tpl +65 -36
  43. package/templates/minimal-app/apps/email/tsconfig.json.tpl +16 -11
  44. package/templates/minimal-app/apps/expo/.gitignore.tpl +44 -0
  45. package/templates/minimal-app/apps/expo/.prettierignore +13 -0
  46. package/templates/minimal-app/apps/expo/.prettierrc.json.tpl +9 -0
  47. package/templates/minimal-app/apps/expo/CHANGELOG.md.tpl +156 -0
  48. package/templates/minimal-app/apps/expo/README.md.tpl +44 -0
  49. package/templates/minimal-app/apps/expo/app.json.tpl +36 -0
  50. package/templates/minimal-app/apps/expo/assets/images/adaptive-icon.png +0 -0
  51. package/templates/minimal-app/apps/expo/assets/images/favicon.png +0 -0
  52. package/templates/minimal-app/apps/expo/assets/images/icon.png +0 -0
  53. package/templates/minimal-app/apps/expo/assets/images/splash-icon.png +0 -0
  54. package/templates/minimal-app/apps/expo/eslint.config.js.tpl +8 -0
  55. package/templates/minimal-app/apps/expo/expo-env.d.ts.tpl +3 -0
  56. package/templates/minimal-app/apps/expo/metro.config.js.tpl +16 -0
  57. package/templates/minimal-app/apps/expo/package.json.tpl +59 -0
  58. package/templates/minimal-app/apps/expo/src/app/_layout.tsx.tpl +31 -0
  59. package/templates/minimal-app/apps/expo/src/app/claim-account.tsx.tpl +151 -0
  60. package/templates/minimal-app/apps/expo/src/app/forgot-password.tsx.tpl +53 -0
  61. package/templates/minimal-app/apps/expo/src/app/index.tsx.tpl +8 -0
  62. package/templates/minimal-app/apps/expo/src/app/login.tsx.tpl +71 -0
  63. package/templates/minimal-app/apps/expo/src/app/logout.tsx.tpl +19 -0
  64. package/templates/minimal-app/apps/expo/src/app/organization/accept-invitation.tsx.tpl +85 -0
  65. package/templates/minimal-app/apps/expo/src/app/posts.tsx.tpl +40 -0
  66. package/templates/minimal-app/apps/expo/src/app/reset-password.tsx.tpl +75 -0
  67. package/templates/minimal-app/apps/expo/src/app/signup.tsx.tpl +193 -0
  68. package/templates/minimal-app/apps/expo/src/components/AuthScaffold.tsx.tpl +34 -0
  69. package/templates/minimal-app/apps/expo/src/components/FormTextField.tsx.tpl +37 -0
  70. package/templates/minimal-app/apps/expo/src/components/LoadingScreen.tsx.tpl +10 -0
  71. package/templates/minimal-app/apps/expo/src/components/StatusText.tsx.tpl +22 -0
  72. package/templates/minimal-app/apps/expo/src/config.ts.tpl +9 -0
  73. package/templates/minimal-app/apps/expo/src/global.css.tpl +11 -0
  74. package/templates/minimal-app/apps/expo/src/lib/trpc.ts.tpl +20 -0
  75. package/templates/minimal-app/apps/expo/tsconfig.json.tpl +11 -0
  76. package/templates/minimal-app/apps/expo/uniwind-env.d.ts.tpl +1 -0
  77. package/templates/minimal-app/apps/expo/uniwind-types.d.ts.tpl +10 -0
  78. package/templates/minimal-app/apps/server/AGENTS.md.tpl +39 -39
  79. package/templates/minimal-app/apps/server/drizzle/0000_wealthy_absorbing_man.sql +268 -0
  80. package/templates/minimal-app/apps/server/drizzle/db.ts.tpl +26 -0
  81. package/templates/minimal-app/apps/server/drizzle/meta/0000_snapshot.json.tpl +1890 -0
  82. package/templates/minimal-app/apps/server/drizzle/meta/_journal.json.tpl +13 -0
  83. package/templates/minimal-app/apps/server/drizzle/reset.ts.tpl +33 -0
  84. package/templates/minimal-app/apps/server/drizzle/seed.e2e.ts.tpl +244 -0
  85. package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +181 -180
  86. package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +14 -21
  87. package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +33 -35
  88. package/templates/minimal-app/apps/server/package.json.tpl +56 -52
  89. package/templates/minimal-app/apps/server/src/app.ts.tpl +142 -126
  90. package/templates/minimal-app/apps/server/src/index.ts.tpl +58 -58
  91. package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +12 -12
  92. package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +25 -25
  93. package/templates/minimal-app/apps/server/src/modules/posts/posts.dto.ts.tpl +23 -0
  94. package/templates/minimal-app/apps/server/src/modules/posts/posts.grants.ts.tpl +30 -40
  95. package/templates/minimal-app/apps/server/src/modules/posts/posts.module.ts.tpl +54 -54
  96. package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +67 -112
  97. package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +104 -170
  98. package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +37 -48
  99. package/templates/minimal-app/apps/server/src/modules/posts/posts.utils.ts.tpl +19 -0
  100. package/templates/minimal-app/apps/server/src/modules/test-harness/test-harness.module.ts.tpl +279 -0
  101. package/templates/minimal-app/apps/server/src/schema.ts.tpl +20 -0
  102. package/templates/minimal-app/apps/server/src/types.ts.tpl +1 -1
  103. package/templates/minimal-app/apps/server/tsconfig.json.tpl +20 -14
  104. package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -9
  105. package/templates/minimal-app/apps/shared/package.json.tpl +24 -24
  106. package/templates/minimal-app/apps/shared/src/modules/app/app.constants.ts.tpl +28 -2
  107. package/templates/minimal-app/apps/shared/src/modules/notification/notification.schema.ts.tpl +3 -3
  108. package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -5
  109. package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +25 -76
  110. package/templates/minimal-app/apps/shared/tsconfig.json.tpl +16 -12
  111. package/templates/minimal-app/apps/webapp/index.html.tpl +22 -22
  112. package/templates/minimal-app/apps/webapp/package.json.tpl +53 -52
  113. package/templates/minimal-app/apps/webapp/public/{push-sw.js → push-sw.js.tpl} +34 -34
  114. package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +44 -13
  115. package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +18 -12
  116. package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +33 -40
  117. package/templates/minimal-app/apps/webapp/src/index.css.tpl +15 -75
  118. package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -26
  119. package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +278 -307
  120. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostCard.tsx.tpl +96 -138
  121. package/templates/minimal-app/apps/webapp/src/modules/posts/components/PostEditorModal.tsx.tpl +118 -124
  122. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostActions.ts.tpl +108 -116
  123. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsList.ts.tpl +15 -9
  124. package/templates/minimal-app/apps/webapp/src/modules/posts/hooks/usePostsRoute.ts.tpl +13 -13
  125. package/templates/minimal-app/apps/webapp/src/modules/posts/posts.utils.ts.tpl +16 -16
  126. package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -13
  127. package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +10 -6
  128. package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -1
  129. package/templates/minimal-app/apps/webapp/translations/{app.json.tpl → en/app.json.tpl} +7 -7
  130. package/templates/minimal-app/apps/webapp/translations/en/{blog-app.json.tpl → {{APP_SLUG}}.json.tpl} +135 -135
  131. package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +17 -17
  132. package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -31
  133. package/templates/minimal-app/template.manifest.json +21 -0
  134. package/templates/minimal-app/apps/server/drizzle/generate-schema.ts.tpl +0 -31
  135. package/templates/minimal-app/apps/server/src/db.ts.tpl +0 -7
  136. package/templates/minimal-app/apps/server/src/generated/schema.ts.tpl +0 -13
  137. package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +0 -8
  138. package/templates/minimal-app/apps/shared/src/modules/app/locale.constants.ts.tpl +0 -7
  139. package/templates/minimal-app/apps/shared/src/modules/app/roles.constants.ts.tpl +0 -17
  140. package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +0 -36
  141. package/templates/minimal-app/apps/webapp/src/modules/notification/PushNotificationsPanel.tsx.tpl +0 -89
  142. package/templates/minimal-app/apps/webapp/src/navigation/navigation.config.tsx.tpl +0 -85
  143. /package/templates/minimal-app/apps/webapp/public/{logo.svg → logo.svg.tpl} +0 -0
@@ -1,170 +1,104 @@
1
- import { BasePermissionService } from "@m5kdev/backend/modules/base/base.service";
2
- import type { Context } from "@m5kdev/backend/utils/trpc";
3
- import type { ServerResultAsync } from "@m5kdev/backend/utils/types";
4
- import type {
5
- PostCreateInputSchema,
6
- PostCreateOutputSchema,
7
- PostPublishInputSchema,
8
- PostPublishOutputSchema,
9
- PostSoftDeleteInputSchema,
10
- PostSoftDeleteOutputSchema,
11
- PostsListInputSchema,
12
- PostsListOutputSchema,
13
- PostUpdateInputSchema,
14
- PostUpdateOutputSchema,
15
- } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
16
- import { err, ok } from "neverthrow";
17
- import type { PostsRepository } from "./posts.repository";
18
-
19
- type RequestContext = Context;
20
-
21
- export class PostsService extends BasePermissionService<
22
- { posts: PostsRepository },
23
- Record<string, never>,
24
- RequestContext
25
- > {
26
- readonly list = this.procedure<PostsListInputSchema>("list")
27
- .requireAuth()
28
- .handle(({ input }): ServerResultAsync<PostsListOutputSchema> => {
29
- return this.repository.posts.list(input);
30
- });
31
-
32
- readonly create = this.procedure<PostCreateInputSchema>("create")
33
- .requireAuth()
34
- .access({
35
- action: "write",
36
- entities: ({ ctx }) => ({
37
- organizationId: ctx.session.activeOrganizationId ?? null,
38
- teamId: ctx.session.activeTeamId ?? null,
39
- }),
40
- })
41
- .handle(async ({ input, ctx }): ServerResultAsync<PostCreateOutputSchema> => {
42
- const uniqueSlug = await this.repository.posts.resolveUniqueSlug(
43
- this.slugify(input.slug ?? input.title)
44
- );
45
- if (uniqueSlug.isErr()) {
46
- return err(uniqueSlug.error);
47
- }
48
-
49
- return this.repository.posts.create({
50
- authorUserId: ctx.user.id,
51
- organizationId: ctx.session.activeOrganizationId ?? null,
52
- teamId: ctx.session.activeTeamId ?? null,
53
- title: input.title.trim(),
54
- slug: uniqueSlug.value,
55
- excerpt: this.createExcerpt(input.excerpt, input.content),
56
- content: input.content.trim(),
57
- status: "draft",
58
- }) as ServerResultAsync<PostCreateOutputSchema>;
59
- });
60
-
61
- readonly update = this.procedure<PostUpdateInputSchema>("update")
62
- .requireAuth()
63
- .use("post", async ({ input }) => {
64
- const current = await this.repository.posts.findById(input.id);
65
- if (current.isErr()) {
66
- return err(current.error);
67
- }
68
- if (!current.value || current.value.deletedAt) {
69
- return this.error("NOT_FOUND", "Post not found");
70
- }
71
-
72
- return current.value;
73
- })
74
- .access({
75
- action: "write",
76
- entityStep: "post",
77
- })
78
- .handle(async ({ input }): ServerResultAsync<PostUpdateOutputSchema> => {
79
- const uniqueSlug = await this.repository.posts.resolveUniqueSlug(
80
- this.slugify(input.slug ?? input.title),
81
- input.id
82
- );
83
- if (uniqueSlug.isErr()) {
84
- return err(uniqueSlug.error);
85
- }
86
-
87
- return this.repository.posts.update({
88
- id: input.id,
89
- title: input.title.trim(),
90
- slug: uniqueSlug.value,
91
- excerpt: this.createExcerpt(input.excerpt, input.content),
92
- content: input.content.trim(),
93
- }) as ServerResultAsync<PostUpdateOutputSchema>;
94
- });
95
-
96
- readonly publish = this.procedure<PostPublishInputSchema>("publish")
97
- .requireAuth()
98
- .use("post", async ({ input }) => {
99
- const current = await this.repository.posts.findById(input.id);
100
- if (current.isErr()) {
101
- return err(current.error);
102
- }
103
- if (!current.value || current.value.deletedAt) {
104
- return this.error("NOT_FOUND", "Post not found");
105
- }
106
-
107
- return current.value;
108
- })
109
- .access({
110
- action: "publish",
111
- entityStep: "post",
112
- })
113
- .handle(({ input, state }): ServerResultAsync<PostPublishOutputSchema> => {
114
- return this.repository.posts.update({
115
- id: input.id,
116
- status: "published",
117
- publishedAt: state.post?.publishedAt ?? new Date(),
118
- }) as ServerResultAsync<PostPublishOutputSchema>;
119
- });
120
-
121
- readonly softDelete = this.procedure<PostSoftDeleteInputSchema>("softDelete")
122
- .requireAuth()
123
- .use("post", async ({ input }) => {
124
- const current = await this.repository.posts.findById(input.id);
125
- if (current.isErr()) {
126
- return err(current.error);
127
- }
128
- if (!current.value || current.value.deletedAt) {
129
- return this.error("NOT_FOUND", "Post not found");
130
- }
131
-
132
- return current.value;
133
- })
134
- .access({
135
- action: "delete",
136
- entityStep: "post",
137
- })
138
- .handle(async ({ input }): ServerResultAsync<PostSoftDeleteOutputSchema> => {
139
- const updated = await this.repository.posts.update({
140
- id: input.id,
141
- deletedAt: new Date(),
142
- });
143
-
144
- if (updated.isErr()) {
145
- return err(updated.error);
146
- }
147
-
148
- return ok({ id: updated.value.id });
149
- });
150
-
151
- private slugify(value: string): string {
152
- const slug = value
153
- .trim()
154
- .toLowerCase()
155
- .replace(/[^a-z0-9]+/g, "-")
156
- .replace(/^-+/g, "")
157
- .replace(/-+$/g, "")
158
- .replace(/-{2,}/g, "-");
159
-
160
- return slug || "post";
161
- }
162
-
163
- private createExcerpt(excerpt: string | undefined, content: string): string {
164
- if (excerpt?.trim()) {
165
- return excerpt.trim();
166
- }
167
-
168
- return content.replace(/\s+/g, " ").trim().slice(0, 180);
169
- }
170
- }
1
+ import { BasePermissionService } from "@m5kdev/backend/modules/base/base.service";
2
+ import type { Context } from "@m5kdev/backend/utils/trpc";
3
+ import { err, ok } from "neverthrow";
4
+ import { postSchemas } from "./posts.dto";
5
+ import type { PostsRepository } from "./posts.repository";
6
+ import { createExcerpt, slugify } from "./posts.utils";
7
+
8
+ export class PostsService extends BasePermissionService<
9
+ { posts: PostsRepository },
10
+ Record<string, never>,
11
+ Context
12
+ > {
13
+ readonly list = this.procedure("list")
14
+ .input(postSchemas.input.list)
15
+ .output(postSchemas.output.list)
16
+ .requireAuth()
17
+ .handle(({ input }) => {
18
+ return this.repository.posts.queryList(input, {
19
+ globalSearchColumns: ["title", "excerpt", "content"],
20
+ });
21
+ });
22
+
23
+ readonly create = this.procedure("create")
24
+ .requireAuth()
25
+ .input(postSchemas.input.create)
26
+ .output(postSchemas.output.single)
27
+ .access({
28
+ action: "write",
29
+ })
30
+ .handle(async ({ input, ctx }) => {
31
+ const uniqueSlug = await this.repository.posts.resolveUniqueSlug({
32
+ candidate: slugify(input.slug ?? input.title),
33
+ });
34
+ if (uniqueSlug.isErr()) {
35
+ return err(uniqueSlug.error);
36
+ }
37
+
38
+ return this.repository.posts.create({
39
+ authorUserId: ctx.user.id,
40
+ organizationId: ctx.session.activeOrganizationId ?? null,
41
+ teamId: ctx.session.activeTeamId ?? null,
42
+ title: input.title.trim(),
43
+ slug: uniqueSlug.value,
44
+ excerpt: createExcerpt(input.excerpt, input.content),
45
+ content: input.content.trim(),
46
+ status: "draft",
47
+ });
48
+ });
49
+
50
+ readonly update = this.procedure("update")
51
+ .requireAuth()
52
+ .input(postSchemas.input.update)
53
+ .output(postSchemas.output.single)
54
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
55
+ .access({
56
+ action: "write",
57
+ entityStep: "post",
58
+ })
59
+ .handle(async ({ input, state }) => {
60
+ const content = input.content?.trim() ?? state.post.content;
61
+
62
+ return this.repository.posts.update({
63
+ id: input.id,
64
+ title: input.title ? input.title.trim() : state.post.title,
65
+ excerpt: createExcerpt(input.excerpt, content),
66
+ content: content.trim(),
67
+ });
68
+ });
69
+
70
+ readonly publish = this.procedure("publish")
71
+ .input(postSchemas.input.publish)
72
+ .output(postSchemas.output.single)
73
+ .requireAuth()
74
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
75
+ .access({
76
+ action: "publish",
77
+ entityStep: "post",
78
+ })
79
+ .handle(({ input, state }) => {
80
+ return this.repository.posts.update({
81
+ id: input.id,
82
+ status: "published",
83
+ publishedAt: state.post?.publishedAt ?? new Date(),
84
+ });
85
+ });
86
+
87
+ readonly softDelete = this.procedure("softDelete")
88
+ .input(postSchemas.input.delete)
89
+ .output(postSchemas.output.uuid)
90
+ .requireAuth()
91
+ .loadResource("post", ({ input }) => this.repository.posts.findById(input.id))
92
+ .access({
93
+ action: "delete",
94
+ entityStep: "post",
95
+ })
96
+ .handle(async ({ input }) => {
97
+ const updated = await this.repository.posts.update({
98
+ id: input.id,
99
+ deletedAt: new Date(),
100
+ });
101
+ if (updated.isErr()) return err(updated.error);
102
+ return ok({ id: updated.value.id });
103
+ });
104
+ }
@@ -1,48 +1,37 @@
1
- import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
2
- import {
3
- postCreateInputSchema,
4
- postCreateOutputSchema,
5
- postPublishInputSchema,
6
- postPublishOutputSchema,
7
- postSoftDeleteInputSchema,
8
- postSoftDeleteOutputSchema,
9
- postsListInputSchema,
10
- postsListOutputSchema,
11
- postUpdateInputSchema,
12
- postUpdateOutputSchema,
13
- } from "{{PACKAGE_SCOPE}}/shared/modules/posts/posts.schema";
14
- import type { PostsService } from "./posts.service";
15
-
16
- export function createPostsTRPC(
17
- { router, privateProcedure: procedure }: TRPCMethods,
18
- postsService: PostsService
19
- ) {
20
- return router({
21
- list: procedure
22
- .input(postsListInputSchema)
23
- .output(postsListOutputSchema)
24
- .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
25
-
26
- create: procedure
27
- .input(postCreateInputSchema)
28
- .output(postCreateOutputSchema)
29
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
30
-
31
- update: procedure
32
- .input(postUpdateInputSchema)
33
- .output(postUpdateOutputSchema)
34
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
35
-
36
- publish: procedure
37
- .input(postPublishInputSchema)
38
- .output(postPublishOutputSchema)
39
- .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
40
-
41
- softDelete: procedure
42
- .input(postSoftDeleteInputSchema)
43
- .output(postSoftDeleteOutputSchema)
44
- .mutation(async ({ ctx, input }) =>
45
- handleTRPCResult(await postsService.softDelete(input, ctx))
46
- ),
47
- });
48
- }
1
+ import { handleTRPCResult, type TRPCMethods } from "@m5kdev/backend/utils/trpc";
2
+ import { postSchemas } from "./posts.dto";
3
+ import type { PostsService } from "./posts.service";
4
+
5
+ export function createPostsTRPC(
6
+ { router, privateProcedure: procedure }: TRPCMethods,
7
+ postsService: PostsService
8
+ ) {
9
+ return router({
10
+ list: procedure
11
+ .input(postSchemas.input.list)
12
+ .output(postSchemas.output.list)
13
+ .query(async ({ ctx, input }) => handleTRPCResult(await postsService.list(input ?? {}, ctx))),
14
+
15
+ create: procedure
16
+ .input(postSchemas.input.create)
17
+ .output(postSchemas.output.single)
18
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.create(input, ctx))),
19
+
20
+ update: procedure
21
+ .input(postSchemas.input.update)
22
+ .output(postSchemas.output.single)
23
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.update(input, ctx))),
24
+
25
+ publish: procedure
26
+ .input(postSchemas.input.publish)
27
+ .output(postSchemas.output.single)
28
+ .mutation(async ({ ctx, input }) => handleTRPCResult(await postsService.publish(input, ctx))),
29
+
30
+ softDelete: procedure
31
+ .input(postSchemas.input.delete)
32
+ .output(postSchemas.output.uuid)
33
+ .mutation(async ({ ctx, input }) =>
34
+ handleTRPCResult(await postsService.softDelete(input, ctx))
35
+ ),
36
+ });
37
+ }
@@ -0,0 +1,19 @@
1
+ export function slugify(value: string): string {
2
+ const slug = value
3
+ .trim()
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9]+/g, "-")
6
+ .replace(/^-+/g, "")
7
+ .replace(/-+$/g, "")
8
+ .replace(/-{2,}/g, "-");
9
+
10
+ return slug || "post";
11
+ }
12
+
13
+ export function createExcerpt(excerpt: string | null | undefined, content: string): string {
14
+ if (excerpt?.trim()) {
15
+ return excerpt.trim();
16
+ }
17
+
18
+ return content.replace(/\s+/g, " ").trim().slice(0, 180);
19
+ }
@@ -0,0 +1,279 @@
1
+ import {
2
+ BaseModule,
3
+ type ModuleExpressContext,
4
+ type TableMap,
5
+ } from "@m5kdev/backend/modules/base/base.module";
6
+ import { and, desc, eq, isNotNull } from "drizzle-orm";
7
+ import type { Request, Response } from "express";
8
+ import { v4 as uuidv4 } from "uuid";
9
+
10
+ type TestHarnessTables = TableMap;
11
+
12
+ function normalizedEmail(value: unknown) {
13
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
14
+ }
15
+
16
+ function normalizedText(value: unknown, fallback: string) {
17
+ return typeof value === "string" && value.trim() ? value.trim() : fallback;
18
+ }
19
+
20
+ function slugPart(value: string) {
21
+ return value
22
+ .trim()
23
+ .toLowerCase()
24
+ .replace(/[^a-z0-9]+/g, "-")
25
+ .replace(/^-+|-+$/g, "")
26
+ .slice(0, 48);
27
+ }
28
+
29
+ function sendJson(res: Response, status: number, body: unknown) {
30
+ res
31
+ .status(status)
32
+ .type("application/json")
33
+ .send(JSON.stringify(body, null, 2));
34
+ }
35
+
36
+ export class TestHarnessModule extends BaseModule<
37
+ Record<string, never>,
38
+ TestHarnessTables,
39
+ Record<string, never>,
40
+ Record<string, never>,
41
+ never
42
+ > {
43
+ readonly id = "auth-e2e-test-harness";
44
+ override readonly dbDependsOn = ["auth"] as const;
45
+
46
+ override express({ db, infra }: ModuleExpressContext<Record<string, never>>) {
47
+ infra.express.get("/__auth-e2e/user.json", async (req: Request, res: Response) => {
48
+ const email = normalizedEmail(req.query.email);
49
+ if (!email) {
50
+ sendJson(res, 400, { error: "email is required" });
51
+ return;
52
+ }
53
+
54
+ const [user] = await db.orm
55
+ .select({
56
+ id: db.schema.users.id,
57
+ name: db.schema.users.name,
58
+ email: db.schema.users.email,
59
+ emailVerified: db.schema.users.emailVerified,
60
+ role: db.schema.users.role,
61
+ banned: db.schema.users.banned,
62
+ })
63
+ .from(db.schema.users)
64
+ .where(eq(db.schema.users.email, email))
65
+ .limit(1);
66
+
67
+ if (!user) {
68
+ sendJson(res, 404, { error: "user not found" });
69
+ return;
70
+ }
71
+
72
+ const organizations = await db.orm
73
+ .select({
74
+ id: db.schema.organizations.id,
75
+ name: db.schema.organizations.name,
76
+ slug: db.schema.organizations.slug,
77
+ type: db.schema.organizations.type,
78
+ parentId: db.schema.organizations.parentId,
79
+ role: db.schema.members.role,
80
+ })
81
+ .from(db.schema.members)
82
+ .innerJoin(
83
+ db.schema.organizations,
84
+ eq(db.schema.members.organizationId, db.schema.organizations.id)
85
+ )
86
+ .where(eq(db.schema.members.userId, user.id));
87
+
88
+ const [credentialAccount] = await db.orm
89
+ .select({ id: db.schema.accounts.id })
90
+ .from(db.schema.accounts)
91
+ .where(
92
+ and(
93
+ eq(db.schema.accounts.userId, user.id),
94
+ eq(db.schema.accounts.providerId, "credential"),
95
+ isNotNull(db.schema.accounts.password)
96
+ )
97
+ )
98
+ .limit(1);
99
+
100
+ const accountClaims = await db.orm
101
+ .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,
108
+ })
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));
117
+
118
+ const [latestSession] = await db.orm
119
+ .select({
120
+ id: db.schema.sessions.id,
121
+ userId: db.schema.sessions.userId,
122
+ activeOrganizationId: db.schema.sessions.activeOrganizationId,
123
+ activeOrganizationRole: db.schema.sessions.activeOrganizationRole,
124
+ activeOrganizationMemberId: db.schema.sessions.activeOrganizationMemberId,
125
+ activeOrganizationType: db.schema.sessions.activeOrganizationType,
126
+ activeTeamId: db.schema.sessions.activeTeamId,
127
+ impersonatedBy: db.schema.sessions.impersonatedBy,
128
+ createdAt: db.schema.sessions.createdAt,
129
+ })
130
+ .from(db.schema.sessions)
131
+ .where(eq(db.schema.sessions.userId, user.id))
132
+ .orderBy(desc(db.schema.sessions.createdAt))
133
+ .limit(1);
134
+
135
+ sendJson(res, 200, {
136
+ user,
137
+ organizations,
138
+ credentialAccount: Boolean(credentialAccount),
139
+ accountClaims,
140
+ pendingAccountClaims: accountClaims.filter((claim) => claim.status === "INVITED").length,
141
+ acceptedAccountClaims: accountClaims.filter((claim) => claim.status === "ACCEPTED").length,
142
+ latestSession: latestSession ?? null,
143
+ });
144
+ });
145
+
146
+ infra.express.post(
147
+ "/__auth-e2e/provisioned-claim-user",
148
+ async (req: Request, res: Response) => {
149
+ const email = normalizedEmail((req.body as { email?: unknown }).email);
150
+ const expiresInHours =
151
+ typeof (req.body as { expiresInHours?: unknown }).expiresInHours === "number"
152
+ ? (req.body as { expiresInHours: number }).expiresInHours
153
+ : 24 * 14;
154
+ const createClaim = (req.body as { createClaim?: unknown }).createClaim === true;
155
+
156
+ if (!email.endsWith("@provisioned.auth-e2e.local")) {
157
+ sendJson(res, 400, { error: "email must use the provisioned auth-e2e domain" });
158
+ return;
159
+ }
160
+
161
+ const [existing] = await db.orm
162
+ .select({ id: db.schema.users.id })
163
+ .from(db.schema.users)
164
+ .where(eq(db.schema.users.email, email))
165
+ .limit(1);
166
+
167
+ if (existing) {
168
+ sendJson(res, 409, { error: "user already exists" });
169
+ return;
170
+ }
171
+
172
+ const now = new Date();
173
+ const userId = uuidv4();
174
+ const organizationId = uuidv4();
175
+ const teamId = uuidv4();
176
+ const claimId = createClaim ? uuidv4() : null;
177
+
178
+ await db.orm.transaction(async (tx) => {
179
+ await tx.insert(db.schema.users).values({
180
+ id: userId,
181
+ name: `Provisioned ${email}`,
182
+ email,
183
+ emailVerified: false,
184
+ role: "user",
185
+ createdAt: now,
186
+ updatedAt: now,
187
+ });
188
+
189
+ await tx.insert(db.schema.organizations).values({
190
+ id: organizationId,
191
+ name: `Provisioned ${userId.slice(0, 8)}`,
192
+ slug: `provisioned-${userId.slice(0, 8)}`,
193
+ type: "organization",
194
+ createdAt: now,
195
+ });
196
+
197
+ await tx.insert(db.schema.members).values({
198
+ id: uuidv4(),
199
+ organizationId,
200
+ userId,
201
+ role: "owner",
202
+ createdAt: now,
203
+ });
204
+
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
+ if (claimId) {
222
+ await tx.insert(db.schema.waitlist).values({
223
+ id: claimId,
224
+ type: "ACCOUNT_CLAIM",
225
+ status: "INVITED",
226
+ claimUserId: userId,
227
+ code: uuidv4(),
228
+ expiresAt: new Date(Date.now() + expiresInHours * 60 * 60 * 1000),
229
+ createdAt: now,
230
+ updatedAt: now,
231
+ });
232
+ }
233
+ });
234
+
235
+ sendJson(res, 201, {
236
+ user: { id: userId, email },
237
+ organization: { id: organizationId },
238
+ claim: claimId ? { id: claimId } : null,
239
+ });
240
+ }
241
+ );
242
+
243
+ infra.express.post("/__auth-e2e/organizations", async (req: Request, res: Response) => {
244
+ const body = req.body as { count?: unknown; prefix?: unknown };
245
+ const count = typeof body.count === "number" ? Math.trunc(body.count) : 12;
246
+ const prefix = normalizedText(body.prefix, "Harness Organization");
247
+
248
+ if (count < 1 || count > 50) {
249
+ sendJson(res, 400, { error: "count must be between 1 and 50" });
250
+ return;
251
+ }
252
+
253
+ const now = Date.now();
254
+ const slugPrefix = slugPart(prefix) || "organization";
255
+ const organizations = Array.from({ length: count }, (_, index) => {
256
+ const id = uuidv4();
257
+ const displayIndex = String(index + 1).padStart(2, "0");
258
+
259
+ return {
260
+ id,
261
+ name: `${prefix} ${displayIndex}`,
262
+ slug: `${slugPrefix}-${displayIndex}-${id.slice(0, 8)}`,
263
+ type: "organization",
264
+ createdAt: new Date(now + index),
265
+ };
266
+ });
267
+
268
+ await db.orm.insert(db.schema.organizations).values(organizations);
269
+
270
+ sendJson(res, 201, {
271
+ organizations: organizations.map((organization) => ({
272
+ id: organization.id,
273
+ name: organization.name,
274
+ slug: organization.slug,
275
+ })),
276
+ });
277
+ });
278
+ }
279
+ }