@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.
Files changed (151) hide show
  1. package/LICENSE +144 -0
  2. package/bin/index.mjs +2 -0
  3. package/package.json +36 -0
  4. package/src/copy.mjs +45 -0
  5. package/src/log.mjs +23 -0
  6. package/src/main.mjs +221 -0
  7. package/src/run.mjs +52 -0
  8. package/src/substitute.mjs +40 -0
  9. package/template/.env.example +36 -0
  10. package/template/.github/workflows/ci.yml +36 -0
  11. package/template/.husky/pre-commit +1 -0
  12. package/template/README.md +146 -0
  13. package/template/_editorconfig +8 -0
  14. package/template/_gitignore +7 -0
  15. package/template/_nvmrc +1 -0
  16. package/template/_package.json +107 -0
  17. package/template/_prettierignore +5 -0
  18. package/template/_prettierrc +6 -0
  19. package/template/docs/API_REFERENCE.md +123 -0
  20. package/template/docs/GETTING_STARTED.md +65 -0
  21. package/template/docs/MODULE_COMPLETION_CHECKLIST.md +40 -0
  22. package/template/docs/OAUTH.md +46 -0
  23. package/template/docs/SAMPLE_MODULE.md +23 -0
  24. package/template/docs/api.http +269 -0
  25. package/template/eslint.config.mjs +51 -0
  26. package/template/nest-cli.json +8 -0
  27. package/template/prisma/migrations/20260530000000_init/migration.sql +248 -0
  28. package/template/prisma/schema.prisma +299 -0
  29. package/template/prisma/seed.ts +44 -0
  30. package/template/scripts/db-create.mjs +126 -0
  31. package/template/scripts/gen-module.mjs +217 -0
  32. package/template/scripts/seed-test-user-org.ts +264 -0
  33. package/template/scripts/setup-local.mjs +224 -0
  34. package/template/scripts/test-db.mjs +69 -0
  35. package/template/src/app.module.ts +58 -0
  36. package/template/src/common/decorators/.gitkeep +1 -0
  37. package/template/src/common/dto/error-response.dto.ts +17 -0
  38. package/template/src/common/dto/membership-response.dto.ts +51 -0
  39. package/template/src/common/dto/mutation-response.dto.ts +11 -0
  40. package/template/src/common/dto/role-summary.dto.ts +18 -0
  41. package/template/src/common/dto/user-summary.dto.ts +23 -0
  42. package/template/src/common/enums/.gitkeep +1 -0
  43. package/template/src/common/filters/.gitkeep +1 -0
  44. package/template/src/common/filters/http-exception.filter.ts +78 -0
  45. package/template/src/common/guards/.gitkeep +1 -0
  46. package/template/src/common/interceptors/.gitkeep +1 -0
  47. package/template/src/common/pipes/.gitkeep +1 -0
  48. package/template/src/common/swagger/api-error-responses.ts +54 -0
  49. package/template/src/common/types/.gitkeep +1 -0
  50. package/template/src/config/app.config.ts +7 -0
  51. package/template/src/config/auth.config.ts +33 -0
  52. package/template/src/config/database.config.ts +6 -0
  53. package/template/src/config/env.validation.ts +131 -0
  54. package/template/src/config/index.ts +5 -0
  55. package/template/src/config/rbac.config.ts +6 -0
  56. package/template/src/database/prisma/prisma-transaction.ts +22 -0
  57. package/template/src/database/prisma/prisma.module.ts +9 -0
  58. package/template/src/database/prisma/prisma.service.ts +16 -0
  59. package/template/src/main.ts +42 -0
  60. package/template/src/modules/access-control/access-control.module.ts +24 -0
  61. package/template/src/modules/access-control/application/route-registry.validator.ts +289 -0
  62. package/template/src/modules/access-control/application/services/ability.factory.ts +28 -0
  63. package/template/src/modules/access-control/application/services/access-control.service.ts +478 -0
  64. package/template/src/modules/access-control/application/services/permission.guard.ts +77 -0
  65. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +148 -0
  66. package/template/src/modules/access-control/dto/access-control-response.dto.ts +79 -0
  67. package/template/src/modules/access-control/dto/create-role.dto.ts +18 -0
  68. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +23 -0
  69. package/template/src/modules/access-control/dto/update-role.dto.ts +19 -0
  70. package/template/src/modules/access-control/presentation/access-control.controller.ts +157 -0
  71. package/template/src/modules/access-control/presentation/permissions.decorator.ts +8 -0
  72. package/template/src/modules/access-control/presentation/public.decorator.ts +7 -0
  73. package/template/src/modules/access-control/types/permission-key.ts +37 -0
  74. package/template/src/modules/access-control/types/rbac-context.ts +11 -0
  75. package/template/src/modules/access-control/types/route-permission-registry.ts +129 -0
  76. package/template/src/modules/audit/application/services/audit.service.ts +97 -0
  77. package/template/src/modules/audit/audit.module.ts +13 -0
  78. package/template/src/modules/audit/dto/audit-response.dto.ts +75 -0
  79. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +75 -0
  80. package/template/src/modules/audit/presentation/audit.controller.ts +37 -0
  81. package/template/src/modules/auth/application/services/auth.service.ts +509 -0
  82. package/template/src/modules/auth/application/services/password.service.ts +15 -0
  83. package/template/src/modules/auth/application/services/token.service.ts +95 -0
  84. package/template/src/modules/auth/auth.module.ts +73 -0
  85. package/template/src/modules/auth/dto/auth-response.dto.ts +29 -0
  86. package/template/src/modules/auth/dto/login.dto.ts +15 -0
  87. package/template/src/modules/auth/dto/logout.dto.ts +3 -0
  88. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +15 -0
  89. package/template/src/modules/auth/dto/refresh-token.dto.ts +14 -0
  90. package/template/src/modules/auth/dto/signup.dto.ts +27 -0
  91. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +27 -0
  92. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +56 -0
  93. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +5 -0
  94. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +43 -0
  95. package/template/src/modules/auth/presentation/auth.controller.ts +148 -0
  96. package/template/src/modules/auth/presentation/current-user.decorator.ts +11 -0
  97. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +33 -0
  98. package/template/src/modules/auth/types/authenticated-user.ts +7 -0
  99. package/template/src/modules/auth/types/google-auth-profile.ts +6 -0
  100. package/template/src/modules/auth/types/jwt-payload.ts +5 -0
  101. package/template/src/modules/health/dto/health-response.dto.ts +9 -0
  102. package/template/src/modules/health/health.module.ts +7 -0
  103. package/template/src/modules/health/presentation/health.controller.ts +33 -0
  104. package/template/src/modules/invitations/application/services/invitations.service.ts +967 -0
  105. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +24 -0
  106. package/template/src/modules/invitations/dto/create-invitation.dto.ts +100 -0
  107. package/template/src/modules/invitations/dto/invitation-response.dto.ts +108 -0
  108. package/template/src/modules/invitations/dto/invitation-token.dto.ts +15 -0
  109. package/template/src/modules/invitations/invitations.module.ts +12 -0
  110. package/template/src/modules/invitations/presentation/invitations.controller.ts +149 -0
  111. package/template/src/modules/memberships/application/services/memberships.service.ts +455 -0
  112. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +11 -0
  113. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +8 -0
  114. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +8 -0
  115. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +11 -0
  116. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +9 -0
  117. package/template/src/modules/memberships/memberships.module.ts +12 -0
  118. package/template/src/modules/memberships/presentation/memberships.controller.ts +193 -0
  119. package/template/src/modules/organisations/application/services/organisations.service.ts +147 -0
  120. package/template/src/modules/organisations/dto/create-organisation.dto.ts +32 -0
  121. package/template/src/modules/organisations/dto/organisation-response.dto.ts +62 -0
  122. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +24 -0
  123. package/template/src/modules/organisations/organisations.module.ts +12 -0
  124. package/template/src/modules/organisations/presentation/organisations.controller.ts +37 -0
  125. package/template/src/modules/organisations/types/default-organisation-data.ts +18 -0
  126. package/template/src/modules/platform-admin/.gitkeep +1 -0
  127. package/template/src/modules/request-context/application/services/request-context.service.ts +79 -0
  128. package/template/src/modules/request-context/presentation/org-scope.guard.ts +26 -0
  129. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +26 -0
  130. package/template/src/modules/request-context/presentation/request-context.middleware.ts +31 -0
  131. package/template/src/modules/request-context/request-context.module.ts +25 -0
  132. package/template/src/modules/request-context/types/request-context.ts +29 -0
  133. package/template/src/modules/sample/application/services/sample.service.ts +67 -0
  134. package/template/src/modules/sample/dto/sample-echo.dto.ts +10 -0
  135. package/template/src/modules/sample/dto/sample-response.dto.ts +41 -0
  136. package/template/src/modules/sample/presentation/sample.controller.ts +63 -0
  137. package/template/src/modules/sample/sample.module.ts +11 -0
  138. package/template/src/modules/settings/application/services/settings.service.ts +139 -0
  139. package/template/src/modules/settings/dto/setting-response.dto.ts +27 -0
  140. package/template/src/modules/settings/dto/update-setting.dto.ts +16 -0
  141. package/template/src/modules/settings/presentation/settings.controller.ts +66 -0
  142. package/template/src/modules/settings/settings.module.ts +12 -0
  143. package/template/src/modules/settings/types/setting-definitions.ts +104 -0
  144. package/template/src/modules/users/.gitkeep +1 -0
  145. package/template/test/.gitkeep +1 -0
  146. package/template/test/jest-e2e.json +9 -0
  147. package/template/test/permission.guard.spec.ts +22 -0
  148. package/template/test/route-registry.validator.spec.ts +90 -0
  149. package/template/test/security.e2e-spec.ts +102 -0
  150. package/template/tsconfig.build.json +4 -0
  151. package/template/tsconfig.json +18 -0
@@ -0,0 +1,147 @@
1
+ import { ConflictException, Injectable } from "@nestjs/common";
2
+ import { Prisma } from "@prisma/client";
3
+ import { PrismaService } from "../../../../database/prisma/prisma.service";
4
+ import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
5
+ import { CreateOrganisationDto } from "../../dto/create-organisation.dto";
6
+ import { OrganisationsRepository } from "../../infrastructure/repositories/organisations.repository";
7
+ import {
8
+ defaultOrganisationRoles,
9
+ defaultOrganisationSettings,
10
+ } from "../../types/default-organisation-data";
11
+
12
+ @Injectable()
13
+ export class OrganisationsService {
14
+ constructor(
15
+ private readonly organisationsRepository: OrganisationsRepository,
16
+ private readonly prisma: PrismaService,
17
+ ) {}
18
+
19
+ async createOrganisation(
20
+ currentUser: AuthenticatedUser,
21
+ dto: CreateOrganisationDto,
22
+ ) {
23
+ const name = dto.name.trim();
24
+ const slug = dto.slug?.trim() || (await this.createAvailableSlug(name));
25
+
26
+ if (dto.slug && (await this.organisationsRepository.findBySlug(slug))) {
27
+ throw new ConflictException("Organisation slug is already in use.");
28
+ }
29
+
30
+ try {
31
+ return await this.prisma.$transaction(async (tx) => {
32
+ const organisation = await this.organisationsRepository.create(
33
+ {
34
+ name,
35
+ slug,
36
+ },
37
+ tx,
38
+ );
39
+
40
+ const roles = await Promise.all(
41
+ defaultOrganisationRoles.map((roleName) =>
42
+ tx.role.create({
43
+ data: {
44
+ orgId: organisation.id,
45
+ name: roleName,
46
+ isSystemSeeded: true,
47
+ },
48
+ }),
49
+ ),
50
+ );
51
+ const ownerRole = roles.find((role) => role.name === "Owner");
52
+
53
+ if (!ownerRole) {
54
+ throw new Error("Owner role was not seeded.");
55
+ }
56
+
57
+ const membership = await tx.membership.create({
58
+ data: {
59
+ userId: currentUser.id,
60
+ orgId: organisation.id,
61
+ roleId: ownerRole.id,
62
+ isOwner: true,
63
+ isBillingContact: true,
64
+ },
65
+ });
66
+
67
+ const settings = await Promise.all(
68
+ defaultOrganisationSettings.map((setting) =>
69
+ tx.organisationSetting.create({
70
+ data: {
71
+ orgId: organisation.id,
72
+ key: setting.key,
73
+ value: setting.value,
74
+ },
75
+ }),
76
+ ),
77
+ );
78
+
79
+ await tx.auditLog.create({
80
+ data: {
81
+ orgId: organisation.id,
82
+ actorUserId: currentUser.id,
83
+ action: "organisation.create",
84
+ targetType: "Organisation",
85
+ targetId: organisation.id,
86
+ metadata: {
87
+ name: organisation.name,
88
+ slug: organisation.slug,
89
+ defaultRoles: roles.map((role) => role.name),
90
+ defaultSettings: settings.map((setting) => setting.key),
91
+ },
92
+ },
93
+ });
94
+
95
+ return {
96
+ organisation,
97
+ membership,
98
+ roles: roles.map((role) => ({
99
+ id: role.id,
100
+ name: role.name,
101
+ isSystemSeeded: role.isSystemSeeded,
102
+ })),
103
+ settings: settings.map((setting) => ({
104
+ key: setting.key,
105
+ value: setting.value,
106
+ })),
107
+ };
108
+ });
109
+ } catch (error) {
110
+ if (isPrismaUniqueError(error)) {
111
+ throw new ConflictException("Organisation slug is already in use.");
112
+ }
113
+
114
+ throw error;
115
+ }
116
+ }
117
+
118
+ private async createAvailableSlug(name: string) {
119
+ const base = slugify(name);
120
+ let candidate = base;
121
+ let suffix = 2;
122
+
123
+ while (await this.organisationsRepository.findBySlug(candidate)) {
124
+ candidate = `${base}-${suffix}`;
125
+ suffix += 1;
126
+ }
127
+
128
+ return candidate;
129
+ }
130
+ }
131
+
132
+ function isPrismaUniqueError(error: unknown) {
133
+ return (
134
+ error instanceof Prisma.PrismaClientKnownRequestError &&
135
+ error.code === "P2002"
136
+ );
137
+ }
138
+
139
+ function slugify(value: string) {
140
+ const slug = value
141
+ .trim()
142
+ .toLowerCase()
143
+ .replace(/[^a-z0-9]+/g, "-")
144
+ .replace(/^-+|-+$/g, "");
145
+
146
+ return slug || `org-${Date.now()}`;
147
+ }
@@ -0,0 +1,32 @@
1
+ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
2
+ import {
3
+ IsOptional,
4
+ IsString,
5
+ Matches,
6
+ MaxLength,
7
+ MinLength,
8
+ } from "class-validator";
9
+
10
+ export class CreateOrganisationDto {
11
+ @ApiProperty({ example: "Acme Operations", minLength: 2, maxLength: 120 })
12
+ @IsString()
13
+ @MinLength(2)
14
+ @MaxLength(120)
15
+ name!: string;
16
+
17
+ @ApiPropertyOptional({
18
+ description:
19
+ "Optional URL-friendly slug. Generated from the name when omitted.",
20
+ example: "acme-operations",
21
+ minLength: 2,
22
+ maxLength: 80,
23
+ })
24
+ @IsOptional()
25
+ @IsString()
26
+ @MinLength(2)
27
+ @MaxLength(80)
28
+ @Matches(/^[a-z0-9][a-z0-9-]*$/, {
29
+ message: "slug must contain lowercase letters, numbers, and hyphens only",
30
+ })
31
+ slug?: string;
32
+ }
@@ -0,0 +1,62 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { OrganisationStatus } from "@prisma/client";
3
+ import { MembershipResponseDto } from "../../../common/dto/membership-response.dto";
4
+
5
+ export class OrganisationResponseDto {
6
+ @ApiProperty({
7
+ example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
8
+ format: "uuid",
9
+ })
10
+ id!: string;
11
+
12
+ @ApiProperty({ example: "Acme Operations" })
13
+ name!: string;
14
+
15
+ @ApiProperty({ example: "acme-operations" })
16
+ slug!: string;
17
+
18
+ @ApiProperty({ enum: OrganisationStatus, example: OrganisationStatus.ACTIVE })
19
+ status!: OrganisationStatus;
20
+
21
+ @ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
22
+ createdAt!: string;
23
+
24
+ @ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
25
+ updatedAt!: string;
26
+ }
27
+
28
+ class SeededOrganisationRoleDto {
29
+ @ApiProperty({
30
+ example: "f602c057-04f4-4ef8-8c84-1b7c62fbf8c5",
31
+ format: "uuid",
32
+ })
33
+ id!: string;
34
+
35
+ @ApiProperty({ example: "Owner" })
36
+ name!: string;
37
+
38
+ @ApiProperty({ example: true })
39
+ isSystemSeeded!: boolean;
40
+ }
41
+
42
+ class SeededOrganisationSettingDto {
43
+ @ApiProperty({ example: "timezone" })
44
+ key!: string;
45
+
46
+ @ApiProperty({ example: "UTC" })
47
+ value!: unknown;
48
+ }
49
+
50
+ export class CreateOrganisationResponseDto {
51
+ @ApiProperty({ type: OrganisationResponseDto })
52
+ organisation!: OrganisationResponseDto;
53
+
54
+ @ApiProperty({ type: MembershipResponseDto })
55
+ membership!: MembershipResponseDto;
56
+
57
+ @ApiProperty({ type: [SeededOrganisationRoleDto] })
58
+ roles!: SeededOrganisationRoleDto[];
59
+
60
+ @ApiProperty({ type: [SeededOrganisationSettingDto] })
61
+ settings!: SeededOrganisationSettingDto[];
62
+ }
@@ -0,0 +1,24 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import { Prisma } from "@prisma/client";
3
+ import { PrismaService } from "../../../../database/prisma/prisma.service";
4
+
5
+ type PrismaClientLike = Prisma.TransactionClient | PrismaService;
6
+
7
+ @Injectable()
8
+ export class OrganisationsRepository {
9
+ constructor(private readonly prisma: PrismaService) {}
10
+
11
+ findBySlug(slug: string, client: PrismaClientLike = this.prisma) {
12
+ return client.organisation.findUnique({
13
+ where: { slug },
14
+ select: { id: true },
15
+ });
16
+ }
17
+
18
+ create(
19
+ data: Prisma.OrganisationCreateInput,
20
+ client: PrismaClientLike = this.prisma,
21
+ ) {
22
+ return client.organisation.create({ data });
23
+ }
24
+ }
@@ -0,0 +1,12 @@
1
+ import { Module } from "@nestjs/common";
2
+ import { AuthModule } from "../auth/auth.module";
3
+ import { OrganisationsService } from "./application/services/organisations.service";
4
+ import { OrganisationsRepository } from "./infrastructure/repositories/organisations.repository";
5
+ import { OrganisationsController } from "./presentation/organisations.controller";
6
+
7
+ @Module({
8
+ imports: [AuthModule],
9
+ controllers: [OrganisationsController],
10
+ providers: [OrganisationsRepository, OrganisationsService],
11
+ })
12
+ export class OrganisationsModule {}
@@ -0,0 +1,37 @@
1
+ import { Body, Controller, Post, UseGuards } from "@nestjs/common";
2
+ import {
3
+ ApiBearerAuth,
4
+ ApiCreatedResponse,
5
+ ApiOperation,
6
+ ApiTags,
7
+ } from "@nestjs/swagger";
8
+ import { ApiErrorResponses } from "../../../common/swagger/api-error-responses";
9
+ import { JwtAuthGuard } from "../../auth/infrastructure/passport/jwt-auth.guard";
10
+ import { CurrentUser } from "../../auth/presentation/current-user.decorator";
11
+ import { AuthenticatedUser } from "../../auth/types/authenticated-user";
12
+ import { OrganisationsService } from "../application/services/organisations.service";
13
+ import { CreateOrganisationDto } from "../dto/create-organisation.dto";
14
+ import { CreateOrganisationResponseDto } from "../dto/organisation-response.dto";
15
+
16
+ @ApiTags("Organisations")
17
+ @ApiBearerAuth()
18
+ @Controller("organisations")
19
+ export class OrganisationsController {
20
+ constructor(private readonly organisationsService: OrganisationsService) {}
21
+
22
+ @Post()
23
+ @UseGuards(JwtAuthGuard)
24
+ @ApiOperation({ summary: "Create an organisation for the current user." })
25
+ @ApiCreatedResponse({
26
+ description:
27
+ "Organisation created with default roles, settings, and owner membership.",
28
+ type: CreateOrganisationResponseDto,
29
+ })
30
+ @ApiErrorResponses(400, 401, 409)
31
+ create(
32
+ @CurrentUser() user: AuthenticatedUser,
33
+ @Body() dto: CreateOrganisationDto,
34
+ ) {
35
+ return this.organisationsService.createOrganisation(user, dto);
36
+ }
37
+ }
@@ -0,0 +1,18 @@
1
+ import { Prisma } from "@prisma/client";
2
+ import { settingDefinitions } from "../../settings/types/setting-definitions";
3
+
4
+ export const defaultOrganisationRoles = [
5
+ "Owner",
6
+ "Admin",
7
+ "Manager",
8
+ "Staff",
9
+ "Viewer",
10
+ ] as const;
11
+
12
+ export const defaultOrganisationSettings: Array<{
13
+ key: string;
14
+ value: Prisma.InputJsonValue;
15
+ }> = Object.entries(settingDefinitions).map(([key, definition]) => ({
16
+ key,
17
+ value: definition.defaultValue,
18
+ }));
@@ -0,0 +1 @@
1
+ # keep
@@ -0,0 +1,79 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ import { randomUUID } from "crypto";
3
+ import { ForbiddenException, Injectable } from "@nestjs/common";
4
+ import { RequestContext } from "../../types/request-context";
5
+
6
+ @Injectable()
7
+ export class RequestContextService {
8
+ private readonly storage = new AsyncLocalStorage<RequestContext>();
9
+
10
+ run<T>(context: Partial<RequestContext>, callback: () => T): T {
11
+ return this.storage.run(
12
+ {
13
+ requestId: context.requestId ?? randomUUID(),
14
+ source: context.source ?? "worker",
15
+ startedAt: context.startedAt ?? new Date(),
16
+ ...context,
17
+ },
18
+ callback,
19
+ );
20
+ }
21
+
22
+ get() {
23
+ return this.storage.getStore();
24
+ }
25
+
26
+ getRequestId() {
27
+ return this.get()?.requestId;
28
+ }
29
+
30
+ getUserId() {
31
+ return this.get()?.userId;
32
+ }
33
+
34
+ getOrgId() {
35
+ return this.get()?.orgId;
36
+ }
37
+
38
+ getMembership() {
39
+ return this.get()?.rbac;
40
+ }
41
+
42
+ getRbacContext() {
43
+ return this.get()?.rbac;
44
+ }
45
+
46
+ getMembershipId() {
47
+ return this.get()?.rbac?.membershipId;
48
+ }
49
+
50
+ getRoleId() {
51
+ return this.get()?.rbac?.roleId;
52
+ }
53
+
54
+ getPermissions() {
55
+ return this.get()?.rbac?.permissionKeys ?? [];
56
+ }
57
+
58
+ merge(patch: Partial<RequestContext>) {
59
+ const context = this.storage.getStore();
60
+ if (!context) {
61
+ return;
62
+ }
63
+
64
+ Object.assign(context, patch);
65
+ }
66
+
67
+ assertOrgScope(orgId: string) {
68
+ const activeOrgId = this.getOrgId();
69
+ if (!activeOrgId) {
70
+ throw new ForbiddenException("Active organisation context is required.");
71
+ }
72
+
73
+ if (activeOrgId !== orgId) {
74
+ throw new ForbiddenException(
75
+ "Request organisation context does not match the requested resource.",
76
+ );
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ CanActivate,
3
+ ExecutionContext,
4
+ ForbiddenException,
5
+ Injectable,
6
+ } from "@nestjs/common";
7
+ import { RequestContextService } from "../application/services/request-context.service";
8
+ import { RequestWithContext } from "../types/request-context";
9
+
10
+ @Injectable()
11
+ export class OrgScopeGuard implements CanActivate {
12
+ constructor(private readonly requestContext: RequestContextService) {}
13
+
14
+ canActivate(context: ExecutionContext) {
15
+ const request = context.switchToHttp().getRequest<RequestWithContext>();
16
+ const orgId = request.params?.orgId;
17
+
18
+ if (!orgId) {
19
+ throw new ForbiddenException("Organisation context is required.");
20
+ }
21
+
22
+ request.orgId = orgId;
23
+ this.requestContext.merge({ orgId });
24
+ return true;
25
+ }
26
+ }
@@ -0,0 +1,26 @@
1
+ import {
2
+ CallHandler,
3
+ ExecutionContext,
4
+ Injectable,
5
+ NestInterceptor,
6
+ } from "@nestjs/common";
7
+ import { Observable } from "rxjs";
8
+ import { RequestContextService } from "../application/services/request-context.service";
9
+ import { RequestWithContext } from "../types/request-context";
10
+
11
+ @Injectable()
12
+ export class RequestContextInterceptor implements NestInterceptor {
13
+ constructor(private readonly requestContext: RequestContextService) {}
14
+
15
+ intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
16
+ const request = context.switchToHttp().getRequest<RequestWithContext>();
17
+
18
+ this.requestContext.merge({
19
+ userId: request.user?.id,
20
+ orgId: request.orgId ?? request.params?.orgId,
21
+ rbac: request.rbac,
22
+ });
23
+
24
+ return next.handle();
25
+ }
26
+ }
@@ -0,0 +1,31 @@
1
+ import { Injectable, NestMiddleware } from "@nestjs/common";
2
+ import { randomUUID } from "crypto";
3
+ import { RequestContextService } from "../application/services/request-context.service";
4
+ import { RequestWithContext } from "../types/request-context";
5
+
6
+ @Injectable()
7
+ export class RequestContextMiddleware implements NestMiddleware {
8
+ constructor(private readonly requestContext: RequestContextService) {}
9
+
10
+ use(request: RequestWithContext, _response: unknown, next: () => void) {
11
+ const requestId =
12
+ headerAsString(request.headers?.["x-request-id"]) || randomUUID();
13
+
14
+ this.requestContext.run(
15
+ {
16
+ requestId,
17
+ source: "http",
18
+ startedAt: new Date(),
19
+ method: request.method,
20
+ path: request.originalUrl ?? request.url,
21
+ ipAddress: request.ip,
22
+ userAgent: headerAsString(request.headers?.["user-agent"]),
23
+ },
24
+ next,
25
+ );
26
+ }
27
+ }
28
+
29
+ function headerAsString(value: string | string[] | undefined) {
30
+ return Array.isArray(value) ? value[0] : value;
31
+ }
@@ -0,0 +1,25 @@
1
+ import { Global, MiddlewareConsumer, Module, NestModule } from "@nestjs/common";
2
+ import { APP_INTERCEPTOR } from "@nestjs/core";
3
+ import { RequestContextService } from "./application/services/request-context.service";
4
+ import { OrgScopeGuard } from "./presentation/org-scope.guard";
5
+ import { RequestContextInterceptor } from "./presentation/request-context.interceptor";
6
+ import { RequestContextMiddleware } from "./presentation/request-context.middleware";
7
+
8
+ @Global()
9
+ @Module({
10
+ providers: [
11
+ OrgScopeGuard,
12
+ RequestContextMiddleware,
13
+ RequestContextService,
14
+ {
15
+ provide: APP_INTERCEPTOR,
16
+ useClass: RequestContextInterceptor,
17
+ },
18
+ ],
19
+ exports: [OrgScopeGuard, RequestContextService],
20
+ })
21
+ export class RequestContextModule implements NestModule {
22
+ configure(consumer: MiddlewareConsumer) {
23
+ consumer.apply(RequestContextMiddleware).forRoutes("*");
24
+ }
25
+ }
@@ -0,0 +1,29 @@
1
+ import { RbacContext } from "../../access-control/types/rbac-context";
2
+
3
+ export type RequestSource = "http" | "worker";
4
+
5
+ export type RequestContext = {
6
+ requestId: string;
7
+ source: RequestSource;
8
+ startedAt: Date;
9
+ method?: string;
10
+ path?: string;
11
+ ipAddress?: string;
12
+ userAgent?: string;
13
+ userId?: string;
14
+ orgId?: string;
15
+ rbac?: RbacContext;
16
+ jobId?: string;
17
+ };
18
+
19
+ export type RequestWithContext = {
20
+ headers?: Record<string, string | string[] | undefined>;
21
+ ip?: string;
22
+ method?: string;
23
+ orgId?: string;
24
+ originalUrl?: string;
25
+ params?: Record<string, string | undefined>;
26
+ rbac?: RbacContext;
27
+ url?: string;
28
+ user?: { id: string };
29
+ };
@@ -0,0 +1,67 @@
1
+ import { Injectable } from "@nestjs/common";
2
+ import { Prisma } from "@prisma/client";
3
+ import { PrismaService } from "../../../../database/prisma/prisma.service";
4
+ import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
5
+ import { RequestContextService } from "../../../request-context/application/services/request-context.service";
6
+ import { SampleEchoDto } from "../../dto/sample-echo.dto";
7
+
8
+ @Injectable()
9
+ export class SampleService {
10
+ constructor(
11
+ private readonly prisma: PrismaService,
12
+ private readonly requestContext: RequestContextService,
13
+ ) {}
14
+
15
+ getStatus(orgId: string) {
16
+ this.requestContext.assertOrgScope(orgId);
17
+
18
+ return {
19
+ ok: true,
20
+ orgId,
21
+ contextOrgId: this.requestContext.getOrgId(),
22
+ requestId: this.requestContext.getRequestId(),
23
+ };
24
+ }
25
+
26
+ async echo(user: AuthenticatedUser, orgId: string, dto: SampleEchoDto) {
27
+ this.requestContext.assertOrgScope(orgId);
28
+ const message = dto.message.trim();
29
+
30
+ await this.writeAudit({
31
+ orgId,
32
+ actorUserId: user.id,
33
+ action: "sample.echo",
34
+ targetId: orgId,
35
+ metadata: {
36
+ messageLength: message.length,
37
+ requestId: this.requestContext.getRequestId(),
38
+ },
39
+ });
40
+
41
+ return {
42
+ orgId,
43
+ contextOrgId: this.requestContext.getOrgId(),
44
+ message,
45
+ requestId: this.requestContext.getRequestId(),
46
+ };
47
+ }
48
+
49
+ private async writeAudit(data: {
50
+ orgId: string;
51
+ actorUserId: string;
52
+ action: string;
53
+ targetId: string;
54
+ metadata?: Prisma.InputJsonObject;
55
+ }) {
56
+ await this.prisma.auditLog.create({
57
+ data: {
58
+ orgId: data.orgId,
59
+ actorUserId: data.actorUserId,
60
+ action: data.action,
61
+ targetType: "Sample",
62
+ targetId: data.targetId,
63
+ metadata: data.metadata,
64
+ },
65
+ });
66
+ }
67
+ }
@@ -0,0 +1,10 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+ import { IsString, MaxLength, MinLength } from "class-validator";
3
+
4
+ export class SampleEchoDto {
5
+ @ApiProperty({ example: "Hello from the sample module." })
6
+ @IsString()
7
+ @MinLength(1)
8
+ @MaxLength(200)
9
+ message!: string;
10
+ }
@@ -0,0 +1,41 @@
1
+ import { ApiProperty } from "@nestjs/swagger";
2
+
3
+ export class SampleStatusResponseDto {
4
+ @ApiProperty({ example: true })
5
+ ok!: boolean;
6
+
7
+ @ApiProperty({
8
+ example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
9
+ format: "uuid",
10
+ })
11
+ orgId!: string;
12
+
13
+ @ApiProperty({
14
+ example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
15
+ format: "uuid",
16
+ })
17
+ contextOrgId!: string;
18
+
19
+ @ApiProperty({ example: "req_01HZX3J8TBMJAEK42S7XK4V7C8" })
20
+ requestId!: string;
21
+ }
22
+
23
+ export class SampleEchoResponseDto {
24
+ @ApiProperty({
25
+ example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
26
+ format: "uuid",
27
+ })
28
+ orgId!: string;
29
+
30
+ @ApiProperty({
31
+ example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
32
+ format: "uuid",
33
+ })
34
+ contextOrgId!: string;
35
+
36
+ @ApiProperty({ example: "Hello from the sample module." })
37
+ message!: string;
38
+
39
+ @ApiProperty({ example: "req_01HZX3J8TBMJAEK42S7XK4V7C8" })
40
+ requestId!: string;
41
+ }