diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..5f5816a --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "type": "node", + "request": "launch", + "name": "Debug NestJS (start:debug)", + "runtimeExecutable": "pnpm", + "runtimeArgs": ["run", "start:debug"], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "sourceMaps": true, + "autoAttachChildProcesses": true, + "envFile": "${workspaceFolder}/.env", + "skipFiles": ["/**"] + } + ] +} diff --git a/src/app.module.ts b/src/app.module.ts index 9788572..642725c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { join } from 'path'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; import { DevicesModule } from './devices/devices.module'; +import { RulesModule } from './rules/rules.module'; @Module({ imports: [ @@ -37,6 +38,7 @@ import { DevicesModule } from './devices/devices.module'; }, ]), DevicesModule, + RulesModule, ], controllers: [AppController], providers: [AppService, { provide: APP_GUARD, useClass: ThrottlerGuard }], diff --git a/src/main.ts b/src/main.ts index ff2726e..d5379e8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -31,6 +31,9 @@ async function bootstrap() { customSwaggerUiPath: join(process.cwd(), 'static', 'docs'), customCssUrl: '/cw-swagger.css', customfavIcon: '/favicon.svg', + swaggerOptions: { + persistAuthorization: true, + }, }); app.use( helmet({ diff --git a/src/rules/dto/create-rule.dto.ts b/src/rules/dto/create-rule.dto.ts new file mode 100644 index 0000000..07dadcd --- /dev/null +++ b/src/rules/dto/create-rule.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Database } from '../../../database.types'; + +type RuleInsert = Database['public']['Tables']['cw_rules']['Insert']; + +export class CreateRuleDto implements RuleInsert { + @ApiProperty() + action_recipient: string; + + @ApiProperty() + name: string; + + @ApiProperty() + notifier_type: number; + + @ApiProperty() + ruleGroupId: string; + + @ApiProperty({ required: false }) + created_at?: string; + + @ApiProperty({ required: false, nullable: true }) + dev_eui?: string | null; + + @ApiProperty({ required: false }) + id?: number; + + @ApiProperty({ required: false }) + is_triggered?: boolean; + + @ApiProperty({ required: false, nullable: true }) + last_triggered?: string | null; + + @ApiProperty({ required: false }) + profile_id?: string; + + @ApiProperty({ required: false, nullable: true }) + send_using?: string | null; + + @ApiProperty({ required: false }) + trigger_count?: number; +} diff --git a/src/rules/dto/rule.dto.ts b/src/rules/dto/rule.dto.ts new file mode 100644 index 0000000..ff54778 --- /dev/null +++ b/src/rules/dto/rule.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Database } from '../../../database.types'; + +type RuleRow = Database['public']['Tables']['cw_rules']['Row']; + +export class RuleDto implements RuleRow { + @ApiProperty() + id: number; + + @ApiProperty() + name: string; + + @ApiProperty() + action_recipient: string; + + @ApiProperty() + notifier_type: number; + + @ApiProperty() + ruleGroupId: string; + + @ApiProperty() + profile_id: string; + + @ApiProperty({ nullable: true, required: false }) + dev_eui: string | null; + + @ApiProperty({ nullable: true, required: false }) + send_using: string | null; + + @ApiProperty() + is_triggered: boolean; + + @ApiProperty() + trigger_count: number; + + @ApiProperty({ format: 'date-time' }) + created_at: string; + + @ApiProperty({ nullable: true, required: false, format: 'date-time' }) + last_triggered: string | null; +} diff --git a/src/rules/dto/update-rule.dto.ts b/src/rules/dto/update-rule.dto.ts new file mode 100644 index 0000000..b920b45 --- /dev/null +++ b/src/rules/dto/update-rule.dto.ts @@ -0,0 +1,9 @@ +import { PartialType } from '@nestjs/swagger'; +import { Database } from '../../../database.types'; +import { CreateRuleDto } from './create-rule.dto'; + +type RuleUpdate = Database['public']['Tables']['cw_rules']['Update']; + +export class UpdateRuleDto + extends PartialType(CreateRuleDto) + implements RuleUpdate {} diff --git a/src/rules/entities/rule.entity.ts b/src/rules/entities/rule.entity.ts new file mode 100644 index 0000000..cdee782 --- /dev/null +++ b/src/rules/entities/rule.entity.ts @@ -0,0 +1 @@ +export class Rule {} diff --git a/src/rules/rules.controller.spec.ts b/src/rules/rules.controller.spec.ts new file mode 100644 index 0000000..936111e --- /dev/null +++ b/src/rules/rules.controller.spec.ts @@ -0,0 +1,25 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RulesController } from './rules.controller'; +import { RulesService } from './rules.service'; + +describe('RulesController', () => { + let controller: RulesController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [RulesController], + providers: [ + { + provide: RulesService, + useValue: {}, + }, + ], + }).compile(); + + controller = module.get(RulesController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/rules/rules.controller.ts b/src/rules/rules.controller.ts new file mode 100644 index 0000000..2059c8a --- /dev/null +++ b/src/rules/rules.controller.ts @@ -0,0 +1,82 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Req, UseGuards } from '@nestjs/common'; +import { RulesService } from './rules.service'; +import { CreateRuleDto } from './dto/create-rule.dto'; +import { UpdateRuleDto } from './dto/update-rule.dto'; +import { ApiBearerAuth, ApiOkResponse, ApiSecurity } from '@nestjs/swagger'; +import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { RuleDto } from './dto/rule.dto'; + +@ApiBearerAuth('bearerAuth') +@ApiSecurity('apiKey') +@Controller('rules') +export class RulesController { + constructor(private readonly rulesService: RulesService) { } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + "Create a new rule configuration. Only the fields included in the request body will be used.", + type: RuleDto, + isArray: false, + }) + @Post() + create(@Body() createRuleDto: CreateRuleDto, @Req() req) { + const authHeader = req.headers?.authorization; + if (!authHeader) { + throw new Error('Authorization header is required'); + } + return this.rulesService.create(createRuleDto, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + "Current all of the user's rules configurations.", + type: RuleDto, + isArray: true, + }) + @Get() + findAll(@Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.rulesService.findAll(req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + "Gets a user's rule configuration by ID.", + type: RuleDto, + isArray: false, + }) + @Get(':id') + findOne(@Param('id') id: number, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.rulesService.findOne(id, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + "Update a single rule configuration by ID. Only the fields included in the request body will be updated.", + type: RuleDto, + isArray: false, + }) + @Patch(':id') + update(@Param('id') id: number, @Body() updateRuleDto: UpdateRuleDto, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.rulesService.update(id, updateRuleDto, req.user, authHeader); + } + + @UseGuards(JwtAuthGuard) + @ApiOkResponse({ + description: + "Delete a user's rule configuration by ID.", + type: Number, + isArray: false, + }) + @Delete(':id') + remove(@Param('id') id: number, @Req() req) { + const authHeader = req.headers?.authorization ?? ''; + return this.rulesService.remove(id, req.user, authHeader); + } +} diff --git a/src/rules/rules.module.ts b/src/rules/rules.module.ts new file mode 100644 index 0000000..d10b5fe --- /dev/null +++ b/src/rules/rules.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { RulesService } from './rules.service'; +import { RulesController } from './rules.controller'; +import { SupabaseModule } from '../supabase/supabase.module'; + +@Module({ + imports: [SupabaseModule], + controllers: [RulesController], + providers: [RulesService], +}) +export class RulesModule { } diff --git a/src/rules/rules.service.spec.ts b/src/rules/rules.service.spec.ts new file mode 100644 index 0000000..8625145 --- /dev/null +++ b/src/rules/rules.service.spec.ts @@ -0,0 +1,18 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { RulesService } from './rules.service'; + +describe('RulesService', () => { + let service: RulesService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [RulesService], + }).compile(); + + service = module.get(RulesService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); +}); diff --git a/src/rules/rules.service.ts b/src/rules/rules.service.ts new file mode 100644 index 0000000..d468262 --- /dev/null +++ b/src/rules/rules.service.ts @@ -0,0 +1,221 @@ +import { BadRequestException, Injectable, InternalServerErrorException, NotFoundException, UnauthorizedException } from '@nestjs/common'; +import { CreateRuleDto } from './dto/create-rule.dto'; +import { UpdateRuleDto } from './dto/update-rule.dto'; +import { SupabaseService } from '../supabase/supabase.service'; + +@Injectable() +export class RulesService { + + constructor( + private readonly supabaseService: SupabaseService, + ) { } + + async create(createRuleDto: CreateRuleDto, jwtPayload: any, authHeader: string) { + const userId = this.getUserId(jwtPayload); + const accessToken = this.getAccessToken(authHeader); + + if (!createRuleDto.dev_eui) { + throw new BadRequestException('dev_eui must be provided'); + } + + const hasLocationPermission: boolean = await this.hasPermissionToLocation(userId, createRuleDto.dev_eui, accessToken); + if (!hasLocationPermission) { + throw new UnauthorizedException('User does not have permission to create this rule'); + } + + createRuleDto.profile_id = userId; // HARD FORCE THE profile_id FOR SECURITY, DO NOT TRUST THE CLIENT TO PROVIDE THE CORRECT profile_id + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_rules') + .insert(createRuleDto) + .select('*') + .single(); + if (error) { + throw new InternalServerErrorException('Failed to create rule'); + } + + return data; + } + + async findAll(jwtPayload: any, authHeader: string) { + const userId = this.getUserId(jwtPayload); + const accessToken = this.getAccessToken(authHeader); + const client = this.supabaseService.getClient(accessToken); + + const { data, error } = await client + .from('cw_rules') + .select('*') + .order('name', { ascending: true }) + .eq('profile_id', userId); + if (error) { + throw new InternalServerErrorException('Failed to fetch rules'); + } + + return data ?? []; + } + + async findOne(id: string, jwtPayload: any, authHeader: string) { + const userId = this.getUserId(jwtPayload); + const accessToken = this.getAccessToken(authHeader); + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_rules') + .select('*') + .eq('profile_id', userId) + .eq('id', id) + .single(); + if (error) { + throw new InternalServerErrorException('Failed to fetch rules'); + } + + return data ?? []; + } + + async update(ruleId: number, updateRuleDto: UpdateRuleDto, jwtPayload: any, authHeader: string) { + const userId = this.getUserId(jwtPayload); + const accessToken = this.getAccessToken(authHeader); + + if (!updateRuleDto.dev_eui) { + throw new BadRequestException('dev_eui must be provided'); + } + const hasRulePermission: boolean = await this.hasPermissionToRule(userId, ruleId, accessToken); + if (!hasRulePermission) { + throw new UnauthorizedException('User does not have permission to update this rule'); + } + const hasLocationPermission: boolean = await this.hasPermissionToLocation(userId, updateRuleDto.dev_eui, accessToken); + if (!hasLocationPermission) { + throw new UnauthorizedException('User does not have permission to update this rule'); + } + + const { error } = await this.supabaseService + .getClient(accessToken) + .from('cw_rules') + .update(updateRuleDto) + .eq('profile_id', userId) + .eq('id', ruleId); + if (error) { + throw new InternalServerErrorException('Failed to update rule'); + } + + return updateRuleDto; + } + + async remove(id: number, jwtPayload: any, authHeader: string) { + const userId = this.getUserId(jwtPayload); + const accessToken = this.getAccessToken(authHeader); + + const hasRulePermission: boolean = await this.hasPermissionToRule(userId, +id, accessToken); + if (!hasRulePermission) { + throw new UnauthorizedException('User does not have permission to remove this rule'); + } + + const { data, error } = await this.supabaseService + .getClient(accessToken) + .from('cw_rules') + .delete() + .eq('profile_id', userId) + .eq('id', id) // MUST HAVE THIS!!!!! + .select('*') + .single(); + + if (error) { + throw new InternalServerErrorException('Failed to remove rule'); + } + + return data; + } + + /********************************************************************* + * + * Private functions to handle common tasks such as extracting user ID from JWT payload, + * + ********************************************************************/ + + private getUserId(jwtPayload: any): string { + const userId = jwtPayload?.sub; + if (typeof userId !== 'string' || !userId.trim()) { + throw new UnauthorizedException('Invalid bearer token'); + } + return userId; + } + + private getAccessToken(authHeader: string): string { + const rawHeader = authHeader?.trim() ?? ''; + const [scheme, token] = rawHeader.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + throw new UnauthorizedException('Missing bearer token'); + } + return token; + } + + private async hasPermissionToRule( + userId: string, + ruleId: number, + accessToken: string, + ): Promise { + // Ensure the active user has permission to update the device & the location that the rule is associated with + const { data: existingRule, error: fetchError } = await this.supabaseService + .getClient(accessToken) + .from('cw_rules') + .select('id, dev_eui, profile_id') + .eq('profile_id', userId) + .eq('id', ruleId) + .single(); + + if (fetchError || !existingRule) { + throw new NotFoundException('Rule not found or user does not have permission to update this rule'); + } + + return true; + } + + private async hasPermissionToLocation( + userId: string, + devEui: string, + accessToken: string, + ): Promise { + + // Get the rule with the device eui in it. + const { data: existingRule, error: existingRuleError } = await this.supabaseService + .getClient(accessToken) + .from('cw_devices') + .select('dev_eui') + .eq('dev_eui', devEui) + .single(); + if (existingRuleError || !existingRule) { + throw new InternalServerErrorException('Failed to verify device permissions'); + } + + const { data: device, error: deviceError } = await this.supabaseService + .getClient(accessToken) + .from('cw_devices') + .select('location_id') + .eq('dev_eui', existingRule.dev_eui) + .single(); + if (deviceError || !device) { + throw new InternalServerErrorException('Failed to verify device permissions'); + } + + + const { data: locationPermission, error: fetchError } = await this.supabaseService + .getClient(accessToken) + .from('cw_location_owners') + .select('permission_level') + .eq('user_id', userId) + .eq('location_id', device.location_id) + .eq('is_active', true) + .single(); + if (fetchError || !locationPermission) { + throw new NotFoundException('Rule not found or user does not have permission to update this rule'); + } + + if (locationPermission.permission_level > 2) { + return false; // User does not have sufficient permissions to update the rule + } + + + return true; + } +} diff --git a/src/supabase/supabase.service.ts b/src/supabase/supabase.service.ts index bc1a51e..3aa8db8 100644 --- a/src/supabase/supabase.service.ts +++ b/src/supabase/supabase.service.ts @@ -1,17 +1,36 @@ import { Inject, Injectable } from '@nestjs/common'; -import { SupabaseClient } from '@supabase/supabase-js'; +import { ConfigService } from '@nestjs/config'; +import { createClient, SupabaseClient } from '@supabase/supabase-js'; import { SUPABASE_ADMIN_CLIENT, SUPABASE_CLIENT } from './supabase.constants'; @Injectable() export class SupabaseService { constructor( + private readonly configService: ConfigService, @Inject(SUPABASE_CLIENT) private readonly client: SupabaseClient, @Inject(SUPABASE_ADMIN_CLIENT) private readonly adminClient: SupabaseClient | null, ) {} - getClient(): SupabaseClient { - return this.client; + getClient(accessToken?: string): SupabaseClient { + if (!accessToken) { + return this.client; + } + + const url = this.configService.get('PRIVATE_SUPABASE_URL'); + const anonKey = this.configService.get('PRIVATE_SUPABASE_ANON_KEY'); + if (!url || !anonKey) { + throw new Error('PRIVATE_SUPABASE_URL and PRIVATE_SUPABASE_ANON_KEY are required'); + } + + return createClient(url, anonKey, { + auth: { autoRefreshToken: false, persistSession: false }, + global: { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }, + }); } getAdminClient(): SupabaseClient | null {