qualia-framework 2.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/README.md +50 -0
- package/bin/cli.js +519 -0
- package/framework/agents/architecture-strategist.md +53 -0
- package/framework/agents/backend-agent.md +150 -0
- package/framework/agents/code-simplicity-reviewer.md +86 -0
- package/framework/agents/frontend-agent.md +111 -0
- package/framework/agents/kieran-typescript-reviewer.md +96 -0
- package/framework/agents/performance-oracle.md +111 -0
- package/framework/agents/qualia-codebase-mapper.md +760 -0
- package/framework/agents/qualia-debugger.md +1203 -0
- package/framework/agents/qualia-executor.md +881 -0
- package/framework/agents/qualia-integration-checker.md +423 -0
- package/framework/agents/qualia-phase-researcher.md +453 -0
- package/framework/agents/qualia-plan-checker.md +699 -0
- package/framework/agents/qualia-planner.md +1241 -0
- package/framework/agents/qualia-project-researcher.md +602 -0
- package/framework/agents/qualia-research-synthesizer.md +236 -0
- package/framework/agents/qualia-roadmapper.md +605 -0
- package/framework/agents/qualia-verifier.md +685 -0
- package/framework/agents/team-orchestrator.md +228 -0
- package/framework/agents/teams/full-stack-team.md +48 -0
- package/framework/agents/teams/optimize-team.md +53 -0
- package/framework/agents/teams/review-team.md +62 -0
- package/framework/agents/teams/ship-team.md +86 -0
- package/framework/agents/test-agent.md +182 -0
- package/framework/askpass.sh +2 -0
- package/framework/commands/design.md +53 -0
- package/framework/commands/quick-db.md +22 -0
- package/framework/config/retention.json +35 -0
- package/framework/core/PRINCIPLES.md +77 -0
- package/framework/hooks/auto-format.sh +45 -0
- package/framework/hooks/block-env-edit.sh +42 -0
- package/framework/hooks/branch-guard.sh +46 -0
- package/framework/hooks/confirm-delete.sh +56 -0
- package/framework/hooks/migration-validate.sh +68 -0
- package/framework/hooks/notification-speak.sh +15 -0
- package/framework/hooks/pre-commit.sh +80 -0
- package/framework/hooks/pre-compact.sh +55 -0
- package/framework/hooks/pre-deploy-gate.sh +151 -0
- package/framework/hooks/qualia-colors.sh +32 -0
- package/framework/hooks/retention-cleanup.sh +43 -0
- package/framework/hooks/save-session-state.sh +153 -0
- package/framework/hooks/session-context-loader.sh +28 -0
- package/framework/hooks/session-learn.sh +30 -0
- package/framework/knowledge/claudecode-bible.md +1384 -0
- package/framework/knowledge/client-prefs.md +22 -0
- package/framework/knowledge/common-fixes.md +25 -0
- package/framework/knowledge/deployment-map.md +35 -0
- package/framework/knowledge/email-signature.html +1 -0
- package/framework/knowledge/employees.md +8 -0
- package/framework/knowledge/learned-patterns.md +51 -0
- package/framework/knowledge/optimization-research-2026.md +137 -0
- package/framework/knowledge/qualia-context.md +67 -0
- package/framework/knowledge/supabase-patterns.md +50 -0
- package/framework/knowledge/voice-agent-patterns.md +46 -0
- package/framework/qualia-engine/VERSION +1 -0
- package/framework/qualia-engine/bin/qualia-tools.js +2160 -0
- package/framework/qualia-engine/bin/qualia-tools.test.js +1054 -0
- package/framework/qualia-engine/references/checkpoints.md +775 -0
- package/framework/qualia-engine/references/continuation-format.md +249 -0
- package/framework/qualia-engine/references/decimal-phase-calculation.md +65 -0
- package/framework/qualia-engine/references/design-quality.md +56 -0
- package/framework/qualia-engine/references/git-integration.md +254 -0
- package/framework/qualia-engine/references/git-planning-commit.md +50 -0
- package/framework/qualia-engine/references/model-profile-resolution.md +32 -0
- package/framework/qualia-engine/references/model-profiles.md +73 -0
- package/framework/qualia-engine/references/phase-argument-parsing.md +61 -0
- package/framework/qualia-engine/references/planning-config.md +195 -0
- package/framework/qualia-engine/references/questioning.md +141 -0
- package/framework/qualia-engine/references/tdd.md +263 -0
- package/framework/qualia-engine/references/ui-brand.md +160 -0
- package/framework/qualia-engine/references/verification-patterns.md +612 -0
- package/framework/qualia-engine/templates/DEBUG.md +159 -0
- package/framework/qualia-engine/templates/DESIGN.md +81 -0
- package/framework/qualia-engine/templates/UAT.md +247 -0
- package/framework/qualia-engine/templates/codebase/architecture.md +255 -0
- package/framework/qualia-engine/templates/codebase/concerns.md +310 -0
- package/framework/qualia-engine/templates/codebase/conventions.md +307 -0
- package/framework/qualia-engine/templates/codebase/integrations.md +280 -0
- package/framework/qualia-engine/templates/codebase/stack.md +186 -0
- package/framework/qualia-engine/templates/codebase/structure.md +285 -0
- package/framework/qualia-engine/templates/codebase/testing.md +480 -0
- package/framework/qualia-engine/templates/config.json +35 -0
- package/framework/qualia-engine/templates/context.md +283 -0
- package/framework/qualia-engine/templates/continue-here.md +78 -0
- package/framework/qualia-engine/templates/debug-subagent-prompt.md +91 -0
- package/framework/qualia-engine/templates/discovery.md +146 -0
- package/framework/qualia-engine/templates/milestone-archive.md +123 -0
- package/framework/qualia-engine/templates/milestone.md +115 -0
- package/framework/qualia-engine/templates/phase-prompt.md +567 -0
- package/framework/qualia-engine/templates/planner-subagent-prompt.md +117 -0
- package/framework/qualia-engine/templates/project.md +184 -0
- package/framework/qualia-engine/templates/projects/ai-agent.md +156 -0
- package/framework/qualia-engine/templates/projects/mobile-app.md +181 -0
- package/framework/qualia-engine/templates/projects/voice-agent.md +134 -0
- package/framework/qualia-engine/templates/projects/website.md +137 -0
- package/framework/qualia-engine/templates/requirements.md +231 -0
- package/framework/qualia-engine/templates/research-project/ARCHITECTURE.md +204 -0
- package/framework/qualia-engine/templates/research-project/FEATURES.md +147 -0
- package/framework/qualia-engine/templates/research-project/PITFALLS.md +200 -0
- package/framework/qualia-engine/templates/research-project/STACK.md +120 -0
- package/framework/qualia-engine/templates/research-project/SUMMARY.md +170 -0
- package/framework/qualia-engine/templates/research.md +552 -0
- package/framework/qualia-engine/templates/roadmap.md +202 -0
- package/framework/qualia-engine/templates/state.md +176 -0
- package/framework/qualia-engine/templates/summary-complex.md +59 -0
- package/framework/qualia-engine/templates/summary-minimal.md +41 -0
- package/framework/qualia-engine/templates/summary-standard.md +48 -0
- package/framework/qualia-engine/templates/summary.md +246 -0
- package/framework/qualia-engine/templates/user-setup.md +311 -0
- package/framework/qualia-engine/templates/verification-report.md +322 -0
- package/framework/qualia-engine/workflows/add-phase.md +179 -0
- package/framework/qualia-engine/workflows/add-todo.md +157 -0
- package/framework/qualia-engine/workflows/audit-milestone.md +241 -0
- package/framework/qualia-engine/workflows/check-todos.md +176 -0
- package/framework/qualia-engine/workflows/complete-milestone.md +858 -0
- package/framework/qualia-engine/workflows/diagnose-issues.md +219 -0
- package/framework/qualia-engine/workflows/discovery-phase.md +289 -0
- package/framework/qualia-engine/workflows/discuss-phase.md +534 -0
- package/framework/qualia-engine/workflows/execute-phase.md +559 -0
- package/framework/qualia-engine/workflows/execute-plan.md +438 -0
- package/framework/qualia-engine/workflows/help.md +470 -0
- package/framework/qualia-engine/workflows/insert-phase.md +220 -0
- package/framework/qualia-engine/workflows/list-phase-assumptions.md +178 -0
- package/framework/qualia-engine/workflows/map-codebase.md +327 -0
- package/framework/qualia-engine/workflows/new-milestone.md +363 -0
- package/framework/qualia-engine/workflows/new-project.md +1037 -0
- package/framework/qualia-engine/workflows/pause-work.md +122 -0
- package/framework/qualia-engine/workflows/plan-milestone-gaps.md +256 -0
- package/framework/qualia-engine/workflows/plan-phase.md +422 -0
- package/framework/qualia-engine/workflows/progress.md +354 -0
- package/framework/qualia-engine/workflows/quick.md +252 -0
- package/framework/qualia-engine/workflows/remove-phase.md +326 -0
- package/framework/qualia-engine/workflows/research-phase.md +74 -0
- package/framework/qualia-engine/workflows/resume-project.md +306 -0
- package/framework/qualia-engine/workflows/set-profile.md +80 -0
- package/framework/qualia-engine/workflows/settings.md +145 -0
- package/framework/qualia-engine/workflows/transition.md +556 -0
- package/framework/qualia-engine/workflows/update.md +197 -0
- package/framework/qualia-engine/workflows/verify-phase.md +195 -0
- package/framework/qualia-engine/workflows/verify-work.md +625 -0
- package/framework/rules/context7.md +11 -0
- package/framework/rules/deployment.md +29 -0
- package/framework/rules/frontend.md +33 -0
- package/framework/rules/security.md +12 -0
- package/framework/rules/speed.md +20 -0
- package/framework/scripts/__pycache__/say.cpython-314.pyc +0 -0
- package/framework/scripts/apply-retention.sh +120 -0
- package/framework/scripts/bootstrap-pop-os.sh +354 -0
- package/framework/scripts/claude-voice +13 -0
- package/framework/scripts/cleanup.sh +131 -0
- package/framework/scripts/cowork-mode.sh +141 -0
- package/framework/scripts/generate-project-claude-md.sh +153 -0
- package/framework/scripts/load-test-webhook.js +172 -0
- package/framework/scripts/say.py +236 -0
- package/framework/scripts/showcase-video-recorder/ffmpeg-builder.js +167 -0
- package/framework/scripts/showcase-video-recorder/playwright-helpers.js +216 -0
- package/framework/scripts/speak.py +55 -0
- package/framework/scripts/speak.sh +18 -0
- package/framework/scripts/status.sh +138 -0
- package/framework/scripts/sync-to-framework.sh +65 -0
- package/framework/scripts/voice-hotkey.py +227 -0
- package/framework/scripts/voice-input.sh +51 -0
- package/framework/skills/animate/SKILL.md +202 -0
- package/framework/skills/bolder/SKILL.md +144 -0
- package/framework/skills/browser-qa/SKILL.md +536 -0
- package/framework/skills/clarify/SKILL.md +179 -0
- package/framework/skills/colorize/SKILL.md +170 -0
- package/framework/skills/critique/SKILL.md +126 -0
- package/framework/skills/deep-research/SKILL.md +271 -0
- package/framework/skills/delight/SKILL.md +329 -0
- package/framework/skills/deploy/SKILL.md +261 -0
- package/framework/skills/deploy-verify/SKILL.md +377 -0
- package/framework/skills/deploy-verify/scripts/canary-check.sh +206 -0
- package/framework/skills/deploy-verify/scripts/check-console-errors.js +147 -0
- package/framework/skills/deploy-verify/scripts/check-cwv.js +139 -0
- package/framework/skills/deploy-verify/scripts/project-detect.sh +84 -0
- package/framework/skills/deploy-verify/scripts/verify.sh +548 -0
- package/framework/skills/design-quieter/SKILL.md +130 -0
- package/framework/skills/distill/SKILL.md +149 -0
- package/framework/skills/docs-lookup/SKILL.md +78 -0
- package/framework/skills/fcm-notifications/SKILL.md +125 -0
- package/framework/skills/financial-ledger/SKILL.md +1039 -0
- package/framework/skills/frontend-master/NOTICE.md +4 -0
- package/framework/skills/frontend-master/SKILL.md +127 -0
- package/framework/skills/frontend-master/reference/color-and-contrast.md +132 -0
- package/framework/skills/frontend-master/reference/interaction-design.md +123 -0
- package/framework/skills/frontend-master/reference/motion-design.md +99 -0
- package/framework/skills/frontend-master/reference/responsive-design.md +114 -0
- package/framework/skills/frontend-master/reference/spatial-design.md +100 -0
- package/framework/skills/frontend-master/reference/typography.md +131 -0
- package/framework/skills/frontend-master/reference/ux-writing.md +107 -0
- package/framework/skills/harden/SKILL.md +357 -0
- package/framework/skills/i18n-rtl/SKILL.md +752 -0
- package/framework/skills/learn/SKILL.md +71 -0
- package/framework/skills/memory/SKILL.md +50 -0
- package/framework/skills/mobile-expo/SKILL.md +864 -0
- package/framework/skills/mobile-expo/references/store-checklist.md +550 -0
- package/framework/skills/nestjs-backend/README.md +73 -0
- package/framework/skills/nestjs-backend/SKILL.md +446 -0
- package/framework/skills/nestjs-backend/references/templates.md +1173 -0
- package/framework/skills/normalize/SKILL.md +79 -0
- package/framework/skills/onboard/SKILL.md +242 -0
- package/framework/skills/polish/SKILL.md +209 -0
- package/framework/skills/pr/SKILL.md +66 -0
- package/framework/skills/qualia/SKILL.md +153 -0
- package/framework/skills/qualia-add-todo/SKILL.md +68 -0
- package/framework/skills/qualia-audit-milestone/SKILL.md +92 -0
- package/framework/skills/qualia-check-todos/SKILL.md +55 -0
- package/framework/skills/qualia-complete-milestone/SKILL.md +108 -0
- package/framework/skills/qualia-debug/SKILL.md +149 -0
- package/framework/skills/qualia-design/SKILL.md +203 -0
- package/framework/skills/qualia-discuss-phase/SKILL.md +72 -0
- package/framework/skills/qualia-execute-phase/SKILL.md +86 -0
- package/framework/skills/qualia-help/SKILL.md +67 -0
- package/framework/skills/qualia-idk/SKILL.md +352 -0
- package/framework/skills/qualia-list-phase-assumptions/SKILL.md +67 -0
- package/framework/skills/qualia-new-milestone/SKILL.md +72 -0
- package/framework/skills/qualia-new-project/SKILL.md +92 -0
- package/framework/skills/qualia-optimize/SKILL.md +417 -0
- package/framework/skills/qualia-pause-work/SKILL.md +96 -0
- package/framework/skills/qualia-plan-milestone-gaps/SKILL.md +57 -0
- package/framework/skills/qualia-plan-phase/SKILL.md +101 -0
- package/framework/skills/qualia-progress/SKILL.md +53 -0
- package/framework/skills/qualia-quick/SKILL.md +89 -0
- package/framework/skills/qualia-research-phase/SKILL.md +88 -0
- package/framework/skills/qualia-resume-work/SKILL.md +62 -0
- package/framework/skills/qualia-review/SKILL.md +263 -0
- package/framework/skills/qualia-start/SKILL.md +182 -0
- package/framework/skills/qualia-verify-work/SKILL.md +105 -0
- package/framework/skills/qualia-workflow/SKILL.md +130 -0
- package/framework/skills/rag/SKILL.md +750 -0
- package/framework/skills/responsive/SKILL.md +231 -0
- package/framework/skills/retro/SKILL.md +284 -0
- package/framework/skills/sakani-conventions/SKILL.md +136 -0
- package/framework/skills/sakani-conventions/evals/evals.json +23 -0
- package/framework/skills/sakani-conventions/references/entities.md +365 -0
- package/framework/skills/sakani-conventions/references/error-codes.md +95 -0
- package/framework/skills/seo-master/SKILL.md +490 -0
- package/framework/skills/seo-master/references/checklist.md +199 -0
- package/framework/skills/seo-master/references/structured-data.md +609 -0
- package/framework/skills/ship/SKILL.md +202 -0
- package/framework/skills/stack-researcher/SKILL.md +215 -0
- package/framework/skills/status/SKILL.md +154 -0
- package/framework/skills/status/scripts/health-check.sh +562 -0
- package/framework/skills/subscription-payments/SKILL.md +250 -0
- package/framework/skills/supabase/SKILL.md +973 -0
- package/framework/skills/supabase/references/templates.md +159 -0
- package/framework/skills/team/SKILL.md +67 -0
- package/framework/skills/test-runner/SKILL.md +202 -0
- package/framework/skills/voice-agent/SKILL.md +407 -0
- package/framework/skills/zoho-workflow/SKILL.md +51 -0
- package/framework/statusline-command.sh +117 -0
- package/package.json +24 -0
- package/profiles/fawzi.json +16 -0
- package/profiles/hasan.json +16 -0
- package/profiles/moayad.json +16 -0
- package/templates/CLAUDE-owner.md +52 -0
- package/templates/CLAUDE.md.hbs +58 -0
- package/templates/env.claude.template +12 -0
- package/templates/settings.json +141 -0
|
@@ -0,0 +1,1173 @@
|
|
|
1
|
+
# NestJS Backend Templates
|
|
2
|
+
|
|
3
|
+
Production-quality starter templates for common NestJS patterns. All templates use Supabase as the database and Zod for validation.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## 1. Complete Feature Module
|
|
8
|
+
|
|
9
|
+
Full working example of a feature module with all layers (module, controller, service, DTO, spec).
|
|
10
|
+
|
|
11
|
+
### building.module.ts
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { Module } from '@nestjs/common';
|
|
15
|
+
import { BuildingController } from './controllers/building.controller';
|
|
16
|
+
import { BuildingService } from './services/building.service';
|
|
17
|
+
import { SupabaseService } from '../database/supabase.service';
|
|
18
|
+
import { LoggerService } from '../logger/logger.service';
|
|
19
|
+
|
|
20
|
+
@Module({
|
|
21
|
+
controllers: [BuildingController],
|
|
22
|
+
providers: [BuildingService, SupabaseService, LoggerService],
|
|
23
|
+
exports: [BuildingService],
|
|
24
|
+
})
|
|
25
|
+
export class BuildingModule {}
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
### controllers/building.controller.ts
|
|
29
|
+
|
|
30
|
+
```typescript
|
|
31
|
+
import {
|
|
32
|
+
Controller,
|
|
33
|
+
Get,
|
|
34
|
+
Post,
|
|
35
|
+
Body,
|
|
36
|
+
Param,
|
|
37
|
+
UseGuards,
|
|
38
|
+
BadRequestException,
|
|
39
|
+
} from '@nestjs/common';
|
|
40
|
+
import { BuildingService } from '../services/building.service';
|
|
41
|
+
import { AuthGuard } from '../../common/guards/auth.guard';
|
|
42
|
+
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
|
43
|
+
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
|
44
|
+
import { JwtPayload } from '../../common/types/jwt.types';
|
|
45
|
+
import {
|
|
46
|
+
CreateBuildingDtoSchema,
|
|
47
|
+
CreateBuildingDto,
|
|
48
|
+
} from '../dtos/create-building.dto';
|
|
49
|
+
import {
|
|
50
|
+
UpdateBuildingDtoSchema,
|
|
51
|
+
UpdateBuildingDto,
|
|
52
|
+
} from '../dtos/update-building.dto';
|
|
53
|
+
|
|
54
|
+
@Controller('buildings')
|
|
55
|
+
@UseGuards(AuthGuard)
|
|
56
|
+
export class BuildingController {
|
|
57
|
+
constructor(private readonly buildingService: BuildingService) {}
|
|
58
|
+
|
|
59
|
+
@Post()
|
|
60
|
+
async create(
|
|
61
|
+
@Body(new ZodValidationPipe(CreateBuildingDtoSchema))
|
|
62
|
+
dto: CreateBuildingDto,
|
|
63
|
+
@CurrentUser() user: JwtPayload,
|
|
64
|
+
) {
|
|
65
|
+
const building = await this.buildingService.create(dto, user.id);
|
|
66
|
+
return { data: building };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
@Get()
|
|
70
|
+
async findAll(@CurrentUser() user: JwtPayload) {
|
|
71
|
+
const buildings = await this.buildingService.findByOwner(user.id);
|
|
72
|
+
return { data: buildings };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@Get(':id')
|
|
76
|
+
async findOne(
|
|
77
|
+
@Param('id') id: string,
|
|
78
|
+
@CurrentUser() user: JwtPayload,
|
|
79
|
+
) {
|
|
80
|
+
const building = await this.buildingService.findById(id, user.id);
|
|
81
|
+
return { data: building };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@Post(':id')
|
|
85
|
+
async update(
|
|
86
|
+
@Param('id') id: string,
|
|
87
|
+
@Body(new ZodValidationPipe(UpdateBuildingDtoSchema))
|
|
88
|
+
dto: UpdateBuildingDto,
|
|
89
|
+
@CurrentUser() user: JwtPayload,
|
|
90
|
+
) {
|
|
91
|
+
const building = await this.buildingService.update(id, dto, user.id);
|
|
92
|
+
return { data: building };
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### services/building.service.ts
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import { Injectable } from '@nestjs/common';
|
|
101
|
+
import { SupabaseService } from '../../database/supabase.service';
|
|
102
|
+
import { LoggerService } from '../../logger/logger.service';
|
|
103
|
+
import { DomainException } from '../../common/exceptions/domain.exception';
|
|
104
|
+
import {
|
|
105
|
+
CreateBuildingDto,
|
|
106
|
+
} from '../dtos/create-building.dto';
|
|
107
|
+
import {
|
|
108
|
+
UpdateBuildingDto,
|
|
109
|
+
} from '../dtos/update-building.dto';
|
|
110
|
+
import { Building } from '../entities/building.entity';
|
|
111
|
+
|
|
112
|
+
@Injectable()
|
|
113
|
+
export class BuildingService {
|
|
114
|
+
constructor(
|
|
115
|
+
private readonly supabase: SupabaseService,
|
|
116
|
+
private readonly logger: LoggerService,
|
|
117
|
+
) {}
|
|
118
|
+
|
|
119
|
+
async create(dto: CreateBuildingDto, userId: string): Promise<Building> {
|
|
120
|
+
// Validate business rules
|
|
121
|
+
const { count, error: countError } = await this.supabase
|
|
122
|
+
.from('buildings')
|
|
123
|
+
.select('id', { count: 'exact', head: true })
|
|
124
|
+
.eq('owner_id', userId);
|
|
125
|
+
|
|
126
|
+
if (countError) {
|
|
127
|
+
this.logger.error('Failed to count buildings', { error: countError });
|
|
128
|
+
throw new DomainException('INTERNAL_ERROR');
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (count >= 100) {
|
|
132
|
+
throw new DomainException('MAX_BUILDINGS_EXCEEDED', {
|
|
133
|
+
limit: 100,
|
|
134
|
+
current: count,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Create record
|
|
139
|
+
const { data, error } = await this.supabase
|
|
140
|
+
.from('buildings')
|
|
141
|
+
.insert([
|
|
142
|
+
{
|
|
143
|
+
...dto,
|
|
144
|
+
owner_id: userId,
|
|
145
|
+
},
|
|
146
|
+
])
|
|
147
|
+
.select()
|
|
148
|
+
.single();
|
|
149
|
+
|
|
150
|
+
if (error) {
|
|
151
|
+
this.logger.error('Failed to create building', { error, dto, userId });
|
|
152
|
+
throw new DomainException('BUILDING_CREATE_FAILED', {
|
|
153
|
+
reason: error.message,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
this.logger.info('Building created', { id: data.id, owner_id: userId });
|
|
158
|
+
return data;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async findByOwner(userId: string): Promise<Building[]> {
|
|
162
|
+
const { data, error } = await this.supabase
|
|
163
|
+
.from('buildings')
|
|
164
|
+
.select('*')
|
|
165
|
+
.eq('owner_id', userId)
|
|
166
|
+
.order('created_at', { ascending: false });
|
|
167
|
+
|
|
168
|
+
if (error) {
|
|
169
|
+
this.logger.error('Failed to fetch buildings', { error, userId });
|
|
170
|
+
throw new DomainException('INTERNAL_ERROR');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return data ?? [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async findById(id: string, userId?: string): Promise<Building> {
|
|
177
|
+
const query = this.supabase
|
|
178
|
+
.from('buildings')
|
|
179
|
+
.select('*')
|
|
180
|
+
.eq('id', id);
|
|
181
|
+
|
|
182
|
+
if (userId) {
|
|
183
|
+
query.eq('owner_id', userId);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const { data, error } = await query.single();
|
|
187
|
+
|
|
188
|
+
if (error || !data) {
|
|
189
|
+
throw new DomainException('BUILDING_NOT_FOUND', { id });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return data;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
async update(
|
|
196
|
+
id: string,
|
|
197
|
+
dto: UpdateBuildingDto,
|
|
198
|
+
userId: string,
|
|
199
|
+
): Promise<Building> {
|
|
200
|
+
// Verify ownership
|
|
201
|
+
await this.findById(id, userId);
|
|
202
|
+
|
|
203
|
+
const { data, error } = await this.supabase
|
|
204
|
+
.from('buildings')
|
|
205
|
+
.update(dto)
|
|
206
|
+
.eq('id', id)
|
|
207
|
+
.eq('owner_id', userId)
|
|
208
|
+
.select()
|
|
209
|
+
.single();
|
|
210
|
+
|
|
211
|
+
if (error) {
|
|
212
|
+
this.logger.error('Failed to update building', { error, id, userId });
|
|
213
|
+
throw new DomainException('BUILDING_UPDATE_FAILED');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
this.logger.info('Building updated', { id, owner_id: userId });
|
|
217
|
+
return data;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
### dtos/create-building.dto.ts
|
|
223
|
+
|
|
224
|
+
```typescript
|
|
225
|
+
import { z } from 'zod';
|
|
226
|
+
|
|
227
|
+
export const CreateBuildingDtoSchema = z.object({
|
|
228
|
+
nameAr: z.string().min(1).max(200),
|
|
229
|
+
nameEn: z.string().min(1).max(200),
|
|
230
|
+
buildingType: z.enum(['RESIDENTIAL', 'COMMERCIAL', 'MIXED']),
|
|
231
|
+
unitCount: z.number().int().positive().max(10000),
|
|
232
|
+
address: z.string().min(1).max(500).optional(),
|
|
233
|
+
latitude: z.number().min(-90).max(90).optional(),
|
|
234
|
+
longitude: z.number().min(-180).max(180).optional(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
export type CreateBuildingDto = z.infer<typeof CreateBuildingDtoSchema>;
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### dtos/update-building.dto.ts
|
|
241
|
+
|
|
242
|
+
```typescript
|
|
243
|
+
import { z } from 'zod';
|
|
244
|
+
|
|
245
|
+
export const UpdateBuildingDtoSchema = z.object({
|
|
246
|
+
nameAr: z.string().min(1).max(200).optional(),
|
|
247
|
+
nameEn: z.string().min(1).max(200).optional(),
|
|
248
|
+
buildingType: z.enum(['RESIDENTIAL', 'COMMERCIAL', 'MIXED']).optional(),
|
|
249
|
+
unitCount: z.number().int().positive().max(10000).optional(),
|
|
250
|
+
address: z.string().min(1).max(500).optional(),
|
|
251
|
+
latitude: z.number().min(-90).max(90).optional(),
|
|
252
|
+
longitude: z.number().min(-180).max(180).optional(),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
export type UpdateBuildingDto = z.infer<typeof UpdateBuildingDtoSchema>;
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### entities/building.entity.ts
|
|
259
|
+
|
|
260
|
+
```typescript
|
|
261
|
+
export interface Building {
|
|
262
|
+
id: string;
|
|
263
|
+
owner_id: string;
|
|
264
|
+
nameAr: string;
|
|
265
|
+
nameEn: string;
|
|
266
|
+
buildingType: 'RESIDENTIAL' | 'COMMERCIAL' | 'MIXED';
|
|
267
|
+
unitCount: number;
|
|
268
|
+
address?: string;
|
|
269
|
+
latitude?: number;
|
|
270
|
+
longitude?: number;
|
|
271
|
+
created_at: string;
|
|
272
|
+
updated_at: string;
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
### __tests__/building.service.spec.ts
|
|
277
|
+
|
|
278
|
+
```typescript
|
|
279
|
+
import { Test, TestingModule } from '@nestjs/testing';
|
|
280
|
+
import { BuildingService } from '../services/building.service';
|
|
281
|
+
import { SupabaseService } from '../../database/supabase.service';
|
|
282
|
+
import { LoggerService } from '../../logger/logger.service';
|
|
283
|
+
import { DomainException } from '../../common/exceptions/domain.exception';
|
|
284
|
+
import { CreateBuildingDto } from '../dtos/create-building.dto';
|
|
285
|
+
|
|
286
|
+
describe('BuildingService', () => {
|
|
287
|
+
let service: BuildingService;
|
|
288
|
+
let mockSupabase: jest.Mocked<SupabaseService>;
|
|
289
|
+
let mockLogger: jest.Mocked<LoggerService>;
|
|
290
|
+
|
|
291
|
+
beforeEach(async () => {
|
|
292
|
+
mockSupabase = {
|
|
293
|
+
from: jest.fn(),
|
|
294
|
+
} as any;
|
|
295
|
+
|
|
296
|
+
mockLogger = {
|
|
297
|
+
error: jest.fn(),
|
|
298
|
+
info: jest.fn(),
|
|
299
|
+
warn: jest.fn(),
|
|
300
|
+
debug: jest.fn(),
|
|
301
|
+
} as any;
|
|
302
|
+
|
|
303
|
+
const module: TestingModule = await Test.createTestingModule({
|
|
304
|
+
providers: [
|
|
305
|
+
BuildingService,
|
|
306
|
+
{ provide: SupabaseService, useValue: mockSupabase },
|
|
307
|
+
{ provide: LoggerService, useValue: mockLogger },
|
|
308
|
+
],
|
|
309
|
+
}).compile();
|
|
310
|
+
|
|
311
|
+
service = module.get<BuildingService>(BuildingService);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
describe('create', () => {
|
|
315
|
+
it('should create a building successfully', async () => {
|
|
316
|
+
const dto: CreateBuildingDto = {
|
|
317
|
+
nameAr: 'مبنى تجريبي',
|
|
318
|
+
nameEn: 'Test Building',
|
|
319
|
+
buildingType: 'RESIDENTIAL',
|
|
320
|
+
unitCount: 20,
|
|
321
|
+
address: '123 Main St',
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
const mockResult = {
|
|
325
|
+
id: '1',
|
|
326
|
+
owner_id: 'user-1',
|
|
327
|
+
...dto,
|
|
328
|
+
created_at: new Date().toISOString(),
|
|
329
|
+
updated_at: new Date().toISOString(),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
mockSupabase.from.mockReturnValue({
|
|
333
|
+
select: jest.fn().mockReturnThis(),
|
|
334
|
+
eq: jest.fn().mockReturnThis(),
|
|
335
|
+
count: jest.fn().mockResolvedValue({ count: 0, error: null }),
|
|
336
|
+
insert: jest.fn().mockReturnThis(),
|
|
337
|
+
single: jest.fn().mockResolvedValue({ data: mockResult, error: null }),
|
|
338
|
+
} as any);
|
|
339
|
+
|
|
340
|
+
const result = await service.create(dto, 'user-1');
|
|
341
|
+
expect(result.id).toBe('1');
|
|
342
|
+
expect(result.nameEn).toBe('Test Building');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('should throw DomainException if max buildings exceeded', async () => {
|
|
346
|
+
const dto: CreateBuildingDto = {
|
|
347
|
+
nameAr: 'مبنى',
|
|
348
|
+
nameEn: 'Building',
|
|
349
|
+
buildingType: 'RESIDENTIAL',
|
|
350
|
+
unitCount: 10,
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
mockSupabase.from.mockReturnValue({
|
|
354
|
+
select: jest.fn().mockReturnThis(),
|
|
355
|
+
eq: jest.fn().mockReturnThis(),
|
|
356
|
+
count: jest.fn().mockResolvedValue({ count: 100, error: null }),
|
|
357
|
+
} as any);
|
|
358
|
+
|
|
359
|
+
await expect(service.create(dto, 'user-1')).rejects.toThrow(
|
|
360
|
+
DomainException,
|
|
361
|
+
);
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('findById', () => {
|
|
366
|
+
it('should find a building by id', async () => {
|
|
367
|
+
const mockBuilding = { id: '1', nameEn: 'Test', owner_id: 'user-1' };
|
|
368
|
+
|
|
369
|
+
mockSupabase.from.mockReturnValue({
|
|
370
|
+
select: jest.fn().mockReturnThis(),
|
|
371
|
+
eq: jest.fn().mockReturnThis(),
|
|
372
|
+
single: jest.fn().mockResolvedValue({ data: mockBuilding, error: null }),
|
|
373
|
+
} as any);
|
|
374
|
+
|
|
375
|
+
const result = await service.findById('1', 'user-1');
|
|
376
|
+
expect(result.id).toBe('1');
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('should throw DomainException if building not found', async () => {
|
|
380
|
+
mockSupabase.from.mockReturnValue({
|
|
381
|
+
select: jest.fn().mockReturnThis(),
|
|
382
|
+
eq: jest.fn().mockReturnThis(),
|
|
383
|
+
single: jest
|
|
384
|
+
.fn()
|
|
385
|
+
.mockResolvedValue({ data: null, error: { message: 'Not found' } }),
|
|
386
|
+
} as any);
|
|
387
|
+
|
|
388
|
+
await expect(service.findById('999', 'user-1')).rejects.toThrow(
|
|
389
|
+
DomainException,
|
|
390
|
+
);
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
---
|
|
397
|
+
|
|
398
|
+
## 2. Global Exception Filter
|
|
399
|
+
|
|
400
|
+
Centralized error handling that converts domain exceptions to HTTP responses.
|
|
401
|
+
|
|
402
|
+
### common/filters/exception.filter.ts
|
|
403
|
+
|
|
404
|
+
```typescript
|
|
405
|
+
import {
|
|
406
|
+
ExceptionFilter,
|
|
407
|
+
Catch,
|
|
408
|
+
ArgumentsHost,
|
|
409
|
+
HttpStatus,
|
|
410
|
+
BadRequestException,
|
|
411
|
+
} from '@nestjs/common';
|
|
412
|
+
import { Response } from 'express';
|
|
413
|
+
import { DomainException } from '../exceptions/domain.exception';
|
|
414
|
+
import { LoggerService } from '../../logger/logger.service';
|
|
415
|
+
|
|
416
|
+
interface ErrorResponse {
|
|
417
|
+
http_status: number;
|
|
418
|
+
error_code: string;
|
|
419
|
+
message_key: string;
|
|
420
|
+
request_id: string;
|
|
421
|
+
details?: Record<string, any>;
|
|
422
|
+
timestamp: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
@Catch()
|
|
426
|
+
export class GlobalExceptionFilter implements ExceptionFilter {
|
|
427
|
+
constructor(private readonly logger: LoggerService) {}
|
|
428
|
+
|
|
429
|
+
catch(exception: unknown, host: ArgumentsHost) {
|
|
430
|
+
const ctx = host.switchToHttp();
|
|
431
|
+
const response = ctx.getResponse<Response>();
|
|
432
|
+
const request = ctx.getRequest();
|
|
433
|
+
const requestId = request.id || 'unknown';
|
|
434
|
+
|
|
435
|
+
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
|
436
|
+
let errorCode = 'INTERNAL_ERROR';
|
|
437
|
+
let messageKey = 'error.internal_error';
|
|
438
|
+
let details: Record<string, any> | undefined;
|
|
439
|
+
|
|
440
|
+
// Handle domain exceptions
|
|
441
|
+
if (exception instanceof DomainException) {
|
|
442
|
+
({ statusCode, errorCode, messageKey, details } =
|
|
443
|
+
this.handleDomainException(exception));
|
|
444
|
+
this.logger.warn('Domain exception', {
|
|
445
|
+
errorCode,
|
|
446
|
+
details,
|
|
447
|
+
requestId,
|
|
448
|
+
path: request.path,
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
// Handle validation errors (Zod)
|
|
452
|
+
else if (exception instanceof BadRequestException) {
|
|
453
|
+
statusCode = HttpStatus.BAD_REQUEST;
|
|
454
|
+
errorCode = 'VALIDATION_FAILED';
|
|
455
|
+
messageKey = 'error.validation_failed';
|
|
456
|
+
const errorResponse = exception.getResponse();
|
|
457
|
+
if (typeof errorResponse === 'object' && 'message' in errorResponse) {
|
|
458
|
+
details = (errorResponse as any).message;
|
|
459
|
+
}
|
|
460
|
+
this.logger.warn('Validation error', {
|
|
461
|
+
details,
|
|
462
|
+
requestId,
|
|
463
|
+
path: request.path,
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
// Handle generic errors
|
|
467
|
+
else {
|
|
468
|
+
this.logger.error('Unhandled exception', {
|
|
469
|
+
error: exception instanceof Error ? exception.message : String(exception),
|
|
470
|
+
stack: exception instanceof Error ? exception.stack : undefined,
|
|
471
|
+
requestId,
|
|
472
|
+
path: request.path,
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const errorResponse: ErrorResponse = {
|
|
477
|
+
http_status: statusCode,
|
|
478
|
+
error_code: errorCode,
|
|
479
|
+
message_key: messageKey,
|
|
480
|
+
request_id: requestId,
|
|
481
|
+
...(details && { details }),
|
|
482
|
+
timestamp: new Date().toISOString(),
|
|
483
|
+
};
|
|
484
|
+
|
|
485
|
+
response.status(statusCode).json(errorResponse);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private handleDomainException(
|
|
489
|
+
exception: DomainException,
|
|
490
|
+
): {
|
|
491
|
+
statusCode: number;
|
|
492
|
+
errorCode: string;
|
|
493
|
+
messageKey: string;
|
|
494
|
+
details?: Record<string, any>;
|
|
495
|
+
} {
|
|
496
|
+
const statusMap: Record<string, number> = {
|
|
497
|
+
NOT_FOUND: HttpStatus.NOT_FOUND,
|
|
498
|
+
UNAUTHORIZED: HttpStatus.UNAUTHORIZED,
|
|
499
|
+
FORBIDDEN: HttpStatus.FORBIDDEN,
|
|
500
|
+
VALIDATION_FAILED: HttpStatus.BAD_REQUEST,
|
|
501
|
+
CONFLICT: HttpStatus.CONFLICT,
|
|
502
|
+
INTERNAL_ERROR: HttpStatus.INTERNAL_SERVER_ERROR,
|
|
503
|
+
MAX_BUILDINGS_EXCEEDED: HttpStatus.BAD_REQUEST,
|
|
504
|
+
BUILDING_CREATE_FAILED: HttpStatus.BAD_REQUEST,
|
|
505
|
+
BUILDING_UPDATE_FAILED: HttpStatus.BAD_REQUEST,
|
|
506
|
+
BUILDING_NOT_FOUND: HttpStatus.NOT_FOUND,
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
const statusCode = statusMap[exception.code] || HttpStatus.INTERNAL_SERVER_ERROR;
|
|
510
|
+
const messageKey = `error.${exception.code.toLowerCase()}`;
|
|
511
|
+
|
|
512
|
+
return {
|
|
513
|
+
statusCode,
|
|
514
|
+
errorCode: exception.code,
|
|
515
|
+
messageKey,
|
|
516
|
+
details: exception.details,
|
|
517
|
+
};
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
```
|
|
521
|
+
|
|
522
|
+
### common/exceptions/domain.exception.ts
|
|
523
|
+
|
|
524
|
+
```typescript
|
|
525
|
+
export class DomainException extends Error {
|
|
526
|
+
constructor(
|
|
527
|
+
public readonly code: string,
|
|
528
|
+
public readonly details?: Record<string, any>,
|
|
529
|
+
) {
|
|
530
|
+
super(`Domain error: ${code}`);
|
|
531
|
+
this.name = 'DomainException';
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### app.module.ts (register filter)
|
|
537
|
+
|
|
538
|
+
```typescript
|
|
539
|
+
import { APP_FILTER } from '@nestjs/core';
|
|
540
|
+
import { GlobalExceptionFilter } from './common/filters/exception.filter';
|
|
541
|
+
import { LoggerService } from './logger/logger.service';
|
|
542
|
+
|
|
543
|
+
@Module({
|
|
544
|
+
imports: [/* ... */],
|
|
545
|
+
controllers: [AppController],
|
|
546
|
+
providers: [
|
|
547
|
+
{
|
|
548
|
+
provide: APP_FILTER,
|
|
549
|
+
useClass: GlobalExceptionFilter,
|
|
550
|
+
},
|
|
551
|
+
LoggerService,
|
|
552
|
+
],
|
|
553
|
+
})
|
|
554
|
+
export class AppModule {}
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
---
|
|
558
|
+
|
|
559
|
+
## 3. Auth Guard with JWT Validation
|
|
560
|
+
|
|
561
|
+
Validates JWT token and attaches user to request.
|
|
562
|
+
|
|
563
|
+
### common/guards/auth.guard.ts
|
|
564
|
+
|
|
565
|
+
```typescript
|
|
566
|
+
import {
|
|
567
|
+
Injectable,
|
|
568
|
+
CanActivate,
|
|
569
|
+
ExecutionContext,
|
|
570
|
+
UnauthorizedException,
|
|
571
|
+
} from '@nestjs/common';
|
|
572
|
+
import { ConfigService } from '@nestjs/config';
|
|
573
|
+
import { Request } from 'express';
|
|
574
|
+
import * as jwt from 'jsonwebtoken';
|
|
575
|
+
import { JwtPayload } from '../types/jwt.types';
|
|
576
|
+
|
|
577
|
+
@Injectable()
|
|
578
|
+
export class AuthGuard implements CanActivate {
|
|
579
|
+
constructor(private readonly configService: ConfigService) {}
|
|
580
|
+
|
|
581
|
+
canActivate(context: ExecutionContext): boolean {
|
|
582
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
583
|
+
const authHeader = request.headers.authorization;
|
|
584
|
+
|
|
585
|
+
if (!authHeader) {
|
|
586
|
+
throw new UnauthorizedException('Missing authorization header');
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const [scheme, token] = authHeader.split(' ');
|
|
590
|
+
|
|
591
|
+
if (scheme !== 'Bearer') {
|
|
592
|
+
throw new UnauthorizedException('Invalid authorization scheme');
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
if (!token) {
|
|
596
|
+
throw new UnauthorizedException('Missing bearer token');
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
try {
|
|
600
|
+
const secret = this.configService.get<string>('JWT_SECRET');
|
|
601
|
+
const payload = jwt.verify(token, secret) as JwtPayload;
|
|
602
|
+
|
|
603
|
+
request.user = payload;
|
|
604
|
+
request.id = request.headers['x-request-id'] as string;
|
|
605
|
+
return true;
|
|
606
|
+
} catch (error) {
|
|
607
|
+
if (error instanceof jwt.TokenExpiredError) {
|
|
608
|
+
throw new UnauthorizedException('Token expired');
|
|
609
|
+
}
|
|
610
|
+
if (error instanceof jwt.JsonWebTokenError) {
|
|
611
|
+
throw new UnauthorizedException('Invalid token');
|
|
612
|
+
}
|
|
613
|
+
throw new UnauthorizedException('Unauthorized');
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
```
|
|
618
|
+
|
|
619
|
+
### common/types/jwt.types.ts
|
|
620
|
+
|
|
621
|
+
```typescript
|
|
622
|
+
export interface JwtPayload {
|
|
623
|
+
sub: string; // user ID
|
|
624
|
+
id: string; // user ID (duplicate for compatibility)
|
|
625
|
+
email: string;
|
|
626
|
+
role: 'USER' | 'ADMIN';
|
|
627
|
+
iat: number;
|
|
628
|
+
exp: number;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
declare global {
|
|
632
|
+
namespace Express {
|
|
633
|
+
interface Request {
|
|
634
|
+
user?: JwtPayload;
|
|
635
|
+
id?: string; // request ID
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
```
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
## 4. Roles Guard with Decorator
|
|
644
|
+
|
|
645
|
+
Checks user role against @Roles() decorator on method.
|
|
646
|
+
|
|
647
|
+
### common/guards/roles.guard.ts
|
|
648
|
+
|
|
649
|
+
```typescript
|
|
650
|
+
import {
|
|
651
|
+
Injectable,
|
|
652
|
+
CanActivate,
|
|
653
|
+
ExecutionContext,
|
|
654
|
+
ForbiddenException,
|
|
655
|
+
} from '@nestjs/common';
|
|
656
|
+
import { Reflector } from '@nestjs/core';
|
|
657
|
+
import { Request } from 'express';
|
|
658
|
+
|
|
659
|
+
@Injectable()
|
|
660
|
+
export class RolesGuard implements CanActivate {
|
|
661
|
+
constructor(private readonly reflector: Reflector) {}
|
|
662
|
+
|
|
663
|
+
canActivate(context: ExecutionContext): boolean {
|
|
664
|
+
const requiredRoles = this.reflector.get<string[]>(
|
|
665
|
+
'roles',
|
|
666
|
+
context.getHandler(),
|
|
667
|
+
);
|
|
668
|
+
|
|
669
|
+
if (!requiredRoles || requiredRoles.length === 0) {
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
674
|
+
const user = request.user;
|
|
675
|
+
|
|
676
|
+
if (!user) {
|
|
677
|
+
throw new ForbiddenException('User not authenticated');
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
if (!requiredRoles.includes(user.role)) {
|
|
681
|
+
throw new ForbiddenException(
|
|
682
|
+
`Insufficient permissions. Required roles: ${requiredRoles.join(', ')}`,
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return true;
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
### common/decorators/roles.decorator.ts
|
|
692
|
+
|
|
693
|
+
```typescript
|
|
694
|
+
import { SetMetadata } from '@nestjs/common';
|
|
695
|
+
|
|
696
|
+
export const Roles = (...roles: string[]) => SetMetadata('roles', roles);
|
|
697
|
+
```
|
|
698
|
+
|
|
699
|
+
### Usage in controller
|
|
700
|
+
|
|
701
|
+
```typescript
|
|
702
|
+
@Post()
|
|
703
|
+
@UseGuards(AuthGuard, RolesGuard)
|
|
704
|
+
@Roles('ADMIN', 'MANAGER')
|
|
705
|
+
async create(@Body() dto: CreateDto) {
|
|
706
|
+
// Only authenticated admins/managers reach here
|
|
707
|
+
}
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
---
|
|
711
|
+
|
|
712
|
+
## 5. Request ID Interceptor
|
|
713
|
+
|
|
714
|
+
Generates or propagates a unique request_id UUID for tracing.
|
|
715
|
+
|
|
716
|
+
### common/interceptors/request-id.interceptor.ts
|
|
717
|
+
|
|
718
|
+
```typescript
|
|
719
|
+
import {
|
|
720
|
+
Injectable,
|
|
721
|
+
NestInterceptor,
|
|
722
|
+
ExecutionContext,
|
|
723
|
+
CallHandler,
|
|
724
|
+
} from '@nestjs/common';
|
|
725
|
+
import { Observable } from 'rxjs';
|
|
726
|
+
import { Request, Response } from 'express';
|
|
727
|
+
import { v4 as uuidv4 } from 'uuid';
|
|
728
|
+
|
|
729
|
+
@Injectable()
|
|
730
|
+
export class RequestIdInterceptor implements NestInterceptor {
|
|
731
|
+
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
|
|
732
|
+
const request = context.switchToHttp().getRequest<Request>();
|
|
733
|
+
const response = context.switchToHttp().getResponse<Response>();
|
|
734
|
+
|
|
735
|
+
// Use existing request ID or generate new one
|
|
736
|
+
const requestId =
|
|
737
|
+
(request.headers['x-request-id'] as string) || uuidv4();
|
|
738
|
+
|
|
739
|
+
request.id = requestId;
|
|
740
|
+
response.setHeader('x-request-id', requestId);
|
|
741
|
+
|
|
742
|
+
return next.handle();
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
### app.module.ts (register interceptor)
|
|
748
|
+
|
|
749
|
+
```typescript
|
|
750
|
+
import { APP_INTERCEPTOR } from '@nestjs/core';
|
|
751
|
+
import { RequestIdInterceptor } from './common/interceptors/request-id.interceptor';
|
|
752
|
+
|
|
753
|
+
@Module({
|
|
754
|
+
providers: [
|
|
755
|
+
{
|
|
756
|
+
provide: APP_INTERCEPTOR,
|
|
757
|
+
useClass: RequestIdInterceptor,
|
|
758
|
+
},
|
|
759
|
+
],
|
|
760
|
+
})
|
|
761
|
+
export class AppModule {}
|
|
762
|
+
```
|
|
763
|
+
|
|
764
|
+
---
|
|
765
|
+
|
|
766
|
+
## 6. Zod Validation Pipe
|
|
767
|
+
|
|
768
|
+
Validates request body against Zod schema.
|
|
769
|
+
|
|
770
|
+
### common/pipes/zod-validation.pipe.ts
|
|
771
|
+
|
|
772
|
+
```typescript
|
|
773
|
+
import {
|
|
774
|
+
PipeTransform,
|
|
775
|
+
Injectable,
|
|
776
|
+
BadRequestException,
|
|
777
|
+
ArgumentMetadata,
|
|
778
|
+
} from '@nestjs/common';
|
|
779
|
+
import { ZodSchema, ZodError } from 'zod';
|
|
780
|
+
|
|
781
|
+
@Injectable()
|
|
782
|
+
export class ZodValidationPipe implements PipeTransform {
|
|
783
|
+
constructor(private schema: ZodSchema) {}
|
|
784
|
+
|
|
785
|
+
transform(value: any, metadata: ArgumentMetadata) {
|
|
786
|
+
try {
|
|
787
|
+
const parsed = this.schema.parse(value);
|
|
788
|
+
return parsed;
|
|
789
|
+
} catch (error) {
|
|
790
|
+
if (error instanceof ZodError) {
|
|
791
|
+
const formatted = error.errors.map((err) => ({
|
|
792
|
+
field: err.path.join('.'),
|
|
793
|
+
message: err.message,
|
|
794
|
+
code: err.code,
|
|
795
|
+
}));
|
|
796
|
+
|
|
797
|
+
throw new BadRequestException({
|
|
798
|
+
message: 'Validation failed',
|
|
799
|
+
errors: formatted,
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
throw error;
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
```
|
|
807
|
+
|
|
808
|
+
### Usage in controller
|
|
809
|
+
|
|
810
|
+
```typescript
|
|
811
|
+
@Post()
|
|
812
|
+
async create(
|
|
813
|
+
@Body(new ZodValidationPipe(CreateBuildingDtoSchema))
|
|
814
|
+
dto: CreateBuildingDto,
|
|
815
|
+
) {
|
|
816
|
+
return { data: await this.buildingService.create(dto) };
|
|
817
|
+
}
|
|
818
|
+
```
|
|
819
|
+
|
|
820
|
+
---
|
|
821
|
+
|
|
822
|
+
## 7. Supabase Database Service
|
|
823
|
+
|
|
824
|
+
Wrapper around Supabase client with RLS and transaction support.
|
|
825
|
+
|
|
826
|
+
### database/supabase.service.ts
|
|
827
|
+
|
|
828
|
+
```typescript
|
|
829
|
+
import { Injectable } from '@nestjs/common';
|
|
830
|
+
import { ConfigService } from '@nestjs/config';
|
|
831
|
+
import { createClient, SupabaseClient } from '@supabase/supabase-js';
|
|
832
|
+
import { LoggerService } from '../logger/logger.service';
|
|
833
|
+
|
|
834
|
+
@Injectable()
|
|
835
|
+
export class SupabaseService {
|
|
836
|
+
private client: SupabaseClient;
|
|
837
|
+
|
|
838
|
+
constructor(
|
|
839
|
+
private readonly configService: ConfigService,
|
|
840
|
+
private readonly logger: LoggerService,
|
|
841
|
+
) {
|
|
842
|
+
const url = this.configService.get<string>('SUPABASE_URL');
|
|
843
|
+
const key = this.configService.get<string>('SUPABASE_ANON_KEY');
|
|
844
|
+
|
|
845
|
+
if (!url || !key) {
|
|
846
|
+
throw new Error('Missing Supabase configuration');
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
this.client = createClient(url, key);
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
/**
|
|
853
|
+
* Get typed access to a table
|
|
854
|
+
* Usage: this.supabase.from('buildings').select('*')
|
|
855
|
+
*/
|
|
856
|
+
from<T = any>(table: string) {
|
|
857
|
+
return this.client.from<T>(table);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
/**
|
|
861
|
+
* Execute a stored procedure (for transactions)
|
|
862
|
+
* Usage: this.supabase.rpc('my_function', { arg1: 'value' })
|
|
863
|
+
*/
|
|
864
|
+
rpc(name: string, params?: Record<string, any>) {
|
|
865
|
+
return this.client.rpc(name, params);
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Direct access to client for advanced operations
|
|
870
|
+
*/
|
|
871
|
+
getClient() {
|
|
872
|
+
return this.client;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Health check
|
|
877
|
+
*/
|
|
878
|
+
async healthCheck(): Promise<boolean> {
|
|
879
|
+
try {
|
|
880
|
+
const { error } = await this.client.from('_supabase_migrations').select('id').limit(1);
|
|
881
|
+
if (error) {
|
|
882
|
+
this.logger.warn('Supabase health check failed', { error });
|
|
883
|
+
return false;
|
|
884
|
+
}
|
|
885
|
+
return true;
|
|
886
|
+
} catch (error) {
|
|
887
|
+
this.logger.error('Supabase health check exception', { error });
|
|
888
|
+
return false;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
```
|
|
893
|
+
|
|
894
|
+
### Usage in services
|
|
895
|
+
|
|
896
|
+
```typescript
|
|
897
|
+
// Select query
|
|
898
|
+
const { data, error } = await this.supabase
|
|
899
|
+
.from('buildings')
|
|
900
|
+
.select('*')
|
|
901
|
+
.eq('owner_id', userId);
|
|
902
|
+
|
|
903
|
+
// Insert
|
|
904
|
+
const { data, error } = await this.supabase
|
|
905
|
+
.from('buildings')
|
|
906
|
+
.insert([{ nameEn: 'Test', owner_id: userId }])
|
|
907
|
+
.select()
|
|
908
|
+
.single();
|
|
909
|
+
|
|
910
|
+
// Update
|
|
911
|
+
const { data, error } = await this.supabase
|
|
912
|
+
.from('buildings')
|
|
913
|
+
.update({ nameEn: 'Updated' })
|
|
914
|
+
.eq('id', buildingId)
|
|
915
|
+
.select()
|
|
916
|
+
.single();
|
|
917
|
+
|
|
918
|
+
// Transaction via stored procedure
|
|
919
|
+
const { data, error } = await this.supabase.rpc('create_building_with_units', {
|
|
920
|
+
p_owner_id: userId,
|
|
921
|
+
p_name_en: 'Test',
|
|
922
|
+
p_unit_count: 10,
|
|
923
|
+
});
|
|
924
|
+
```
|
|
925
|
+
|
|
926
|
+
---
|
|
927
|
+
|
|
928
|
+
## 8. Config Module with Zod Validation
|
|
929
|
+
|
|
930
|
+
Centralized configuration with startup validation.
|
|
931
|
+
|
|
932
|
+
### config/configuration.ts
|
|
933
|
+
|
|
934
|
+
```typescript
|
|
935
|
+
import { z } from 'zod';
|
|
936
|
+
|
|
937
|
+
const envSchema = z.object({
|
|
938
|
+
// Server
|
|
939
|
+
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
|
940
|
+
PORT: z.string().pipe(z.coerce.number()).default('3000'),
|
|
941
|
+
|
|
942
|
+
// Database
|
|
943
|
+
SUPABASE_URL: z.string().url(),
|
|
944
|
+
SUPABASE_ANON_KEY: z.string(),
|
|
945
|
+
SUPABASE_SERVICE_ROLE_KEY: z.string().optional(),
|
|
946
|
+
|
|
947
|
+
// JWT
|
|
948
|
+
JWT_SECRET: z.string().min(32),
|
|
949
|
+
JWT_EXPIRY: z.string().default('24h'),
|
|
950
|
+
|
|
951
|
+
// Logging
|
|
952
|
+
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
|
953
|
+
|
|
954
|
+
// CORS
|
|
955
|
+
CORS_ORIGIN: z.string().default('*'),
|
|
956
|
+
|
|
957
|
+
// Features
|
|
958
|
+
FEATURE_BUILDING_VERIFICATION: z
|
|
959
|
+
.string()
|
|
960
|
+
.transform((v) => v === 'true')
|
|
961
|
+
.default('true'),
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
export type Environment = z.infer<typeof envSchema>;
|
|
965
|
+
|
|
966
|
+
export const validateConfig = (config: Record<string, unknown>): Environment => {
|
|
967
|
+
try {
|
|
968
|
+
return envSchema.parse(config);
|
|
969
|
+
} catch (error) {
|
|
970
|
+
if (error instanceof z.ZodError) {
|
|
971
|
+
const formatted = error.errors
|
|
972
|
+
.map((err) => `${err.path.join('.')}: ${err.message}`)
|
|
973
|
+
.join(', ');
|
|
974
|
+
throw new Error(`Configuration validation failed: ${formatted}`);
|
|
975
|
+
}
|
|
976
|
+
throw error;
|
|
977
|
+
}
|
|
978
|
+
};
|
|
979
|
+
|
|
980
|
+
export default () => ({
|
|
981
|
+
nodeEnv: process.env.NODE_ENV,
|
|
982
|
+
port: parseInt(process.env.PORT || '3000', 10),
|
|
983
|
+
supabase: {
|
|
984
|
+
url: process.env.SUPABASE_URL,
|
|
985
|
+
anonKey: process.env.SUPABASE_ANON_KEY,
|
|
986
|
+
serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY,
|
|
987
|
+
},
|
|
988
|
+
jwt: {
|
|
989
|
+
secret: process.env.JWT_SECRET,
|
|
990
|
+
expiry: process.env.JWT_EXPIRY,
|
|
991
|
+
},
|
|
992
|
+
logging: {
|
|
993
|
+
level: process.env.LOG_LEVEL,
|
|
994
|
+
},
|
|
995
|
+
cors: {
|
|
996
|
+
origin: process.env.CORS_ORIGIN,
|
|
997
|
+
},
|
|
998
|
+
features: {
|
|
999
|
+
buildingVerification: process.env.FEATURE_BUILDING_VERIFICATION === 'true',
|
|
1000
|
+
},
|
|
1001
|
+
});
|
|
1002
|
+
```
|
|
1003
|
+
|
|
1004
|
+
### config/config.module.ts
|
|
1005
|
+
|
|
1006
|
+
```typescript
|
|
1007
|
+
import { Module } from '@nestjs/common';
|
|
1008
|
+
import { ConfigModule as NestConfigModule } from '@nestjs/config';
|
|
1009
|
+
import configuration, { validateConfig } from './configuration';
|
|
1010
|
+
|
|
1011
|
+
@Module({
|
|
1012
|
+
imports: [
|
|
1013
|
+
NestConfigModule.forRoot({
|
|
1014
|
+
load: [configuration],
|
|
1015
|
+
validate: validateConfig,
|
|
1016
|
+
isGlobal: true,
|
|
1017
|
+
expandVariables: true,
|
|
1018
|
+
}),
|
|
1019
|
+
],
|
|
1020
|
+
})
|
|
1021
|
+
export class ConfigModule {}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
### app.module.ts (import config)
|
|
1025
|
+
|
|
1026
|
+
```typescript
|
|
1027
|
+
import { Module } from '@nestjs/common';
|
|
1028
|
+
import { ConfigModule } from './config/config.module';
|
|
1029
|
+
import { BuildingModule } from './modules/building/building.module';
|
|
1030
|
+
|
|
1031
|
+
@Module({
|
|
1032
|
+
imports: [ConfigModule, BuildingModule],
|
|
1033
|
+
})
|
|
1034
|
+
export class AppModule {}
|
|
1035
|
+
```
|
|
1036
|
+
|
|
1037
|
+
### Usage in services
|
|
1038
|
+
|
|
1039
|
+
```typescript
|
|
1040
|
+
import { ConfigService } from '@nestjs/config';
|
|
1041
|
+
import { Environment } from '../config/configuration';
|
|
1042
|
+
|
|
1043
|
+
constructor(private readonly configService: ConfigService<Environment>) {}
|
|
1044
|
+
|
|
1045
|
+
someMethod() {
|
|
1046
|
+
const secret = this.configService.get('JWT_SECRET');
|
|
1047
|
+
const supabaseUrl = this.configService.get('SUPABASE_URL');
|
|
1048
|
+
const isFeatureEnabled = this.configService.get('FEATURE_BUILDING_VERIFICATION');
|
|
1049
|
+
}
|
|
1050
|
+
```
|
|
1051
|
+
|
|
1052
|
+
---
|
|
1053
|
+
|
|
1054
|
+
## 9. Current User Decorator
|
|
1055
|
+
|
|
1056
|
+
Extracts authenticated user from request.
|
|
1057
|
+
|
|
1058
|
+
### common/decorators/current-user.decorator.ts
|
|
1059
|
+
|
|
1060
|
+
```typescript
|
|
1061
|
+
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
|
|
1062
|
+
import { Request } from 'express';
|
|
1063
|
+
import { JwtPayload } from '../types/jwt.types';
|
|
1064
|
+
|
|
1065
|
+
export const CurrentUser = createParamDecorator(
|
|
1066
|
+
(data: unknown, ctx: ExecutionContext): JwtPayload | undefined => {
|
|
1067
|
+
const request = ctx.switchToHttp().getRequest<Request>();
|
|
1068
|
+
return request.user;
|
|
1069
|
+
},
|
|
1070
|
+
);
|
|
1071
|
+
```
|
|
1072
|
+
|
|
1073
|
+
### Usage in controller
|
|
1074
|
+
|
|
1075
|
+
```typescript
|
|
1076
|
+
@Get()
|
|
1077
|
+
async getProfile(@CurrentUser() user: JwtPayload) {
|
|
1078
|
+
return { data: { id: user.id, email: user.email } };
|
|
1079
|
+
}
|
|
1080
|
+
```
|
|
1081
|
+
|
|
1082
|
+
---
|
|
1083
|
+
|
|
1084
|
+
## 10. Logging Service
|
|
1085
|
+
|
|
1086
|
+
Structured logging with request tracing.
|
|
1087
|
+
|
|
1088
|
+
### logger/logger.service.ts
|
|
1089
|
+
|
|
1090
|
+
```typescript
|
|
1091
|
+
import { Injectable } from '@nestjs/common';
|
|
1092
|
+
import { ConfigService } from '@nestjs/config';
|
|
1093
|
+
|
|
1094
|
+
export enum LogLevel {
|
|
1095
|
+
DEBUG = 'debug',
|
|
1096
|
+
INFO = 'info',
|
|
1097
|
+
WARN = 'warn',
|
|
1098
|
+
ERROR = 'error',
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
@Injectable()
|
|
1102
|
+
export class LoggerService {
|
|
1103
|
+
private logLevel: LogLevel = LogLevel.INFO;
|
|
1104
|
+
|
|
1105
|
+
constructor(private readonly configService: ConfigService) {
|
|
1106
|
+
const level = this.configService.get<string>('LOG_LEVEL');
|
|
1107
|
+
if (level) {
|
|
1108
|
+
this.logLevel = level as LogLevel;
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
private shouldLog(level: LogLevel): boolean {
|
|
1113
|
+
const levels = [LogLevel.DEBUG, LogLevel.INFO, LogLevel.WARN, LogLevel.ERROR];
|
|
1114
|
+
return levels.indexOf(level) >= levels.indexOf(this.logLevel);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
debug(message: string, context?: Record<string, any>) {
|
|
1118
|
+
if (this.shouldLog(LogLevel.DEBUG)) {
|
|
1119
|
+
console.debug(
|
|
1120
|
+
JSON.stringify({
|
|
1121
|
+
level: 'debug',
|
|
1122
|
+
timestamp: new Date().toISOString(),
|
|
1123
|
+
message,
|
|
1124
|
+
...context,
|
|
1125
|
+
}),
|
|
1126
|
+
);
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
info(message: string, context?: Record<string, any>) {
|
|
1131
|
+
if (this.shouldLog(LogLevel.INFO)) {
|
|
1132
|
+
console.log(
|
|
1133
|
+
JSON.stringify({
|
|
1134
|
+
level: 'info',
|
|
1135
|
+
timestamp: new Date().toISOString(),
|
|
1136
|
+
message,
|
|
1137
|
+
...context,
|
|
1138
|
+
}),
|
|
1139
|
+
);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
warn(message: string, context?: Record<string, any>) {
|
|
1144
|
+
if (this.shouldLog(LogLevel.WARN)) {
|
|
1145
|
+
console.warn(
|
|
1146
|
+
JSON.stringify({
|
|
1147
|
+
level: 'warn',
|
|
1148
|
+
timestamp: new Date().toISOString(),
|
|
1149
|
+
message,
|
|
1150
|
+
...context,
|
|
1151
|
+
}),
|
|
1152
|
+
);
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
|
|
1156
|
+
error(message: string, context?: Record<string, any>) {
|
|
1157
|
+
if (this.shouldLog(LogLevel.ERROR)) {
|
|
1158
|
+
console.error(
|
|
1159
|
+
JSON.stringify({
|
|
1160
|
+
level: 'error',
|
|
1161
|
+
timestamp: new Date().toISOString(),
|
|
1162
|
+
message,
|
|
1163
|
+
...context,
|
|
1164
|
+
}),
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
```
|
|
1170
|
+
|
|
1171
|
+
---
|
|
1172
|
+
|
|
1173
|
+
All templates are production-ready and follow NestJS best practices. Adapt to your specific needs while maintaining the architectural patterns.
|