@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,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Body,
|
|
3
|
+
Controller,
|
|
4
|
+
Get,
|
|
5
|
+
HttpCode,
|
|
6
|
+
Param,
|
|
7
|
+
Post,
|
|
8
|
+
UseGuards,
|
|
9
|
+
} from "@nestjs/common";
|
|
10
|
+
import {
|
|
11
|
+
ApiBearerAuth,
|
|
12
|
+
ApiOkResponse,
|
|
13
|
+
ApiOperation,
|
|
14
|
+
ApiParam,
|
|
15
|
+
ApiTags,
|
|
16
|
+
} from "@nestjs/swagger";
|
|
17
|
+
import { ApiProtectedErrorResponses } from "../../../common/swagger/api-error-responses";
|
|
18
|
+
import { PermissionGuard } from "../../access-control/application/services/permission.guard";
|
|
19
|
+
import { RequirePermissions } from "../../access-control/presentation/permissions.decorator";
|
|
20
|
+
import { JwtAuthGuard } from "../../auth/infrastructure/passport/jwt-auth.guard";
|
|
21
|
+
import { CurrentUser } from "../../auth/presentation/current-user.decorator";
|
|
22
|
+
import { AuthenticatedUser } from "../../auth/types/authenticated-user";
|
|
23
|
+
import { OrgScopeGuard } from "../../request-context/presentation/org-scope.guard";
|
|
24
|
+
import { SampleEchoDto } from "../dto/sample-echo.dto";
|
|
25
|
+
import {
|
|
26
|
+
SampleEchoResponseDto,
|
|
27
|
+
SampleStatusResponseDto,
|
|
28
|
+
} from "../dto/sample-response.dto";
|
|
29
|
+
import { SampleService } from "../application/services/sample.service";
|
|
30
|
+
|
|
31
|
+
@ApiTags("Sample")
|
|
32
|
+
@ApiBearerAuth()
|
|
33
|
+
@ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
|
|
34
|
+
@ApiProtectedErrorResponses()
|
|
35
|
+
@Controller("organisations/:orgId/sample")
|
|
36
|
+
@UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
|
|
37
|
+
export class SampleController {
|
|
38
|
+
constructor(private readonly sampleService: SampleService) {}
|
|
39
|
+
|
|
40
|
+
@Get("status")
|
|
41
|
+
@RequirePermissions("organisations.read")
|
|
42
|
+
@ApiOperation({ summary: "Return sample module status and request context." })
|
|
43
|
+
@ApiOkResponse({
|
|
44
|
+
description: "Sample status.",
|
|
45
|
+
type: SampleStatusResponseDto,
|
|
46
|
+
})
|
|
47
|
+
status(@Param("orgId") orgId: string) {
|
|
48
|
+
return this.sampleService.getStatus(orgId);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
@Post("echo")
|
|
52
|
+
@HttpCode(200)
|
|
53
|
+
@RequirePermissions("organisations.update")
|
|
54
|
+
@ApiOperation({ summary: "Echo a message and write a sample audit record." })
|
|
55
|
+
@ApiOkResponse({ description: "Echo result.", type: SampleEchoResponseDto })
|
|
56
|
+
echo(
|
|
57
|
+
@CurrentUser() user: AuthenticatedUser,
|
|
58
|
+
@Param("orgId") orgId: string,
|
|
59
|
+
@Body() dto: SampleEchoDto,
|
|
60
|
+
) {
|
|
61
|
+
return this.sampleService.echo(user, orgId, dto);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
import { AuthModule } from "../auth/auth.module";
|
|
3
|
+
import { SampleService } from "./application/services/sample.service";
|
|
4
|
+
import { SampleController } from "./presentation/sample.controller";
|
|
5
|
+
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [AuthModule],
|
|
8
|
+
controllers: [SampleController],
|
|
9
|
+
providers: [SampleService],
|
|
10
|
+
})
|
|
11
|
+
export class SampleModule {}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BadRequestException,
|
|
3
|
+
Injectable,
|
|
4
|
+
NotFoundException,
|
|
5
|
+
} from "@nestjs/common";
|
|
6
|
+
import { Prisma } from "@prisma/client";
|
|
7
|
+
import { PrismaService } from "../../../../database/prisma/prisma.service";
|
|
8
|
+
import { AuditService } from "../../../audit/application/services/audit.service";
|
|
9
|
+
import { AuthenticatedUser } from "../../../auth/types/authenticated-user";
|
|
10
|
+
import { RequestContextService } from "../../../request-context/application/services/request-context.service";
|
|
11
|
+
import { UpdateSettingDto } from "../../dto/update-setting.dto";
|
|
12
|
+
import {
|
|
13
|
+
settingDefinitions,
|
|
14
|
+
SettingKey,
|
|
15
|
+
} from "../../types/setting-definitions";
|
|
16
|
+
|
|
17
|
+
@Injectable()
|
|
18
|
+
export class SettingsService {
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly auditService: AuditService,
|
|
21
|
+
private readonly prisma: PrismaService,
|
|
22
|
+
private readonly requestContext: RequestContextService,
|
|
23
|
+
) {}
|
|
24
|
+
|
|
25
|
+
async listSettings(orgId: string) {
|
|
26
|
+
this.requestContext.assertOrgScope(orgId);
|
|
27
|
+
|
|
28
|
+
const settings = await this.prisma.organisationSetting.findMany({
|
|
29
|
+
where: { orgId },
|
|
30
|
+
orderBy: { key: "asc" },
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return settings.map(serializeSetting);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async getSetting(orgId: string, key: string) {
|
|
37
|
+
this.requestContext.assertOrgScope(orgId);
|
|
38
|
+
assertKnownSettingKey(key);
|
|
39
|
+
|
|
40
|
+
const setting = await this.prisma.organisationSetting.findUnique({
|
|
41
|
+
where: {
|
|
42
|
+
orgId_key: {
|
|
43
|
+
orgId,
|
|
44
|
+
key,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
if (!setting) {
|
|
50
|
+
throw new NotFoundException(
|
|
51
|
+
"Setting was not found for this organisation.",
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return serializeSetting(setting);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async updateSetting(
|
|
59
|
+
currentUser: AuthenticatedUser,
|
|
60
|
+
orgId: string,
|
|
61
|
+
dto: UpdateSettingDto,
|
|
62
|
+
) {
|
|
63
|
+
this.requestContext.assertOrgScope(orgId);
|
|
64
|
+
const definition = getSettingDefinition(dto.key);
|
|
65
|
+
const value = definition.parse(dto.value);
|
|
66
|
+
|
|
67
|
+
const updated = await this.prisma.$transaction(async (tx) => {
|
|
68
|
+
const previous = await tx.organisationSetting.findUnique({
|
|
69
|
+
where: {
|
|
70
|
+
orgId_key: {
|
|
71
|
+
orgId,
|
|
72
|
+
key: dto.key,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const setting = await tx.organisationSetting.upsert({
|
|
78
|
+
where: {
|
|
79
|
+
orgId_key: {
|
|
80
|
+
orgId,
|
|
81
|
+
key: dto.key,
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
update: { value },
|
|
85
|
+
create: {
|
|
86
|
+
orgId,
|
|
87
|
+
key: dto.key,
|
|
88
|
+
value,
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
await this.auditService.write(tx, {
|
|
93
|
+
orgId,
|
|
94
|
+
actorUserId: currentUser.id,
|
|
95
|
+
action: "organisation.setting.update",
|
|
96
|
+
targetType: "OrganisationSetting",
|
|
97
|
+
targetId: setting.id,
|
|
98
|
+
metadata: {
|
|
99
|
+
key: dto.key,
|
|
100
|
+
previousValue: previous?.value ?? null,
|
|
101
|
+
nextValue: value,
|
|
102
|
+
},
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return setting;
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
return serializeSetting(updated);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function assertKnownSettingKey(key: string): asserts key is SettingKey {
|
|
113
|
+
if (!Object.hasOwn(settingDefinitions, key)) {
|
|
114
|
+
throw new BadRequestException("Unknown organisation setting key.");
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function getSettingDefinition(key: string) {
|
|
119
|
+
assertKnownSettingKey(key);
|
|
120
|
+
return settingDefinitions[key];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function serializeSetting(setting: {
|
|
124
|
+
id: string;
|
|
125
|
+
orgId: string;
|
|
126
|
+
key: string;
|
|
127
|
+
value: Prisma.JsonValue;
|
|
128
|
+
createdAt: Date;
|
|
129
|
+
updatedAt: Date;
|
|
130
|
+
}) {
|
|
131
|
+
return {
|
|
132
|
+
id: setting.id,
|
|
133
|
+
orgId: setting.orgId,
|
|
134
|
+
key: setting.key,
|
|
135
|
+
value: setting.value,
|
|
136
|
+
createdAt: setting.createdAt,
|
|
137
|
+
updatedAt: setting.updatedAt,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ApiProperty } from "@nestjs/swagger";
|
|
2
|
+
|
|
3
|
+
export class SettingResponseDto {
|
|
4
|
+
@ApiProperty({
|
|
5
|
+
example: "949b2c7c-b6b4-4db6-a2ed-663fa5f6f877",
|
|
6
|
+
format: "uuid",
|
|
7
|
+
})
|
|
8
|
+
id!: string;
|
|
9
|
+
|
|
10
|
+
@ApiProperty({
|
|
11
|
+
example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
|
|
12
|
+
format: "uuid",
|
|
13
|
+
})
|
|
14
|
+
orgId!: string;
|
|
15
|
+
|
|
16
|
+
@ApiProperty({ example: "timezone" })
|
|
17
|
+
key!: string;
|
|
18
|
+
|
|
19
|
+
@ApiProperty({ example: "UTC" })
|
|
20
|
+
value!: unknown;
|
|
21
|
+
|
|
22
|
+
@ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
|
|
23
|
+
createdAt!: string;
|
|
24
|
+
|
|
25
|
+
@ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
|
|
26
|
+
updatedAt!: string;
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { ApiProperty } from "@nestjs/swagger";
|
|
2
|
+
import { IsDefined, IsString, MaxLength } from "class-validator";
|
|
3
|
+
|
|
4
|
+
export class UpdateSettingDto {
|
|
5
|
+
@ApiProperty({ example: "timezone", maxLength: 120 })
|
|
6
|
+
@IsString()
|
|
7
|
+
@MaxLength(120)
|
|
8
|
+
key!: string;
|
|
9
|
+
|
|
10
|
+
@ApiProperty({
|
|
11
|
+
description: "JSON value accepted by the setting definition.",
|
|
12
|
+
example: "UTC",
|
|
13
|
+
})
|
|
14
|
+
@IsDefined()
|
|
15
|
+
value!: unknown;
|
|
16
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Body, Controller, Get, Param, Patch, UseGuards } from "@nestjs/common";
|
|
2
|
+
import {
|
|
3
|
+
ApiBearerAuth,
|
|
4
|
+
ApiOkResponse,
|
|
5
|
+
ApiOperation,
|
|
6
|
+
ApiParam,
|
|
7
|
+
ApiTags,
|
|
8
|
+
} from "@nestjs/swagger";
|
|
9
|
+
import { ApiProtectedErrorResponses } from "../../../common/swagger/api-error-responses";
|
|
10
|
+
import { PermissionGuard } from "../../access-control/application/services/permission.guard";
|
|
11
|
+
import { RequirePermissions } from "../../access-control/presentation/permissions.decorator";
|
|
12
|
+
import { JwtAuthGuard } from "../../auth/infrastructure/passport/jwt-auth.guard";
|
|
13
|
+
import { CurrentUser } from "../../auth/presentation/current-user.decorator";
|
|
14
|
+
import { AuthenticatedUser } from "../../auth/types/authenticated-user";
|
|
15
|
+
import { OrgScopeGuard } from "../../request-context/presentation/org-scope.guard";
|
|
16
|
+
import { UpdateSettingDto } from "../dto/update-setting.dto";
|
|
17
|
+
import { SettingsService } from "../application/services/settings.service";
|
|
18
|
+
import { SettingResponseDto } from "../dto/setting-response.dto";
|
|
19
|
+
|
|
20
|
+
@ApiTags("Settings")
|
|
21
|
+
@ApiBearerAuth()
|
|
22
|
+
@ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
|
|
23
|
+
@ApiProtectedErrorResponses(404)
|
|
24
|
+
@Controller("organisations/:orgId/settings")
|
|
25
|
+
@UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
|
|
26
|
+
export class SettingsController {
|
|
27
|
+
constructor(private readonly settingsService: SettingsService) {}
|
|
28
|
+
|
|
29
|
+
@Get()
|
|
30
|
+
@RequirePermissions("settings.read")
|
|
31
|
+
@ApiOperation({ summary: "List organisation settings." })
|
|
32
|
+
@ApiOkResponse({
|
|
33
|
+
description: "Organisation settings.",
|
|
34
|
+
type: [SettingResponseDto],
|
|
35
|
+
})
|
|
36
|
+
list(@Param("orgId") orgId: string) {
|
|
37
|
+
return this.settingsService.listSettings(orgId);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@Get(":key")
|
|
41
|
+
@RequirePermissions("settings.read")
|
|
42
|
+
@ApiParam({ name: "key", description: "Setting key.", example: "timezone" })
|
|
43
|
+
@ApiOperation({ summary: "Return one organisation setting." })
|
|
44
|
+
@ApiOkResponse({
|
|
45
|
+
description: "Organisation setting.",
|
|
46
|
+
type: SettingResponseDto,
|
|
47
|
+
})
|
|
48
|
+
get(@Param("orgId") orgId: string, @Param("key") key: string) {
|
|
49
|
+
return this.settingsService.getSetting(orgId, key);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@Patch()
|
|
53
|
+
@RequirePermissions("settings.update")
|
|
54
|
+
@ApiOperation({ summary: "Update one organisation setting." })
|
|
55
|
+
@ApiOkResponse({
|
|
56
|
+
description: "Updated organisation setting.",
|
|
57
|
+
type: SettingResponseDto,
|
|
58
|
+
})
|
|
59
|
+
update(
|
|
60
|
+
@CurrentUser() user: AuthenticatedUser,
|
|
61
|
+
@Param("orgId") orgId: string,
|
|
62
|
+
@Body() dto: UpdateSettingDto,
|
|
63
|
+
) {
|
|
64
|
+
return this.settingsService.updateSetting(user, orgId, dto);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from "@nestjs/common";
|
|
2
|
+
import { AuditModule } from "../audit/audit.module";
|
|
3
|
+
import { AuthModule } from "../auth/auth.module";
|
|
4
|
+
import { SettingsService } from "./application/services/settings.service";
|
|
5
|
+
import { SettingsController } from "./presentation/settings.controller";
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [AuditModule, AuthModule],
|
|
9
|
+
controllers: [SettingsController],
|
|
10
|
+
providers: [SettingsService],
|
|
11
|
+
})
|
|
12
|
+
export class SettingsModule {}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { BadRequestException } from "@nestjs/common";
|
|
2
|
+
import { Prisma } from "@prisma/client";
|
|
3
|
+
|
|
4
|
+
type SettingDefinition = {
|
|
5
|
+
defaultValue: Prisma.InputJsonValue;
|
|
6
|
+
parse: (value: unknown) => Prisma.InputJsonValue;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export const settingDefinitions = {
|
|
10
|
+
"billing.plan": {
|
|
11
|
+
defaultValue: "free",
|
|
12
|
+
parse: parseEnum(["free", "starter", "pro", "enterprise"]),
|
|
13
|
+
},
|
|
14
|
+
"features.inventory_enabled": {
|
|
15
|
+
defaultValue: false,
|
|
16
|
+
parse: parseBoolean,
|
|
17
|
+
},
|
|
18
|
+
"features.reports_enabled": {
|
|
19
|
+
defaultValue: false,
|
|
20
|
+
parse: parseBoolean,
|
|
21
|
+
},
|
|
22
|
+
"notifications.email_enabled": {
|
|
23
|
+
defaultValue: true,
|
|
24
|
+
parse: parseBoolean,
|
|
25
|
+
},
|
|
26
|
+
"invoice.prefix": {
|
|
27
|
+
defaultValue: "INV",
|
|
28
|
+
parse: parseShortString("invoice.prefix", 1, 20),
|
|
29
|
+
},
|
|
30
|
+
"invoice.approval_required": {
|
|
31
|
+
defaultValue: false,
|
|
32
|
+
parse: parseBoolean,
|
|
33
|
+
},
|
|
34
|
+
"branding.logo_url": {
|
|
35
|
+
defaultValue: "",
|
|
36
|
+
parse: parseOptionalUrl,
|
|
37
|
+
},
|
|
38
|
+
} satisfies Record<string, SettingDefinition>;
|
|
39
|
+
|
|
40
|
+
export type SettingKey = keyof typeof settingDefinitions;
|
|
41
|
+
|
|
42
|
+
function parseBoolean(value: unknown) {
|
|
43
|
+
if (typeof value !== "boolean") {
|
|
44
|
+
throw new BadRequestException("Setting value must be a boolean.");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function parseEnum(allowed: string[]) {
|
|
51
|
+
return (value: unknown) => {
|
|
52
|
+
if (typeof value !== "string" || !allowed.includes(value)) {
|
|
53
|
+
throw new BadRequestException(
|
|
54
|
+
`Setting value must be one of: ${allowed.join(", ")}.`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return value;
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseShortString(key: string, min: number, max: number) {
|
|
63
|
+
return (value: unknown) => {
|
|
64
|
+
if (typeof value !== "string") {
|
|
65
|
+
throw new BadRequestException(`${key} must be a string.`);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const normalized = value.trim();
|
|
69
|
+
if (normalized.length < min || normalized.length > max) {
|
|
70
|
+
throw new BadRequestException(
|
|
71
|
+
`${key} must be between ${min} and ${max} characters.`,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return normalized;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseOptionalUrl(value: unknown) {
|
|
80
|
+
if (value === null || value === "") {
|
|
81
|
+
return "";
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (typeof value !== "string") {
|
|
85
|
+
throw new BadRequestException("branding.logo_url must be a URL string.");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
try {
|
|
89
|
+
const url = new URL(value);
|
|
90
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
91
|
+
throw new BadRequestException(
|
|
92
|
+
"branding.logo_url must use http or https.",
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return url.toString();
|
|
97
|
+
} catch (error) {
|
|
98
|
+
if (error instanceof BadRequestException) {
|
|
99
|
+
throw error;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
throw new BadRequestException("branding.logo_url must be a valid URL.");
|
|
103
|
+
}
|
|
104
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# keep
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { ExecutionContext, ForbiddenException } from '@nestjs/common';
|
|
2
|
+
import { PermissionGuard } from '../src/modules/access-control/application/services/permission.guard';
|
|
3
|
+
|
|
4
|
+
describe('PermissionGuard', () => {
|
|
5
|
+
it('fails closed when a protected route has no permission metadata', async () => {
|
|
6
|
+
const guard = new PermissionGuard(
|
|
7
|
+
{} as never,
|
|
8
|
+
{
|
|
9
|
+
getAllAndOverride: () => undefined,
|
|
10
|
+
} as never,
|
|
11
|
+
{} as never,
|
|
12
|
+
{} as never,
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
const context = {
|
|
16
|
+
getHandler: () => function handler() {},
|
|
17
|
+
getClass: () => class Controller {},
|
|
18
|
+
} as unknown as ExecutionContext;
|
|
19
|
+
|
|
20
|
+
await expect(guard.canActivate(context)).rejects.toBeInstanceOf(ForbiddenException);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { MetadataScanner, Reflector } from '@nestjs/core';
|
|
3
|
+
import { AccessControlController } from '../src/modules/access-control/presentation/access-control.controller';
|
|
4
|
+
import { RequirePermissions } from '../src/modules/access-control/presentation/permissions.decorator';
|
|
5
|
+
import { RouteRegistryValidator } from '../src/modules/access-control/application/route-registry.validator';
|
|
6
|
+
import { PermissionKey } from '../src/modules/access-control/types/permission-key';
|
|
7
|
+
import { AuditController } from '../src/modules/audit/presentation/audit.controller';
|
|
8
|
+
import { AuthController } from '../src/modules/auth/presentation/auth.controller';
|
|
9
|
+
import { InvitationsController } from '../src/modules/invitations/presentation/invitations.controller';
|
|
10
|
+
import { MembershipsController } from '../src/modules/memberships/presentation/memberships.controller';
|
|
11
|
+
import { OrganisationsController } from '../src/modules/organisations/presentation/organisations.controller';
|
|
12
|
+
import { SampleController } from '../src/modules/sample/presentation/sample.controller';
|
|
13
|
+
import { SettingsController } from '../src/modules/settings/presentation/settings.controller';
|
|
14
|
+
|
|
15
|
+
const controllers = [
|
|
16
|
+
AccessControlController,
|
|
17
|
+
AuditController,
|
|
18
|
+
AuthController,
|
|
19
|
+
InvitationsController,
|
|
20
|
+
MembershipsController,
|
|
21
|
+
OrganisationsController,
|
|
22
|
+
SampleController,
|
|
23
|
+
SettingsController,
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
describe('RouteRegistryValidator', () => {
|
|
27
|
+
it('accepts the checked-in route permission registry', () => {
|
|
28
|
+
expect(() => createValidator(controllers).onApplicationBootstrap()).not.toThrow();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('fails when a permission-gated route is missing from the registry', () => {
|
|
32
|
+
class DriftController {
|
|
33
|
+
probe() {
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
39
|
+
DriftController.prototype,
|
|
40
|
+
'probe',
|
|
41
|
+
) as TypedPropertyDescriptor<() => undefined>;
|
|
42
|
+
Controller('organisations/:orgId/drift')(DriftController);
|
|
43
|
+
Get('probe')(DriftController.prototype, 'probe', descriptor);
|
|
44
|
+
RequirePermissions('users.read')(DriftController.prototype, 'probe', descriptor);
|
|
45
|
+
|
|
46
|
+
expect(() =>
|
|
47
|
+
createValidator([...controllers, DriftController]).onApplicationBootstrap(),
|
|
48
|
+
).toThrow(/GET \/organisations\/:orgId\/drift\/probe/u);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('fails when a route references an unknown permission key', () => {
|
|
52
|
+
class UnknownPermissionController {
|
|
53
|
+
probe() {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const descriptor = Object.getOwnPropertyDescriptor(
|
|
59
|
+
UnknownPermissionController.prototype,
|
|
60
|
+
'probe',
|
|
61
|
+
) as TypedPropertyDescriptor<() => undefined>;
|
|
62
|
+
Controller('organisations/:orgId/unknown-permission')(UnknownPermissionController);
|
|
63
|
+
Get('probe')(UnknownPermissionController.prototype, 'probe', descriptor);
|
|
64
|
+
RequirePermissions('unknown.read' as PermissionKey)(
|
|
65
|
+
UnknownPermissionController.prototype,
|
|
66
|
+
'probe',
|
|
67
|
+
descriptor,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
expect(() =>
|
|
71
|
+
createValidator([...controllers, UnknownPermissionController]).onApplicationBootstrap(),
|
|
72
|
+
).toThrow(/unknown\.read/u);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
type ControllerType = new (...args: never[]) => object;
|
|
77
|
+
|
|
78
|
+
function createValidator(metatypes: ControllerType[]) {
|
|
79
|
+
return new RouteRegistryValidator(
|
|
80
|
+
{
|
|
81
|
+
getControllers: () =>
|
|
82
|
+
metatypes.map((metatype) => ({
|
|
83
|
+
instance: Object.create(metatype.prototype),
|
|
84
|
+
metatype,
|
|
85
|
+
})),
|
|
86
|
+
} as never,
|
|
87
|
+
new MetadataScanner(),
|
|
88
|
+
new Reflector(),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
|
2
|
+
import { Test } from '@nestjs/testing';
|
|
3
|
+
import { MembershipStatus } from '@prisma/client';
|
|
4
|
+
import request from 'supertest';
|
|
5
|
+
import { HttpExceptionFilter } from '../src/common/filters/http-exception.filter';
|
|
6
|
+
import { PrismaService } from '../src/database/prisma/prisma.service';
|
|
7
|
+
|
|
8
|
+
describe('Security invariants (e2e)', () => {
|
|
9
|
+
let app: INestApplication;
|
|
10
|
+
let prisma: PrismaService;
|
|
11
|
+
|
|
12
|
+
beforeAll(async () => {
|
|
13
|
+
process.env.DATABASE_URL = process.env.TEST_DATABASE_URL ?? process.env.DATABASE_URL;
|
|
14
|
+
process.env.JWT_SECRET ||= 'test-jwt-secret-at-least-16-chars';
|
|
15
|
+
process.env.AUTH_GOOGLE_ENABLED ||= 'false';
|
|
16
|
+
process.env.AUTH_EMAIL_PASSWORD_ENABLED ||= 'true';
|
|
17
|
+
process.env.NODE_ENV = 'test';
|
|
18
|
+
|
|
19
|
+
const { AppModule } = await import('../src/app.module');
|
|
20
|
+
const moduleRef = await Test.createTestingModule({
|
|
21
|
+
imports: [AppModule],
|
|
22
|
+
}).compile();
|
|
23
|
+
|
|
24
|
+
app = moduleRef.createNestApplication();
|
|
25
|
+
app.useGlobalFilters(new HttpExceptionFilter());
|
|
26
|
+
app.useGlobalPipes(
|
|
27
|
+
new ValidationPipe({
|
|
28
|
+
forbidNonWhitelisted: true,
|
|
29
|
+
transform: true,
|
|
30
|
+
whitelist: true,
|
|
31
|
+
}),
|
|
32
|
+
);
|
|
33
|
+
await app.init();
|
|
34
|
+
prisma = app.get(PrismaService);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
afterAll(async () => {
|
|
38
|
+
await app.close();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('denies cross-organisation access', async () => {
|
|
42
|
+
const first = await createUserAndOrg('cross-a');
|
|
43
|
+
const second = await createUserAndOrg('cross-b');
|
|
44
|
+
|
|
45
|
+
await request(app.getHttpServer())
|
|
46
|
+
.get(`/organisations/${second.orgId}/sample/status`)
|
|
47
|
+
.set('Authorization', `Bearer ${first.accessToken}`)
|
|
48
|
+
.expect(403);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('prevents removing the last active owner', async () => {
|
|
52
|
+
const { accessToken, orgId, membershipId } = await createUserAndOrg('last-owner');
|
|
53
|
+
|
|
54
|
+
await request(app.getHttpServer())
|
|
55
|
+
.patch(`/organisations/${orgId}/memberships/${membershipId}/owner`)
|
|
56
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
57
|
+
.send({ isOwner: false })
|
|
58
|
+
.expect(409);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('blocks suspended memberships in OrgScopeGuard/RBAC flow', async () => {
|
|
62
|
+
const { accessToken, orgId, membershipId } = await createUserAndOrg('suspended');
|
|
63
|
+
|
|
64
|
+
await prisma.membership.update({
|
|
65
|
+
where: { id: membershipId },
|
|
66
|
+
data: { status: MembershipStatus.SUSPENDED },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
await request(app.getHttpServer())
|
|
70
|
+
.get(`/organisations/${orgId}/sample/status`)
|
|
71
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
72
|
+
.expect(403);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
async function createUserAndOrg(label: string) {
|
|
76
|
+
const suffix = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
77
|
+
const signup = await request(app.getHttpServer())
|
|
78
|
+
.post('/auth/signup')
|
|
79
|
+
.send({
|
|
80
|
+
email: `${label}-${suffix}@example.com`,
|
|
81
|
+
password: 'test-password-123',
|
|
82
|
+
displayName: label,
|
|
83
|
+
})
|
|
84
|
+
.expect(201);
|
|
85
|
+
|
|
86
|
+
const accessToken = signup.body.accessToken as string;
|
|
87
|
+
const org = await request(app.getHttpServer())
|
|
88
|
+
.post('/organisations')
|
|
89
|
+
.set('Authorization', `Bearer ${accessToken}`)
|
|
90
|
+
.send({
|
|
91
|
+
name: `${label} ${suffix}`,
|
|
92
|
+
slug: `${label}-${suffix}`,
|
|
93
|
+
})
|
|
94
|
+
.expect(201);
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
accessToken,
|
|
98
|
+
orgId: org.body.organisation.id as string,
|
|
99
|
+
membershipId: org.body.membership.id as string,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
});
|