@ftisindia/create-app 0.1.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/LICENSE +144 -0
- package/bin/index.mjs +2 -0
- package/package.json +36 -0
- package/src/copy.mjs +45 -0
- package/src/log.mjs +23 -0
- package/src/main.mjs +221 -0
- package/src/run.mjs +52 -0
- package/src/substitute.mjs +40 -0
- package/template/.env.example +36 -0
- package/template/.github/workflows/ci.yml +36 -0
- package/template/.husky/pre-commit +1 -0
- package/template/README.md +146 -0
- package/template/_editorconfig +8 -0
- package/template/_gitignore +7 -0
- package/template/_nvmrc +1 -0
- package/template/_package.json +107 -0
- package/template/_prettierignore +5 -0
- package/template/_prettierrc +6 -0
- package/template/docs/API_REFERENCE.md +123 -0
- package/template/docs/GETTING_STARTED.md +65 -0
- package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
- package/template/docs/OAUTH.md +46 -0
- package/template/docs/SAMPLE_MODULE.md +23 -0
- package/template/docs/api.http +269 -0
- package/template/eslint.config.mjs +51 -0
- package/template/nest-cli.json +8 -0
- package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
- package/template/prisma/schema.prisma +299 -0
- package/template/prisma/seed.ts +44 -0
- package/template/scripts/db-create.mjs +126 -0
- package/template/scripts/gen-module.mjs +217 -0
- package/template/scripts/seed-test-user-org.ts +264 -0
- package/template/scripts/setup-local.mjs +224 -0
- package/template/scripts/test-db.mjs +69 -0
- package/template/src/app.module.ts +58 -0
- package/template/src/common/decorators/.gitkeep +1 -0
- package/template/src/common/dto/error-response.dto.ts +17 -0
- package/template/src/common/dto/membership-response.dto.ts +51 -0
- package/template/src/common/dto/mutation-response.dto.ts +11 -0
- package/template/src/common/dto/role-summary.dto.ts +18 -0
- package/template/src/common/dto/user-summary.dto.ts +23 -0
- package/template/src/common/enums/.gitkeep +1 -0
- package/template/src/common/filters/.gitkeep +1 -0
- package/template/src/common/filters/http-exception.filter.ts +78 -0
- package/template/src/common/guards/.gitkeep +1 -0
- package/template/src/common/interceptors/.gitkeep +1 -0
- package/template/src/common/pipes/.gitkeep +1 -0
- package/template/src/common/swagger/api-error-responses.ts +54 -0
- package/template/src/common/types/.gitkeep +1 -0
- package/template/src/config/app.config.ts +7 -0
- package/template/src/config/auth.config.ts +33 -0
- package/template/src/config/database.config.ts +6 -0
- package/template/src/config/env.validation.ts +131 -0
- package/template/src/config/index.ts +5 -0
- package/template/src/config/rbac.config.ts +6 -0
- package/template/src/database/prisma/prisma-transaction.ts +22 -0
- package/template/src/database/prisma/prisma.module.ts +9 -0
- package/template/src/database/prisma/prisma.service.ts +16 -0
- package/template/src/main.ts +42 -0
- package/template/src/modules/access-control/access-control.module.ts +24 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
- package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
- package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
- package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
- package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
- package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
- package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
- package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
- package/template/src/modules/access-control/types/permission-key.ts +37 -0
- package/template/src/modules/access-control/types/rbac-context.ts +11 -0
- package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
- package/template/src/modules/audit/application/services/audit.service.ts +97 -0
- package/template/src/modules/audit/audit.module.ts +13 -0
- package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
- package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
- package/template/src/modules/auth/application/services/auth.service.ts +509 -0
- package/template/src/modules/auth/application/services/password.service.ts +15 -0
- package/template/src/modules/auth/application/services/token.service.ts +95 -0
- package/template/src/modules/auth/auth.module.ts +73 -0
- package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
- package/template/src/modules/auth/dto/login.dto.ts +15 -0
- package/template/src/modules/auth/dto/logout.dto.ts +3 -0
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
- package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
- package/template/src/modules/auth/dto/signup.dto.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
- package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
- package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
- package/template/src/modules/auth/types/authenticated-user.ts +7 -0
- package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
- package/template/src/modules/auth/types/jwt-payload.ts +5 -0
- package/template/src/modules/health/dto/health-response.dto.ts +9 -0
- package/template/src/modules/health/health.module.ts +7 -0
- package/template/src/modules/health/presentation/health.controller.ts +33 -0
- package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
- package/template/src/modules/invitations/invitations.module.ts +12 -0
- package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
- package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
- package/template/src/modules/memberships/memberships.module.ts +12 -0
- package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
- package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
- package/template/src/modules/organisations/organisations.module.ts +12 -0
- package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
- package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
- package/template/src/modules/platform-admin/.gitkeep +1 -0
- package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
- package/template/src/modules/request-context/request-context.module.ts +25 -0
- package/template/src/modules/request-context/types/request-context.ts +29 -0
- package/template/src/modules/sample/application/services/sample.service.ts +67 -0
- package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
- package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
- package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
- package/template/src/modules/sample/sample.module.ts +11 -0
- package/template/src/modules/settings/application/services/settings.service.ts +139 -0
- package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
- package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
- package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
- package/template/src/modules/settings/settings.module.ts +12 -0
- package/template/src/modules/settings/types/setting-definitions.ts +104 -0
- package/template/src/modules/users/.gitkeep +1 -0
- package/template/test/.gitkeep +1 -0
- package/template/test/jest-e2e.json +9 -0
- package/template/test/permission.guard.spec.ts +22 -0
- package/template/test/route-registry.validator.spec.ts +90 -0
- package/template/test/security.e2e-spec.ts +102 -0
- package/template/tsconfig.build.json +4 -0
- package/template/tsconfig.json +18 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { resolve } from 'path';
|
|
3
|
+
import bcrypt from 'bcryptjs';
|
|
4
|
+
import { randomBytes } from 'crypto';
|
|
5
|
+
import { AuthProvider, MembershipStatus, Prisma, PrismaClient } from '@prisma/client';
|
|
6
|
+
import {
|
|
7
|
+
defaultOrganisationRoles,
|
|
8
|
+
defaultOrganisationSettings,
|
|
9
|
+
} from '../src/modules/organisations/types/default-organisation-data';
|
|
10
|
+
|
|
11
|
+
loadEnvFile();
|
|
12
|
+
|
|
13
|
+
const databaseUrl = process.env.DATABASE_URL;
|
|
14
|
+
if (!databaseUrl) {
|
|
15
|
+
throw new Error('DATABASE_URL is required. Set it in .env before running this script.');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const prisma = new PrismaClient({
|
|
19
|
+
datasources: {
|
|
20
|
+
db: {
|
|
21
|
+
url: databaseUrl,
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const email = normalizeEmail(process.env.TEST_USER_EMAIL ?? 'owner@example.com');
|
|
27
|
+
const configuredPassword = process.env.TEST_USER_PASSWORD?.trim();
|
|
28
|
+
const password = configuredPassword || randomBytes(18).toString('base64url');
|
|
29
|
+
const displayName = process.env.TEST_USER_DISPLAY_NAME ?? 'Starter Owner';
|
|
30
|
+
const orgName = process.env.TEST_ORG_NAME ?? 'Demo Organisation';
|
|
31
|
+
const orgSlug = normalizeSlug(process.env.TEST_ORG_SLUG ?? 'demo');
|
|
32
|
+
|
|
33
|
+
async function main() {
|
|
34
|
+
assertSafeEnvironment();
|
|
35
|
+
assertPassword(password);
|
|
36
|
+
|
|
37
|
+
const passwordHash = await bcrypt.hash(password, 12);
|
|
38
|
+
|
|
39
|
+
const result = await prisma.$transaction(async (tx) => {
|
|
40
|
+
const user = await tx.user.upsert({
|
|
41
|
+
where: { email },
|
|
42
|
+
update: {
|
|
43
|
+
displayName,
|
|
44
|
+
passwordHash,
|
|
45
|
+
isActive: true,
|
|
46
|
+
},
|
|
47
|
+
create: {
|
|
48
|
+
email,
|
|
49
|
+
displayName,
|
|
50
|
+
passwordHash,
|
|
51
|
+
isActive: true,
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
await tx.authIdentity.upsert({
|
|
56
|
+
where: {
|
|
57
|
+
provider_providerUserId: {
|
|
58
|
+
provider: AuthProvider.EMAIL_PASSWORD,
|
|
59
|
+
providerUserId: email,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
update: {
|
|
63
|
+
userId: user.id,
|
|
64
|
+
email,
|
|
65
|
+
},
|
|
66
|
+
create: {
|
|
67
|
+
userId: user.id,
|
|
68
|
+
provider: AuthProvider.EMAIL_PASSWORD,
|
|
69
|
+
providerUserId: email,
|
|
70
|
+
email,
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const organisation = await tx.organisation.upsert({
|
|
75
|
+
where: { slug: orgSlug },
|
|
76
|
+
update: {
|
|
77
|
+
name: orgName,
|
|
78
|
+
},
|
|
79
|
+
create: {
|
|
80
|
+
name: orgName,
|
|
81
|
+
slug: orgSlug,
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const roles = await Promise.all(
|
|
86
|
+
defaultOrganisationRoles.map((roleName) =>
|
|
87
|
+
tx.role.upsert({
|
|
88
|
+
where: {
|
|
89
|
+
orgId_name: {
|
|
90
|
+
orgId: organisation.id,
|
|
91
|
+
name: roleName,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
update: {
|
|
95
|
+
isSystemSeeded: true,
|
|
96
|
+
},
|
|
97
|
+
create: {
|
|
98
|
+
orgId: organisation.id,
|
|
99
|
+
name: roleName,
|
|
100
|
+
isSystemSeeded: true,
|
|
101
|
+
},
|
|
102
|
+
}),
|
|
103
|
+
),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
const ownerRole = roles.find((role) => role.name === 'Owner');
|
|
107
|
+
if (!ownerRole) {
|
|
108
|
+
throw new Error('Owner role was not seeded.');
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
await Promise.all(
|
|
112
|
+
defaultOrganisationSettings.map((setting) =>
|
|
113
|
+
tx.organisationSetting.upsert({
|
|
114
|
+
where: {
|
|
115
|
+
orgId_key: {
|
|
116
|
+
orgId: organisation.id,
|
|
117
|
+
key: setting.key,
|
|
118
|
+
},
|
|
119
|
+
},
|
|
120
|
+
update: {},
|
|
121
|
+
create: {
|
|
122
|
+
orgId: organisation.id,
|
|
123
|
+
key: setting.key,
|
|
124
|
+
value: setting.value,
|
|
125
|
+
},
|
|
126
|
+
}),
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
const membership = await tx.membership.upsert({
|
|
131
|
+
where: {
|
|
132
|
+
userId_orgId: {
|
|
133
|
+
userId: user.id,
|
|
134
|
+
orgId: organisation.id,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
update: {
|
|
138
|
+
roleId: ownerRole.id,
|
|
139
|
+
status: MembershipStatus.ACTIVE,
|
|
140
|
+
isOwner: true,
|
|
141
|
+
isBillingContact: true,
|
|
142
|
+
},
|
|
143
|
+
create: {
|
|
144
|
+
userId: user.id,
|
|
145
|
+
orgId: organisation.id,
|
|
146
|
+
roleId: ownerRole.id,
|
|
147
|
+
status: MembershipStatus.ACTIVE,
|
|
148
|
+
isOwner: true,
|
|
149
|
+
isBillingContact: true,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
await tx.auditLog.create({
|
|
154
|
+
data: {
|
|
155
|
+
orgId: organisation.id,
|
|
156
|
+
actorUserId: user.id,
|
|
157
|
+
action: 'bootstrap.test_user_org',
|
|
158
|
+
targetType: 'Organisation',
|
|
159
|
+
targetId: organisation.id,
|
|
160
|
+
metadata: {
|
|
161
|
+
email,
|
|
162
|
+
orgSlug,
|
|
163
|
+
membershipId: membership.id,
|
|
164
|
+
} satisfies Prisma.InputJsonObject,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return { membership, organisation, user };
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
console.log('Test user and organisation are ready.');
|
|
172
|
+
console.log(`Email: ${email}`);
|
|
173
|
+
if (configuredPassword) {
|
|
174
|
+
console.log('Password: value from TEST_USER_PASSWORD');
|
|
175
|
+
} else {
|
|
176
|
+
console.log(`Generated password: ${password}`);
|
|
177
|
+
}
|
|
178
|
+
console.log(`Organisation: ${result.organisation.name} (${result.organisation.slug})`);
|
|
179
|
+
console.log(`Organisation ID: ${result.organisation.id}`);
|
|
180
|
+
console.log(`Membership ID: ${result.membership.id}`);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function assertSafeEnvironment() {
|
|
184
|
+
const nodeEnv = process.env.NODE_ENV ?? 'development';
|
|
185
|
+
const isSafeDefaultEnvironment = nodeEnv === 'development' || nodeEnv === 'test';
|
|
186
|
+
if (!isSafeDefaultEnvironment && process.env.ALLOW_TEST_BOOTSTRAP !== 'true') {
|
|
187
|
+
throw new Error(
|
|
188
|
+
'Refusing to seed test user/org outside development/test. Set ALLOW_TEST_BOOTSTRAP=true to override.',
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function assertPassword(value: string) {
|
|
194
|
+
if (value.length < 8 || value.length > 128) {
|
|
195
|
+
throw new Error('TEST_USER_PASSWORD must be between 8 and 128 characters.');
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function loadEnvFile() {
|
|
200
|
+
const envPath = resolve(process.cwd(), '.env');
|
|
201
|
+
if (!existsSync(envPath)) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const lines = readFileSync(envPath, 'utf8').split(/\r?\n/);
|
|
206
|
+
for (const line of lines) {
|
|
207
|
+
const trimmed = line.trim();
|
|
208
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
213
|
+
if (equalsIndex === -1) {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const key = trimmed.slice(0, equalsIndex).trim();
|
|
218
|
+
const rawValue = trimmed.slice(equalsIndex + 1).trim();
|
|
219
|
+
if (!key || process.env[key] !== undefined) {
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
process.env[key] = stripQuotes(rawValue);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function stripQuotes(value: string) {
|
|
228
|
+
if (
|
|
229
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
230
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
231
|
+
) {
|
|
232
|
+
return value.slice(1, -1);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function normalizeEmail(value: string) {
|
|
239
|
+
return value.trim().toLowerCase();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function normalizeSlug(value: string) {
|
|
243
|
+
const slug = value
|
|
244
|
+
.trim()
|
|
245
|
+
.toLowerCase()
|
|
246
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
247
|
+
.replace(/^-+|-+$/g, '');
|
|
248
|
+
|
|
249
|
+
if (!slug) {
|
|
250
|
+
throw new Error('TEST_ORG_SLUG must contain at least one letter or number.');
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return slug;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
main()
|
|
257
|
+
.then(async () => {
|
|
258
|
+
await prisma.$disconnect();
|
|
259
|
+
})
|
|
260
|
+
.catch(async (error) => {
|
|
261
|
+
console.error(error);
|
|
262
|
+
await prisma.$disconnect();
|
|
263
|
+
process.exit(1);
|
|
264
|
+
});
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { randomBytes } from 'crypto';
|
|
3
|
+
import { copyFileSync, existsSync, readFileSync, writeFileSync } from 'fs';
|
|
4
|
+
import { createInterface } from 'readline/promises';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/app';
|
|
7
|
+
const ENV_FILE = '.env';
|
|
8
|
+
const ENV_EXAMPLE_FILE = '.env.example';
|
|
9
|
+
|
|
10
|
+
const yes = process.argv.includes('--yes') || process.argv.includes('-y') || !process.stdin.isTTY;
|
|
11
|
+
const envUpdates = ensureLocalEnv();
|
|
12
|
+
|
|
13
|
+
printHeader(envUpdates);
|
|
14
|
+
|
|
15
|
+
if (!yes) {
|
|
16
|
+
const confirmed = await confirm('Create local databases, run migrations, and seed demo data?');
|
|
17
|
+
if (!confirmed) {
|
|
18
|
+
console.log('Setup cancelled. No database changes were made.');
|
|
19
|
+
process.exit(0);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const steps = [
|
|
24
|
+
{ label: 'Create local databases if missing', command: 'npm run db:create' },
|
|
25
|
+
{ label: 'Generate Prisma client', command: 'npm run prisma:generate' },
|
|
26
|
+
{
|
|
27
|
+
label: 'Apply Prisma migrations',
|
|
28
|
+
command: 'npm run prisma:deploy',
|
|
29
|
+
retryOnP1001: true,
|
|
30
|
+
},
|
|
31
|
+
{ label: 'Seed permission catalogue', command: 'npm run prisma:seed' },
|
|
32
|
+
{
|
|
33
|
+
label: 'Seed demo owner and organisation',
|
|
34
|
+
command: 'npm run seed:test-user-org',
|
|
35
|
+
},
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
for (const step of steps) {
|
|
39
|
+
await runStep(step);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
printDone();
|
|
43
|
+
|
|
44
|
+
async function runStep(step) {
|
|
45
|
+
const maxAttempts = step.retryOnP1001 ? 2 : 1;
|
|
46
|
+
|
|
47
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
|
48
|
+
console.log(`\n[ ] ${step.label}`);
|
|
49
|
+
const result = await run(step.command);
|
|
50
|
+
|
|
51
|
+
if (result.code === 0) {
|
|
52
|
+
console.log(`[x] ${step.label}`);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const canRetry = step.retryOnP1001 && attempt < maxAttempts && result.output.includes('P1001');
|
|
57
|
+
if (!canRetry) {
|
|
58
|
+
console.error(`[!] ${step.label} failed.`);
|
|
59
|
+
process.exit(result.code ?? 1);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
console.warn('Prisma reported P1001. Retrying once...');
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function ensureLocalEnv() {
|
|
67
|
+
if (!existsSync(ENV_FILE)) {
|
|
68
|
+
if (!existsSync(ENV_EXAMPLE_FILE)) {
|
|
69
|
+
throw new Error('.env.example was not found.');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
copyFileSync(ENV_EXAMPLE_FILE, ENV_FILE);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const current = loadEnvFile();
|
|
76
|
+
const updates = new Map();
|
|
77
|
+
const databaseUrl = current.DATABASE_URL || DEFAULT_DATABASE_URL;
|
|
78
|
+
|
|
79
|
+
if (!current.DATABASE_URL) {
|
|
80
|
+
updates.set('DATABASE_URL', databaseUrl);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (!current.TEST_DATABASE_URL) {
|
|
84
|
+
updates.set('TEST_DATABASE_URL', deriveTestDatabaseUrl(databaseUrl));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!current.JWT_SECRET) {
|
|
88
|
+
updates.set('JWT_SECRET', randomBytes(32).toString('base64url'));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!current.TEST_USER_PASSWORD) {
|
|
92
|
+
updates.set('TEST_USER_PASSWORD', randomBytes(18).toString('base64url'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (updates.size > 0) {
|
|
96
|
+
writeEnvUpdates(updates);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return updates;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function loadEnvFile() {
|
|
103
|
+
if (!existsSync(ENV_FILE)) {
|
|
104
|
+
return {};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const env = {};
|
|
108
|
+
for (const line of readFileSync(ENV_FILE, 'utf8').split(/\r?\n/)) {
|
|
109
|
+
const trimmed = line.trim();
|
|
110
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
115
|
+
if (equalsIndex === -1) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const key = trimmed.slice(0, equalsIndex).trim();
|
|
120
|
+
const value = trimmed.slice(equalsIndex + 1).trim();
|
|
121
|
+
env[key] = stripQuotes(value);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return env;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeEnvUpdates(updates) {
|
|
128
|
+
const lines = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, 'utf8').split(/\r?\n/) : [];
|
|
129
|
+
const seen = new Set();
|
|
130
|
+
const updated = lines.map((line) => {
|
|
131
|
+
const match = line.match(/^([A-Z0-9_]+)=/);
|
|
132
|
+
if (!match || !updates.has(match[1])) {
|
|
133
|
+
return line;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
seen.add(match[1]);
|
|
137
|
+
return `${match[1]}=${updates.get(match[1])}`;
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
for (const [key, value] of updates) {
|
|
141
|
+
if (!seen.has(key)) {
|
|
142
|
+
updated.push(`${key}=${value}`);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
writeFileSync(ENV_FILE, `${updated.join('\n').replace(/\n+$/u, '')}\n`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function deriveTestDatabaseUrl(databaseUrl) {
|
|
150
|
+
const url = new URL(databaseUrl);
|
|
151
|
+
const databaseName = decodeURIComponent(url.pathname.replace(/^\//u, '')) || 'app';
|
|
152
|
+
url.pathname = `/${databaseName.endsWith('_test') ? databaseName : `${databaseName}_test`}`;
|
|
153
|
+
return url.toString();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function printHeader(updates) {
|
|
157
|
+
console.log('\nFoundation starter local setup');
|
|
158
|
+
console.log(
|
|
159
|
+
'This command is non-destructive: it creates missing databases, deploys migrations, and upserts seed data.',
|
|
160
|
+
);
|
|
161
|
+
console.log('\nChecklist:');
|
|
162
|
+
console.log(`[x] ${ENV_FILE} exists`);
|
|
163
|
+
for (const key of ['DATABASE_URL', 'TEST_DATABASE_URL', 'JWT_SECRET', 'TEST_USER_PASSWORD']) {
|
|
164
|
+
const status = updates.has(key) ? 'created' : 'already set';
|
|
165
|
+
console.log(`[x] ${key} ${status}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function printDone() {
|
|
170
|
+
const latestEnv = loadEnvFile();
|
|
171
|
+
console.log('\nLocal setup complete.');
|
|
172
|
+
console.log('\nDemo login');
|
|
173
|
+
console.log(`Email: ${latestEnv.TEST_USER_EMAIL || 'owner@example.com'}`);
|
|
174
|
+
console.log(`Password: ${latestEnv.TEST_USER_PASSWORD}`);
|
|
175
|
+
console.log(`Organisation slug: ${latestEnv.TEST_ORG_SLUG || 'demo'}`);
|
|
176
|
+
console.log('\nNext command: npm run start:dev');
|
|
177
|
+
console.log(
|
|
178
|
+
'Then open http://localhost:3000/docs and authorize with the login response access token.',
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function confirm(question) {
|
|
183
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
184
|
+
try {
|
|
185
|
+
const answer = await rl.question(`${question} [Y/n] `);
|
|
186
|
+
return answer.trim().toLowerCase() !== 'n';
|
|
187
|
+
} finally {
|
|
188
|
+
rl.close();
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function run(command) {
|
|
193
|
+
return new Promise((resolve) => {
|
|
194
|
+
const child = spawn(command, {
|
|
195
|
+
env: process.env,
|
|
196
|
+
shell: true,
|
|
197
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
let output = '';
|
|
201
|
+
child.stdout.on('data', (chunk) => {
|
|
202
|
+
const text = chunk.toString();
|
|
203
|
+
output += text;
|
|
204
|
+
process.stdout.write(text);
|
|
205
|
+
});
|
|
206
|
+
child.stderr.on('data', (chunk) => {
|
|
207
|
+
const text = chunk.toString();
|
|
208
|
+
output += text;
|
|
209
|
+
process.stderr.write(text);
|
|
210
|
+
});
|
|
211
|
+
child.once('exit', (code) => resolve({ code, output }));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function stripQuotes(value) {
|
|
216
|
+
if (
|
|
217
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
218
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
219
|
+
) {
|
|
220
|
+
return value.slice(1, -1);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return value;
|
|
224
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { existsSync, readFileSync } from 'fs';
|
|
3
|
+
|
|
4
|
+
loadEnvFile();
|
|
5
|
+
|
|
6
|
+
const testDatabaseUrl = process.env.TEST_DATABASE_URL;
|
|
7
|
+
if (!testDatabaseUrl) {
|
|
8
|
+
throw new Error('TEST_DATABASE_URL is required for npm run test:e2e.');
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
await run('npm run db:create');
|
|
12
|
+
await run('npx prisma migrate deploy', { DATABASE_URL: testDatabaseUrl });
|
|
13
|
+
await run('npx prisma db seed', { DATABASE_URL: testDatabaseUrl });
|
|
14
|
+
|
|
15
|
+
function run(command, env = {}) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const child = spawn(command, {
|
|
18
|
+
env: { ...process.env, ...env },
|
|
19
|
+
shell: true,
|
|
20
|
+
stdio: 'inherit',
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
child.once('exit', (code) => {
|
|
24
|
+
if (code === 0) {
|
|
25
|
+
resolve();
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
reject(new Error(`${command} exited with code ${code}`));
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function loadEnvFile() {
|
|
35
|
+
if (!existsSync('.env')) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
for (const line of readFileSync('.env', 'utf8').split(/\r?\n/)) {
|
|
40
|
+
const trimmed = line.trim();
|
|
41
|
+
if (!trimmed || trimmed.startsWith('#')) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const equalsIndex = trimmed.indexOf('=');
|
|
46
|
+
if (equalsIndex === -1) {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const key = trimmed.slice(0, equalsIndex).trim();
|
|
51
|
+
const rawValue = trimmed.slice(equalsIndex + 1).trim();
|
|
52
|
+
if (!key || process.env[key] !== undefined) {
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
process.env[key] = stripQuotes(rawValue);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stripQuotes(value) {
|
|
61
|
+
if (
|
|
62
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
63
|
+
(value.startsWith("'") && value.endsWith("'"))
|
|
64
|
+
) {
|
|
65
|
+
return value.slice(1, -1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
import { ConfigModule } from "@nestjs/config";
|
|
3
|
+
import { APP_GUARD } from "@nestjs/core";
|
|
4
|
+
import { ThrottlerGuard, ThrottlerModule } from "@nestjs/throttler";
|
|
5
|
+
import {
|
|
6
|
+
appConfig,
|
|
7
|
+
authConfig,
|
|
8
|
+
databaseConfig,
|
|
9
|
+
rbacConfig,
|
|
10
|
+
validate,
|
|
11
|
+
} from "./config";
|
|
12
|
+
import { PrismaModule } from "./database/prisma/prisma.module";
|
|
13
|
+
import { AccessControlModule } from "./modules/access-control/access-control.module";
|
|
14
|
+
import { AuditModule } from "./modules/audit/audit.module";
|
|
15
|
+
import { AuthModule } from "./modules/auth/auth.module";
|
|
16
|
+
import { HealthModule } from "./modules/health/health.module";
|
|
17
|
+
import { InvitationsModule } from "./modules/invitations/invitations.module";
|
|
18
|
+
import { MembershipsModule } from "./modules/memberships/memberships.module";
|
|
19
|
+
import { OrganisationsModule } from "./modules/organisations/organisations.module";
|
|
20
|
+
import { RequestContextModule } from "./modules/request-context/request-context.module";
|
|
21
|
+
import { SampleModule } from "./modules/sample/sample.module";
|
|
22
|
+
import { SettingsModule } from "./modules/settings/settings.module";
|
|
23
|
+
|
|
24
|
+
@Module({
|
|
25
|
+
imports: [
|
|
26
|
+
ConfigModule.forRoot({
|
|
27
|
+
isGlobal: true,
|
|
28
|
+
cache: true,
|
|
29
|
+
expandVariables: true,
|
|
30
|
+
load: [appConfig, authConfig, databaseConfig, rbacConfig],
|
|
31
|
+
validate,
|
|
32
|
+
}),
|
|
33
|
+
ThrottlerModule.forRoot([
|
|
34
|
+
{
|
|
35
|
+
ttl: 60_000,
|
|
36
|
+
limit: 100,
|
|
37
|
+
},
|
|
38
|
+
]),
|
|
39
|
+
PrismaModule,
|
|
40
|
+
HealthModule,
|
|
41
|
+
AuthModule,
|
|
42
|
+
RequestContextModule,
|
|
43
|
+
AccessControlModule,
|
|
44
|
+
AuditModule,
|
|
45
|
+
OrganisationsModule,
|
|
46
|
+
MembershipsModule,
|
|
47
|
+
InvitationsModule,
|
|
48
|
+
SettingsModule,
|
|
49
|
+
SampleModule,
|
|
50
|
+
],
|
|
51
|
+
providers: [
|
|
52
|
+
{
|
|
53
|
+
provide: APP_GUARD,
|
|
54
|
+
useClass: ThrottlerGuard,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
})
|
|
58
|
+
export class AppModule {}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
2
|
+
|
|
3
|
+
class ErrorBodyDto {
|
|
4
|
+
@ApiProperty({ example: "BAD_REQUEST" })
|
|
5
|
+
code!: string;
|
|
6
|
+
|
|
7
|
+
@ApiProperty({ example: "Validation failed." })
|
|
8
|
+
message!: string;
|
|
9
|
+
|
|
10
|
+
@ApiPropertyOptional({ type: Object })
|
|
11
|
+
details?: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export class ErrorResponseDto {
|
|
15
|
+
@ApiProperty({ type: ErrorBodyDto })
|
|
16
|
+
error!: ErrorBodyDto;
|
|
17
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
|
|
2
|
+
import { MembershipStatus } from "@prisma/client";
|
|
3
|
+
import { RoleSummaryDto } from "./role-summary.dto";
|
|
4
|
+
import { ActiveUserSummaryDto } from "./user-summary.dto";
|
|
5
|
+
|
|
6
|
+
export class MembershipResponseDto {
|
|
7
|
+
@ApiProperty({
|
|
8
|
+
example: "0a57fb4a-95c6-4f7e-bd5a-f96dbe0599e3",
|
|
9
|
+
format: "uuid",
|
|
10
|
+
})
|
|
11
|
+
id!: string;
|
|
12
|
+
|
|
13
|
+
@ApiProperty({
|
|
14
|
+
example: "4a4f0d8a-4bd2-469f-a6a9-3e1cb6a2b456",
|
|
15
|
+
format: "uuid",
|
|
16
|
+
})
|
|
17
|
+
userId!: string;
|
|
18
|
+
|
|
19
|
+
@ApiProperty({
|
|
20
|
+
example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
|
|
21
|
+
format: "uuid",
|
|
22
|
+
})
|
|
23
|
+
orgId!: string;
|
|
24
|
+
|
|
25
|
+
@ApiProperty({
|
|
26
|
+
example: "f602c057-04f4-4ef8-8c84-1b7c62fbf8c5",
|
|
27
|
+
format: "uuid",
|
|
28
|
+
})
|
|
29
|
+
roleId!: string;
|
|
30
|
+
|
|
31
|
+
@ApiProperty({ enum: MembershipStatus, example: MembershipStatus.ACTIVE })
|
|
32
|
+
status!: MembershipStatus;
|
|
33
|
+
|
|
34
|
+
@ApiProperty({ example: true })
|
|
35
|
+
isOwner!: boolean;
|
|
36
|
+
|
|
37
|
+
@ApiProperty({ example: true })
|
|
38
|
+
isBillingContact!: boolean;
|
|
39
|
+
|
|
40
|
+
@ApiPropertyOptional({ type: ActiveUserSummaryDto })
|
|
41
|
+
user?: ActiveUserSummaryDto;
|
|
42
|
+
|
|
43
|
+
@ApiPropertyOptional({ type: RoleSummaryDto })
|
|
44
|
+
role?: RoleSummaryDto;
|
|
45
|
+
|
|
46
|
+
@ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
|
|
47
|
+
createdAt!: string;
|
|
48
|
+
|
|
49
|
+
@ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
|
|
50
|
+
updatedAt!: string;
|
|
51
|
+
}
|