create-m5kdev 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/__tests__/create.smoke.test.d.ts +1 -0
- package/dist/src/__tests__/create.smoke.test.js +56 -0
- package/dist/src/__tests__/create.test.d.ts +1 -0
- package/dist/src/__tests__/create.test.js +55 -0
- package/dist/src/__tests__/runCli.test.d.ts +1 -0
- package/dist/src/__tests__/runCli.test.js +44 -0
- package/dist/src/__tests__/strings.test.d.ts +1 -0
- package/dist/src/__tests__/strings.test.js +24 -0
- package/dist/src/constants.d.ts +3 -0
- package/dist/src/constants.js +9 -0
- package/dist/src/create.d.ts +7 -0
- package/dist/src/create.js +36 -0
- package/dist/src/fs.d.ts +5 -0
- package/dist/src/fs.js +60 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +9 -0
- package/dist/src/paths.d.ts +1 -0
- package/dist/src/paths.js +14 -0
- package/dist/src/prompts.d.ts +2 -0
- package/dist/src/prompts.js +55 -0
- package/dist/src/runCli.d.ts +9 -0
- package/dist/src/runCli.js +107 -0
- package/dist/src/strings.d.ts +6 -0
- package/dist/src/strings.js +47 -0
- package/dist/src/types.d.ts +18 -0
- package/dist/src/types.js +2 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +38 -0
- package/templates/minimal-app/.gitignore.tpl +9 -0
- package/templates/minimal-app/AGENTS.md.tpl +29 -0
- package/templates/minimal-app/README.md.tpl +35 -0
- package/templates/minimal-app/apps/email/package.json.tpl +27 -0
- package/templates/minimal-app/apps/email/src/components/BaseEmail.tsx.tpl +117 -0
- package/templates/minimal-app/apps/email/src/emails/accountDeletionEmail.tsx.tpl +26 -0
- package/templates/minimal-app/apps/email/src/emails/organizationInviteEmail.tsx.tpl +31 -0
- package/templates/minimal-app/apps/email/src/emails/passwordResetEmail.tsx.tpl +25 -0
- package/templates/minimal-app/apps/email/src/emails/verificationEmail.tsx.tpl +26 -0
- package/templates/minimal-app/apps/email/src/index.ts.tpl +32 -0
- package/templates/minimal-app/apps/email/tsconfig.json.tpl +11 -0
- package/templates/minimal-app/apps/server/AGENTS.md.tpl +30 -0
- package/templates/minimal-app/apps/server/drizzle/seed.ts.tpl +111 -0
- package/templates/minimal-app/apps/server/drizzle/sync.ts.tpl +18 -0
- package/templates/minimal-app/apps/server/drizzle.config.ts.tpl +38 -0
- package/templates/minimal-app/apps/server/package.json.tpl +50 -0
- package/templates/minimal-app/apps/server/src/db.ts.tpl +33 -0
- package/templates/minimal-app/apps/server/src/index.ts.tpl +34 -0
- package/templates/minimal-app/apps/server/src/lib/auth.ts.tpl +172 -0
- package/templates/minimal-app/apps/server/src/lib/localEmailService.ts.tpl +58 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.db.ts.tpl +23 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.repository.ts.tpl +106 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.service.ts.tpl +150 -0
- package/templates/minimal-app/apps/server/src/modules/posts/posts.trpc.ts.tpl +44 -0
- package/templates/minimal-app/apps/server/src/repository.ts.tpl +6 -0
- package/templates/minimal-app/apps/server/src/service.ts.tpl +12 -0
- package/templates/minimal-app/apps/server/src/trpc.ts.tpl +11 -0
- package/templates/minimal-app/apps/server/src/types.ts.tpl +3 -0
- package/templates/minimal-app/apps/server/src/utils/trpc.ts.tpl +27 -0
- package/templates/minimal-app/apps/server/tsconfig.json.tpl +13 -0
- package/templates/minimal-app/apps/server/tsup.config.ts.tpl +9 -0
- package/templates/minimal-app/apps/shared/.env.example.tpl +17 -0
- package/templates/minimal-app/apps/shared/.env.tpl +19 -0
- package/templates/minimal-app/apps/shared/package.json.tpl +24 -0
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.constants.ts.tpl +5 -0
- package/templates/minimal-app/apps/shared/src/modules/posts/posts.schema.ts.tpl +76 -0
- package/templates/minimal-app/apps/shared/tsconfig.json.tpl +12 -0
- package/templates/minimal-app/apps/webapp/AGENTS.md.tpl +18 -0
- package/templates/minimal-app/apps/webapp/index.html.tpl +22 -0
- package/templates/minimal-app/apps/webapp/package.json.tpl +52 -0
- package/templates/minimal-app/apps/webapp/src/App.tsx.tpl +13 -0
- package/templates/minimal-app/apps/webapp/src/Layout.tsx.tpl +139 -0
- package/templates/minimal-app/apps/webapp/src/Providers.tsx.tpl +28 -0
- package/templates/minimal-app/apps/webapp/src/Router.tsx.tpl +53 -0
- package/templates/minimal-app/apps/webapp/src/components/TrpcQueryProvider.tsx.tpl +61 -0
- package/templates/minimal-app/apps/webapp/src/hero.ts.tpl +99 -0
- package/templates/minimal-app/apps/webapp/src/index.css.tpl +75 -0
- package/templates/minimal-app/apps/webapp/src/main.tsx.tpl +26 -0
- package/templates/minimal-app/apps/webapp/src/modules/posts/PostsRoute.tsx.tpl +650 -0
- package/templates/minimal-app/apps/webapp/src/utils/i18n.ts.tpl +13 -0
- package/templates/minimal-app/apps/webapp/src/utils/trpc.ts.tpl +4 -0
- package/templates/minimal-app/apps/webapp/src/vite-env.d.ts.tpl +1 -0
- package/templates/minimal-app/apps/webapp/translations/en/blog-app.json.tpl +107 -0
- package/templates/minimal-app/apps/webapp/tsconfig.json.tpl +16 -0
- package/templates/minimal-app/apps/webapp/vite.config.ts.tpl +31 -0
- package/templates/minimal-app/biome.json.tpl +76 -0
- package/templates/minimal-app/package.json.tpl +21 -0
- package/templates/minimal-app/pnpm-workspace.yaml.tpl +58 -0
- package/templates/minimal-app/turbo.json.tpl +26 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { Text } from "@react-email/components";
|
|
2
|
+
import { BaseEmail } from "../components/BaseEmail";
|
|
3
|
+
|
|
4
|
+
export default function PasswordResetEmail({
|
|
5
|
+
previewText = "Reset your password",
|
|
6
|
+
url,
|
|
7
|
+
}: {
|
|
8
|
+
previewText?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<BaseEmail
|
|
13
|
+
previewText={previewText}
|
|
14
|
+
eyebrow="Access"
|
|
15
|
+
title="Reset your password"
|
|
16
|
+
ctaLabel="Choose a new password"
|
|
17
|
+
ctaUrl={url}
|
|
18
|
+
body={
|
|
19
|
+
<Text style={{ margin: "0" }}>
|
|
20
|
+
Use the secure link below to choose a new password for your {{APP_NAME}} account.
|
|
21
|
+
</Text>
|
|
22
|
+
}
|
|
23
|
+
/>
|
|
24
|
+
);
|
|
25
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Text } from "@react-email/components";
|
|
2
|
+
import { BaseEmail } from "../components/BaseEmail";
|
|
3
|
+
|
|
4
|
+
export default function VerificationEmail({
|
|
5
|
+
previewText = "Verify your email",
|
|
6
|
+
url,
|
|
7
|
+
}: {
|
|
8
|
+
previewText?: string;
|
|
9
|
+
url: string;
|
|
10
|
+
}) {
|
|
11
|
+
return (
|
|
12
|
+
<BaseEmail
|
|
13
|
+
previewText={previewText}
|
|
14
|
+
eyebrow="Welcome"
|
|
15
|
+
title="Verify your email"
|
|
16
|
+
ctaLabel="Verify account"
|
|
17
|
+
ctaUrl={url}
|
|
18
|
+
body={
|
|
19
|
+
<Text style={{ margin: "0" }}>
|
|
20
|
+
Confirm your email address to finish setting up {{APP_NAME}} and access your editorial
|
|
21
|
+
workspace.
|
|
22
|
+
</Text>
|
|
23
|
+
}
|
|
24
|
+
/>
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { FunctionComponent } from "react";
|
|
2
|
+
import AccountDeletionEmail from "./emails/accountDeletionEmail";
|
|
3
|
+
import OrganizationInviteEmail from "./emails/organizationInviteEmail";
|
|
4
|
+
import PasswordResetEmail from "./emails/passwordResetEmail";
|
|
5
|
+
import VerificationEmail from "./emails/verificationEmail";
|
|
6
|
+
|
|
7
|
+
export const templates = {
|
|
8
|
+
accountDeletion: {
|
|
9
|
+
id: "account-deletion",
|
|
10
|
+
subject: "Delete your account",
|
|
11
|
+
previewText: "Confirm your account deletion",
|
|
12
|
+
react: AccountDeletionEmail as FunctionComponent<Record<string, unknown>>,
|
|
13
|
+
},
|
|
14
|
+
passwordReset: {
|
|
15
|
+
id: "password-reset",
|
|
16
|
+
subject: "Reset your password",
|
|
17
|
+
previewText: "Reset your password request",
|
|
18
|
+
react: PasswordResetEmail as FunctionComponent<Record<string, unknown>>,
|
|
19
|
+
},
|
|
20
|
+
verification: {
|
|
21
|
+
id: "verification",
|
|
22
|
+
subject: "Verify your email",
|
|
23
|
+
previewText: "Verify your email address",
|
|
24
|
+
react: VerificationEmail as FunctionComponent<Record<string, unknown>>,
|
|
25
|
+
},
|
|
26
|
+
organizationInvite: {
|
|
27
|
+
id: "organization-invite",
|
|
28
|
+
subject: "Join the organization",
|
|
29
|
+
previewText: "You have been invited to join an organization",
|
|
30
|
+
react: OrganizationInviteEmail as FunctionComponent<Record<string, unknown>>,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "@m5kdev/config/tsconfig.node.json",
|
|
3
|
+
"rootDir": ".",
|
|
4
|
+
"compilerOptions": {
|
|
5
|
+
"jsx": "react-jsx",
|
|
6
|
+
"noEmit": true,
|
|
7
|
+
"tsBuildInfoFile": "dist/tsconfig.tsbuildinfo"
|
|
8
|
+
},
|
|
9
|
+
"exclude": ["node_modules"],
|
|
10
|
+
"include": ["src/**/*.ts", "src/**/*.tsx"]
|
|
11
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Server Module Structure Guide
|
|
2
|
+
|
|
3
|
+
Use this for modules in `apps/server/src/modules/**`.
|
|
4
|
+
|
|
5
|
+
## Preferred Module Layout
|
|
6
|
+
|
|
7
|
+
Shared contracts:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
apps/shared/src/modules/<module>/
|
|
11
|
+
├── <module>.constants.ts
|
|
12
|
+
└── <module>.schema.ts
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Server module:
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
apps/server/src/modules/<module>/
|
|
19
|
+
├── <module>.db.ts
|
|
20
|
+
├── <module>.repository.ts
|
|
21
|
+
├── <module>.service.ts
|
|
22
|
+
└── <module>.trpc.ts
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Layer Boundaries
|
|
26
|
+
|
|
27
|
+
- Repositories own persistence and query construction.
|
|
28
|
+
- Services own business rules, orchestration, and context-aware defaults.
|
|
29
|
+
- tRPC files own transport only and must delegate to services.
|
|
30
|
+
- Keep composition explicit in `db.ts`, `repository.ts`, `service.ts`, and `trpc.ts`.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { eq } from "drizzle-orm";
|
|
2
|
+
import { orm, schema } from "../src/db";
|
|
3
|
+
import { auth } from "../src/lib/auth";
|
|
4
|
+
import { syncDatabase } from "./sync";
|
|
5
|
+
|
|
6
|
+
const DEMO_EMAIL = "admin@{{APP_SLUG}}.local";
|
|
7
|
+
const DEMO_PASSWORD = "password1234";
|
|
8
|
+
|
|
9
|
+
async function ensureDemoUser() {
|
|
10
|
+
const [existingUser] = await orm
|
|
11
|
+
.select()
|
|
12
|
+
.from(schema.users)
|
|
13
|
+
.where(eq(schema.users.email, DEMO_EMAIL))
|
|
14
|
+
.limit(1);
|
|
15
|
+
|
|
16
|
+
if (existingUser) {
|
|
17
|
+
return existingUser;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
await auth.api.createUser({
|
|
21
|
+
body: {
|
|
22
|
+
name: "Demo Editor",
|
|
23
|
+
email: DEMO_EMAIL,
|
|
24
|
+
password: DEMO_PASSWORD,
|
|
25
|
+
role: "admin",
|
|
26
|
+
},
|
|
27
|
+
} as never);
|
|
28
|
+
|
|
29
|
+
const [createdUser] = await orm
|
|
30
|
+
.select()
|
|
31
|
+
.from(schema.users)
|
|
32
|
+
.where(eq(schema.users.email, DEMO_EMAIL))
|
|
33
|
+
.limit(1);
|
|
34
|
+
|
|
35
|
+
if (!createdUser) {
|
|
36
|
+
throw new Error("Failed to create demo user.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return createdUser;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function seedPosts(userId: string, organizationId: string | null, teamId: string | null) {
|
|
43
|
+
const existingPosts = await orm.select().from(schema.posts).limit(1);
|
|
44
|
+
if (existingPosts.length > 0) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await orm.insert(schema.posts).values([
|
|
49
|
+
{
|
|
50
|
+
authorUserId: userId,
|
|
51
|
+
organizationId,
|
|
52
|
+
teamId,
|
|
53
|
+
title: "An editorial workflow you can ship in an afternoon",
|
|
54
|
+
slug: "editorial-workflow-in-an-afternoon",
|
|
55
|
+
excerpt: "A first draft on how this minimal starter is wired together.",
|
|
56
|
+
content:
|
|
57
|
+
"This starter keeps the moving parts visible. Shared contracts live in the shared app, business rules stay in services, and the web layer only does composition and UI work.",
|
|
58
|
+
status: "published",
|
|
59
|
+
publishedAt: new Date(),
|
|
60
|
+
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 5),
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
authorUserId: userId,
|
|
64
|
+
organizationId,
|
|
65
|
+
teamId,
|
|
66
|
+
title: "Three habits that keep CRUD apps from drifting into chaos",
|
|
67
|
+
slug: "three-habits-that-keep-crud-apps-focused",
|
|
68
|
+
excerpt: "A draft about explicit composition, typed contracts, and URL state.",
|
|
69
|
+
content:
|
|
70
|
+
"Keep your composition explicit, keep your schemas honest, and keep the URL involved in user intent. Those three choices do most of the architectural work in a small product.",
|
|
71
|
+
status: "draft",
|
|
72
|
+
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 24 * 2),
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
authorUserId: userId,
|
|
76
|
+
organizationId,
|
|
77
|
+
teamId,
|
|
78
|
+
title: "What a minimal platform starter should still refuse to compromise on",
|
|
79
|
+
slug: "minimal-platform-starter-non-negotiables",
|
|
80
|
+
excerpt: "A published note on keeping the foundation opinionated without being heavy.",
|
|
81
|
+
content:
|
|
82
|
+
"Even a minimal starter should include auth, structured modules, and a coherent app shell. Cutting those corners only moves the complexity into the first week of work.",
|
|
83
|
+
status: "published",
|
|
84
|
+
publishedAt: new Date(Date.now() - 1000 * 60 * 60 * 16),
|
|
85
|
+
createdAt: new Date(Date.now() - 1000 * 60 * 60 * 18),
|
|
86
|
+
},
|
|
87
|
+
]);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
async function seed() {
|
|
91
|
+
await syncDatabase();
|
|
92
|
+
const user = await ensureDemoUser();
|
|
93
|
+
|
|
94
|
+
const [member] = await orm
|
|
95
|
+
.select()
|
|
96
|
+
.from(schema.members)
|
|
97
|
+
.where(eq(schema.members.userId, user.id))
|
|
98
|
+
.limit(1);
|
|
99
|
+
|
|
100
|
+
const [teamMember] = await orm
|
|
101
|
+
.select()
|
|
102
|
+
.from(schema.teamMembers)
|
|
103
|
+
.where(eq(schema.teamMembers.userId, user.id))
|
|
104
|
+
.limit(1);
|
|
105
|
+
|
|
106
|
+
await seedPosts(user.id, member?.organizationId ?? null, teamMember?.teamId ?? null);
|
|
107
|
+
|
|
108
|
+
console.info(`Seed completed. Demo login: ${DEMO_EMAIL} / ${DEMO_PASSWORD}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
seed();
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
export async function syncDatabase(): Promise<void> {
|
|
4
|
+
const command = process.platform === "win32" ? "drizzle-kit.cmd" : "drizzle-kit";
|
|
5
|
+
const result = spawnSync(command, ["push", "--config", "drizzle.config.ts", "--force"], {
|
|
6
|
+
cwd: process.cwd(),
|
|
7
|
+
env: process.env,
|
|
8
|
+
stdio: "inherit",
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
if (result.status !== 0) {
|
|
12
|
+
throw new Error(`drizzle-kit push failed with exit code ${result.status ?? "unknown"}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
syncDatabase().then(() => {
|
|
17
|
+
console.info("Sync completed");
|
|
18
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as dotenv from "dotenv";
|
|
2
|
+
import { defineConfig } from "drizzle-kit";
|
|
3
|
+
|
|
4
|
+
dotenv.config({ path: "../shared/.env" });
|
|
5
|
+
|
|
6
|
+
const url = process.env.TURSO_DATABASE_URL || process.env.DATABASE_URL;
|
|
7
|
+
const authToken = process.env.TURSO_AUTH_TOKEN;
|
|
8
|
+
const schema = [
|
|
9
|
+
"./src/modules/**/*.db.ts",
|
|
10
|
+
"./node_modules/@m5kdev/backend/dist/src/modules/auth/*.db.js",
|
|
11
|
+
];
|
|
12
|
+
|
|
13
|
+
if (!url) {
|
|
14
|
+
throw new Error("DATABASE_URL or TURSO_DATABASE_URL must be set");
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const isRemote = Boolean(process.env.TURSO_DATABASE_URL && authToken);
|
|
18
|
+
|
|
19
|
+
export default defineConfig(
|
|
20
|
+
isRemote
|
|
21
|
+
? {
|
|
22
|
+
dialect: "turso",
|
|
23
|
+
schema,
|
|
24
|
+
out: "./drizzle",
|
|
25
|
+
dbCredentials: {
|
|
26
|
+
url,
|
|
27
|
+
authToken,
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
: {
|
|
31
|
+
dialect: "sqlite",
|
|
32
|
+
schema,
|
|
33
|
+
out: "./drizzle",
|
|
34
|
+
dbCredentials: {
|
|
35
|
+
url,
|
|
36
|
+
},
|
|
37
|
+
}
|
|
38
|
+
);
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "{{PACKAGE_SCOPE}}/server",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"dev": "tsx watch --env-file=../shared/.env src/index.ts",
|
|
8
|
+
"build": "tsup",
|
|
9
|
+
"start": "node dist/index.js",
|
|
10
|
+
"lint": "biome check .",
|
|
11
|
+
"lint:fix": "biome check . --write",
|
|
12
|
+
"check-types": "tsc --noEmit",
|
|
13
|
+
"sync": "tsx --env-file=../shared/.env drizzle/sync.ts",
|
|
14
|
+
"seed": "tsx --env-file=../shared/.env drizzle/seed.ts"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"{{PACKAGE_SCOPE}}/email": "workspace:*",
|
|
18
|
+
"{{PACKAGE_SCOPE}}/shared": "workspace:*",
|
|
19
|
+
"@libsql/client": "catalog:",
|
|
20
|
+
"@m5kdev/backend": "catalog:",
|
|
21
|
+
"@m5kdev/commons": "catalog:",
|
|
22
|
+
"@trpc/server": "catalog:",
|
|
23
|
+
"better-auth": "catalog:",
|
|
24
|
+
"cors": "catalog:",
|
|
25
|
+
"dotenv": "catalog:",
|
|
26
|
+
"drizzle-orm": "catalog:",
|
|
27
|
+
"express": "catalog:",
|
|
28
|
+
"neverthrow": "catalog:",
|
|
29
|
+
"react": "catalog:",
|
|
30
|
+
"react-dom": "catalog:",
|
|
31
|
+
"uuid": "catalog:",
|
|
32
|
+
"zod": "catalog:"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@m5kdev/config": "catalog:",
|
|
36
|
+
"@types/cors": "catalog:",
|
|
37
|
+
"@types/express": "catalog:",
|
|
38
|
+
"@types/node": "catalog:",
|
|
39
|
+
"@types/react": "catalog:",
|
|
40
|
+
"@types/react-dom": "catalog:",
|
|
41
|
+
"drizzle-kit": "catalog:",
|
|
42
|
+
"tslib": "catalog:",
|
|
43
|
+
"tsup": "catalog:",
|
|
44
|
+
"tsx": "catalog:",
|
|
45
|
+
"typescript": "catalog:"
|
|
46
|
+
},
|
|
47
|
+
"exports": {
|
|
48
|
+
"./types": "./src/types.ts"
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import * as auth from "@m5kdev/backend/modules/auth/auth.db";
|
|
2
|
+
import { drizzle } from "drizzle-orm/libsql";
|
|
3
|
+
import * as posts from "./modules/posts/posts.db";
|
|
4
|
+
|
|
5
|
+
export const schema = {
|
|
6
|
+
...auth,
|
|
7
|
+
...posts,
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
const databaseUrl = process.env.DATABASE_URL ?? "file:./local.db";
|
|
11
|
+
const syncUrl = process.env.TURSO_DATABASE_URL;
|
|
12
|
+
const authToken = process.env.TURSO_AUTH_TOKEN;
|
|
13
|
+
|
|
14
|
+
const connection =
|
|
15
|
+
syncUrl && authToken
|
|
16
|
+
? {
|
|
17
|
+
url: databaseUrl,
|
|
18
|
+
syncUrl,
|
|
19
|
+
authToken,
|
|
20
|
+
syncInterval: 60,
|
|
21
|
+
readYourWrites: true,
|
|
22
|
+
}
|
|
23
|
+
: {
|
|
24
|
+
url: databaseUrl,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const orm = drizzle({
|
|
28
|
+
connection,
|
|
29
|
+
schema,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
export type Orm = typeof orm;
|
|
33
|
+
export type Schema = typeof schema;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { createAuthContext } from "@m5kdev/backend/utils/trpc";
|
|
2
|
+
import * as trpcExpress from "@trpc/server/adapters/express";
|
|
3
|
+
import { toNodeHandler } from "better-auth/node";
|
|
4
|
+
import cors from "cors";
|
|
5
|
+
import express from "express";
|
|
6
|
+
import { auth } from "./lib/auth";
|
|
7
|
+
import { appRouter } from "./trpc";
|
|
8
|
+
|
|
9
|
+
const app = express();
|
|
10
|
+
const port = process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 8080;
|
|
11
|
+
|
|
12
|
+
app.use(express.json());
|
|
13
|
+
app.use(
|
|
14
|
+
cors({
|
|
15
|
+
origin: [process.env.VITE_APP_URL ?? "http://localhost:5173"],
|
|
16
|
+
credentials: true,
|
|
17
|
+
methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
|
18
|
+
allowedHeaders: ["Content-Type", "Authorization"],
|
|
19
|
+
})
|
|
20
|
+
);
|
|
21
|
+
|
|
22
|
+
app.use(
|
|
23
|
+
"/trpc",
|
|
24
|
+
trpcExpress.createExpressMiddleware({
|
|
25
|
+
router: appRouter,
|
|
26
|
+
createContext: createAuthContext(auth as never),
|
|
27
|
+
})
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
app.all("/api/auth/*", toNodeHandler(auth));
|
|
31
|
+
|
|
32
|
+
app.listen(port, () => {
|
|
33
|
+
console.info(`Server running at ${process.env.VITE_SERVER_URL ?? `http://localhost:${port}`}`);
|
|
34
|
+
});
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createOrganizationAndTeam,
|
|
3
|
+
getActiveOrganizationAndTeam,
|
|
4
|
+
} from "@m5kdev/backend/modules/auth/auth.utils";
|
|
5
|
+
import { betterAuth } from "better-auth";
|
|
6
|
+
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
|
7
|
+
import { admin, lastLoginMethod, organization } from "better-auth/plugins";
|
|
8
|
+
import { orm, schema } from "../db";
|
|
9
|
+
import { emailService } from "../service";
|
|
10
|
+
|
|
11
|
+
export const auth = betterAuth({
|
|
12
|
+
secret: process.env.BETTER_AUTH_SECRET,
|
|
13
|
+
baseURL: process.env.VITE_SERVER_URL ?? "http://localhost:8080",
|
|
14
|
+
session: {
|
|
15
|
+
expiresIn: 60 * 60 * 24 * 7,
|
|
16
|
+
updateAge: 60 * 60 * 24,
|
|
17
|
+
cookieCache: {
|
|
18
|
+
enabled: true,
|
|
19
|
+
maxAge: 60 * 5,
|
|
20
|
+
},
|
|
21
|
+
additionalFields: {
|
|
22
|
+
activeOrganizationRole: {
|
|
23
|
+
type: "string",
|
|
24
|
+
required: false,
|
|
25
|
+
defaultValue: null,
|
|
26
|
+
},
|
|
27
|
+
activeTeamRole: {
|
|
28
|
+
type: "string",
|
|
29
|
+
required: false,
|
|
30
|
+
defaultValue: null,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
user: {
|
|
35
|
+
additionalFields: {
|
|
36
|
+
onboarding: {
|
|
37
|
+
type: "number",
|
|
38
|
+
required: false,
|
|
39
|
+
defaultValue: null,
|
|
40
|
+
},
|
|
41
|
+
preferences: {
|
|
42
|
+
type: "string",
|
|
43
|
+
required: false,
|
|
44
|
+
defaultValue: null,
|
|
45
|
+
},
|
|
46
|
+
flags: {
|
|
47
|
+
type: "string",
|
|
48
|
+
required: false,
|
|
49
|
+
defaultValue: null,
|
|
50
|
+
},
|
|
51
|
+
stripeCustomerId: {
|
|
52
|
+
type: "string",
|
|
53
|
+
required: false,
|
|
54
|
+
defaultValue: null,
|
|
55
|
+
input: false,
|
|
56
|
+
},
|
|
57
|
+
paymentCustomerId: {
|
|
58
|
+
type: "string",
|
|
59
|
+
required: false,
|
|
60
|
+
defaultValue: null,
|
|
61
|
+
input: false,
|
|
62
|
+
},
|
|
63
|
+
paymentPlanTier: {
|
|
64
|
+
type: "string",
|
|
65
|
+
required: false,
|
|
66
|
+
defaultValue: null,
|
|
67
|
+
input: false,
|
|
68
|
+
},
|
|
69
|
+
paymentPlanExpiresAt: {
|
|
70
|
+
type: "number",
|
|
71
|
+
required: false,
|
|
72
|
+
defaultValue: null,
|
|
73
|
+
input: false,
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
database: drizzleAdapter(orm, {
|
|
78
|
+
provider: "sqlite",
|
|
79
|
+
schema,
|
|
80
|
+
usePlural: true,
|
|
81
|
+
}),
|
|
82
|
+
emailAndPassword: {
|
|
83
|
+
enabled: true,
|
|
84
|
+
requireEmailVerification: false,
|
|
85
|
+
sendResetPassword: async ({ user, url }) => {
|
|
86
|
+
await emailService.sendResetPassword(user.email, url);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
emailVerification: {
|
|
90
|
+
sendVerificationEmail: async ({ user, url }) => {
|
|
91
|
+
await emailService.sendVerification(user.email, url);
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
plugins: [
|
|
95
|
+
admin(),
|
|
96
|
+
lastLoginMethod(),
|
|
97
|
+
organization({
|
|
98
|
+
allowUserToCreateOrganization: false,
|
|
99
|
+
teams: {
|
|
100
|
+
enabled: true,
|
|
101
|
+
allowRemovingAllTeams: false,
|
|
102
|
+
},
|
|
103
|
+
sendInvitationEmail: async (data) => {
|
|
104
|
+
const invitationUrl = `${process.env.VITE_APP_URL ?? "http://localhost:5173"}/organization/accept-invitation?id=${data.id}`;
|
|
105
|
+
await emailService.sendOrganizationInvite(
|
|
106
|
+
data.email,
|
|
107
|
+
data.organization.name,
|
|
108
|
+
data.inviter.user.name || data.inviter.user.email,
|
|
109
|
+
data.role,
|
|
110
|
+
invitationUrl
|
|
111
|
+
);
|
|
112
|
+
},
|
|
113
|
+
schema: {
|
|
114
|
+
team: {
|
|
115
|
+
modelName: "team",
|
|
116
|
+
},
|
|
117
|
+
teamMember: {
|
|
118
|
+
modelName: "teamMember",
|
|
119
|
+
additionalFields: {
|
|
120
|
+
role: {
|
|
121
|
+
type: "string",
|
|
122
|
+
required: true,
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
member: {
|
|
127
|
+
modelName: "member",
|
|
128
|
+
},
|
|
129
|
+
invitation: {
|
|
130
|
+
modelName: "invitation",
|
|
131
|
+
},
|
|
132
|
+
organization: {
|
|
133
|
+
modelName: "organization",
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}),
|
|
137
|
+
],
|
|
138
|
+
trustedOrigins: [
|
|
139
|
+
process.env.VITE_APP_URL ?? "http://localhost:5173",
|
|
140
|
+
process.env.VITE_SERVER_URL ?? "http://localhost:8080",
|
|
141
|
+
],
|
|
142
|
+
databaseHooks: {
|
|
143
|
+
user: {
|
|
144
|
+
create: {
|
|
145
|
+
after: async (user) => {
|
|
146
|
+
await createOrganizationAndTeam(orm, schema, user);
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
session: {
|
|
151
|
+
create: {
|
|
152
|
+
before: async (session) => {
|
|
153
|
+
const { organizationId, teamId, organizationRole, teamRole } =
|
|
154
|
+
await getActiveOrganizationAndTeam(orm, schema, session.userId);
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
data: {
|
|
158
|
+
...session,
|
|
159
|
+
activeOrganizationId: organizationId,
|
|
160
|
+
activeTeamId: teamId,
|
|
161
|
+
activeOrganizationRole: organizationRole,
|
|
162
|
+
activeTeamRole: teamRole,
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
export type Session = typeof auth.$Infer.Session;
|
|
172
|
+
export type User = Session["user"];
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { templates } from "{{PACKAGE_SCOPE}}/email";
|
|
4
|
+
import { EmailService } from "@m5kdev/backend/modules/email/email.service";
|
|
5
|
+
import { logger } from "@m5kdev/backend/utils/logger";
|
|
6
|
+
import { ok } from "neverthrow";
|
|
7
|
+
|
|
8
|
+
interface LocalEmailServiceProps {
|
|
9
|
+
appName: string;
|
|
10
|
+
appUrl: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class LocalEmailService extends EmailService {
|
|
14
|
+
constructor(props: LocalEmailServiceProps) {
|
|
15
|
+
super({
|
|
16
|
+
resendApiKey: "local",
|
|
17
|
+
brand: {
|
|
18
|
+
name: props.appName,
|
|
19
|
+
logo: `${props.appUrl}/mark.svg`,
|
|
20
|
+
tagline: `${props.appName} publishing workspace`,
|
|
21
|
+
},
|
|
22
|
+
noReplyFrom: "no-reply@local.m5kdev.test",
|
|
23
|
+
systemNotificationEmail: "ops@local.m5kdev.test",
|
|
24
|
+
templates: templates as never,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
override async sendTemplate(
|
|
29
|
+
to: Parameters<EmailService["sendTemplate"]>[0],
|
|
30
|
+
templateKey: Parameters<EmailService["sendTemplate"]>[1],
|
|
31
|
+
templateProps: Parameters<EmailService["sendTemplate"]>[2],
|
|
32
|
+
options?: Parameters<EmailService["sendTemplate"]>[3]
|
|
33
|
+
) {
|
|
34
|
+
const template = this.templates[String(templateKey)];
|
|
35
|
+
if (!template) {
|
|
36
|
+
return this.error("NOT_FOUND", `Email template not found: ${String(templateKey)}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const outputDirectory = path.resolve(process.cwd(), ".emails");
|
|
40
|
+
await fs.mkdir(outputDirectory, { recursive: true });
|
|
41
|
+
|
|
42
|
+
const payload = {
|
|
43
|
+
to,
|
|
44
|
+
templateId: template.id,
|
|
45
|
+
subject: options?.subject ?? template.subject ?? String(templateKey),
|
|
46
|
+
previewText: options?.previewText ?? template.previewText ?? String(templateKey),
|
|
47
|
+
props: templateProps,
|
|
48
|
+
createdAt: new Date().toISOString(),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const filename = `${Date.now()}-${template.id}.json`;
|
|
52
|
+
const outputPath = path.join(outputDirectory, filename);
|
|
53
|
+
await fs.writeFile(outputPath, JSON.stringify(payload, null, 2), "utf8");
|
|
54
|
+
|
|
55
|
+
logger.info(`Local email written to ${outputPath}`);
|
|
56
|
+
return ok();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { organizations, teams, users } from "@m5kdev/backend/modules/auth/auth.db";
|
|
2
|
+
import { integer, sqliteTable as table, text } from "drizzle-orm/sqlite-core";
|
|
3
|
+
import { v4 as uuidv4 } from "uuid";
|
|
4
|
+
|
|
5
|
+
export const posts = table("posts", {
|
|
6
|
+
id: text("id").primaryKey().$default(uuidv4),
|
|
7
|
+
authorUserId: text("author_user_id").references(() => users.id, { onDelete: "set null" }),
|
|
8
|
+
organizationId: text("organization_id").references(() => organizations.id, {
|
|
9
|
+
onDelete: "set null",
|
|
10
|
+
}),
|
|
11
|
+
teamId: text("team_id").references(() => teams.id, { onDelete: "set null" }),
|
|
12
|
+
title: text("title").notNull(),
|
|
13
|
+
slug: text("slug").notNull().unique(),
|
|
14
|
+
excerpt: text("excerpt"),
|
|
15
|
+
content: text("content").notNull(),
|
|
16
|
+
status: text("status").$type<"draft" | "published">().notNull().default("draft"),
|
|
17
|
+
publishedAt: integer("published_at", { mode: "timestamp" }),
|
|
18
|
+
createdAt: integer("created_at", { mode: "timestamp" })
|
|
19
|
+
.notNull()
|
|
20
|
+
.$default(() => new Date()),
|
|
21
|
+
updatedAt: integer("updated_at", { mode: "timestamp" }),
|
|
22
|
+
deletedAt: integer("deleted_at", { mode: "timestamp" }),
|
|
23
|
+
});
|