@ftisindia/create-app 0.1.3 → 0.1.5

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 (114) hide show
  1. package/package.json +1 -1
  2. package/template/.env.example +6 -0
  3. package/template/README.md +11 -1
  4. package/template/_package.json +0 -2
  5. package/template/docs/API_REFERENCE.md +13 -0
  6. package/template/docs/OAUTH.md +7 -3
  7. package/template/scripts/gen-module.mjs +2 -0
  8. package/template/src/app.module.ts +16 -22
  9. package/template/src/common/dto/error-response.dto.ts +3 -3
  10. package/template/src/common/dto/membership-response.dto.ts +26 -14
  11. package/template/src/common/dto/mutation-response.dto.ts +1 -1
  12. package/template/src/common/dto/pagination-query.dto.ts +37 -0
  13. package/template/src/common/dto/role-summary.dto.ts +5 -5
  14. package/template/src/common/dto/user-summary.dto.ts +6 -6
  15. package/template/src/common/filters/http-exception.filter.ts +9 -19
  16. package/template/src/common/swagger/api-error-responses.ts +12 -12
  17. package/template/src/config/app.config.ts +8 -3
  18. package/template/src/config/auth.config.ts +3 -3
  19. package/template/src/config/database.config.ts +3 -3
  20. package/template/src/config/env.validation.ts +78 -40
  21. package/template/src/config/index.ts +5 -5
  22. package/template/src/config/rbac.config.ts +3 -3
  23. package/template/src/database/prisma/prisma-transaction.ts +1 -1
  24. package/template/src/database/prisma/prisma.module.ts +2 -2
  25. package/template/src/database/prisma/prisma.service.ts +3 -6
  26. package/template/src/main.ts +24 -11
  27. package/template/src/modules/access-control/access-control.module.ts +11 -10
  28. package/template/src/modules/access-control/application/role-permission-policy.ts +71 -0
  29. package/template/src/modules/access-control/application/route-registry.validator.ts +34 -63
  30. package/template/src/modules/access-control/application/services/ability.factory.ts +5 -9
  31. package/template/src/modules/access-control/application/services/access-control.service.ts +78 -85
  32. package/template/src/modules/access-control/application/services/permission.guard.ts +16 -21
  33. package/template/src/modules/access-control/application/services/rbac-cache.service.ts +7 -9
  34. package/template/src/modules/access-control/dto/access-control-response.dto.ts +32 -20
  35. package/template/src/modules/access-control/dto/create-role.dto.ts +6 -6
  36. package/template/src/modules/access-control/dto/current-access-control-response.dto.ts +31 -0
  37. package/template/src/modules/access-control/dto/update-role-permissions.dto.ts +3 -10
  38. package/template/src/modules/access-control/dto/update-role.dto.ts +6 -6
  39. package/template/src/modules/access-control/presentation/access-control.controller.ts +69 -74
  40. package/template/src/modules/access-control/presentation/current-access-control.controller.ts +40 -0
  41. package/template/src/modules/access-control/presentation/permissions.decorator.ts +3 -3
  42. package/template/src/modules/access-control/presentation/public.decorator.ts +2 -2
  43. package/template/src/modules/access-control/types/permission-key.ts +19 -19
  44. package/template/src/modules/access-control/types/route-permission-registry.ts +76 -76
  45. package/template/src/modules/audit/application/services/audit.service.ts +7 -7
  46. package/template/src/modules/audit/audit.module.ts +4 -4
  47. package/template/src/modules/audit/dto/audit-response.dto.ts +18 -18
  48. package/template/src/modules/audit/dto/list-audit-logs-query.dto.ts +14 -14
  49. package/template/src/modules/audit/presentation/audit.controller.ts +17 -23
  50. package/template/src/modules/auth/application/services/auth.service.ts +147 -110
  51. package/template/src/modules/auth/application/services/password.service.ts +2 -2
  52. package/template/src/modules/auth/application/services/token.service.ts +20 -21
  53. package/template/src/modules/auth/auth.module.ts +20 -47
  54. package/template/src/modules/auth/dto/auth-response.dto.ts +9 -10
  55. package/template/src/modules/auth/dto/login.dto.ts +4 -4
  56. package/template/src/modules/auth/dto/logout.dto.ts +1 -1
  57. package/template/src/modules/auth/dto/oauth-exchange.dto.ts +4 -5
  58. package/template/src/modules/auth/dto/refresh-token.dto.ts +4 -5
  59. package/template/src/modules/auth/dto/signup.dto.ts +5 -11
  60. package/template/src/modules/auth/infrastructure/passport/google-auth.guard.ts +6 -14
  61. package/template/src/modules/auth/infrastructure/passport/google-oauth-state.store.ts +98 -0
  62. package/template/src/modules/auth/infrastructure/passport/google.strategy.ts +21 -30
  63. package/template/src/modules/auth/infrastructure/passport/jwt-auth.guard.ts +3 -3
  64. package/template/src/modules/auth/infrastructure/passport/jwt.strategy.ts +11 -11
  65. package/template/src/modules/auth/presentation/auth.controller.ts +45 -45
  66. package/template/src/modules/auth/presentation/current-user.decorator.ts +3 -5
  67. package/template/src/modules/auth/presentation/google-oauth-exception.filter.ts +5 -10
  68. package/template/src/modules/health/dto/health-response.dto.ts +5 -5
  69. package/template/src/modules/health/health.module.ts +2 -2
  70. package/template/src/modules/health/presentation/health.controller.ts +13 -13
  71. package/template/src/modules/invitations/application/services/invitations.service.ts +127 -176
  72. package/template/src/modules/invitations/dto/accept-invitation.dto.ts +6 -7
  73. package/template/src/modules/invitations/dto/create-invitation.dto.ts +14 -15
  74. package/template/src/modules/invitations/dto/invitation-response.dto.ts +37 -29
  75. package/template/src/modules/invitations/dto/invitation-token.dto.ts +4 -4
  76. package/template/src/modules/invitations/invitations.module.ts +5 -5
  77. package/template/src/modules/invitations/presentation/invitations.controller.ts +61 -63
  78. package/template/src/modules/memberships/application/services/memberships.service.ts +70 -84
  79. package/template/src/modules/memberships/dto/transfer-owner.dto.ts +4 -4
  80. package/template/src/modules/memberships/dto/update-billing-contact.dto.ts +2 -2
  81. package/template/src/modules/memberships/dto/update-membership-owner.dto.ts +2 -2
  82. package/template/src/modules/memberships/dto/update-membership-role.dto.ts +4 -4
  83. package/template/src/modules/memberships/dto/update-membership-status.dto.ts +3 -3
  84. package/template/src/modules/memberships/memberships.module.ts +4 -4
  85. package/template/src/modules/memberships/presentation/memberships.controller.ts +83 -99
  86. package/template/src/modules/organisations/application/services/organisations.service.ts +87 -23
  87. package/template/src/modules/organisations/dto/create-organisation.dto.ts +6 -13
  88. package/template/src/modules/organisations/dto/organisation-response.dto.ts +65 -14
  89. package/template/src/modules/organisations/infrastructure/repositories/organisations.repository.ts +4 -7
  90. package/template/src/modules/organisations/organisations.module.ts +5 -5
  91. package/template/src/modules/organisations/presentation/organisations.controller.ts +31 -18
  92. package/template/src/modules/organisations/types/default-organisation-data.ts +3 -9
  93. package/template/src/modules/request-context/application/services/request-context.service.ts +15 -7
  94. package/template/src/modules/request-context/presentation/org-scope.guard.ts +4 -9
  95. package/template/src/modules/request-context/presentation/request-context.interceptor.ts +4 -9
  96. package/template/src/modules/request-context/presentation/request-context.middleware.ts +7 -8
  97. package/template/src/modules/request-context/request-context.module.ts +7 -7
  98. package/template/src/modules/request-context/types/request-context.ts +2 -2
  99. package/template/src/modules/sample/application/services/sample.service.ts +10 -8
  100. package/template/src/modules/sample/dto/sample-echo.dto.ts +3 -3
  101. package/template/src/modules/sample/dto/sample-response.dto.ts +12 -12
  102. package/template/src/modules/sample/presentation/sample.controller.ts +25 -42
  103. package/template/src/modules/sample/sample.module.ts +4 -4
  104. package/template/src/modules/settings/application/services/settings.service.ts +15 -27
  105. package/template/src/modules/settings/dto/setting-response.dto.ts +9 -9
  106. package/template/src/modules/settings/dto/update-setting.dto.ts +5 -5
  107. package/template/src/modules/settings/presentation/settings.controller.ts +29 -35
  108. package/template/src/modules/settings/settings.module.ts +5 -5
  109. package/template/src/modules/settings/types/setting-definitions.ts +49 -33
  110. package/template/test/auth-refresh.spec.ts +90 -0
  111. package/template/test/frontend-bootstrap.spec.ts +181 -0
  112. package/template/test/role-permission-policy.spec.ts +94 -0
  113. package/template/test/route-registry.validator.spec.ts +12 -0
  114. package/template/test/security.e2e-spec.ts +134 -2
@@ -1,8 +1,8 @@
1
- import { ApiProperty } from "@nestjs/swagger";
2
- import { IsString, MaxLength, MinLength } from "class-validator";
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { IsString, MaxLength, MinLength } from 'class-validator';
3
3
 
4
4
  export class SampleEchoDto {
5
- @ApiProperty({ example: "Hello from the sample module." })
5
+ @ApiProperty({ example: 'Hello from the sample module.' })
6
6
  @IsString()
7
7
  @MinLength(1)
8
8
  @MaxLength(200)
@@ -1,41 +1,41 @@
1
- import { ApiProperty } from "@nestjs/swagger";
1
+ import { ApiProperty } from '@nestjs/swagger';
2
2
 
3
3
  export class SampleStatusResponseDto {
4
4
  @ApiProperty({ example: true })
5
5
  ok!: boolean;
6
6
 
7
7
  @ApiProperty({
8
- example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
9
- format: "uuid",
8
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
9
+ format: 'uuid',
10
10
  })
11
11
  orgId!: string;
12
12
 
13
13
  @ApiProperty({
14
- example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
15
- format: "uuid",
14
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
15
+ format: 'uuid',
16
16
  })
17
17
  contextOrgId!: string;
18
18
 
19
- @ApiProperty({ example: "req_01HZX3J8TBMJAEK42S7XK4V7C8" })
19
+ @ApiProperty({ example: 'req_01HZX3J8TBMJAEK42S7XK4V7C8' })
20
20
  requestId!: string;
21
21
  }
22
22
 
23
23
  export class SampleEchoResponseDto {
24
24
  @ApiProperty({
25
- example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
26
- format: "uuid",
25
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
26
+ format: 'uuid',
27
27
  })
28
28
  orgId!: string;
29
29
 
30
30
  @ApiProperty({
31
- example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
32
- format: "uuid",
31
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
32
+ format: 'uuid',
33
33
  })
34
34
  contextOrgId!: string;
35
35
 
36
- @ApiProperty({ example: "Hello from the sample module." })
36
+ @ApiProperty({ example: 'Hello from the sample module.' })
37
37
  message!: string;
38
38
 
39
- @ApiProperty({ example: "req_01HZX3J8TBMJAEK42S7XK4V7C8" })
39
+ @ApiProperty({ example: 'req_01HZX3J8TBMJAEK42S7XK4V7C8' })
40
40
  requestId!: string;
41
41
  }
@@ -1,61 +1,44 @@
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";
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("Sample")
14
+ @ApiTags('Sample')
32
15
  @ApiBearerAuth()
33
- @ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
16
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
34
17
  @ApiProtectedErrorResponses()
35
- @Controller("organisations/:orgId/sample")
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("status")
41
- @RequirePermissions("organisations.read")
42
- @ApiOperation({ summary: "Return sample module status and request context." })
23
+ @Get('status')
24
+ @RequirePermissions('organisations.read')
25
+ @ApiOperation({ summary: 'Return sample module status and request context.' })
43
26
  @ApiOkResponse({
44
- description: "Sample status.",
27
+ description: 'Sample status.',
45
28
  type: SampleStatusResponseDto,
46
29
  })
47
- status(@Param("orgId") orgId: string) {
30
+ status(@Param('orgId') orgId: string) {
48
31
  return this.sampleService.getStatus(orgId);
49
32
  }
50
33
 
51
- @Post("echo")
34
+ @Post('echo')
52
35
  @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 })
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("orgId") orgId: string,
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 "@nestjs/common";
2
- import { AuthModule } from "../auth/auth.module";
3
- import { SampleService } from "./application/services/sample.service";
4
- import { SampleController } from "./presentation/sample.controller";
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
- 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";
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: "asc" },
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: "organisation.setting.update",
96
- targetType: "OrganisationSetting",
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("Unknown organisation setting key.");
102
+ throw new BadRequestException('Unknown organisation setting key.');
115
103
  }
116
104
  }
117
105
 
@@ -1,27 +1,27 @@
1
- import { ApiProperty } from "@nestjs/swagger";
1
+ import { ApiProperty } from '@nestjs/swagger';
2
2
 
3
3
  export class SettingResponseDto {
4
4
  @ApiProperty({
5
- example: "949b2c7c-b6b4-4db6-a2ed-663fa5f6f877",
6
- format: "uuid",
5
+ example: '949b2c7c-b6b4-4db6-a2ed-663fa5f6f877',
6
+ format: 'uuid',
7
7
  })
8
8
  id!: string;
9
9
 
10
10
  @ApiProperty({
11
- example: "2c67399d-670c-4025-a5fd-1ea9a211891e",
12
- format: "uuid",
11
+ example: '2c67399d-670c-4025-a5fd-1ea9a211891e',
12
+ format: 'uuid',
13
13
  })
14
14
  orgId!: string;
15
15
 
16
- @ApiProperty({ example: "timezone" })
16
+ @ApiProperty({ example: 'timezone' })
17
17
  key!: string;
18
18
 
19
- @ApiProperty({ example: "UTC" })
19
+ @ApiProperty({ example: 'UTC' })
20
20
  value!: unknown;
21
21
 
22
- @ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
22
+ @ApiProperty({ example: '2026-06-01T10:30:00.000Z', format: 'date-time' })
23
23
  createdAt!: string;
24
24
 
25
- @ApiProperty({ example: "2026-06-01T10:30:00.000Z", format: "date-time" })
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 "@nestjs/swagger";
2
- import { IsDefined, IsString, MaxLength } from "class-validator";
1
+ import { ApiProperty } from '@nestjs/swagger';
2
+ import { IsDefined, IsString, MaxLength } from 'class-validator';
3
3
 
4
4
  export class UpdateSettingDto {
5
- @ApiProperty({ example: "timezone", maxLength: 120 })
5
+ @ApiProperty({ example: 'timezone', maxLength: 120 })
6
6
  @IsString()
7
7
  @MaxLength(120)
8
8
  key!: string;
9
9
 
10
10
  @ApiProperty({
11
- description: "JSON value accepted by the setting definition.",
12
- example: "UTC",
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 "@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";
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("Settings")
14
+ @ApiTags('Settings')
21
15
  @ApiBearerAuth()
22
- @ApiParam({ name: "orgId", description: "Organisation ID.", format: "uuid" })
16
+ @ApiParam({ name: 'orgId', description: 'Organisation ID.', format: 'uuid' })
23
17
  @ApiProtectedErrorResponses(404)
24
- @Controller("organisations/:orgId/settings")
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("settings.read")
31
- @ApiOperation({ summary: "List organisation settings." })
24
+ @RequirePermissions('settings.read')
25
+ @ApiOperation({ summary: 'List organisation settings.' })
32
26
  @ApiOkResponse({
33
- description: "Organisation settings.",
27
+ description: 'Organisation settings.',
34
28
  type: [SettingResponseDto],
35
29
  })
36
- list(@Param("orgId") orgId: string) {
30
+ list(@Param('orgId') orgId: string) {
37
31
  return this.settingsService.listSettings(orgId);
38
32
  }
39
33
 
40
- @Get(":key")
41
- @RequirePermissions("settings.read")
42
- @ApiParam({ name: "key", description: "Setting key.", example: "timezone" })
43
- @ApiOperation({ summary: "Return one organisation setting." })
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: "Organisation setting.",
39
+ description: 'Organisation setting.',
46
40
  type: SettingResponseDto,
47
41
  })
48
- get(@Param("orgId") orgId: string, @Param("key") key: string) {
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("settings.update")
54
- @ApiOperation({ summary: "Update one organisation setting." })
47
+ @RequirePermissions('settings.update')
48
+ @ApiOperation({ summary: 'Update one organisation setting.' })
55
49
  @ApiOkResponse({
56
- description: "Updated organisation setting.",
50
+ description: 'Updated organisation setting.',
57
51
  type: SettingResponseDto,
58
52
  })
59
53
  update(
60
54
  @CurrentUser() user: AuthenticatedUser,
61
- @Param("orgId") orgId: string,
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 "@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";
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 "@nestjs/common";
2
- import { Prisma } from "@prisma/client";
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
- "billing.plan": {
11
- defaultValue: "free",
12
- parse: parseEnum(["free", "starter", "pro", "enterprise"]),
10
+ 'billing.plan': {
11
+ defaultValue: 'free',
12
+ parse: parseEnum(['free', 'starter', 'pro', 'enterprise']),
13
13
  },
14
- "features.inventory_enabled": {
14
+ 'features.inventory_enabled': {
15
15
  defaultValue: false,
16
16
  parse: parseBoolean,
17
17
  },
18
- "features.reports_enabled": {
18
+ 'features.reports_enabled': {
19
19
  defaultValue: false,
20
20
  parse: parseBoolean,
21
21
  },
22
- "notifications.email_enabled": {
22
+ 'notifications.email_enabled': {
23
23
  defaultValue: true,
24
24
  parse: parseBoolean,
25
25
  },
26
- "invoice.prefix": {
27
- defaultValue: "INV",
28
- parse: parseShortString("invoice.prefix", 1, 20),
26
+ 'invoice.prefix': {
27
+ defaultValue: 'INV',
28
+ parse: parseShortString('invoice.prefix', 1, 20),
29
29
  },
30
- "invoice.approval_required": {
30
+ 'invoice.approval_required': {
31
31
  defaultValue: false,
32
32
  parse: parseBoolean,
33
33
  },
34
- "branding.logo_url": {
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 !== "boolean") {
44
- throw new BadRequestException("Setting value must be a boolean.");
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 !== "string" || !allowed.includes(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 !== "string") {
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 !== "string") {
85
- throw new BadRequestException("branding.logo_url must be a URL string.");
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 !== "http:" && url.protocol !== "https:") {
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("branding.logo_url must be a valid URL.");
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
  }