@ftisindia/create-app 0.1.2 → 0.1.4
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/README.md +65 -0
- package/package.json +1 -1
- package/template/README.md +65 -1
- package/template/_package.json +0 -2
- package/template/docs/API_REFERENCE.md +13 -0
- package/template/docs/OAUTH.md +7 -3
- package/template/scripts/gen-module.mjs +2 -0
- package/template/src/app.module.ts +16 -22
- package/template/src/common/dto/error-response.dto.ts +3 -3
- package/template/src/common/dto/membership-response.dto.ts +26 -14
- package/template/src/common/dto/mutation-response.dto.ts +1 -1
- package/template/src/common/dto/pagination-query.dto.ts +37 -0
- package/template/src/common/dto/role-summary.dto.ts +5 -5
- package/template/src/common/dto/user-summary.dto.ts +6 -6
- package/template/src/common/filters/http-exception.filter.ts +9 -19
- package/template/src/common/swagger/api-error-responses.ts +12 -12
- package/template/src/config/app.config.ts +3 -3
- package/template/src/config/auth.config.ts +3 -3
- package/template/src/config/database.config.ts +3 -3
- package/template/src/config/env.validation.ts +58 -40
- package/template/src/config/index.ts +5 -5
- package/template/src/config/rbac.config.ts +3 -3
- package/template/src/database/prisma/prisma-transaction.ts +1 -1
- package/template/src/database/prisma/prisma.module.ts +2 -2
- package/template/src/database/prisma/prisma.service.ts +3 -6
- package/template/src/main.ts +11 -11
- package/template/src/modules/access-control/access-control.module.ts +9 -9
- package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
- package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
- package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
- package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
- package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
- package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
- package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
- package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
- package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
- package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
- package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
- package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
- package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
- package/template/src/modules/access-control/types/permission-key.ts +19 -19
- package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
- package/template/src/modules/audit/application/services/audit.service.ts +7 -7
- package/template/src/modules/audit/audit.module.ts +4 -4
- package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
- package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
- package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
- package/template/src/modules/auth/application/services/auth.service.ts +147 -110
- package/template/src/modules/auth/application/services/password.service.ts +2 -2
- package/template/src/modules/auth/application/services/token.service.ts +20 -21
- package/template/src/modules/auth/auth.module.ts +20 -47
- package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
- package/template/src/modules/auth/dto/login.dto.ts +4 -4
- package/template/src/modules/auth/dto/logout.dto.ts +1 -1
- package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
- package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
- package/template/src/modules/auth/dto/signup.dto.ts +5 -11
- package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
- package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
- package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
- package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
- package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
- package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
- package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
- package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
- package/template/src/modules/health/dto/health-response.dto.ts +5 -5
- package/template/src/modules/health/health.module.ts +2 -2
- package/template/src/modules/health/presentation/health.controller.ts +13 -13
- package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
- package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
- package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
- package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
- package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
- package/template/src/modules/invitations/invitations.module.ts +5 -5
- package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
- package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
- package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
- package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
- package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
- package/template/src/modules/memberships/memberships.module.ts +4 -4
- package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
- package/template/src/modules/organisations/application/services/organisations.service.ts +21 -23
- package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
- package/template/src/modules/organisations/dto/organisation-response.dto.ts +14 -14
- package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
- package/template/src/modules/organisations/organisations.module.ts +5 -5
- package/template/src/modules/organisations/presentation/organisations.controller.ts +14 -23
- package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
- package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
- package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
- package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
- package/template/src/modules/request-context/request-context.module.ts +7 -7
- package/template/src/modules/request-context/types/request-context.ts +2 -2
- package/template/src/modules/sample/application/services/sample.service.ts +10 -8
- package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
- package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
- package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
- package/template/src/modules/sample/sample.module.ts +4 -4
- package/template/src/modules/settings/application/services/settings.service.ts +15 -27
- package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
- package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
- package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
- package/template/src/modules/settings/settings.module.ts +5 -5
- package/template/src/modules/settings/types/setting-definitions.ts +49 -33
- package/template/test/auth-refresh.spec.ts +90 -0
- package/template/test/role-permission-policy.spec.ts +94 -0
|
@@ -1,61 +1,44 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
} from
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
|
|
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";
|
|
1
|
+
import { Body, Controller, Get, HttpCode, Param, Post, UseGuards } from '@nestjs/common';
|
|
2
|
+
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
|
3
|
+
import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
|
|
4
|
+
import { PermissionGuard } from '../../access-control/application/services/permission.guard';
|
|
5
|
+
import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
|
|
6
|
+
import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
|
|
7
|
+
import { CurrentUser } from '../../auth/presentation/current-user.decorator';
|
|
8
|
+
import { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
|
9
|
+
import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
|
|
10
|
+
import { SampleEchoDto } from '../dto/sample-echo.dto';
|
|
11
|
+
import { SampleEchoResponseDto, SampleStatusResponseDto } from '../dto/sample-response.dto';
|
|
12
|
+
import { SampleService } from '../application/services/sample.service';
|
|
30
13
|
|
|
31
|
-
@ApiTags(
|
|
14
|
+
@ApiTags('Sample')
|
|
32
15
|
@ApiBearerAuth()
|
|
33
|
-
@ApiParam({ name:
|
|
16
|
+
@ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
|
|
34
17
|
@ApiProtectedErrorResponses()
|
|
35
|
-
@Controller(
|
|
18
|
+
@Controller('organisations/:orgId/sample')
|
|
36
19
|
@UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
|
|
37
20
|
export class SampleController {
|
|
38
21
|
constructor(private readonly sampleService: SampleService) {}
|
|
39
22
|
|
|
40
|
-
@Get(
|
|
41
|
-
@RequirePermissions(
|
|
42
|
-
@ApiOperation({ summary:
|
|
23
|
+
@Get('status')
|
|
24
|
+
@RequirePermissions('organisations.read')
|
|
25
|
+
@ApiOperation({ summary: 'Return sample module status and request context.' })
|
|
43
26
|
@ApiOkResponse({
|
|
44
|
-
description:
|
|
27
|
+
description: 'Sample status.',
|
|
45
28
|
type: SampleStatusResponseDto,
|
|
46
29
|
})
|
|
47
|
-
status(@Param(
|
|
30
|
+
status(@Param('orgId') orgId: string) {
|
|
48
31
|
return this.sampleService.getStatus(orgId);
|
|
49
32
|
}
|
|
50
33
|
|
|
51
|
-
@Post(
|
|
34
|
+
@Post('echo')
|
|
52
35
|
@HttpCode(200)
|
|
53
|
-
@RequirePermissions(
|
|
54
|
-
@ApiOperation({ summary:
|
|
55
|
-
@ApiOkResponse({ description:
|
|
36
|
+
@RequirePermissions('organisations.update')
|
|
37
|
+
@ApiOperation({ summary: 'Echo a message and write a sample audit record.' })
|
|
38
|
+
@ApiOkResponse({ description: 'Echo result.', type: SampleEchoResponseDto })
|
|
56
39
|
echo(
|
|
57
40
|
@CurrentUser() user: AuthenticatedUser,
|
|
58
|
-
@Param(
|
|
41
|
+
@Param('orgId') orgId: string,
|
|
59
42
|
@Body() dto: SampleEchoDto,
|
|
60
43
|
) {
|
|
61
44
|
return this.sampleService.echo(user, orgId, dto);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { Module } from
|
|
2
|
-
import { AuthModule } from
|
|
3
|
-
import { SampleService } from
|
|
4
|
-
import { SampleController } from
|
|
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
5
|
|
|
6
6
|
@Module({
|
|
7
7
|
imports: [AuthModule],
|
|
@@ -1,18 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
} from
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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";
|
|
1
|
+
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
|
+
import { PrismaService } from '../../../../database/prisma/prisma.service';
|
|
4
|
+
import { AuditService } from '../../../audit/application/services/audit.service';
|
|
5
|
+
import { AuthenticatedUser } from '../../../auth/types/authenticated-user';
|
|
6
|
+
import { RequestContextService } from '../../../request-context/application/services/request-context.service';
|
|
7
|
+
import { UpdateSettingDto } from '../../dto/update-setting.dto';
|
|
8
|
+
import { settingDefinitions, SettingKey } from '../../types/setting-definitions';
|
|
16
9
|
|
|
17
10
|
@Injectable()
|
|
18
11
|
export class SettingsService {
|
|
@@ -27,7 +20,8 @@ export class SettingsService {
|
|
|
27
20
|
|
|
28
21
|
const settings = await this.prisma.organisationSetting.findMany({
|
|
29
22
|
where: { orgId },
|
|
30
|
-
orderBy: { key:
|
|
23
|
+
orderBy: { key: 'asc' },
|
|
24
|
+
take: Object.keys(settingDefinitions).length,
|
|
31
25
|
});
|
|
32
26
|
|
|
33
27
|
return settings.map(serializeSetting);
|
|
@@ -47,19 +41,13 @@ export class SettingsService {
|
|
|
47
41
|
});
|
|
48
42
|
|
|
49
43
|
if (!setting) {
|
|
50
|
-
throw new NotFoundException(
|
|
51
|
-
"Setting was not found for this organisation.",
|
|
52
|
-
);
|
|
44
|
+
throw new NotFoundException('Setting was not found for this organisation.');
|
|
53
45
|
}
|
|
54
46
|
|
|
55
47
|
return serializeSetting(setting);
|
|
56
48
|
}
|
|
57
49
|
|
|
58
|
-
async updateSetting(
|
|
59
|
-
currentUser: AuthenticatedUser,
|
|
60
|
-
orgId: string,
|
|
61
|
-
dto: UpdateSettingDto,
|
|
62
|
-
) {
|
|
50
|
+
async updateSetting(currentUser: AuthenticatedUser, orgId: string, dto: UpdateSettingDto) {
|
|
63
51
|
this.requestContext.assertOrgScope(orgId);
|
|
64
52
|
const definition = getSettingDefinition(dto.key);
|
|
65
53
|
const value = definition.parse(dto.value);
|
|
@@ -92,8 +80,8 @@ export class SettingsService {
|
|
|
92
80
|
await this.auditService.write(tx, {
|
|
93
81
|
orgId,
|
|
94
82
|
actorUserId: currentUser.id,
|
|
95
|
-
action:
|
|
96
|
-
targetType:
|
|
83
|
+
action: 'organisation.setting.update',
|
|
84
|
+
targetType: 'OrganisationSetting',
|
|
97
85
|
targetId: setting.id,
|
|
98
86
|
metadata: {
|
|
99
87
|
key: dto.key,
|
|
@@ -111,7 +99,7 @@ export class SettingsService {
|
|
|
111
99
|
|
|
112
100
|
function assertKnownSettingKey(key: string): asserts key is SettingKey {
|
|
113
101
|
if (!Object.hasOwn(settingDefinitions, key)) {
|
|
114
|
-
throw new BadRequestException(
|
|
102
|
+
throw new BadRequestException('Unknown organisation setting key.');
|
|
115
103
|
}
|
|
116
104
|
}
|
|
117
105
|
|
|
@@ -1,27 +1,27 @@
|
|
|
1
|
-
import { ApiProperty } from
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
2
|
|
|
3
3
|
export class SettingResponseDto {
|
|
4
4
|
@ApiProperty({
|
|
5
|
-
example:
|
|
6
|
-
format:
|
|
5
|
+
example: '949b2c7c-b6b4-4db6-a2ed-663fa5f6f877',
|
|
6
|
+
format: 'uuid',
|
|
7
7
|
})
|
|
8
8
|
id!: string;
|
|
9
9
|
|
|
10
10
|
@ApiProperty({
|
|
11
|
-
example:
|
|
12
|
-
format:
|
|
11
|
+
example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
|
|
12
|
+
format: 'uuid',
|
|
13
13
|
})
|
|
14
14
|
orgId!: string;
|
|
15
15
|
|
|
16
|
-
@ApiProperty({ example:
|
|
16
|
+
@ApiProperty({ example: 'timezone' })
|
|
17
17
|
key!: string;
|
|
18
18
|
|
|
19
|
-
@ApiProperty({ example:
|
|
19
|
+
@ApiProperty({ example: 'UTC' })
|
|
20
20
|
value!: unknown;
|
|
21
21
|
|
|
22
|
-
@ApiProperty({ example:
|
|
22
|
+
@ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
|
|
23
23
|
createdAt!: string;
|
|
24
24
|
|
|
25
|
-
@ApiProperty({ example:
|
|
25
|
+
@ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
|
|
26
26
|
updatedAt!: string;
|
|
27
27
|
}
|
|
@@ -1,15 +1,15 @@
|
|
|
1
|
-
import { ApiProperty } from
|
|
2
|
-
import { IsDefined, IsString, MaxLength } from
|
|
1
|
+
import { ApiProperty } from '@nestjs/swagger';
|
|
2
|
+
import { IsDefined, IsString, MaxLength } from 'class-validator';
|
|
3
3
|
|
|
4
4
|
export class UpdateSettingDto {
|
|
5
|
-
@ApiProperty({ example:
|
|
5
|
+
@ApiProperty({ example: 'timezone', maxLength: 120 })
|
|
6
6
|
@IsString()
|
|
7
7
|
@MaxLength(120)
|
|
8
8
|
key!: string;
|
|
9
9
|
|
|
10
10
|
@ApiProperty({
|
|
11
|
-
description:
|
|
12
|
-
example:
|
|
11
|
+
description: 'JSON value accepted by the setting definition.',
|
|
12
|
+
example: 'UTC',
|
|
13
13
|
})
|
|
14
14
|
@IsDefined()
|
|
15
15
|
value!: unknown;
|
|
@@ -1,64 +1,58 @@
|
|
|
1
|
-
import { Body, Controller, Get, Param, Patch, UseGuards } from
|
|
2
|
-
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
} from
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
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";
|
|
1
|
+
import { Body, Controller, Get, Param, Patch, UseGuards } from '@nestjs/common';
|
|
2
|
+
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiParam, ApiTags } from '@nestjs/swagger';
|
|
3
|
+
import { ApiProtectedErrorResponses } from '../../../common/swagger/api-error-responses';
|
|
4
|
+
import { PermissionGuard } from '../../access-control/application/services/permission.guard';
|
|
5
|
+
import { RequirePermissions } from '../../access-control/presentation/permissions.decorator';
|
|
6
|
+
import { JwtAuthGuard } from '../../auth/infrastructure/passport/jwt-auth.guard';
|
|
7
|
+
import { CurrentUser } from '../../auth/presentation/current-user.decorator';
|
|
8
|
+
import { AuthenticatedUser } from '../../auth/types/authenticated-user';
|
|
9
|
+
import { OrgScopeGuard } from '../../request-context/presentation/org-scope.guard';
|
|
10
|
+
import { UpdateSettingDto } from '../dto/update-setting.dto';
|
|
11
|
+
import { SettingsService } from '../application/services/settings.service';
|
|
12
|
+
import { SettingResponseDto } from '../dto/setting-response.dto';
|
|
19
13
|
|
|
20
|
-
@ApiTags(
|
|
14
|
+
@ApiTags('Settings')
|
|
21
15
|
@ApiBearerAuth()
|
|
22
|
-
@ApiParam({ name:
|
|
16
|
+
@ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
|
|
23
17
|
@ApiProtectedErrorResponses(404)
|
|
24
|
-
@Controller(
|
|
18
|
+
@Controller('organisations/:orgId/settings')
|
|
25
19
|
@UseGuards(JwtAuthGuard, OrgScopeGuard, PermissionGuard)
|
|
26
20
|
export class SettingsController {
|
|
27
21
|
constructor(private readonly settingsService: SettingsService) {}
|
|
28
22
|
|
|
29
23
|
@Get()
|
|
30
|
-
@RequirePermissions(
|
|
31
|
-
@ApiOperation({ summary:
|
|
24
|
+
@RequirePermissions('settings.read')
|
|
25
|
+
@ApiOperation({ summary: 'List organisation settings.' })
|
|
32
26
|
@ApiOkResponse({
|
|
33
|
-
description:
|
|
27
|
+
description: 'Organisation settings.',
|
|
34
28
|
type: [SettingResponseDto],
|
|
35
29
|
})
|
|
36
|
-
list(@Param(
|
|
30
|
+
list(@Param('orgId') orgId: string) {
|
|
37
31
|
return this.settingsService.listSettings(orgId);
|
|
38
32
|
}
|
|
39
33
|
|
|
40
|
-
@Get(
|
|
41
|
-
@RequirePermissions(
|
|
42
|
-
@ApiParam({ name:
|
|
43
|
-
@ApiOperation({ summary:
|
|
34
|
+
@Get(':key')
|
|
35
|
+
@RequirePermissions('settings.read')
|
|
36
|
+
@ApiParam({ name: 'key', description: 'Setting key.', example: 'timezone' })
|
|
37
|
+
@ApiOperation({ summary: 'Return one organisation setting.' })
|
|
44
38
|
@ApiOkResponse({
|
|
45
|
-
description:
|
|
39
|
+
description: 'Organisation setting.',
|
|
46
40
|
type: SettingResponseDto,
|
|
47
41
|
})
|
|
48
|
-
get(@Param(
|
|
42
|
+
get(@Param('orgId') orgId: string, @Param('key') key: string) {
|
|
49
43
|
return this.settingsService.getSetting(orgId, key);
|
|
50
44
|
}
|
|
51
45
|
|
|
52
46
|
@Patch()
|
|
53
|
-
@RequirePermissions(
|
|
54
|
-
@ApiOperation({ summary:
|
|
47
|
+
@RequirePermissions('settings.update')
|
|
48
|
+
@ApiOperation({ summary: 'Update one organisation setting.' })
|
|
55
49
|
@ApiOkResponse({
|
|
56
|
-
description:
|
|
50
|
+
description: 'Updated organisation setting.',
|
|
57
51
|
type: SettingResponseDto,
|
|
58
52
|
})
|
|
59
53
|
update(
|
|
60
54
|
@CurrentUser() user: AuthenticatedUser,
|
|
61
|
-
@Param(
|
|
55
|
+
@Param('orgId') orgId: string,
|
|
62
56
|
@Body() dto: UpdateSettingDto,
|
|
63
57
|
) {
|
|
64
58
|
return this.settingsService.updateSetting(user, orgId, dto);
|
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
import { Module } from
|
|
2
|
-
import { AuditModule } from
|
|
3
|
-
import { AuthModule } from
|
|
4
|
-
import { SettingsService } from
|
|
5
|
-
import { SettingsController } from
|
|
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
6
|
|
|
7
7
|
@Module({
|
|
8
8
|
imports: [AuditModule, AuthModule],
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { BadRequestException } from
|
|
2
|
-
import { Prisma } from
|
|
1
|
+
import { BadRequestException } from '@nestjs/common';
|
|
2
|
+
import { Prisma } from '@prisma/client';
|
|
3
3
|
|
|
4
4
|
type SettingDefinition = {
|
|
5
5
|
defaultValue: Prisma.InputJsonValue;
|
|
@@ -7,41 +7,45 @@ type SettingDefinition = {
|
|
|
7
7
|
};
|
|
8
8
|
|
|
9
9
|
export const settingDefinitions = {
|
|
10
|
-
|
|
11
|
-
defaultValue:
|
|
12
|
-
parse: parseEnum([
|
|
10
|
+
'billing.plan': {
|
|
11
|
+
defaultValue: 'free',
|
|
12
|
+
parse: parseEnum(['free', 'starter', 'pro', 'enterprise']),
|
|
13
13
|
},
|
|
14
|
-
|
|
14
|
+
'features.inventory_enabled': {
|
|
15
15
|
defaultValue: false,
|
|
16
16
|
parse: parseBoolean,
|
|
17
17
|
},
|
|
18
|
-
|
|
18
|
+
'features.reports_enabled': {
|
|
19
19
|
defaultValue: false,
|
|
20
20
|
parse: parseBoolean,
|
|
21
21
|
},
|
|
22
|
-
|
|
22
|
+
'notifications.email_enabled': {
|
|
23
23
|
defaultValue: true,
|
|
24
24
|
parse: parseBoolean,
|
|
25
25
|
},
|
|
26
|
-
|
|
27
|
-
defaultValue:
|
|
28
|
-
parse: parseShortString(
|
|
26
|
+
'invoice.prefix': {
|
|
27
|
+
defaultValue: 'INV',
|
|
28
|
+
parse: parseShortString('invoice.prefix', 1, 20),
|
|
29
29
|
},
|
|
30
|
-
|
|
30
|
+
'invoice.approval_required': {
|
|
31
31
|
defaultValue: false,
|
|
32
32
|
parse: parseBoolean,
|
|
33
33
|
},
|
|
34
|
-
|
|
35
|
-
defaultValue:
|
|
34
|
+
'branding.logo_url': {
|
|
35
|
+
defaultValue: '',
|
|
36
36
|
parse: parseOptionalUrl,
|
|
37
37
|
},
|
|
38
|
+
timezone: {
|
|
39
|
+
defaultValue: 'UTC',
|
|
40
|
+
parse: parseTimezone,
|
|
41
|
+
},
|
|
38
42
|
} satisfies Record<string, SettingDefinition>;
|
|
39
43
|
|
|
40
44
|
export type SettingKey = keyof typeof settingDefinitions;
|
|
41
45
|
|
|
42
46
|
function parseBoolean(value: unknown) {
|
|
43
|
-
if (typeof value !==
|
|
44
|
-
throw new BadRequestException(
|
|
47
|
+
if (typeof value !== 'boolean') {
|
|
48
|
+
throw new BadRequestException('Setting value must be a boolean.');
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
return value;
|
|
@@ -49,10 +53,8 @@ function parseBoolean(value: unknown) {
|
|
|
49
53
|
|
|
50
54
|
function parseEnum(allowed: string[]) {
|
|
51
55
|
return (value: unknown) => {
|
|
52
|
-
if (typeof value !==
|
|
53
|
-
throw new BadRequestException(
|
|
54
|
-
`Setting value must be one of: ${allowed.join(", ")}.`,
|
|
55
|
-
);
|
|
56
|
+
if (typeof value !== 'string' || !allowed.includes(value)) {
|
|
57
|
+
throw new BadRequestException(`Setting value must be one of: ${allowed.join(', ')}.`);
|
|
56
58
|
}
|
|
57
59
|
|
|
58
60
|
return value;
|
|
@@ -61,15 +63,13 @@ function parseEnum(allowed: string[]) {
|
|
|
61
63
|
|
|
62
64
|
function parseShortString(key: string, min: number, max: number) {
|
|
63
65
|
return (value: unknown) => {
|
|
64
|
-
if (typeof value !==
|
|
66
|
+
if (typeof value !== 'string') {
|
|
65
67
|
throw new BadRequestException(`${key} must be a string.`);
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
const normalized = value.trim();
|
|
69
71
|
if (normalized.length < min || normalized.length > max) {
|
|
70
|
-
throw new BadRequestException(
|
|
71
|
-
`${key} must be between ${min} and ${max} characters.`,
|
|
72
|
-
);
|
|
72
|
+
throw new BadRequestException(`${key} must be between ${min} and ${max} characters.`);
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
return normalized;
|
|
@@ -77,20 +77,18 @@ function parseShortString(key: string, min: number, max: number) {
|
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
function parseOptionalUrl(value: unknown) {
|
|
80
|
-
if (value === null || value ===
|
|
81
|
-
return
|
|
80
|
+
if (value === null || value === '') {
|
|
81
|
+
return '';
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
if (typeof value !==
|
|
85
|
-
throw new BadRequestException(
|
|
84
|
+
if (typeof value !== 'string') {
|
|
85
|
+
throw new BadRequestException('branding.logo_url must be a URL string.');
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
try {
|
|
89
89
|
const url = new URL(value);
|
|
90
|
-
if (url.protocol !==
|
|
91
|
-
throw new BadRequestException(
|
|
92
|
-
"branding.logo_url must use http or https.",
|
|
93
|
-
);
|
|
90
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
91
|
+
throw new BadRequestException('branding.logo_url must use http or https.');
|
|
94
92
|
}
|
|
95
93
|
|
|
96
94
|
return url.toString();
|
|
@@ -99,6 +97,24 @@ function parseOptionalUrl(value: unknown) {
|
|
|
99
97
|
throw error;
|
|
100
98
|
}
|
|
101
99
|
|
|
102
|
-
throw new BadRequestException(
|
|
100
|
+
throw new BadRequestException('branding.logo_url must be a valid URL.');
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseTimezone(value: unknown) {
|
|
105
|
+
if (typeof value !== 'string') {
|
|
106
|
+
throw new BadRequestException('timezone must be a string.');
|
|
103
107
|
}
|
|
108
|
+
|
|
109
|
+
const normalized = value.trim();
|
|
110
|
+
try {
|
|
111
|
+
// Validate the zone, but don't adopt its canonical form: resolvedOptions()
|
|
112
|
+
// rewrites aliases (e.g. Asia/Kolkata -> Asia/Calcutta) in an ICU/Node-version
|
|
113
|
+
// dependent way. Preserve the caller's input so settings round-trip stably.
|
|
114
|
+
new Intl.DateTimeFormat('en-US', { timeZone: normalized }).resolvedOptions();
|
|
115
|
+
} catch {
|
|
116
|
+
throw new BadRequestException('timezone must be a valid IANA time zone.');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return normalized;
|
|
104
120
|
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { UnauthorizedException } from '@nestjs/common';
|
|
2
|
+
import { AuthService } from '../src/modules/auth/application/services/auth.service';
|
|
3
|
+
|
|
4
|
+
describe('AuthService.refresh', () => {
|
|
5
|
+
it('commits refresh-token family revocation when a concurrent replay loses the claim', async () => {
|
|
6
|
+
const existingToken = {
|
|
7
|
+
id: 'old-token-id',
|
|
8
|
+
userId: 'user-id',
|
|
9
|
+
revokedAt: null,
|
|
10
|
+
replacedByTokenId: null,
|
|
11
|
+
expiresAt: new Date(Date.now() + 60_000),
|
|
12
|
+
user: {
|
|
13
|
+
id: 'user-id',
|
|
14
|
+
email: 'owner@example.com',
|
|
15
|
+
mobile: null,
|
|
16
|
+
displayName: null,
|
|
17
|
+
isActive: true,
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
const tx = {
|
|
21
|
+
refreshToken: {
|
|
22
|
+
updateMany: jest
|
|
23
|
+
.fn()
|
|
24
|
+
.mockResolvedValueOnce({ count: 0 })
|
|
25
|
+
.mockResolvedValueOnce({ count: 2 }),
|
|
26
|
+
findUnique: jest
|
|
27
|
+
.fn()
|
|
28
|
+
.mockResolvedValueOnce({ replacedByTokenId: 'next-token-id' })
|
|
29
|
+
.mockResolvedValueOnce({ replacedByTokenId: 'next-token-id' })
|
|
30
|
+
.mockResolvedValueOnce({ replacedByTokenId: null }),
|
|
31
|
+
},
|
|
32
|
+
auditLog: {
|
|
33
|
+
create: jest.fn().mockResolvedValue({}),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
const prisma = {
|
|
37
|
+
refreshToken: {
|
|
38
|
+
findUnique: jest.fn().mockResolvedValue(existingToken),
|
|
39
|
+
},
|
|
40
|
+
$transaction: jest.fn((callback: (txClient: typeof tx) => unknown) =>
|
|
41
|
+
Promise.resolve(callback(tx)),
|
|
42
|
+
),
|
|
43
|
+
};
|
|
44
|
+
const tokenService = {
|
|
45
|
+
hashRefreshToken: jest.fn().mockReturnValue('refresh-token-hash'),
|
|
46
|
+
createRefreshToken: jest.fn(),
|
|
47
|
+
signAccessToken: jest.fn(),
|
|
48
|
+
getAccessTokenTtlSeconds: jest.fn(),
|
|
49
|
+
};
|
|
50
|
+
const requestContext = {
|
|
51
|
+
getIpAddress: jest.fn(),
|
|
52
|
+
getUserAgent: jest.fn(),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const service = new AuthService(
|
|
56
|
+
{} as never,
|
|
57
|
+
{} as never,
|
|
58
|
+
prisma as never,
|
|
59
|
+
requestContext as never,
|
|
60
|
+
tokenService as never,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await expect(
|
|
64
|
+
service.refresh({ refreshToken: 'stale-refresh-token-value' }),
|
|
65
|
+
).rejects.toBeInstanceOf(UnauthorizedException);
|
|
66
|
+
|
|
67
|
+
expect(tx.refreshToken.updateMany).toHaveBeenNthCalledWith(
|
|
68
|
+
2,
|
|
69
|
+
expect.objectContaining({
|
|
70
|
+
where: {
|
|
71
|
+
id: {
|
|
72
|
+
in: ['old-token-id', 'next-token-id'],
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
data: {
|
|
76
|
+
revokedAt: expect.any(Date),
|
|
77
|
+
},
|
|
78
|
+
}),
|
|
79
|
+
);
|
|
80
|
+
expect(tx.auditLog.create).toHaveBeenCalledWith(
|
|
81
|
+
expect.objectContaining({
|
|
82
|
+
data: expect.objectContaining({
|
|
83
|
+
action: 'auth.refresh.reuse_detected',
|
|
84
|
+
targetId: 'old-token-id',
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
expect(tokenService.createRefreshToken).not.toHaveBeenCalled();
|
|
89
|
+
});
|
|
90
|
+
});
|