From 8c72bdfbc2d0a499dbfa82c2f322bccaa111a9e0 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 11:50:59 +0900 Subject: [PATCH 01/11] test: fix failing auth spec and stale route-contract snapshot - provide the ConfigService mock AuthService now requires - regenerate the v1 input-contract snapshot for the new /v1/auth/preferences endpoints (additions only) Restores a green suite: 135/135. Co-Authored-By: Claude Fable 5 --- src/v1/auth/auth.service.spec.ts | 7 + .../v1-route-input-contract.spec.ts.snap | 179 ++++++++++++++++++ 2 files changed, 186 insertions(+) diff --git a/src/v1/auth/auth.service.spec.ts b/src/v1/auth/auth.service.spec.ts index df552af..3e968ea 100644 --- a/src/v1/auth/auth.service.spec.ts +++ b/src/v1/auth/auth.service.spec.ts @@ -1,5 +1,6 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BadRequestException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { AuthService } from './auth.service'; import { SupabaseService } from '../../supabase/supabase.service'; @@ -27,6 +28,12 @@ describe('AuthService', () => { getClient: jest.fn(), }, }, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, ], }).compile(); diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index 56859fb..6ea3ce9 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -168,6 +168,18 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot ], "type": "object", }, + "UpdateEmailDto": { + "properties": { + "email": { + "example": "user@example.com", + "type": "string", + }, + }, + "required": [ + "email", + ], + "type": "object", + }, "UpdateLocationDto": { "properties": { "created_at": { @@ -242,6 +254,144 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, "type": "object", }, + "UpdatePreferencesDto": { + "properties": { + "area_unit": { + "enum": [ + "hectares", + "acres", + "square_meters", + ], + "nullable": true, + "type": "string", + }, + "co2_unit": { + "enum": [ + "ppm", + "mg_m3", + ], + "nullable": true, + "type": "string", + }, + "date_format": { + "enum": [ + "yyyy_mm_dd", + "dd_mm_yyyy", + "mm_dd_yyyy", + ], + "nullable": true, + "type": "string", + }, + "distance_unit": { + "enum": [ + "km", + "mi", + ], + "nullable": true, + "type": "string", + }, + "ec_unit": { + "enum": [ + "ms_cm", + "ds_cm", + "us_cm", + ], + "nullable": true, + "type": "string", + }, + "pressure_unit": { + "enum": [ + "hpa", + "kpa", + "bar", + "psi", + ], + "nullable": true, + "type": "string", + }, + "rainfall_unit": { + "enum": [ + "mm", + "cm", + "in", + ], + "nullable": true, + "type": "string", + }, + "soil_moisture_unit": { + "enum": [ + "vwc_percent", + "relative_percent", + "kpa", + "centibar", + ], + "nullable": true, + "type": "string", + }, + "temperature_unit": { + "enum": [ + "celsius", + "fahrenheit", + "kelvin", + ], + "nullable": true, + "type": "string", + }, + "theme": { + "enum": [ + "light", + "dark", + "system", + ], + "nullable": true, + "type": "string", + }, + "time_format": { + "enum": [ + "24h", + "12h", + ], + "nullable": true, + "type": "string", + }, + "timezone": { + "example": "Asia/Tokyo", + "nullable": true, + "type": "object", + }, + "water_level_unit": { + "enum": [ + "cm", + "mm", + "inch", + "foot", + "meter", + "yard", + ], + "nullable": true, + "type": "string", + }, + "weight_unit": { + "enum": [ + "kg", + "lb", + ], + "nullable": true, + "type": "string", + }, + "wind_speed_unit": { + "enum": [ + "m_s", + "km_h", + "mph", + "kt", + ], + "nullable": true, + "type": "string", + }, + }, + "type": "object", + }, "UpdateUserProfileDto": { "properties": { "employer": { @@ -369,6 +519,20 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "/v1/auth": { "get": {}, }, + "/v1/auth/email": { + "patch": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEmailDto", + }, + }, + }, + "required": true, + }, + }, + }, "/v1/auth/login": { "post": { "requestBody": { @@ -397,6 +561,21 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, }, }, + "/v1/auth/preferences": { + "get": {}, + "patch": { + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdatePreferencesDto", + }, + }, + }, + "required": true, + }, + }, + }, "/v1/auth/user-profile": { "get": {}, "patch": { From 2167d0b4003164927c90563dd03f11dbb07e14a3 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 11:51:59 +0900 Subject: [PATCH 02/11] style: eslint --fix pass (prettier formatting) + add lint:check script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-fixes only — no behavior changes. lint:check runs eslint without --fix so CI can gate on it without mutating the tree. Co-Authored-By: Claude Fable 5 --- package.json | 1 + src/app.controller.ts | 1 - src/main.ts | 26 +- src/supabase/supabase-token.helper.ts | 39 ++- src/v1/air/air.service.spec.ts | 4 +- src/v1/auth/auth.controller.spec.ts | 5 +- src/v1/auth/auth.controller.ts | 52 ++- src/v1/auth/auth.service.spec.ts | 17 +- src/v1/auth/auth.service.ts | 41 ++- src/v1/auth/dto/update-preferences.dto.ts | 16 +- src/v1/common/base-data.service.ts | 10 +- src/v1/common/owner-filter.helper.spec.ts | 96 +++--- src/v1/common/owner-filter.helper.ts | 28 +- src/v1/common/permission-levels.ts | 27 +- src/v1/dashboard/dashboard.controller.ts | 47 ++- src/v1/dashboard/dashboard.service.ts | 67 ++-- src/v1/dashboard/dashboard.types.ts | 4 +- src/v1/devices/devices.controller.ts | 234 ++++++++++--- src/v1/devices/devices.service.spec.ts | 67 +++- src/v1/devices/devices.service.ts | 83 +++-- .../dto/UpdateDeviceNameGroupLocal.dto.ts | 57 ++-- .../devices/dto/UpdateDevicePermission.dto.ts | 43 ++- src/v1/devices/dto/create-device-owner.dto.ts | 3 +- src/v1/devices/dto/device.dto.ts | 1 - src/v1/devices/dto/replace-device.dto.ts | 13 +- src/v1/gateway/gateway.service.ts | 14 +- .../dto/create-location-owner.dto.ts | 19 +- src/v1/locations/dto/create-location.dto.ts | 10 +- src/v1/locations/dto/location-owner.dto.ts | 3 +- .../dto/update-location-owner.dto.ts | 4 +- src/v1/locations/locations.controller.ts | 151 +++++++-- src/v1/locations/locations.service.spec.ts | 36 +- src/v1/locations/locations.service.ts | 318 ++++++++++++------ src/v1/mcp/tools/devices.tools.ts | 18 +- .../payments/dto/create-base-checkout.dto.ts | 3 +- src/v1/payments/payments.controller.ts | 48 ++- src/v1/payments/payments.service.ts | 102 ++++-- src/v1/payments/polar.service.ts | 27 +- src/v1/relay/relay-command-profile.ts | 10 +- src/v1/relay/relay-confirmation.ts | 25 +- src/v1/relay/relay.controller.ts | 9 +- src/v1/relay/relay.module.ts | 2 +- src/v1/relay/relay.service.spec.ts | 61 ++-- src/v1/relay/relay.service.ts | 24 +- src/v1/relay/tti-client.ts | 5 +- src/v1/relay/tti-device-id.ts | 7 +- .../dto/report-form-context.dto.ts | 6 +- src/v1/reports-new/dto/report-template.dto.ts | 9 +- .../dto/save-report-template.dto.ts | 18 +- src/v1/reports-new/reports-new.controller.ts | 6 +- src/v1/reports-new/reports-new.service.ts | 133 +++++--- .../dto/rule-template-assignment.dto.ts | 6 +- src/v1/rules-new/rules-new.controller.ts | 16 +- src/v1/rules-new/rules-new.service.spec.ts | 44 ++- src/v1/rules-new/rules-new.service.ts | 37 +- .../traffic/dto/traffic-monthly-report.dto.ts | 10 +- src/v1/traffic/traffic.controller.ts | 14 +- src/v1/traffic/traffic.service.ts | 18 +- 58 files changed, 1552 insertions(+), 643 deletions(-) diff --git a/package.json b/package.json index 5d609f8..3eb5051 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\"", "test": "jest", "test:watch": "jest --watch", "test:cov": "jest --coverage", diff --git a/src/app.controller.ts b/src/app.controller.ts index fed21d1..6d95229 100644 --- a/src/app.controller.ts +++ b/src/app.controller.ts @@ -5,7 +5,6 @@ import type { Response } from 'express'; @Controller() export class AppController { - @Get() @ApiExcludeEndpoint() @ApiOkResponse({ diff --git a/src/main.ts b/src/main.ts index 3ee391c..fdc221f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -28,7 +28,7 @@ async function bootstrap() { // the Polar webhook route can verify the signature over the unmodified payload. const app = await NestFactory.create(AppModule, { rawBody: true }); const logger = new Logger('NestApplication'); - const expressApp = app.getHttpAdapter().getInstance() as any; + const expressApp = app.getHttpAdapter().getInstance(); expressApp.set('trust proxy', true); app.enableCors(); @@ -63,11 +63,13 @@ async function bootstrap() { next(); }); - app.useGlobalPipes(new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true, - })); + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }), + ); app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1', @@ -99,9 +101,15 @@ Developer notes: 'bearerAuth', ) .addApiKey({ type: 'apiKey', in: 'header', name: 'x-api-key' }, 'apiKey') - .setLicense('License & Distribution', 'https://www.cropwatch.io/legal/license') + .setLicense( + 'License & Distribution', + 'https://www.cropwatch.io/legal/license', + ) .setTermsOfService('https://www.cropwatch.io/legal/terms-of-service') - .setExternalDoc('GitHub Repository', 'https://github.com/CropWatchDevelopment/api') + .setExternalDoc( + 'GitHub Repository', + 'https://github.com/CropWatchDevelopment/api', + ) .setContact( 'CropWatch Support', 'https://github.com/CropWatchDevelopment/api/issues', @@ -115,7 +123,7 @@ Developer notes: return { ...doc, paths: Object.fromEntries( - Object.entries(doc.paths).filter(([path]) => path.startsWith(prefix)) + Object.entries(doc.paths).filter(([path]) => path.startsWith(prefix)), ), }; } diff --git a/src/supabase/supabase-token.helper.ts b/src/supabase/supabase-token.helper.ts index 12d12e0..25d9618 100644 --- a/src/supabase/supabase-token.helper.ts +++ b/src/supabase/supabase-token.helper.ts @@ -1,28 +1,31 @@ -import { UnauthorizedException } from "@nestjs/common"; +import { UnauthorizedException } from '@nestjs/common'; /********************************************************************* - * - * Private functions to handle common tasks such as extracting user ID from JWT payload, - * - ********************************************************************/ + * + * Private functions to handle common tasks such as extracting user ID from JWT payload, + * + ********************************************************************/ export function getUserId(jwtPayload: any): string { - const userId = jwtPayload?.sub; - if (typeof userId !== 'string' || !userId.trim()) { - throw new UnauthorizedException('Invalid bearer token'); - } - return userId; + const userId = jwtPayload?.sub; + if (typeof userId !== 'string' || !userId.trim()) { + throw new UnauthorizedException('Invalid bearer token'); + } + return userId; } export function 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; + const rawHeader = authHeader?.trim() ?? ''; + const [scheme, token] = rawHeader.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + throw new UnauthorizedException('Missing bearer token'); + } + return token; } export function isCropwatchStaff(jwtPayload: any): boolean { - const email = jwtPayload?.email; - return typeof email === 'string' && email.trim().toLowerCase().endsWith('@cropwatch.io'); + const email = jwtPayload?.email; + return ( + typeof email === 'string' && + email.trim().toLowerCase().endsWith('@cropwatch.io') + ); } diff --git a/src/v1/air/air.service.spec.ts b/src/v1/air/air.service.spec.ts index f7974d6..a5dabde 100644 --- a/src/v1/air/air.service.spec.ts +++ b/src/v1/air/air.service.spec.ts @@ -125,9 +125,7 @@ describe('AirService', () => { throw new Error(`Unexpected table ${table}`); }); - await expect( - service.createNote(dto, jwtPayload), - ).resolves.toEqual({ + await expect(service.createNote(dto, jwtPayload)).resolves.toEqual({ created_at: resolvedCreatedAt, created_by: 'user-123@example.com', dev_eui: '2CF7F1C073800102', diff --git a/src/v1/auth/auth.controller.spec.ts b/src/v1/auth/auth.controller.spec.ts index e06ef31..09da8b5 100644 --- a/src/v1/auth/auth.controller.spec.ts +++ b/src/v1/auth/auth.controller.spec.ts @@ -39,7 +39,10 @@ describe('AuthController', () => { const result = await controller.login(dto); - expect(mockAuthService.loginWithPassword).toHaveBeenCalledWith(dto.email, dto.password); + expect(mockAuthService.loginWithPassword).toHaveBeenCalledWith( + dto.email, + dto.password, + ); expect(result).toEqual(expected); }); }); diff --git a/src/v1/auth/auth.controller.ts b/src/v1/auth/auth.controller.ts index 11e4632..1cf7c09 100644 --- a/src/v1/auth/auth.controller.ts +++ b/src/v1/auth/auth.controller.ts @@ -1,4 +1,12 @@ -import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Patch, + Post, + Req, + UseGuards, +} from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard } from './guards/jwt.auth.guard'; import { @@ -24,7 +32,7 @@ import { UpdatePreferencesDto } from './dto/update-preferences.dto'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') export class AuthController { - constructor(private readonly authService: AuthService) { } + constructor(private readonly authService: AuthService) {} @Get() @UseGuards(JwtAuthGuard) @@ -61,7 +69,11 @@ export class AuthController { }, }) async getUserProfile(@Req() req) { - return this.authService.getUserProfile(req.user, req.headers?.authorization, req.user); + return this.authService.getUserProfile( + req.user, + req.headers?.authorization, + req.user, + ); } @Patch('user-profile') @@ -93,12 +105,18 @@ export class AuthController { type: ErrorResponseDto, }) async updateUserProfile(@Body() body: UpdateUserProfileDto, @Req() req) { - return this.authService.updateUserProfile(body, req.headers?.authorization, req.user); + return this.authService.updateUserProfile( + body, + req.headers?.authorization, + req.user, + ); } @Patch('email') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Start a verified email change for the authenticated user' }) + @ApiOperation({ + summary: 'Start a verified email change for the authenticated user', + }) @ApiOkResponse({ description: 'Confirmation email sent; the change applies once confirmed.', schema: { type: 'object', additionalProperties: true }, @@ -112,7 +130,8 @@ export class AuthController { type: ErrorResponseDto, }) @ApiForbiddenResponse({ - description: 'CropWatch (cropwatch.io / cropwatch.co.jp) accounts cannot change their email.', + description: + 'CropWatch (cropwatch.io / cropwatch.co.jp) accounts cannot change their email.', type: ErrorResponseDto, }) @ApiInternalServerErrorResponse({ @@ -120,12 +139,18 @@ export class AuthController { type: ErrorResponseDto, }) async updateEmail(@Body() body: UpdateEmailDto, @Req() req) { - return this.authService.updateEmail(req.headers?.authorization, body.email, req.user); + return this.authService.updateEmail( + req.headers?.authorization, + body.email, + req.user, + ); } @Get('preferences') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Get the authenticated user preferences (creates on first access)' }) + @ApiOperation({ + summary: 'Get the authenticated user preferences (creates on first access)', + }) @ApiOkResponse({ description: 'User preferences returned successfully.', schema: { type: 'object', additionalProperties: true }, @@ -139,7 +164,10 @@ export class AuthController { type: ErrorResponseDto, }) async getPreferences(@Req() req) { - return this.authService.getPreferences(req.user, req.headers?.authorization); + return this.authService.getPreferences( + req.user, + req.headers?.authorization, + ); } @Patch('preferences') @@ -162,7 +190,11 @@ export class AuthController { type: ErrorResponseDto, }) async updatePreferences(@Body() body: UpdatePreferencesDto, @Req() req) { - return this.authService.updatePreferences(body, req.headers?.authorization, req.user); + return this.authService.updatePreferences( + body, + req.headers?.authorization, + req.user, + ); } @Throttle({ default: { limit: 2, ttl: 60000 } }) diff --git a/src/v1/auth/auth.service.spec.ts b/src/v1/auth/auth.service.spec.ts index 3e968ea..a85b5f7 100644 --- a/src/v1/auth/auth.service.spec.ts +++ b/src/v1/auth/auth.service.spec.ts @@ -46,11 +46,15 @@ describe('AuthService', () => { describe('loginWithPassword', () => { it('should throw BadRequestException if the email is invalid', async () => { - await expect(service.loginWithPassword('not-an-email', 'StrongPassword123!')).rejects.toThrow(BadRequestException); + await expect( + service.loginWithPassword('not-an-email', 'StrongPassword123!'), + ).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException if the password is missing', async () => { - await expect(service.loginWithPassword('john@example.com', '')).rejects.toThrow(BadRequestException); + await expect( + service.loginWithPassword('john@example.com', ''), + ).rejects.toThrow(BadRequestException); }); it('should throw BadRequestException if supabase signInWithPassword returns error', async () => { @@ -59,7 +63,9 @@ describe('AuthService', () => { error: { message: 'Invalid login credentials' }, }); - await expect(service.loginWithPassword('john@example.com', 'StrongPassword123!')).rejects.toThrow(BadRequestException); + await expect( + service.loginWithPassword('john@example.com', 'StrongPassword123!'), + ).rejects.toThrow(BadRequestException); }); it('should return login metadata when sign in succeeds', async () => { @@ -75,7 +81,10 @@ describe('AuthService', () => { error: null, }); - const result = await service.loginWithPassword('john@example.com', 'StrongPassword123!'); + const result = await service.loginWithPassword( + 'john@example.com', + 'StrongPassword123!', + ); expect(mockSignIn).toHaveBeenCalledWith({ email: 'john@example.com', diff --git a/src/v1/auth/auth.service.ts b/src/v1/auth/auth.service.ts index b6fa968..e9c8cbc 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -8,7 +8,10 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SupabaseService } from '../../supabase/supabase.service'; -import { getAccessToken, getUserId } from '../../supabase/supabase-token.helper'; +import { + getAccessToken, + getUserId, +} from '../../supabase/supabase-token.helper'; import { UpdateUserProfileDto } from './dto/update-user-profile.dto'; import { UpdatePreferencesDto } from './dto/update-preferences.dto'; @@ -131,7 +134,10 @@ export class AuthService { } // Defensive pre-check: username must be unique across all profiles. - if (typeof normalized.username === 'string' && normalized.username.length > 0) { + if ( + typeof normalized.username === 'string' && + normalized.username.length > 0 + ) { const { data: existing, error: lookupError } = await client .from('profiles') .select('id') @@ -141,7 +147,9 @@ export class AuthService { .maybeSingle(); if (lookupError) { - throw new InternalServerErrorException('Failed to verify username availability'); + throw new InternalServerErrorException( + 'Failed to verify username availability', + ); } if (existing) { @@ -178,8 +186,15 @@ export class AuthService { const accessToken = getAccessToken(authHeader); // Corporate CropWatch accounts are locked to their email of record. - const currentEmail = (jwtPayload?.email ?? '').toString().trim().toLowerCase(); - if (RESTRICTED_EMAIL_CHANGE_DOMAINS.some((domain) => currentEmail.endsWith(domain))) { + const currentEmail = (jwtPayload?.email ?? '') + .toString() + .trim() + .toLowerCase(); + if ( + RESTRICTED_EMAIL_CHANGE_DOMAINS.some((domain) => + currentEmail.endsWith(domain), + ) + ) { throw new ForbiddenException( 'CropWatch email addresses (cropwatch.io / cropwatch.co.jp) cannot change their email.', ); @@ -215,7 +230,10 @@ export class AuthService { message?: string; }; const message = - payload.msg || payload.error_description || payload.message || 'Failed to start email change'; + payload.msg || + payload.error_description || + payload.message || + 'Failed to start email change'; if (response.status === 401) { throw new UnauthorizedException(message); } @@ -227,7 +245,8 @@ export class AuthService { return { pending: true, - message: 'Confirmation email sent. Check your inbox to complete the change.', + message: + 'Confirmation email sent. Check your inbox to complete the change.', }; } @@ -251,7 +270,10 @@ export class AuthService { const { data: inserted, error: insertError } = await client .from('profile_preferences') - .upsert({ user_id: userId }, { onConflict: 'user_id', ignoreDuplicates: false }) + .upsert( + { user_id: userId }, + { onConflict: 'user_id', ignoreDuplicates: false }, + ) .select('*') .single(); if (insertError || !inserted) { @@ -274,7 +296,8 @@ export class AuthService { for (const key of PREFERENCE_KEYS) { if (updateDto[key] !== undefined) { const value = updateDto[key]; - patch[key] = typeof value === 'string' && value.trim() === '' ? null : value; + patch[key] = + typeof value === 'string' && value.trim() === '' ? null : value; } } diff --git a/src/v1/auth/dto/update-preferences.dto.ts b/src/v1/auth/dto/update-preferences.dto.ts index 9437725..0493ba9 100644 --- a/src/v1/auth/dto/update-preferences.dto.ts +++ b/src/v1/auth/dto/update-preferences.dto.ts @@ -6,10 +6,22 @@ const THEMES = ['light', 'dark', 'system'] as const; const TEMPERATURE_UNITS = ['celsius', 'fahrenheit', 'kelvin'] as const; const WEIGHT_UNITS = ['kg', 'lb'] as const; const EC_UNITS = ['ms_cm', 'ds_cm', 'us_cm'] as const; -const WATER_LEVEL_UNITS = ['cm', 'mm', 'inch', 'foot', 'meter', 'yard'] as const; +const WATER_LEVEL_UNITS = [ + 'cm', + 'mm', + 'inch', + 'foot', + 'meter', + 'yard', +] as const; const DISTANCE_UNITS = ['km', 'mi'] as const; const AREA_UNITS = ['hectares', 'acres', 'square_meters'] as const; -const SOIL_MOISTURE_UNITS = ['vwc_percent', 'relative_percent', 'kpa', 'centibar'] as const; +const SOIL_MOISTURE_UNITS = [ + 'vwc_percent', + 'relative_percent', + 'kpa', + 'centibar', +] as const; const PRESSURE_UNITS = ['hpa', 'kpa', 'bar', 'psi'] as const; const RAINFALL_UNITS = ['mm', 'cm', 'in'] as const; const WIND_SPEED_UNITS = ['m_s', 'km_h', 'mph', 'kt'] as const; diff --git a/src/v1/common/base-data.service.ts b/src/v1/common/base-data.service.ts index 7b70085..9b98dd3 100644 --- a/src/v1/common/base-data.service.ts +++ b/src/v1/common/base-data.service.ts @@ -7,7 +7,10 @@ import { import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from './timezone-formatter.service'; import { TableRow, TableName } from '../types/supabase'; -import { getUserId, isCropwatchStaff } from '../../supabase/supabase-token.helper'; +import { + getUserId, + isCropwatchStaff, +} from '../../supabase/supabase-token.helper'; import { READ_EXCLUSIVE_CEILING } from './permission-levels'; /** @@ -62,7 +65,6 @@ export abstract class BaseDataService { ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return return (data ?? []).map((row) => ({ ...row, created_at: this.timezoneFormatter.formatTimestamp( @@ -95,7 +97,9 @@ export abstract class BaseDataService { const { data, error } = await query.maybeSingle(); if (error) { - throw new InternalServerErrorException('Failed to validate device access'); + throw new InternalServerErrorException( + 'Failed to validate device access', + ); } if (!data) { diff --git a/src/v1/common/owner-filter.helper.spec.ts b/src/v1/common/owner-filter.helper.spec.ts index e28e90c..68aa0fd 100644 --- a/src/v1/common/owner-filter.helper.spec.ts +++ b/src/v1/common/owner-filter.helper.spec.ts @@ -1,60 +1,60 @@ import { filterStaffOwnerRows, isStaffEmail } from './owner-filter.helper'; describe('owner-filter.helper', () => { - const customerRow = { - user_id: 'user-1', - profiles: { email: 'farmer@example.com' }, - }; - const staffRow = { - user_id: 'staff-1', - profiles: { email: 'support@cropwatch.io' }, - }; - const staffRowMixedCase = { - user_id: 'staff-2', - profiles: { email: 'Kevin@CropWatch.IO ' }, - }; - const rowWithoutProfile = { user_id: 'user-2', profiles: null }; + const customerRow = { + user_id: 'user-1', + profiles: { email: 'farmer@example.com' }, + }; + const staffRow = { + user_id: 'staff-1', + profiles: { email: 'support@cropwatch.io' }, + }; + const staffRowMixedCase = { + user_id: 'staff-2', + profiles: { email: 'Kevin@CropWatch.IO ' }, + }; + const rowWithoutProfile = { user_id: 'user-2', profiles: null }; - describe('isStaffEmail', () => { - it('matches the @cropwatch.io suffix case-insensitively', () => { - expect(isStaffEmail('support@cropwatch.io')).toBe(true); - expect(isStaffEmail('Kevin@CropWatch.IO')).toBe(true); - }); + describe('isStaffEmail', () => { + it('matches the @cropwatch.io suffix case-insensitively', () => { + expect(isStaffEmail('support@cropwatch.io')).toBe(true); + expect(isStaffEmail('Kevin@CropWatch.IO')).toBe(true); + }); - it('rejects non-staff and lookalike domains', () => { - expect(isStaffEmail('farmer@example.com')).toBe(false); - expect(isStaffEmail('attacker@notcropwatch.ioo')).toBe(false); - expect(isStaffEmail('cropwatch.io@example.com')).toBe(false); - expect(isStaffEmail(null)).toBe(false); - expect(isStaffEmail(undefined)).toBe(false); - }); + it('rejects non-staff and lookalike domains', () => { + expect(isStaffEmail('farmer@example.com')).toBe(false); + expect(isStaffEmail('attacker@notcropwatch.ioo')).toBe(false); + expect(isStaffEmail('cropwatch.io@example.com')).toBe(false); + expect(isStaffEmail(null)).toBe(false); + expect(isStaffEmail(undefined)).toBe(false); }); + }); - describe('filterStaffOwnerRows', () => { - it('hides staff rows from non-staff requesters', () => { - const result = filterStaffOwnerRows( - [customerRow, staffRow, staffRowMixedCase, rowWithoutProfile], - false, - ); - expect(result).toEqual([customerRow, rowWithoutProfile]); - }); + describe('filterStaffOwnerRows', () => { + it('hides staff rows from non-staff requesters', () => { + const result = filterStaffOwnerRows( + [customerRow, staffRow, staffRowMixedCase, rowWithoutProfile], + false, + ); + expect(result).toEqual([customerRow, rowWithoutProfile]); + }); - it('returns all rows to staff requesters', () => { - const rows = [customerRow, staffRow]; - expect(filterStaffOwnerRows(rows, true)).toEqual(rows); - }); + it('returns all rows to staff requesters', () => { + const rows = [customerRow, staffRow]; + expect(filterStaffOwnerRows(rows, true)).toEqual(rows); + }); - it('handles array-shaped profile embeds', () => { - const arrayProfileStaff = { - user_id: 'staff-3', - profiles: [{ email: 'ops@cropwatch.io' }], - }; - expect(filterStaffOwnerRows([arrayProfileStaff], false)).toEqual([]); - }); + it('handles array-shaped profile embeds', () => { + const arrayProfileStaff = { + user_id: 'staff-3', + profiles: [{ email: 'ops@cropwatch.io' }], + }; + expect(filterStaffOwnerRows([arrayProfileStaff], false)).toEqual([]); + }); - it('tolerates null and undefined input', () => { - expect(filterStaffOwnerRows(null, false)).toEqual([]); - expect(filterStaffOwnerRows(undefined, true)).toEqual([]); - }); + it('tolerates null and undefined input', () => { + expect(filterStaffOwnerRows(null, false)).toEqual([]); + expect(filterStaffOwnerRows(undefined, true)).toEqual([]); }); + }); }); diff --git a/src/v1/common/owner-filter.helper.ts b/src/v1/common/owner-filter.helper.ts index d9a9dfd..dc02f4c 100644 --- a/src/v1/common/owner-filter.helper.ts +++ b/src/v1/common/owner-filter.helper.ts @@ -1,19 +1,19 @@ const STAFF_EMAIL_SUFFIX = '@cropwatch.io'; interface OwnerRowWithProfile { - profiles?: { email?: string | null } | { email?: string | null }[] | null; + profiles?: { email?: string | null } | { email?: string | null }[] | null; } export function isStaffEmail(email: string | null | undefined): boolean { - return ( - typeof email === 'string' && - email.trim().toLowerCase().endsWith(STAFF_EMAIL_SUFFIX) - ); + return ( + typeof email === 'string' && + email.trim().toLowerCase().endsWith(STAFF_EMAIL_SUFFIX) + ); } function rowHasStaffProfile(row: OwnerRowWithProfile): boolean { - const profile = Array.isArray(row.profiles) ? row.profiles[0] : row.profiles; - return isStaffEmail(profile?.email); + const profile = Array.isArray(row.profiles) ? row.profiles[0] : row.profiles; + return isStaffEmail(profile?.email); } /** @@ -25,12 +25,12 @@ function rowHasStaffProfile(row: OwnerRowWithProfile): boolean { * the API response. */ export function filterStaffOwnerRows( - rows: T[] | null | undefined, - requesterIsStaff: boolean, + rows: T[] | null | undefined, + requesterIsStaff: boolean, ): T[] { - const safeRows = rows ?? []; - if (requesterIsStaff) { - return safeRows; - } - return safeRows.filter((row) => !rowHasStaffProfile(row)); + const safeRows = rows ?? []; + if (requesterIsStaff) { + return safeRows; + } + return safeRows.filter((row) => !rowHasStaffProfile(row)); } diff --git a/src/v1/common/permission-levels.ts b/src/v1/common/permission-levels.ts index fda1c1e..952c5c5 100644 --- a/src/v1/common/permission-levels.ts +++ b/src/v1/common/permission-levels.ts @@ -9,14 +9,15 @@ * keep the two files in sync. Full spec: docs/permission-model.md. */ export const PermissionLevel = { - ADMIN: 1, - MANAGER: 2, - USER: 3, - VIEWER: 4, - DISABLED: 5, + ADMIN: 1, + MANAGER: 2, + USER: 3, + VIEWER: 4, + DISABLED: 5, } as const; -export type PermissionLevel = (typeof PermissionLevel)[keyof typeof PermissionLevel]; +export type PermissionLevel = + (typeof PermissionLevel)[keyof typeof PermissionLevel]; export const MIN_PERMISSION_LEVEL: PermissionLevel = PermissionLevel.ADMIN; export const MAX_PERMISSION_LEVEL: PermissionLevel = PermissionLevel.DISABLED; @@ -34,17 +35,17 @@ export const READ_EXCLUSIVE_CEILING: PermissionLevel = PermissionLevel.DISABLED; export const MANAGE_CEILING: PermissionLevel = PermissionLevel.MANAGER; export function canRead(level: number | null | undefined): boolean { - return level != null && level < PermissionLevel.DISABLED; + return level != null && level < PermissionLevel.DISABLED; } export function canManage(level: number | null | undefined): boolean { - return level != null && level <= PermissionLevel.MANAGER; + return level != null && level <= PermissionLevel.MANAGER; } export function isValidPermissionLevel(level: number): boolean { - return ( - Number.isInteger(level) && - level >= MIN_PERMISSION_LEVEL && - level <= MAX_PERMISSION_LEVEL - ); + return ( + Number.isInteger(level) && + level >= MIN_PERMISSION_LEVEL && + level <= MAX_PERMISSION_LEVEL + ); } diff --git a/src/v1/dashboard/dashboard.controller.ts b/src/v1/dashboard/dashboard.controller.ts index 9b5d75f..006cc6f 100644 --- a/src/v1/dashboard/dashboard.controller.ts +++ b/src/v1/dashboard/dashboard.controller.ts @@ -1,4 +1,12 @@ -import { Controller, Get, Param, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { + Controller, + Get, + Param, + Query, + Req, + Res, + UseGuards, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -20,7 +28,8 @@ export class DashboardController { @Get('devices') @UseGuards(JwtAuthGuard) @ApiOperation({ - summary: 'Paginated list of devices the user can see, with latest primary/secondary readings', + summary: + 'Paginated list of devices the user can see, with latest primary/secondary readings', }) @ApiQuery({ name: 'skip', required: false }) @ApiQuery({ name: 'take', required: false }) @@ -28,7 +37,10 @@ export class DashboardController { @ApiQuery({ name: 'name', required: false }) @ApiQuery({ name: 'location', required: false }) @ApiQuery({ name: 'locationGroup', required: false }) - async getDevices(@Req() req: any, @Query() q: Record) { + async getDevices( + @Req() req: any, + @Query() q: Record, + ) { const query: DashboardQuery = { skip: q.skip ? Number(q.skip) : undefined, take: q.take ? Number(q.take) : undefined, @@ -37,13 +49,18 @@ export class DashboardController { location: q.location?.trim() || undefined, locationGroup: q.locationGroup?.trim() || undefined, }; - return this.dashboardService.getDevices(req.user, req.headers.authorization, query); + return this.dashboardService.getDevices( + req.user, + req.headers.authorization, + query, + ); } @Get('locations') @UseGuards(JwtAuthGuard) @ApiOperation({ - summary: 'Paginated list of locations the user can see, each with its devices and latest readings', + summary: + 'Paginated list of locations the user can see, each with its devices and latest readings', }) @ApiQuery({ name: 'skip', required: false }) @ApiQuery({ name: 'take', required: false }) @@ -51,7 +68,10 @@ export class DashboardController { @ApiQuery({ name: 'name', required: false }) @ApiQuery({ name: 'location', required: false }) @ApiQuery({ name: 'locationGroup', required: false }) - async getLocations(@Req() req: any, @Query() q: Record) { + async getLocations( + @Req() req: any, + @Query() q: Record, + ) { const query: DashboardQuery = { skip: q.skip ? Number(q.skip) : undefined, take: q.take ? Number(q.take) : undefined, @@ -60,13 +80,18 @@ export class DashboardController { location: q.location?.trim() || undefined, locationGroup: q.locationGroup?.trim() || undefined, }; - return this.dashboardService.getLocations(req.user, req.headers.authorization, query); + return this.dashboardService.getLocations( + req.user, + req.headers.authorization, + query, + ); } @Get('devices/:dev_eui/latest') @UseGuards(JwtAuthGuard) @ApiOperation({ - summary: 'Latest full data row for a single device (all columns from its data_table_v2)', + summary: + 'Latest full data row for a single device (all columns from its data_table_v2)', }) @ApiParam({ name: 'dev_eui', required: true }) async getLatest( @@ -74,7 +99,11 @@ export class DashboardController { @Param('dev_eui') devEui: string, @Res({ passthrough: true }) res: Response, ) { - const row = await this.dashboardService.getLatest(req.user, devEui, req.headers.authorization); + const row = await this.dashboardService.getLatest( + req.user, + devEui, + req.headers.authorization, + ); if (row === null) { res.status(204); return; diff --git a/src/v1/dashboard/dashboard.service.ts b/src/v1/dashboard/dashboard.service.ts index ca5522d..a7e6f89 100644 --- a/src/v1/dashboard/dashboard.service.ts +++ b/src/v1/dashboard/dashboard.service.ts @@ -57,17 +57,19 @@ export class DashboardService { ? 'cw_locations!inner(location_id, name, group)' : 'cw_locations(location_id, name, group)'; - let devicesQuery = client - .from('cw_devices') - .select( - `dev_eui, name, "group", upload_interval, last_data_updated_at, error_status, + let devicesQuery = client.from('cw_devices').select( + `dev_eui, name, "group", upload_interval, last_data_updated_at, error_status, cw_device_type(id, name, data_table_v2, primary_data_v2, secondary_data_v2, default_upload_interval), ${locationSelect}, owner_match:cw_device_owners()`, - { count: 'exact' }, - ); + { count: 'exact' }, + ); - devicesQuery = this.applyDeviceReadScope(devicesQuery, userId, isGlobalUser); + devicesQuery = this.applyDeviceReadScope( + devicesQuery, + userId, + isGlobalUser, + ); if (query.group) { devicesQuery = devicesQuery.ilike('group', `%${query.group}%`); @@ -93,7 +95,9 @@ export class DashboardService { if (error) { this.logger.error(`Failed to fetch dashboard devices: ${error.message}`); - throw new InternalServerErrorException('Failed to fetch dashboard devices'); + throw new InternalServerErrorException( + 'Failed to fetch dashboard devices', + ); } const devices = data ?? []; @@ -138,9 +142,7 @@ export class DashboardService { : 'cw_locations(location_id, name, "group")'; let locsQuery = client .from('cw_devices') - .select( - `location_id, ${locationSelect}, owner_match:cw_device_owners()`, - ); + .select(`location_id, ${locationSelect}, owner_match:cw_device_owners()`); locsQuery = this.applyDeviceReadScope(locsQuery, userId, isGlobalUser); if (query.group) locsQuery = locsQuery.ilike('group', `%${query.group}%`); if (query.name) { @@ -161,8 +163,12 @@ export class DashboardService { const { data: locData, error: locError } = await locsQuery; if (locError) { - this.logger.error(`Failed to list dashboard locations: ${locError.message}`); - throw new InternalServerErrorException('Failed to list dashboard locations'); + this.logger.error( + `Failed to list dashboard locations: ${locError.message}`, + ); + throw new InternalServerErrorException( + 'Failed to list dashboard locations', + ); } // Dedupe by location_id; null becomes the special 'none' bucket. @@ -210,15 +216,17 @@ export class DashboardService { .map(([, v]) => (v as { location_id: number }).location_id); const includeNoLoc = pagedLocs.some(([k]) => k === 'none'); - let devicesQuery = client - .from('cw_devices') - .select( - `dev_eui, name, "group", upload_interval, last_data_updated_at, error_status, + let devicesQuery = client.from('cw_devices').select( + `dev_eui, name, "group", upload_interval, last_data_updated_at, error_status, cw_device_type(id, name, data_table_v2, primary_data_v2, secondary_data_v2, default_upload_interval), cw_locations(location_id, name, "group"), owner_match:cw_device_owners()`, - ); - devicesQuery = this.applyDeviceReadScope(devicesQuery, userId, isGlobalUser); + ); + devicesQuery = this.applyDeviceReadScope( + devicesQuery, + userId, + isGlobalUser, + ); if (includeNoLoc && locIds.length > 0) { devicesQuery = devicesQuery.or( @@ -230,7 +238,8 @@ export class DashboardService { devicesQuery = devicesQuery.in('location_id', locIds); } - if (query.group) devicesQuery = devicesQuery.ilike('group', `%${query.group}%`); + if (query.group) + devicesQuery = devicesQuery.ilike('group', `%${query.group}%`); if (query.name) { devicesQuery = devicesQuery.or( this.buildNameOrFilter(query.name, nameLocationIds), @@ -245,7 +254,9 @@ export class DashboardService { this.logger.error( `Failed to fetch devices for dashboard locations: ${devicesError.message}`, ); - throw new InternalServerErrorException('Failed to fetch dashboard devices'); + throw new InternalServerErrorException( + 'Failed to fetch dashboard devices', + ); } const rows = ( @@ -259,13 +270,17 @@ export class DashboardService { } for (const row of rows) { const key = - row.location?.location_id != null ? String(row.location.location_id) : 'none'; + row.location?.location_id != null + ? String(row.location.location_id) + : 'none'; const bucket = groupsByKey.get(key); if (bucket) bucket.devices.push(row); } // Drop empty buckets (can happen when name filter excludes all devices in a slot). - const groups = [...groupsByKey.values()].filter((g) => g.devices.length > 0); + const groups = [...groupsByKey.values()].filter( + (g) => g.devices.length > 0, + ); return { groups, total, skip, take }; } @@ -294,7 +309,8 @@ export class DashboardService { deviceQuery = this.applyDeviceReadScope(deviceQuery, userId, isGlobalUser); - const { data: device, error: deviceError } = await deviceQuery.maybeSingle(); + const { data: device, error: deviceError } = + await deviceQuery.maybeSingle(); if (deviceError) { this.logger.error( @@ -418,7 +434,8 @@ export class DashboardService { Boolean(secondaryCol) && secondaryCol !== '-' && secondaryCol !== ''; return { created_at: (row.created_at as string | null) ?? null, - primary: primaryCol && primaryCol !== '-' ? (row[primaryCol] ?? null) : null, + primary: + primaryCol && primaryCol !== '-' ? (row[primaryCol] ?? null) : null, secondary: hasSecondary ? (row[secondaryCol] ?? null) : null, } as DashboardRow['latest']; } diff --git a/src/v1/dashboard/dashboard.types.ts b/src/v1/dashboard/dashboard.types.ts index 5777df8..20b0cee 100644 --- a/src/v1/dashboard/dashboard.types.ts +++ b/src/v1/dashboard/dashboard.types.ts @@ -73,7 +73,9 @@ export const DASHBOARD_DATA_TABLES = [ export type DashboardDataTable = (typeof DASHBOARD_DATA_TABLES)[number]; -export function isDashboardDataTable(name: unknown): name is DashboardDataTable { +export function isDashboardDataTable( + name: unknown, +): name is DashboardDataTable { return ( typeof name === 'string' && (DASHBOARD_DATA_TABLES as readonly string[]).includes(name) diff --git a/src/v1/devices/devices.controller.ts b/src/v1/devices/devices.controller.ts index 1cbed71..7dc1cf0 100644 --- a/src/v1/devices/devices.controller.ts +++ b/src/v1/devices/devices.controller.ts @@ -35,7 +35,7 @@ import { ReplaceDeviceDto } from './dto/replace-device.dto'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') export class DevicesController { - constructor(private readonly devicesService: DevicesService) { } + constructor(private readonly devicesService: DevicesService) {} @Get() @UseGuards(JwtAuthGuard) @@ -68,11 +68,31 @@ export class DevicesController { description: ` Returns all devices for the authenticated user.`, }) - @ApiQuery({ name: 'skip', description: 'Number of records to skip for pagination', required: false }) - @ApiQuery({ name: 'take', description: 'Number of records to take for pagination', required: false }) - @ApiQuery({ name: 'group', description: 'Filter by device group', required: false }) - @ApiQuery({ name: 'name', description: 'Filter by device name or dev_eui', required: false }) - @ApiQuery({ name: 'location', description: 'Filter by device location', required: false }) + @ApiQuery({ + name: 'skip', + description: 'Number of records to skip for pagination', + required: false, + }) + @ApiQuery({ + name: 'take', + description: 'Number of records to take for pagination', + required: false, + }) + @ApiQuery({ + name: 'group', + description: 'Filter by device group', + required: false, + }) + @ApiQuery({ + name: 'name', + description: 'Filter by device name or dev_eui', + required: false, + }) + @ApiQuery({ + name: 'location', + description: 'Filter by device location', + required: false, + }) findAll(@Req() req) { const parsedSkip = parseInt(req.query.skip, 10); const parsedTake = parseInt(req.query.take, 10); @@ -80,8 +100,18 @@ export class DevicesController { const take = Math.min(Number.isNaN(parsedTake) ? 100 : parsedTake, 1000); const searchGroup = req.query.group ? String(req.query.group) : undefined; const searchName = req.query.name ? String(req.query.name) : undefined; - const searchLocation = req.query.location ? String(req.query.location) : undefined; - return this.devicesService.findAll(req.user, req.headers.authorization, skip, take, searchGroup, searchName, searchLocation); + const searchLocation = req.query.location + ? String(req.query.location) + : undefined; + return this.devicesService.findAll( + req.user, + req.headers.authorization, + skip, + take, + searchGroup, + searchName, + searchLocation, + ); } @Get('status') @@ -96,7 +126,10 @@ export class DevicesController { const parsedTake = parseInt(req.query.take, 10); const skip = Number.isNaN(parsedSkip) ? 0 : parsedSkip; const take = Number.isNaN(parsedTake) ? undefined : parsedTake; - return this.devicesService.findAllStatus(req.user, req.headers.authorization); + return this.devicesService.findAllStatus( + req.user, + req.headers.authorization, + ); } @Get('groups') @@ -107,7 +140,10 @@ export class DevicesController { Returns the device groups for the authenticated user.`, }) findAllDeviceGroups(@Req() req) { - return this.devicesService.findAllDeviceGroups(req.user, req.headers.authorization); + return this.devicesService.findAllDeviceGroups( + req.user, + req.headers.authorization, + ); } @Get('device-types') @@ -118,17 +154,44 @@ export class DevicesController { Returns the device types for the authenticated user.`, }) findAllDeviceTypes(@Req() req) { - return this.devicesService.findAllDeviceTypes(req.user, req.headers.authorization); + return this.devicesService.findAllDeviceTypes( + req.user, + req.headers.authorization, + ); } @Get('latest-primary-data') @UseGuards(JwtAuthGuard) - @ApiQuery({ name: 'skip', description: 'Number of records to skip for pagination', required: false }) - @ApiQuery({ name: 'take', description: 'Number of records to take for pagination', required: false }) - @ApiQuery({ name: 'group-by-device-group', description: 'Filter by device group', required: false }) - @ApiQuery({ name: 'name', description: 'Filter by device name or dev_eui', required: false }) - @ApiQuery({ name: 'location', description: 'Filter by device location', required: false }) - @ApiQuery({ name: 'locationGroup', description: 'Group devices by location', required: false }) + @ApiQuery({ + name: 'skip', + description: 'Number of records to skip for pagination', + required: false, + }) + @ApiQuery({ + name: 'take', + description: 'Number of records to take for pagination', + required: false, + }) + @ApiQuery({ + name: 'group-by-device-group', + description: 'Filter by device group', + required: false, + }) + @ApiQuery({ + name: 'name', + description: 'Filter by device name or dev_eui', + required: false, + }) + @ApiQuery({ + name: 'location', + description: 'Filter by device location', + required: false, + }) + @ApiQuery({ + name: 'locationGroup', + description: 'Group devices by location', + required: false, + }) @ApiOperation({ summary: 'Get the latest primary data for all devices (paginated)', description: ` @@ -137,23 +200,48 @@ export class DevicesController { allLatestPrimaryData(@Req() req) { const skip = parseInt(req.query.skip, 10) || 0; const take = parseInt(req.query.take, 10) || 10; - const searchDeviceGroup = req.query['group-by-device-group'] ? String(req.query['group-by-device-group']) : undefined; - const locationGroup = req.query.locationGroup ? String(req.query.locationGroup) : undefined; + const searchDeviceGroup = req.query['group-by-device-group'] + ? String(req.query['group-by-device-group']) + : undefined; + const locationGroup = req.query.locationGroup + ? String(req.query.locationGroup) + : undefined; const searchName = req.query.name ? String(req.query.name) : undefined; - const searchLocation = req.query.location ? String(req.query.location) : undefined; - return this.devicesService.findAllLatestData(req.user, skip, take, req.headers.authorization, searchDeviceGroup, searchName, searchLocation, locationGroup); + const searchLocation = req.query.location + ? String(req.query.location) + : undefined; + return this.devicesService.findAllLatestData( + req.user, + skip, + take, + req.headers.authorization, + searchDeviceGroup, + searchName, + searchLocation, + locationGroup, + ); } @Get('location/:location_id') @UseGuards(JwtAuthGuard) - @ApiParam({ name: 'location_id', description: 'Location ID', type: Number, required: true }) + @ApiParam({ + name: 'location_id', + description: 'Location ID', + type: Number, + required: true, + }) @ApiOperation({ - summary: 'Get the latest primary data for all devices in a location (paginated)', + summary: + 'Get the latest primary data for all devices in a location (paginated)', description: ` Returns the latest, 2 primary data values from the table record for all devices in a specific location.`, }) allDevicesInLocation(@Req() req, @Param('location_id') locationId: number) { - return this.devicesService.findAllDevicesInLocation(req.user, locationId, req.headers.authorization); + return this.devicesService.findAllDevicesInLocation( + req.user, + locationId, + req.headers.authorization, + ); } @Get(':dev_eui') @@ -208,14 +296,26 @@ export class DevicesController { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - return this.devicesService.findOne(req.user, devEui, req.headers.authorization); + return this.devicesService.findOne( + req.user, + devEui, + req.headers.authorization, + ); } @Get(':dev_eui/data') @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) - @ApiParam({ name: 'skip (0)', description: 'Number of records to skip for pagination', required: false }) - @ApiParam({ name: 'take (144)', description: 'Number of records to take for pagination', required: false }) + @ApiParam({ + name: 'skip (0)', + description: 'Number of records to skip for pagination', + required: false, + }) + @ApiParam({ + name: 'take (144)', + description: 'Number of records to take for pagination', + required: false, + }) @ApiOperation({ summary: 'Get the latest FULL data for a device (paginated)', description: ` @@ -227,7 +327,13 @@ export class DevicesController { } const skip = parseInt(req.query.skip, 10) || 0; const take = parseInt(req.query.take, 10) || 144; - return this.devicesService.findData(req.user, devEui, skip, take, req.headers.authorization); + return this.devicesService.findData( + req.user, + devEui, + skip, + take, + req.headers.authorization, + ); } @Get(':dev_eui/data-within-range') @@ -256,18 +362,24 @@ export class DevicesController { example: new Date().toISOString(), }) @ApiParam({ - name: 'skip (0)', description: 'Number of records to skip for pagination', schema: { + name: 'skip (0)', + description: 'Number of records to skip for pagination', + schema: { type: 'number', format: 'int32', default: 0, - }, required: false + }, + required: false, }) @ApiParam({ - name: 'take (144)', description: 'Number of records to take for pagination', schema: { + name: 'take (144)', + description: 'Number of records to take for pagination', + schema: { type: 'number', format: 'int32', default: 144, - }, required: false + }, + required: false, }) @ApiOperation({ summary: 'Get device FULL data within a date/time range and paginated', @@ -282,9 +394,19 @@ export class DevicesController { } const skip = parseInt(req.query.skip, 10) || 0; const take = parseInt(req.query.take, 10) || 144; - const start = req.query.start || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const start = + req.query.start || + new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); const end = req.query.end || new Date().toISOString(); - return this.devicesService.findDataWithinRange(req.user, devEui, req.headers.authorization, start, end, skip, take); + return this.devicesService.findDataWithinRange( + req.user, + devEui, + req.headers.authorization, + start, + end, + skip, + take, + ); } @Get(':dev_eui/latest-data') @@ -299,7 +421,11 @@ export class DevicesController { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - return this.devicesService.findLatestData(req.user, devEui, req.headers.authorization); + return this.devicesService.findLatestData( + req.user, + devEui, + req.headers.authorization, + ); } @Get(':dev_eui/latest-primary-data') @@ -314,7 +440,12 @@ export class DevicesController { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - return this.devicesService.findLatestData(req.user, devEui, req.headers.authorization, true); + return this.devicesService.findLatestData( + req.user, + devEui, + req.headers.authorization, + true, + ); } @Post(':dev_eui') @@ -342,7 +473,8 @@ export class DevicesController { @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ - summary: '🔄 Replace a device with another device for the authenticated user', + summary: + '🔄 Replace a device with another device for the authenticated user', description: ` Replaces an existing device with a new device for the authenticated user. This endpoint is not yet implemented and will return a 501 Not Implemented response until it is implemented. @@ -356,14 +488,23 @@ export class DevicesController { ) { const normalizedDevEui = devEui?.trim(); if (body.dev_eui?.trim() && body.dev_eui.trim() !== normalizedDevEui) { - throw new BadRequestException('dev_eui in body must match route parameter'); + throw new BadRequestException( + 'dev_eui in body must match route parameter', + ); } const replacementDevice: ReplaceDeviceDto = body; - const insertResult = this.devicesService.replaceDevice(req.user, normalizedDevEui, replacementDevice, req.headers.authorization); + const insertResult = this.devicesService.replaceDevice( + req.user, + normalizedDevEui, + replacementDevice, + req.headers.authorization, + ); if (!insertResult) { - throw new InternalServerErrorException('Device replacement is not yet implemented. Please contact support if you would like this feature to be prioritized.'); + throw new InternalServerErrorException( + 'Device replacement is not yet implemented. Please contact support if you would like this feature to be prioritized.', + ); } return insertResult; } @@ -390,7 +531,9 @@ export class DevicesController { throw new BadRequestException('dev_eui is required'); } if (body.dev_eui?.trim() && body.dev_eui.trim() !== normalizedDevEui) { - throw new BadRequestException('dev_eui in body must match route parameter'); + throw new BadRequestException( + 'dev_eui in body must match route parameter', + ); } if (!body.targetUserEmail?.trim()) { throw new BadRequestException('Target User Email is required'); @@ -430,6 +573,13 @@ export class DevicesController { if (!body.location_id) { throw new BadRequestException('Device location is required'); } - return this.devicesService.updateDevice(req.user, devEui, body.name, body.group ?? null, body.location_id, req.headers.authorization); + return this.devicesService.updateDevice( + req.user, + devEui, + body.name, + body.group ?? null, + body.location_id, + req.headers.authorization, + ); } } diff --git a/src/v1/devices/devices.service.spec.ts b/src/v1/devices/devices.service.spec.ts index e9990d2..c27e007 100644 --- a/src/v1/devices/devices.service.spec.ts +++ b/src/v1/devices/devices.service.spec.ts @@ -219,7 +219,9 @@ describe('DevicesService', () => { eq: jest.fn().mockReturnThis(), lte: jest.fn().mockReturnThis(), or: jest.fn().mockReturnThis(), - maybeSingle: jest.fn().mockResolvedValue({ data: locationRow, error: null }), + maybeSingle: jest + .fn() + .mockResolvedValue({ data: locationRow, error: null }), }; } @@ -227,7 +229,9 @@ describe('DevicesService', () => { return { update: jest.fn().mockReturnThis(), eq: jest.fn().mockReturnThis(), - select: jest.fn().mockResolvedValue({ data: [{ dev_eui: 'DEV-001' }], error: null }), + select: jest + .fn() + .mockResolvedValue({ data: [{ dev_eui: 'DEV-001' }], error: null }), }; } @@ -254,7 +258,10 @@ describe('DevicesService', () => { getAdminClient: jest.fn(), }; return { - service: new DevicesService(supabaseService as unknown as SupabaseService, {} as any), + service: new DevicesService( + supabaseService as unknown as SupabaseService, + {} as any, + ), fromMock, }; } @@ -286,11 +293,21 @@ describe('DevicesService', () => { insertBuilder, ]); - await service.updateDevice(jwt, 'DEV-001', 'Sensor', null, 2, 'Bearer token-1'); + await service.updateDevice( + jwt, + 'DEV-001', + 'Sensor', + null, + 2, + 'Bearer token-1', + ); // Mover needed manage scope on the destination location. expect(destinationBuilder.eq).toHaveBeenCalledWith('location_id', 2); - expect(destinationBuilder.lte).toHaveBeenCalledWith('owner_match.permission_level', 2); + expect(destinationBuilder.lte).toHaveBeenCalledWith( + 'owner_match.permission_level', + 2, + ); // Device ownership follows the destination location owner. expect(updateBuilder.update).toHaveBeenCalledWith({ @@ -323,12 +340,23 @@ describe('DevicesService', () => { user_id: 'old-owner', }); const destinationBuilder = createDestinationBuilder(null); - const { service, fromMock } = createService([permissionBuilder, destinationBuilder]); + const { service, fromMock } = createService([ + permissionBuilder, + destinationBuilder, + ]); await expect( - service.updateDevice(jwt, 'DEV-001', 'Sensor', null, 2, 'Bearer token-1'), + service.updateDevice( + jwt, + 'DEV-001', + 'Sensor', + null, + 2, + 'Bearer token-1', + ), ).rejects.toMatchObject({ - message: 'You do not have permission to move this device to that location', + message: + 'You do not have permission to move this device to that location', }); // The device update must never run. @@ -342,9 +370,19 @@ describe('DevicesService', () => { user_id: 'old-owner', }); const updateBuilder = createUpdateBuilder(); - const { service, fromMock } = createService([permissionBuilder, updateBuilder]); + const { service, fromMock } = createService([ + permissionBuilder, + updateBuilder, + ]); - await service.updateDevice(jwt, 'DEV-001', 'Renamed', 'greenhouse', 2, 'Bearer token-1'); + await service.updateDevice( + jwt, + 'DEV-001', + 'Renamed', + 'greenhouse', + 2, + 'Bearer token-1', + ); expect(updateBuilder.update).toHaveBeenCalledWith({ name: 'Renamed', @@ -377,7 +415,14 @@ describe('DevicesService', () => { insertBuilder, ]); - await service.updateDevice(jwt, 'DEV-001', 'Sensor', null, 2, 'Bearer token-1'); + await service.updateDevice( + jwt, + 'DEV-001', + 'Sensor', + null, + 2, + 'Bearer token-1', + ); expect(updateBuilder.update).toHaveBeenCalledWith({ name: 'Sensor', diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index 1843c2b..80f57c3 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -33,7 +33,10 @@ export interface PagedDevicesResponse { export class DevicesService { private readonly logger = new Logger(DevicesService.name); - constructor(private readonly supabaseService: SupabaseService, private readonly locationsService: LocationsService) { } + constructor( + private readonly supabaseService: SupabaseService, + private readonly locationsService: LocationsService, + ) {} async findAll( jwtPayload: any, @@ -49,17 +52,15 @@ export class DevicesService { const userId = getUserId(jwtPayload); const isGlobalUser = isCropwatchStaff(jwtPayload); - let devicesQuery = client - .from('cw_devices') - .select( - ` + let devicesQuery = client.from('cw_devices').select( + ` *, owner_match:cw_device_owners(), cw_device_owners(*), cw_locations(name, location_id) `, - { count: 'exact' }, - ); + { count: 'exact' }, + ); devicesQuery = this.applyDeviceReadScope( devicesQuery, @@ -72,7 +73,9 @@ export class DevicesService { } if (searchName) { const safeName = sanitizeOrFilterTerm(searchName); - devicesQuery = devicesQuery.or(`name.ilike.%${safeName}%,dev_eui.ilike.%${safeName}%`); + devicesQuery = devicesQuery.or( + `name.ilike.%${safeName}%,dev_eui.ilike.%${safeName}%`, + ); } if (searchLocation) { devicesQuery = devicesQuery.ilike('location', `%${searchLocation}%`); @@ -162,7 +165,9 @@ export class DevicesService { query = this.applyDeviceReadScope(query, userId, isGlobalUser); - const { data: devices, error: devicesError } = await query.order('name', { ascending: true }); + const { data: devices, error: devicesError } = await query.order('name', { + ascending: true, + }); if (devicesError) { throw new InternalServerErrorException('Failed to fetch devices'); @@ -418,9 +423,7 @@ export class DevicesService { let deviceQuery = client .from('cw_devices') - .select( - `*, owner_match:cw_device_owners(), cw_device_owners(*)`, - ) + .select(`*, owner_match:cw_device_owners(), cw_device_owners(*)`) .eq('dev_eui', normalizedDevEui); deviceQuery = this.applyDeviceReadScope(deviceQuery, userId, isGlobalUser); @@ -525,7 +528,9 @@ export class DevicesService { } if (searchName) { const safeName = sanitizeOrFilterTerm(searchName); - devicesQuery = devicesQuery.or(`name.ilike.%${safeName}%,dev_eui.ilike.%${safeName}%`); + devicesQuery = devicesQuery.or( + `name.ilike.%${safeName}%,dev_eui.ilike.%${safeName}%`, + ); } if (hasLocationFilter && Number.isFinite(locationIdFilter)) { devicesQuery = devicesQuery.eq( @@ -582,7 +587,7 @@ export class DevicesService { const latestFields = new Set([ 'created_at', deviceType.primary_data_v2, - deviceType.secondary_data_v2 + deviceType.secondary_data_v2, ]); if (deviceType.data_table_v2 === 'cw_air_data') { @@ -591,7 +596,7 @@ export class DevicesService { // sometimes the SET has empty values, we need to clean them const latestFieldsCleaned = new Set( - [...latestFields].filter(v => v !== '') + [...latestFields].filter((v) => v !== ''), ); const { data: latestData, error: dataError } = await client @@ -668,7 +673,9 @@ export class DevicesService { query = this.applyDeviceReadScope(query, userId, isGlobalUser); - const { data: devices, error: devicesError } = await query.order('name', { ascending: true }); + const { data: devices, error: devicesError } = await query.order('name', { + ascending: true, + }); if (devicesError) { throw new InternalServerErrorException('Failed to fetch devices'); @@ -756,7 +763,8 @@ export class DevicesService { device_type: deviceType.name, created_at: device.last_data_updated_at ?? device.created_at, lastUpdated: device.last_data_updated_at, - upload_interval: device.upload_interval ?? deviceType.default_upload_interval, + upload_interval: + device.upload_interval ?? deviceType.default_upload_interval, location_id: device.location_id, [primaryField]: latestData[primaryField], [secondaryField]: latestData[secondaryField], @@ -786,12 +794,18 @@ export class DevicesService { if (!device.location_id) { throw new BadRequestException('location_id is required'); } - const location = await this.locationsService.findOne(device.location_id, jwtPayload, authHeader); + const location = await this.locationsService.findOne( + device.location_id, + jwtPayload, + authHeader, + ); if (!location) { throw new BadRequestException('Invalid location'); } if (!isGlobalUser && location.owner_id !== userId) { - throw new UnauthorizedException('You do not have permission to create a device in this location'); + throw new UnauthorizedException( + 'You do not have permission to create a device in this location', + ); } // create the device @@ -819,9 +833,9 @@ export class DevicesService { * This makes sense because you can view locations, and all devices inside of them * there is no point in having permission to a device, but no permission to view the lcoation * as even if you could see a device, you would have no route to get to said device. - * + * * Let's add permissions for all existing location users here!!! - *********************************************************************************/ + *********************************************************************************/ const { data: locationUsers, error: locationUsersError } = await client .from('cw_location_owners') @@ -833,8 +847,11 @@ export class DevicesService { } // REmove YOU from the list of location users to add, as you are already the owner of the device and have all permissions - if (locationUsers.find(user => user.user_id === userId)) { - locationUsers.splice(locationUsers.findIndex(user => user.user_id === userId), 1); + if (locationUsers.find((user) => user.user_id === userId)) { + locationUsers.splice( + locationUsers.findIndex((user) => user.user_id === userId), + 1, + ); } // Add permissions for all existing location users @@ -848,7 +865,9 @@ export class DevicesService { }); if (addPermissionError) { - throw new InternalServerErrorException('Failed to add device permissions for location users'); + throw new InternalServerErrorException( + 'Failed to add device permissions for location users', + ); } } @@ -883,7 +902,8 @@ export class DevicesService { PermissionLevel.ADMIN, ); - const { data: device, error: deviceError } = await existingDeviceQuery.single(); + const { data: device, error: deviceError } = + await existingDeviceQuery.single(); if (deviceError) { throw new InternalServerErrorException('Failed to fetch device'); @@ -906,7 +926,8 @@ export class DevicesService { PermissionLevel.ADMIN, ); - const { data: newDeviceData, error: newDeviceError } = await newDeviceQuery.single(); + const { data: newDeviceData, error: newDeviceError } = + await newDeviceQuery.single(); if (newDeviceError) { throw new InternalServerErrorException('Failed to fetch new device'); @@ -1113,7 +1134,9 @@ export class DevicesService { destinationOwnerId = destination.owner_id ?? null; destinationMemberIds = (destination.cw_location_owners ?? []) .map((member: { user_id: string | null }) => member.user_id) - .filter((memberId: string | null): memberId is string => Boolean(memberId)); + .filter((memberId: string | null): memberId is string => + Boolean(memberId), + ); } const updatePayload: Record = { name, group, location_id }; @@ -1198,7 +1221,11 @@ export class DevicesService { } } - private applyDeviceReadScope(query: any, userId: string, isGlobalUser: boolean) { + private applyDeviceReadScope( + query: any, + userId: string, + isGlobalUser: boolean, + ) { if (isGlobalUser) { return query; } diff --git a/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts b/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts index f02dae1..da85f41 100644 --- a/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts +++ b/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts @@ -1,29 +1,44 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsInt, IsNotEmpty, IsOptional, IsString, MaxLength, Min } from 'class-validator'; +import { + IsInt, + IsNotEmpty, + IsOptional, + IsString, + MaxLength, + Min, +} from 'class-validator'; export class UpdateDeviceNameGroupLocalDto { - @ApiProperty({ example: 'New Device Name', type: String, required: true }) - @IsString() - @IsNotEmpty() - @MaxLength(120) - name: string; + @ApiProperty({ example: 'New Device Name', type: String, required: true }) + @IsString() + @IsNotEmpty() + @MaxLength(120) + name: string; - @ApiPropertyOptional({ example: 'New Device Group', type: String, required: false }) - @IsOptional() - @IsString() - @MaxLength(120) - group?: string | null; + @ApiPropertyOptional({ + example: 'New Device Group', + type: String, + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(120) + group?: string | null; - @ApiProperty({ example: 54, type: Number, required: true }) - @Type(() => Number) - @IsInt() - @Min(1) - location_id: number; + @ApiProperty({ example: 54, type: Number, required: true }) + @Type(() => Number) + @IsInt() + @Min(1) + location_id: number; - @ApiPropertyOptional({ example: 'eui-device-name', type: String, required: false }) - @IsOptional() - @IsString() - @MaxLength(255) - tti_name?: string | null; + @ApiPropertyOptional({ + example: 'eui-device-name', + type: String, + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(255) + tti_name?: string | null; } diff --git a/src/v1/devices/dto/UpdateDevicePermission.dto.ts b/src/v1/devices/dto/UpdateDevicePermission.dto.ts index a7c94be..966f001 100644 --- a/src/v1/devices/dto/UpdateDevicePermission.dto.ts +++ b/src/v1/devices/dto/UpdateDevicePermission.dto.ts @@ -1,23 +1,34 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsEmail, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; -import { MAX_PERMISSION_LEVEL, MIN_PERMISSION_LEVEL } from '../../common/permission-levels'; +import { + IsEmail, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { + MAX_PERMISSION_LEVEL, + MIN_PERMISSION_LEVEL, +} from '../../common/permission-levels'; export class UpdateDevicePermissionDto { - @ApiProperty({ example: 'user@example.com', type: 'string', required: true }) - @IsEmail() - targetUserEmail: string; + @ApiProperty({ example: 'user@example.com', type: 'string', required: true }) + @IsEmail() + targetUserEmail: string; - // Accept the current UI payload shape even though dev_eui is sourced from the route param. - @IsOptional() - @IsString() - @IsNotEmpty() - dev_eui?: string; + // Accept the current UI payload shape even though dev_eui is sourced from the route param. + @IsOptional() + @IsString() + @IsNotEmpty() + dev_eui?: string; - @ApiProperty({ example: 2, type: Number, required: true }) - @Type(() => Number) - @IsInt() - @Min(MIN_PERMISSION_LEVEL) - @Max(MAX_PERMISSION_LEVEL) - permissionLevel: number; + @ApiProperty({ example: 2, type: Number, required: true }) + @Type(() => Number) + @IsInt() + @Min(MIN_PERMISSION_LEVEL) + @Max(MAX_PERMISSION_LEVEL) + permissionLevel: number; } diff --git a/src/v1/devices/dto/create-device-owner.dto.ts b/src/v1/devices/dto/create-device-owner.dto.ts index 5aecf16..8984daa 100644 --- a/src/v1/devices/dto/create-device-owner.dto.ts +++ b/src/v1/devices/dto/create-device-owner.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Database } from '../../../../database.types'; -type DeviceOwnerInsert = Database['public']['Tables']['cw_device_owners']['Insert']; +type DeviceOwnerInsert = + Database['public']['Tables']['cw_device_owners']['Insert']; export class CreateDeviceOwnerDto implements DeviceOwnerInsert { @ApiProperty() diff --git a/src/v1/devices/dto/device.dto.ts b/src/v1/devices/dto/device.dto.ts index 24647c4..c0de4f9 100644 --- a/src/v1/devices/dto/device.dto.ts +++ b/src/v1/devices/dto/device.dto.ts @@ -78,7 +78,6 @@ export class DeviceDto { @ApiProperty({ required: false, nullable: true }) report_endpoint: string | null; - @ApiProperty({ required: false, nullable: true, diff --git a/src/v1/devices/dto/replace-device.dto.ts b/src/v1/devices/dto/replace-device.dto.ts index 351f247..b541f75 100644 --- a/src/v1/devices/dto/replace-device.dto.ts +++ b/src/v1/devices/dto/replace-device.dto.ts @@ -2,8 +2,11 @@ import { ApiProperty, PartialType } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class ReplaceDeviceDto { - @ApiProperty({ description: 'LoRaWAN dev_eui for replacement device', example: 'A1B2C3D4E5F60708' }) - @IsString() - @IsNotEmpty() - dev_eui: string; -} \ No newline at end of file + @ApiProperty({ + description: 'LoRaWAN dev_eui for replacement device', + example: 'A1B2C3D4E5F60708', + }) + @IsString() + @IsNotEmpty() + dev_eui: string; +} diff --git a/src/v1/gateway/gateway.service.ts b/src/v1/gateway/gateway.service.ts index 91b740c..0369e28 100644 --- a/src/v1/gateway/gateway.service.ts +++ b/src/v1/gateway/gateway.service.ts @@ -42,11 +42,19 @@ export class GatewayService { ); const allGateways = [ - ...(ownedGateways ?? []).map((og) => ({ id: og.id, gateway_id: og.gateway_id, is_online: og.is_online, is_public: og.is_public, gateway_name: og.gateway_name, updated_at: og.updated_at })), - ...(publicGateways ?? []).filter((pg) => !ownedGatewayIds.has(pg.gateway_id)), + ...(ownedGateways ?? []).map((og) => ({ + id: og.id, + gateway_id: og.gateway_id, + is_online: og.is_online, + is_public: og.is_public, + gateway_name: og.gateway_name, + updated_at: og.updated_at, + })), + ...(publicGateways ?? []).filter( + (pg) => !ownedGatewayIds.has(pg.gateway_id), + ), ]; - return allGateways ?? []; } diff --git a/src/v1/locations/dto/create-location-owner.dto.ts b/src/v1/locations/dto/create-location-owner.dto.ts index 3a4fb07..889213f 100644 --- a/src/v1/locations/dto/create-location-owner.dto.ts +++ b/src/v1/locations/dto/create-location-owner.dto.ts @@ -1,10 +1,23 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsBoolean, IsEmail, IsInt, IsNotEmpty, IsOptional, IsString, Max, Min } from 'class-validator'; +import { + IsBoolean, + IsEmail, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; import { Database } from '../../../../database.types'; -import { MAX_PERMISSION_LEVEL, MIN_PERMISSION_LEVEL } from '../../common/permission-levels'; +import { + MAX_PERMISSION_LEVEL, + MIN_PERMISSION_LEVEL, +} from '../../common/permission-levels'; -type LocationOwnerInsert = Database['public']['Tables']['cw_location_owners']['Insert']; +type LocationOwnerInsert = + Database['public']['Tables']['cw_location_owners']['Insert']; export class CreateLocationOwnerDto implements LocationOwnerInsert { @ApiProperty() diff --git a/src/v1/locations/dto/create-location.dto.ts b/src/v1/locations/dto/create-location.dto.ts index 34d9bca..2f4cb72 100644 --- a/src/v1/locations/dto/create-location.dto.ts +++ b/src/v1/locations/dto/create-location.dto.ts @@ -1,6 +1,14 @@ import { ApiProperty } from '@nestjs/swagger'; import { Type } from 'class-transformer'; -import { IsISO8601, IsInt, IsNotEmpty, IsNumber, IsOptional, IsString, Min } from 'class-validator'; +import { + IsISO8601, + IsInt, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, + Min, +} from 'class-validator'; import { Database } from '../../../../database.types'; type LocationInsert = Database['public']['Tables']['cw_locations']['Insert']; diff --git a/src/v1/locations/dto/location-owner.dto.ts b/src/v1/locations/dto/location-owner.dto.ts index 90437e9..29ef308 100644 --- a/src/v1/locations/dto/location-owner.dto.ts +++ b/src/v1/locations/dto/location-owner.dto.ts @@ -1,7 +1,8 @@ import { ApiProperty } from '@nestjs/swagger'; import { Database } from '../../../../database.types'; -type LocationOwnerRow = Database['public']['Tables']['cw_location_owners']['Row']; +type LocationOwnerRow = + Database['public']['Tables']['cw_location_owners']['Row']; export class LocationOwnerDto implements LocationOwnerRow { @ApiProperty() diff --git a/src/v1/locations/dto/update-location-owner.dto.ts b/src/v1/locations/dto/update-location-owner.dto.ts index 853ae0b..460c6da 100644 --- a/src/v1/locations/dto/update-location-owner.dto.ts +++ b/src/v1/locations/dto/update-location-owner.dto.ts @@ -1,4 +1,6 @@ import { PartialType } from '@nestjs/swagger'; import { CreateLocationOwnerDto } from './create-location-owner.dto'; -export class UpdateLocationOwnerDto extends PartialType(CreateLocationOwnerDto) {} +export class UpdateLocationOwnerDto extends PartialType( + CreateLocationOwnerDto, +) {} diff --git a/src/v1/locations/locations.controller.ts b/src/v1/locations/locations.controller.ts index 9333eb0..b3ae151 100644 --- a/src/v1/locations/locations.controller.ts +++ b/src/v1/locations/locations.controller.ts @@ -15,7 +15,13 @@ import { LocationsService } from './locations.service'; import { CreateLocationDto } from './dto/create-location.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; -import { ApiBearerAuth, ApiOkResponse, ApiParam, ApiQuery, ApiSecurity } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiOkResponse, + ApiParam, + ApiQuery, + ApiSecurity, +} from '@nestjs/swagger'; import { LocationDto } from './dto/location.dto'; import { UpdateLocationOwnerDto } from './dto/update-location-owner.dto'; import { CreateLocationOwnerDto } from './dto/create-location-owner.dto'; @@ -29,23 +35,30 @@ import { @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') export class LocationsController { - constructor(private readonly locationsService: LocationsService) { } + constructor(private readonly locationsService: LocationsService) {} @Post() @UseGuards(JwtAuthGuard) create(@Body() createLocationDto: CreateLocationDto, @Req() req) { const authHeader = req.headers?.authorization ?? ''; - return this.locationsService.create(createLocationDto, req.user, authHeader); + return this.locationsService.create( + createLocationDto, + req.user, + authHeader, + ); } @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: - "Current all of the user's rules configurations.", + description: "Current all of the user's rules configurations.", type: LocationDto, isArray: true, }) - @ApiQuery({ name: 'name', description: 'Filter by location name', required: false }) + @ApiQuery({ + name: 'name', + description: 'Filter by location name', + required: false, + }) @Get() findAll(@Req() req) { const authHeader = req.headers?.authorization ?? ''; @@ -55,8 +68,7 @@ export class LocationsController { @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: - "Current all of the user's location groups.", + description: "Current all of the user's location groups.", type: String, isArray: true, }) @@ -68,8 +80,7 @@ export class LocationsController { @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: - "Get a user's location configuration by ID.", + description: "Get a user's location configuration by ID.", type: LocationDto, isArray: false, }) @@ -81,16 +92,40 @@ export class LocationsController { @Patch(':id') @UseGuards(JwtAuthGuard) - update(@Param('id') id: string, @Body() updateLocationDto: UpdateLocationDto, @Req() req) { + update( + @Param('id') id: string, + @Body() updateLocationDto: UpdateLocationDto, + @Req() req, + ) { const authHeader = req.headers?.authorization ?? ''; - return this.locationsService.update(+id, updateLocationDto, req.user, authHeader); + return this.locationsService.update( + +id, + updateLocationDto, + req.user, + authHeader, + ); } @Post(':id/permission') @UseGuards(JwtAuthGuard) - @ApiParam({ name: 'id', description: 'ID of the location to update permissions for', type: Number }) - @ApiParam({ name: 'newUserEmail', description: 'Email of the user to grant permissions to', type: String, example: 'user@example.com' }) - @ApiQuery({ name: 'applyToAllDevices', description: 'Whether to apply the permission change to all devices associated with the location', type: Boolean, required: false }) + @ApiParam({ + name: 'id', + description: 'ID of the location to update permissions for', + type: Number, + }) + @ApiParam({ + name: 'newUserEmail', + description: 'Email of the user to grant permissions to', + type: String, + example: 'user@example.com', + }) + @ApiQuery({ + name: 'applyToAllDevices', + description: + 'Whether to apply the permission change to all devices associated with the location', + type: Boolean, + required: false, + }) @ApiOkResponse({ description: 'The location permission has been successfully updated.', type: LocationDto, @@ -105,8 +140,11 @@ export class LocationsController { ) { const authHeader = req.headers?.authorization ?? ''; const locationId = Number.parseInt(id, 10); - const normalizedNewUserEmail = createLocationOwnerDto.user_email?.trim() || newUserEmail?.trim(); - const applyToAllDevicesFlag = applyToAllDevices === 'true' || createLocationOwnerDto.applyToAllDevices === true; + const normalizedNewUserEmail = + createLocationOwnerDto.user_email?.trim() || newUserEmail?.trim(); + const applyToAllDevicesFlag = + applyToAllDevices === 'true' || + createLocationOwnerDto.applyToAllDevices === true; if (!Number.isInteger(locationId) || locationId < 1) { throw new BadRequestException('Location ID is required'); @@ -123,7 +161,9 @@ export class LocationsController { typeof createLocationOwnerDto.location_id === 'number' && createLocationOwnerDto.location_id !== locationId ) { - throw new BadRequestException('location_id in body must match route parameter'); + throw new BadRequestException( + 'location_id in body must match route parameter', + ); } return this.locationsService.createLocationPermission( @@ -142,40 +182,93 @@ export class LocationsController { @Patch(':id/permission') @UseGuards(JwtAuthGuard) - @ApiParam({ name: 'id', description: 'ID of the location to update permissions for', type: Number }) - @ApiQuery({ name: 'applyToAllDevices', description: 'Whether to apply the permission change to all devices associated with the location', type: Boolean, required: false }) + @ApiParam({ + name: 'id', + description: 'ID of the location to update permissions for', + type: Number, + }) + @ApiQuery({ + name: 'applyToAllDevices', + description: + 'Whether to apply the permission change to all devices associated with the location', + type: Boolean, + required: false, + }) @ApiOkResponse({ description: 'The location permission has been successfully updated.', type: LocationDto, }) - async updateLocationPermission(@Param('id') id: string, @Body() updateLocationOwnerDto: UpdateLocationOwnerDto, @Query('applyToAllDevices') applyToAllDevices: string = 'false', @Req() req) { + async updateLocationPermission( + @Param('id') id: string, + @Body() updateLocationOwnerDto: UpdateLocationOwnerDto, + @Query('applyToAllDevices') applyToAllDevices: string = 'false', + @Req() req, + ) { const authHeader = req.headers?.authorization ?? ''; const applyToAllDevicesFlag = applyToAllDevices === 'true'; - return this.locationsService.updateLocationPermission(+id, updateLocationOwnerDto, applyToAllDevicesFlag, req.user, authHeader); + return this.locationsService.updateLocationPermission( + +id, + updateLocationOwnerDto, + applyToAllDevicesFlag, + req.user, + authHeader, + ); } @Patch(':id/permission-level') @UseGuards(JwtAuthGuard) - @ApiParam({ name: 'id', description: 'ID of the location to update permissions for', type: Number }) - @ApiQuery({ name: 'applyToAllDevices', description: 'Whether to apply the permission change to all devices associated with the location', type: Boolean, required: false }) + @ApiParam({ + name: 'id', + description: 'ID of the location to update permissions for', + type: Number, + }) + @ApiQuery({ + name: 'applyToAllDevices', + description: + 'Whether to apply the permission change to all devices associated with the location', + type: Boolean, + required: false, + }) @ApiOkResponse({ description: 'The location permission has been successfully updated.', type: LocationDto, }) - async updateUserPermissionLevel(@Param('id') id: string, @Body() updateLocationUserPermissionLevelDto: any, @Query('applyToAllDevices') applyToAllDevices: string = 'false', @Req() req) { + async updateUserPermissionLevel( + @Param('id') id: string, + @Body() updateLocationUserPermissionLevelDto: any, + @Query('applyToAllDevices') applyToAllDevices: string = 'false', + @Req() req, + ) { const authHeader = req.headers?.authorization ?? ''; const applyToAllDevicesFlag = applyToAllDevices === 'true'; - return this.locationsService.updateUserPermissionLevel(+id, updateLocationUserPermissionLevelDto, applyToAllDevicesFlag, req.user, authHeader); + return this.locationsService.updateUserPermissionLevel( + +id, + updateLocationUserPermissionLevelDto, + applyToAllDevicesFlag, + req.user, + authHeader, + ); } @Delete(':id/permission') @UseGuards(JwtAuthGuard) - remove(@Param('id') id: number, @Query('permission_id') permissionId: number, @Req() req) { + remove( + @Param('id') id: number, + @Query('permission_id') permissionId: number, + @Req() req, + ) { if (!id || !permissionId) { - throw new BadRequestException('Location ID and Permission ID are required'); + throw new BadRequestException( + 'Location ID and Permission ID are required', + ); } const authHeader = req.headers?.authorization ?? ''; - return this.locationsService.removeLocationPermission(id, permissionId, req.user, authHeader); + return this.locationsService.removeLocationPermission( + id, + permissionId, + req.user, + authHeader, + ); } } diff --git a/src/v1/locations/locations.service.spec.ts b/src/v1/locations/locations.service.spec.ts index 7b69a29..2d71241 100644 --- a/src/v1/locations/locations.service.spec.ts +++ b/src/v1/locations/locations.service.spec.ts @@ -1,4 +1,7 @@ -import { InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { LocationsService } from './locations.service'; import { SupabaseService } from '../../supabase/supabase.service'; import { PaymentsService } from '../payments/payments.service'; @@ -60,7 +63,9 @@ describe('LocationsService', () => { }); const service = createService(client); - await expect(service.findOne(123, { sub: 'user-1' }, 'Bearer token-1')).rejects.toBeInstanceOf(NotFoundException); + await expect( + service.findOne(123, { sub: 'user-1' }, 'Bearer token-1'), + ).rejects.toBeInstanceOf(NotFoundException); expect(locationQuery.maybeSingle).toHaveBeenCalledTimes(1); }); @@ -123,7 +128,11 @@ describe('LocationsService', () => { const service = createService(client); await expect( - service.findOne(84, { sub: 'user-1', email: 'farmer@example.com' }, 'Bearer token-1'), + service.findOne( + 84, + { sub: 'user-1', email: 'farmer@example.com' }, + 'Bearer token-1', + ), ).resolves.toEqual({ location_id: 84, name: 'Customer Location', @@ -132,8 +141,14 @@ describe('LocationsService', () => { }); it('updateLocationPermission should not clean up when a device permission upsert fails', async () => { - const permissionCheckQuery = createBuilder({ data: { location_id: 77 }, error: null }); - const locationOwnerUpsertQuery = createBuilder({ data: { id: 1 }, error: null }); + const permissionCheckQuery = createBuilder({ + data: { location_id: 77 }, + error: null, + }); + const locationOwnerUpsertQuery = createBuilder({ + data: { id: 1 }, + error: null, + }); const locationDevicesQuery = createBuilder({ data: [{ dev_eui: 'ABC123' }, { dev_eui: 'XYZ789' }], error: null, @@ -166,8 +181,15 @@ describe('LocationsService', () => { ).rejects.toBeInstanceOf(InternalServerErrorException); // Assert we made no rollback/cleanup attempts. - const calledTables = client.from.mock.calls.map(([table]: [string]) => table); - expect(calledTables).toEqual(['cw_locations', 'cw_location_owners', 'cw_devices', 'cw_device_owners']); + const calledTables = client.from.mock.calls.map( + ([table]: [string]) => table, + ); + expect(calledTables).toEqual([ + 'cw_locations', + 'cw_location_owners', + 'cw_devices', + 'cw_device_owners', + ]); expect(locationOwnerUpsertQuery.delete).not.toHaveBeenCalled(); expect(failingDeviceOwnerUpsertQuery.delete).not.toHaveBeenCalled(); }); diff --git a/src/v1/locations/locations.service.ts b/src/v1/locations/locations.service.ts index 2d9c789..ec548e4 100644 --- a/src/v1/locations/locations.service.ts +++ b/src/v1/locations/locations.service.ts @@ -1,4 +1,11 @@ -import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, NotImplementedException, UnauthorizedException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, + NotImplementedException, + UnauthorizedException, +} from '@nestjs/common'; import { CreateLocationDto } from './dto/create-location.dto'; import { CreateLocationOwnerDto } from './dto/create-location-owner.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; @@ -21,13 +28,16 @@ import { filterStaffOwnerRows } from '../common/owner-filter.helper'; @Injectable() export class LocationsService { - constructor( private readonly supabaseService: SupabaseService, private readonly paymentsService: PaymentsService, - ) { } + ) {} - async create(createLocationDto: CreateLocationDto, jwtPayload: any, authHeader: string) { + async create( + createLocationDto: CreateLocationDto, + jwtPayload: any, + authHeader: string, + ) { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -36,7 +46,10 @@ export class LocationsService { // are exempt, mirroring the rest of the permission model. if ( !isCropwatchStaff(jwtPayload) && - !(await this.paymentsService.hasActiveBaseSubscription(jwtPayload, authHeader)) + !(await this.paymentsService.hasActiveBaseSubscription( + jwtPayload, + authHeader, + )) ) { throw new ForbiddenException( 'An active base subscription is required to create a location.', @@ -45,11 +58,14 @@ export class LocationsService { createLocationDto.owner_id = userId; // Ensure the owner_id is set to the authenticated user - const { data: locationData, error: locationError } = await client.from('cw_locations').insert({ - ...createLocationDto, - owner_id: userId, - }).select('*') - .single(); + const { data: locationData, error: locationError } = await client + .from('cw_locations') + .insert({ + ...createLocationDto, + owner_id: userId, + }) + .select('*') + .single(); if (locationError) { throw new InternalServerErrorException('Failed to create location'); @@ -66,11 +82,13 @@ export class LocationsService { description: null, }; - const { data: ownerData, error: ownerError } = await client.from('cw_location_owners').insert({ - ...locationOwnerObject - }) - .select('*') - .single(); + const { data: ownerData, error: ownerError } = await client + .from('cw_location_owners') + .insert({ + ...locationOwnerObject, + }) + .select('*') + .single(); if (ownerError) { throw new InternalServerErrorException('Failed to create location owner'); @@ -85,9 +103,7 @@ export class LocationsService { const client = this.supabaseService.getClient(accessToken); const isGlobalUser = isCropwatchStaff(jwtPayload); - let query = client - .from('cw_locations') - .select(` + let query = client.from('cw_locations').select(` *, owner_match:cw_location_owners(), cw_location_owners(*) @@ -116,7 +132,9 @@ export class LocationsService { let query = client .from('cw_locations') - .select(`*,owner_match:cw_location_owners(),cw_location_owners(*, profiles(id, full_name, email))`) + .select( + `*,owner_match:cw_location_owners(),cw_location_owners(*, profiles(id, full_name, email))`, + ) .eq('location_id', id); query = this.applyLocationReadScope(query, userId, isGlobalUser); @@ -125,7 +143,6 @@ export class LocationsService { .order('name', { ascending: true }) .maybeSingle(); - if (error) { throw new InternalServerErrorException('Failed to fetch location'); } @@ -143,7 +160,12 @@ export class LocationsService { }; } - async update(id: number, updateLocationDto: UpdateLocationDto, jwtPayload: any, authHeader: string) { + async update( + id: number, + updateLocationDto: UpdateLocationDto, + jwtPayload: any, + authHeader: string, + ) { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -152,20 +174,29 @@ export class LocationsService { // check if you have permission to update location permissions let permissionQuery = client .from('cw_locations') - .select(` + .select( + ` *, owner_match:cw_location_owners(), cw_location_owners(*, profiles(id, full_name, email)) - `) + `, + ) .eq('location_id', id); permissionQuery = this.applyLocationManageScope( permissionQuery, userId, isGlobalUser, ); - const { data: locationCurrentPermission, error: locationPermissionError } = await permissionQuery.maybeSingle(); - if (locationPermissionError) throw new InternalServerErrorException('Failed to fetch location permissions'); - if (!locationCurrentPermission) throw new UnauthorizedException('You do not have permission to update this location'); + const { data: locationCurrentPermission, error: locationPermissionError } = + await permissionQuery.maybeSingle(); + if (locationPermissionError) + throw new InternalServerErrorException( + 'Failed to fetch location permissions', + ); + if (!locationCurrentPermission) + throw new UnauthorizedException( + 'You do not have permission to update this location', + ); let updateQuery = client .from('cw_locations') @@ -179,22 +210,25 @@ export class LocationsService { updateQuery = updateQuery.eq('owner_id', userId); } - const { data, error } = await updateQuery - .select('*') - .single(); + const { data, error } = await updateQuery.select('*').single(); if (error) { throw new InternalServerErrorException('Failed to update location'); } if (!data) { - throw new NotFoundException('Location not found or you do not have permission to update'); + throw new NotFoundException( + 'Location not found or you do not have permission to update', + ); } return data; } - async findAllLocationGroups(jwtPayload: any, authHeader: string): Promise { + async findAllLocationGroups( + jwtPayload: any, + authHeader: string, + ): Promise { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -219,12 +253,21 @@ export class LocationsService { throw new InternalServerErrorException('Failed to fetch location groups'); } - const uniqueGroupArray = Array.from(new Set(data.map(item => item.group))).filter(group => group !== null); + const uniqueGroupArray = Array.from( + new Set(data.map((item) => item.group)), + ).filter((group) => group !== null); return uniqueGroupArray; } - async createLocationPermission(id: number, createLocationOwnerDto: CreateLocationOwnerDto, permissionLevel: number, applyPermissionToAllDevices: boolean, jwtPayload: any, authHeader: string) { + async createLocationPermission( + id: number, + createLocationOwnerDto: CreateLocationOwnerDto, + permissionLevel: number, + applyPermissionToAllDevices: boolean, + jwtPayload: any, + authHeader: string, + ) { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -233,20 +276,29 @@ export class LocationsService { // check if you have permission to update location permissions let permissionQuery = client .from('cw_locations') - .select(` + .select( + ` *, owner_match:cw_location_owners(), cw_location_owners(*, profiles(id, full_name, email)) - `) + `, + ) .eq('location_id', id); permissionQuery = this.applyLocationManageScope( permissionQuery, userId, isGlobalUser, ); - const { data: locationCurrentPermission, error: locationPermissionError } = await permissionQuery.maybeSingle(); - if (locationPermissionError) throw new InternalServerErrorException('Failed to fetch location permissions'); - if (!locationCurrentPermission) throw new UnauthorizedException('You do not have permission to update this location'); + const { data: locationCurrentPermission, error: locationPermissionError } = + await permissionQuery.maybeSingle(); + if (locationPermissionError) + throw new InternalServerErrorException( + 'Failed to fetch location permissions', + ); + if (!locationCurrentPermission) + throw new UnauthorizedException( + 'You do not have permission to update this location', + ); // If we got here, then it means we have the necessary permissions to update location permissions, so we can proceed with upserting the location owner and potentially updating device permissions as well. @@ -258,31 +310,34 @@ export class LocationsService { .eq('email', createLocationOwnerDto.user_email) .maybeSingle(); - if (userError) throw new InternalServerErrorException('Failed to fetch user data'); - if (!userData) throw new NotFoundException('User with the provided email not found'); - + if (userError) + throw new InternalServerErrorException('Failed to fetch user data'); + if (!userData) + throw new NotFoundException('User with the provided email not found'); // upsert user to location const { error: locationOwnerError } = await client .from('cw_location_owners') - .upsert( - { - user_id: userData.id, - permission_level: permissionLevel, - location_id: id, - is_active: true, // as we are inserting for the fist time, this should always be true. - admin_user_id: userId, - }, - ) + .upsert({ + user_id: userData.id, + permission_level: permissionLevel, + location_id: id, + is_active: true, // as we are inserting for the fist time, this should always be true. + admin_user_id: userId, + }) .single(); - if (locationOwnerError) throw new InternalServerErrorException('Failed to update location owner'); + if (locationOwnerError) + throw new InternalServerErrorException('Failed to update location owner'); // get All devices inside of location const { data: locationDevices, error: locationDevicesError } = await client .from('cw_devices') .select('dev_eui') .eq('location_id', id); - if (locationDevicesError) throw new InternalServerErrorException('Failed to fetch location devices'); + if (locationDevicesError) + throw new InternalServerErrorException( + 'Failed to fetch location devices', + ); const locationPermissionLevel = permissionLevel ?? PermissionLevel.DISABLED; @@ -302,13 +357,20 @@ export class LocationsService { { onConflict: 'dev_eui,user_id' }, ) .single(); - if (deviceOwnerError) throw new InternalServerErrorException('Failed to update device owner'); + if (deviceOwnerError) + throw new InternalServerErrorException('Failed to update device owner'); } return { message: 'Location permission successfully updated' }; } - async updateLocationPermission(id: number, updateLocationOwnerDto: UpdateLocationOwnerDto, applyPermissionToAllDevices: boolean, jwtPayload: any, authHeader: string) { + async updateLocationPermission( + id: number, + updateLocationOwnerDto: UpdateLocationOwnerDto, + applyPermissionToAllDevices: boolean, + jwtPayload: any, + authHeader: string, + ) { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -317,20 +379,29 @@ export class LocationsService { // check if you have permission to update location permissions let permissionQuery = client .from('cw_locations') - .select(` + .select( + ` *, owner_match:cw_location_owners(), cw_location_owners(*) - `) + `, + ) .eq('location_id', id); permissionQuery = this.applyLocationManageScope( permissionQuery, userId, isGlobalUser, ); - const { data: locationCurrentPermission, error: locationPermissionError } = await permissionQuery.maybeSingle(); - if (locationPermissionError) throw new InternalServerErrorException('Failed to fetch location permissions'); - if (!locationCurrentPermission) throw new UnauthorizedException('You do not have permission to update this location'); + const { data: locationCurrentPermission, error: locationPermissionError } = + await permissionQuery.maybeSingle(); + if (locationPermissionError) + throw new InternalServerErrorException( + 'Failed to fetch location permissions', + ); + if (!locationCurrentPermission) + throw new UnauthorizedException( + 'You do not have permission to update this location', + ); // If we got here, then it means we have the necessary permissions to update location permissions, so we can proceed with upserting the location owner and potentially updating device permissions as well. @@ -348,14 +419,18 @@ export class LocationsService { { onConflict: 'location_id,user_id' }, ) .single(); - if (locationOwnerError) throw new InternalServerErrorException('Failed to update location owner'); + if (locationOwnerError) + throw new InternalServerErrorException('Failed to update location owner'); // get All devices inside of location const { data: locationDevices, error: locationDevicesError } = await client .from('cw_devices') .select('dev_eui') .eq('location_id', locationCurrentPermission.location_id); - if (locationDevicesError) throw new InternalServerErrorException('Failed to fetch location devices'); + if (locationDevicesError) + throw new InternalServerErrorException( + 'Failed to fetch location devices', + ); const locationPermissionLevel = updateLocationOwnerDto.permission_level ?? PermissionLevel.DISABLED; @@ -376,11 +451,18 @@ export class LocationsService { { onConflict: 'dev_eui,user_id' }, ) .single(); - if (deviceOwnerError) throw new InternalServerErrorException('Failed to update device owner'); + if (deviceOwnerError) + throw new InternalServerErrorException('Failed to update device owner'); } } - async updateUserPermissionLevel(id: number, updateLocationOwnerDto: any, applyPermissionToAllDevices: boolean, jwtPayload: any, authHeader: string) { + async updateUserPermissionLevel( + id: number, + updateLocationOwnerDto: any, + applyPermissionToAllDevices: boolean, + jwtPayload: any, + authHeader: string, + ) { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -393,20 +475,29 @@ export class LocationsService { // check if you have permission to update location permissions let permissionQuery = client .from('cw_locations') - .select(` + .select( + ` *, owner_match:cw_location_owners(), cw_location_owners(*) - `) + `, + ) .eq('location_id', id); permissionQuery = this.applyLocationManageScope( permissionQuery, userId, isGlobalUser, ); - const { data: locationCurrentPermission, error: locationPermissionError } = await permissionQuery.maybeSingle(); - if (locationPermissionError) throw new InternalServerErrorException('Failed to fetch location permissions'); - if (!locationCurrentPermission) throw new UnauthorizedException('You do not have permission to update this location'); + const { data: locationCurrentPermission, error: locationPermissionError } = + await permissionQuery.maybeSingle(); + if (locationPermissionError) + throw new InternalServerErrorException( + 'Failed to fetch location permissions', + ); + if (!locationCurrentPermission) + throw new UnauthorizedException( + 'You do not have permission to update this location', + ); // If we got here, then it means we have the necessary permissions to update location permissions, so we can proceed with upserting the location owner and potentially updating device permissions as well. const { data: userData, error: userError } = await client @@ -415,28 +506,35 @@ export class LocationsService { .eq('email', email) .maybeSingle(); - if (userError) throw new InternalServerErrorException('Failed to fetch user data'); - if (!userData) throw new NotFoundException('User with the provided email not found'); + if (userError) + throw new InternalServerErrorException('Failed to fetch user data'); + if (!userData) + throw new NotFoundException('User with the provided email not found'); // upsert user to location const { error: locationOwnerError } = await client .from('cw_location_owners') - .update( - { - user_id: userData.id, - permission_level: permission_level, - location_id: location_id, - is_active: true, // as we are inserting for the fist time, this should always be true. - }) + .update({ + user_id: userData.id, + permission_level: permission_level, + location_id: location_id, + is_active: true, // as we are inserting for the fist time, this should always be true. + }) .eq('location_id', location_id) .eq('user_id', userData.id) .single(); - if (locationOwnerError) throw new InternalServerErrorException('Failed to update location owner'); + if (locationOwnerError) + throw new InternalServerErrorException('Failed to update location owner'); return { message: 'Location permission level successfully updated' }; } - async removeLocationPermission(location_id: number, permissionId: number, jwtPayload: any, authHeader: string) { + async removeLocationPermission( + location_id: number, + permissionId: number, + jwtPayload: any, + authHeader: string, + ) { const userId = getUserId(jwtPayload); const accessToken = getAccessToken(authHeader); const client = this.supabaseService.getClient(accessToken); @@ -445,11 +543,13 @@ export class LocationsService { // Check if current user has permissions to remove another user's permissions from the location let permissionQuery = client .from('cw_locations') - .select(` + .select( + ` *, owner_match:cw_location_owners(), cw_location_owners(*) - `) + `, + ) .eq('location_id', location_id); permissionQuery = this.applyLocationManageScope( permissionQuery, @@ -459,22 +559,32 @@ export class LocationsService { const { data: requestingUser, error } = await permissionQuery.maybeSingle(); if (error) { - throw new InternalServerErrorException('Failed to fetch location permissions'); + throw new InternalServerErrorException( + 'Failed to fetch location permissions', + ); } if (!requestingUser) { - throw new UnauthorizedException('You do not have permission to update this location'); + throw new UnauthorizedException( + 'You do not have permission to update this location', + ); } - // GET THE ROW WITH THE ACTUAL USER ID THAT WE WILL DELETE EVERYWHERE LATER ON - const { data: locationPermissionRecord, error: locationPermissionRecordError } = await client + const { + data: locationPermissionRecord, + error: locationPermissionRecordError, + } = await client .from('cw_location_owners') .select('*') .eq('id', permissionId) .eq('location_id', location_id) .maybeSingle(); - if (locationPermissionRecordError) throw new InternalServerErrorException('Failed to fetch location permission record'); - if (!locationPermissionRecord) throw new NotFoundException('Location permission record not found'); + if (locationPermissionRecordError) + throw new InternalServerErrorException( + 'Failed to fetch location permission record', + ); + if (!locationPermissionRecord) + throw new NotFoundException('Location permission record not found'); const user_id_to_delete = locationPermissionRecord.user_id; @@ -484,14 +594,20 @@ export class LocationsService { .delete() .eq('id', permissionId) .eq('location_id', location_id); - if (deleteLocationPermissionError) throw new InternalServerErrorException('Failed to delete location permission'); + if (deleteLocationPermissionError) + throw new InternalServerErrorException( + 'Failed to delete location permission', + ); // get All devices inside of location const { data: locationDevices, error: locationDevicesError } = await client .from('cw_devices') .select('dev_eui') .eq('location_id', location_id); - if (locationDevicesError) throw new InternalServerErrorException('Failed to fetch location devices'); + if (locationDevicesError) + throw new InternalServerErrorException( + 'Failed to fetch location devices', + ); // delete user's permissions from all devices in the location for (const device of locationDevices ?? []) { @@ -500,14 +616,23 @@ export class LocationsService { .delete() .eq('user_id', user_id_to_delete) .eq('dev_eui', device.dev_eui); - if (deleteDevicePermissionError) throw new InternalServerErrorException('Failed to delete device permission'); + if (deleteDevicePermissionError) + throw new InternalServerErrorException( + 'Failed to delete device permission', + ); } - return { message: 'Location permission and associated device permissions successfully deleted' }; - + return { + message: + 'Location permission and associated device permissions successfully deleted', + }; } - private applyLocationReadScope(query: any, userId: string, isGlobalUser: boolean) { + private applyLocationReadScope( + query: any, + userId: string, + isGlobalUser: boolean, + ) { if (isGlobalUser) { return query; } @@ -518,7 +643,11 @@ export class LocationsService { .or(`owner_id.eq.${userId},owner_match.not.is.null`); } - private applyLocationManageScope(query: any, userId: string, isGlobalUser: boolean) { + private applyLocationManageScope( + query: any, + userId: string, + isGlobalUser: boolean, + ) { if (isGlobalUser) { return query; } @@ -528,5 +657,4 @@ export class LocationsService { .lte('owner_match.permission_level', MANAGE_CEILING) .or(`owner_id.eq.${userId},owner_match.not.is.null`); } - } diff --git a/src/v1/mcp/tools/devices.tools.ts b/src/v1/mcp/tools/devices.tools.ts index 970deff..635bc8b 100644 --- a/src/v1/mcp/tools/devices.tools.ts +++ b/src/v1/mcp/tools/devices.tools.ts @@ -39,11 +39,15 @@ export class DeviceMcpTools { .min(1) .max(1000) .optional() - .describe('Maximum number of devices to return (default 100, max 1000).'), + .describe( + 'Maximum number of devices to return (default 100, max 1000).', + ), name: z .string() .optional() - .describe('Filter by device name or dev_eui (case-insensitive contains).'), + .describe( + 'Filter by device name or dev_eui (case-insensitive contains).', + ), group: z .string() .optional() @@ -97,11 +101,7 @@ export class DeviceMcpTools { openWorldHint: false, }, }) - async getDevice( - { dev_eui }, - _context: Context, - request: AuthedRequest, - ) { + async getDevice({ dev_eui }, _context: Context, request: AuthedRequest) { const authHeader = request.headers.authorization ?? ''; const device = await this.devicesService.findOne( request.user, @@ -114,7 +114,9 @@ export class DeviceMcpTools { /** Wrap any serializable value as an MCP text-content result. */ private json(value: unknown) { return { - content: [{ type: 'text' as const, text: JSON.stringify(value, null, 2) }], + content: [ + { type: 'text' as const, text: JSON.stringify(value, null, 2) }, + ], }; } } diff --git a/src/v1/payments/dto/create-base-checkout.dto.ts b/src/v1/payments/dto/create-base-checkout.dto.ts index 5244da5..f22b406 100644 --- a/src/v1/payments/dto/create-base-checkout.dto.ts +++ b/src/v1/payments/dto/create-base-checkout.dto.ts @@ -5,7 +5,8 @@ export class CreateBaseCheckoutDto { @ApiProperty({ required: false, nullable: true, - description: 'Optional Polar discount id to apply to the base subscription.', + description: + 'Optional Polar discount id to apply to the base subscription.', }) @IsOptional() @IsString() diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index 6c63808..41bc244 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -47,19 +47,27 @@ export class PaymentsController { summary: 'Get the full billing overview (base sub, device seats, licenses)', }) getState(@Req() req) { - return this.paymentsService.getState(req.user, req.headers?.authorization ?? ''); + return this.paymentsService.getState( + req.user, + req.headers?.authorization ?? '', + ); } @Get('licenses') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: "List the user's device licenses" }) getLicenses(@Req() req) { - return this.paymentsService.getLicenses(req.user, req.headers?.authorization ?? ''); + return this.paymentsService.getLicenses( + req.user, + req.headers?.authorization ?? '', + ); } @Post('subscriptions/base/checkout') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Create a hosted checkout for the base subscription' }) + @ApiOperation({ + summary: 'Create a hosted checkout for the base subscription', + }) createBaseCheckout(@Body() dto: CreateBaseCheckoutDto, @Req() req) { return this.paymentsService.createBaseCheckout( req.user, @@ -70,7 +78,9 @@ export class PaymentsController { @Post('subscriptions/device/checkout') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Create a hosted checkout for device licenses (seats)' }) + @ApiOperation({ + summary: 'Create a hosted checkout for device licenses (seats)', + }) createDeviceCheckout(@Body() dto: CreateDeviceCheckoutDto, @Req() req) { return this.paymentsService.createDeviceCheckout( req.user, @@ -94,7 +104,11 @@ export class PaymentsController { @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) @ApiOperation({ summary: 'Assign a license to a device' }) - assignLicense(@Param('id') id: string, @Body() dto: AssignLicenseDto, @Req() req) { + assignLicense( + @Param('id') id: string, + @Body() dto: AssignLicenseDto, + @Req() req, + ) { return this.paymentsService.assignLicense( req.user, req.headers?.authorization ?? '', @@ -107,7 +121,11 @@ export class PaymentsController { @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) @ApiOperation({ summary: 'Move a license to a different device' }) - moveLicense(@Param('id') id: string, @Body() dto: MoveLicenseDto, @Req() req) { + moveLicense( + @Param('id') id: string, + @Body() dto: MoveLicenseDto, + @Req() req, + ) { return this.paymentsService.moveLicense( req.user, req.headers?.authorization ?? '', @@ -131,7 +149,9 @@ export class PaymentsController { @Post('licenses/:id/cancel') @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) - @ApiOperation({ summary: 'Cancel an unassigned license (reduce the paid seat count by one)' }) + @ApiOperation({ + summary: 'Cancel an unassigned license (reduce the paid seat count by one)', + }) cancelLicense(@Param('id') id: string, @Req() req) { return this.paymentsService.cancelLicense( req.user, @@ -144,7 +164,10 @@ export class PaymentsController { @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Open the Polar customer billing portal' }) openPortal(@Req() req) { - return this.paymentsService.openPortal(req.user, req.headers?.authorization ?? ''); + return this.paymentsService.openPortal( + req.user, + req.headers?.authorization ?? '', + ); } @Delete('subscriptions/base') @@ -160,13 +183,18 @@ export class PaymentsController { @Post('webhook') @HttpCode(HttpStatus.ACCEPTED) - @ApiOperation({ summary: 'Receive Polar webhook events (signature-verified)' }) + @ApiOperation({ + summary: 'Receive Polar webhook events (signature-verified)', + }) handleWebhook(@Req() req) { const rawBody: Buffer | undefined = req.rawBody; if (!rawBody) { throw new BadRequestException('Missing webhook body'); } - return this.paymentsService.handleWebhook(rawBody, this.normalizeHeaders(req.headers)); + return this.paymentsService.handleWebhook( + rawBody, + this.normalizeHeaders(req.headers), + ); } private parseId(id: string): number { diff --git a/src/v1/payments/payments.service.ts b/src/v1/payments/payments.service.ts index 1040f21..6e185e3 100644 --- a/src/v1/payments/payments.service.ts +++ b/src/v1/payments/payments.service.ts @@ -51,9 +51,11 @@ export class PaymentsService { this.polarService.deviceProductId, ]); return { - base: products.find((p) => p.id === this.polarService.baseProductId) ?? null, + base: + products.find((p) => p.id === this.polarService.baseProductId) ?? null, device: - products.find((p) => p.id === this.polarService.deviceProductId) ?? null, + products.find((p) => p.id === this.polarService.deviceProductId) ?? + null, }; } @@ -67,7 +69,10 @@ export class PaymentsService { await this.ensureBillingCustomer(client, userId); const subscriptions = await this.listSubscriptionsSafe(userId); - const baseSub = this.pickSubscription(subscriptions, this.polarService.baseProductId); + const baseSub = this.pickSubscription( + subscriptions, + this.polarService.baseProductId, + ); const deviceSub = this.pickSubscription( subscriptions, this.polarService.deviceProductId, @@ -107,7 +112,10 @@ export class PaymentsService { }; } - async getLicenses(jwtPayload: any, authHeader: string): Promise { + async getLicenses( + jwtPayload: any, + authHeader: string, + ): Promise { const userId = getUserId(jwtPayload); const client = this.supabaseService.getClient(getAccessToken(authHeader)); return this.fetchLicenses(client, userId); @@ -119,11 +127,17 @@ export class PaymentsService { * cached `billing_customers.base_status` so a transient outage doesn't block * a legitimately-subscribed user. */ - async hasActiveBaseSubscription(jwtPayload: any, authHeader: string): Promise { + async hasActiveBaseSubscription( + jwtPayload: any, + authHeader: string, + ): Promise { const userId = getUserId(jwtPayload); try { const subscriptions = await this.polarService.listSubscriptions(userId); - const baseSub = this.pickSubscription(subscriptions, this.polarService.baseProductId); + const baseSub = this.pickSubscription( + subscriptions, + this.polarService.baseProductId, + ); return !!baseSub && ACTIVE_SUBSCRIPTION_STATUSES.includes(baseSub.status); } catch (error) { this.logger.warn( @@ -136,7 +150,8 @@ export class PaymentsService { .eq('user_id', userId) .maybeSingle(); return ( - !!data?.base_status && ACTIVE_SUBSCRIPTION_STATUSES.includes(data.base_status) + !!data?.base_status && + ACTIVE_SUBSCRIPTION_STATUSES.includes(data.base_status) ); } } @@ -155,7 +170,10 @@ export class PaymentsService { await this.ensureBillingCustomer(client, userId); const subscriptions = await this.listSubscriptionsSafe(userId); - const existing = this.pickSubscription(subscriptions, this.polarService.baseProductId); + const existing = this.pickSubscription( + subscriptions, + this.polarService.baseProductId, + ); if (existing && ACTIVE_SUBSCRIPTION_STATUSES.includes(existing.status)) { throw new ConflictException('A base subscription is already active.'); } @@ -179,7 +197,10 @@ export class PaymentsService { await this.ensureBillingCustomer(client, userId); const subscriptions = await this.listSubscriptionsSafe(userId); - const existing = this.pickSubscription(subscriptions, this.polarService.deviceProductId); + const existing = this.pickSubscription( + subscriptions, + this.polarService.deviceProductId, + ); if (existing && ACTIVE_SUBSCRIPTION_STATUSES.includes(existing.status)) { throw new ConflictException( 'A device subscription already exists. Change the seat count instead.', @@ -187,7 +208,10 @@ export class PaymentsService { } const checkoutUrl = await this.polarService.createCheckout({ - productId: this.requireProductId(this.polarService.deviceProductId, 'device'), + productId: this.requireProductId( + this.polarService.deviceProductId, + 'device', + ), externalCustomerId: userId, customerEmail: this.readEmail(jwtPayload), // Informational: the seat count is confirmed by Polar (webhook / getState @@ -217,7 +241,9 @@ export class PaymentsService { } const licenses = await this.fetchLicenses(client, userId); - const assigned = licenses.filter((l) => l.status === 'assigned' && l.devEui); + const assigned = licenses.filter( + (l) => l.status === 'assigned' && l.devEui, + ); if (seats < assigned.length) { const names = assigned .map((l) => l.deviceName ?? l.devEui) @@ -260,12 +286,18 @@ export class PaymentsService { const client = this.supabaseService.getClient(getAccessToken(authHeader)); const subscriptions = await this.listSubscriptionsSafe(userId); - const baseSub = this.pickSubscription(subscriptions, this.polarService.baseProductId); + const baseSub = this.pickSubscription( + subscriptions, + this.polarService.baseProductId, + ); if (!baseSub) { throw new NotFoundException('No base subscription to cancel.'); } - const updated = await this.polarService.cancelSubscription(baseSub.id, atPeriodEnd); + const updated = await this.polarService.cancelSubscription( + baseSub.id, + atPeriodEnd, + ); // The device subscription (all device licenses) cannot exist without the // base subscription, so cancel it with the same timing. The license rows are @@ -341,7 +373,11 @@ export class PaymentsService { const { error } = await client .from('device_licenses') - .update({ dev_eui: null, status: 'unassigned', updated_at: new Date().toISOString() }) + .update({ + dev_eui: null, + status: 'unassigned', + updated_at: new Date().toISOString(), + }) .eq('id', licenseId) .eq('user_id', userId); if (error) { @@ -510,7 +546,10 @@ export class PaymentsService { // EVERY license, assigned or not. A still-scheduled cancel // ('subscription.canceled' while status stays 'active') keeps the seats // live, so we fall through and reconcile to the current paid seat count. - if (eventType === 'subscription.revoked' || subscription.status === 'canceled') { + if ( + eventType === 'subscription.revoked' || + subscription.status === 'canceled' + ) { await this.deleteAllLicenses(client, userId); await this.patchBillingCustomer(client, userId, { device_subscription_id: null, @@ -579,7 +618,9 @@ export class PaymentsService { const removable = rows .filter((r) => r.status !== 'assigned' && !r.dev_eui) .sort((a, b) => b.seat_index - a.seat_index); - const toRemove = removable.slice(0, current - targetSeats).map((r) => r.id); + const toRemove = removable + .slice(0, current - targetSeats) + .map((r) => r.id); if (toRemove.length < current - targetSeats) { this.logger.warn( @@ -593,7 +634,9 @@ export class PaymentsService { .delete() .in('id', toRemove); if (deleteError) { - throw new InternalServerErrorException('Failed to remove device licenses'); + throw new InternalServerErrorException( + 'Failed to remove device licenses', + ); } } } @@ -621,11 +664,16 @@ export class PaymentsService { const { data: inserted, error: insertError } = await client .from('billing_customers') - .upsert({ user_id: userId }, { onConflict: 'user_id', ignoreDuplicates: false }) + .upsert( + { user_id: userId }, + { onConflict: 'user_id', ignoreDuplicates: false }, + ) .select('*') .single(); if (insertError || !inserted) { - throw new InternalServerErrorException('Failed to create billing customer'); + throw new InternalServerErrorException( + 'Failed to create billing customer', + ); } return inserted; } @@ -649,7 +697,9 @@ export class PaymentsService { { onConflict: 'user_id' }, ); if (error) { - this.logger.warn(`Failed to link billing customer ${userId}: ${error.message}`); + this.logger.warn( + `Failed to link billing customer ${userId}: ${error.message}`, + ); } } @@ -690,7 +740,9 @@ export class PaymentsService { try { return await this.polarService.listSubscriptions(userId); } catch (error) { - this.logger.warn(`Failed to list Polar subscriptions for ${userId}: ${error}`); + this.logger.warn( + `Failed to list Polar subscriptions for ${userId}: ${error}`, + ); return []; } } @@ -832,7 +884,9 @@ export class PaymentsService { .delete() .eq('user_id', userId); if (error) { - this.logger.warn(`Failed to delete device licenses for ${userId}: ${error.message}`); + this.logger.warn( + `Failed to delete device licenses for ${userId}: ${error.message}`, + ); } } @@ -876,8 +930,8 @@ export class PaymentsService { cw_devices?: { name: string | null } | { name: string | null }[] | null; }): BillingLicense { const device = Array.isArray(row.cw_devices) - ? row.cw_devices[0] ?? null - : row.cw_devices ?? null; + ? (row.cw_devices[0] ?? null) + : (row.cw_devices ?? null); return { id: row.id, seatIndex: row.seat_index, diff --git a/src/v1/payments/polar.service.ts b/src/v1/payments/polar.service.ts index ed97169..d43580a 100644 --- a/src/v1/payments/polar.service.ts +++ b/src/v1/payments/polar.service.ts @@ -1,7 +1,10 @@ import { Injectable, Logger } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { Polar } from '@polar-sh/sdk'; -import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks'; +import { + validateEvent, + WebhookVerificationError, +} from '@polar-sh/sdk/webhooks'; export { WebhookVerificationError }; @@ -45,9 +48,11 @@ export class PolarService { private readonly client: Polar; constructor(private readonly configService: ConfigService) { - const accessToken = this.configService.get('POLAR_ACCESS_TOKEN') ?? ''; + const accessToken = + this.configService.get('POLAR_ACCESS_TOKEN') ?? ''; const server = - (this.configService.get('POLAR_SERVER') ?? 'sandbox') === 'production' + (this.configService.get('POLAR_SERVER') ?? 'sandbox') === + 'production' ? 'production' : 'sandbox'; @@ -98,7 +103,8 @@ export class PolarService { : 'seatTiers' in price ? (price.seatTiers.tiers[0]?.pricePerSeat ?? null) : null, - priceCurrency: 'priceCurrency' in price ? price.priceCurrency : null, + priceCurrency: + 'priceCurrency' in price ? price.priceCurrency : null, })), }); } @@ -106,8 +112,12 @@ export class PolarService { return products; } - async listSubscriptions(externalCustomerId: string): Promise { - const iterator = await this.client.subscriptions.list({ externalCustomerId }); + async listSubscriptions( + externalCustomerId: string, + ): Promise { + const iterator = await this.client.subscriptions.list({ + externalCustomerId, + }); const subscriptions: PolarSubscriptionInfo[] = []; for await (const page of iterator) { @@ -136,7 +146,10 @@ export class PolarService { return checkout.url; } - async updateSeats(subscriptionId: string, seats: number): Promise { + async updateSeats( + subscriptionId: string, + seats: number, + ): Promise { const subscription = await this.client.subscriptions.update({ id: subscriptionId, subscriptionUpdate: { seats }, diff --git a/src/v1/relay/relay-command-profile.ts b/src/v1/relay/relay-command-profile.ts index c474fb4..aee421e 100644 --- a/src/v1/relay/relay-command-profile.ts +++ b/src/v1/relay/relay-command-profile.ts @@ -42,8 +42,14 @@ function encodeTimedRelayState(relayOn: boolean): number { } function encodeTimedDurationMs(durationMs: number): number[] { - if (!Number.isInteger(durationMs) || durationMs < 1 || durationMs > 0xffffffff) { - throw new RangeError('durationMs must be an integer between 1 and 4294967295'); + if ( + !Number.isInteger(durationMs) || + durationMs < 1 || + durationMs > 0xffffffff + ) { + throw new RangeError( + 'durationMs must be an integer between 1 and 4294967295', + ); } if (durationMs <= 0xffff) { diff --git a/src/v1/relay/relay-confirmation.ts b/src/v1/relay/relay-confirmation.ts index 41a6685..1896c93 100644 --- a/src/v1/relay/relay-confirmation.ts +++ b/src/v1/relay/relay-confirmation.ts @@ -50,19 +50,26 @@ function readRelayStatus(value: unknown): boolean | null { return null; } -function readDecodedPayload(payload: Record): Record { +function readDecodedPayload( + payload: Record, +): Record { const data = readApplicationUp(payload); - const uplinkMessage = isRecord(data.uplink_message) ? data.uplink_message : null; - const decodedPayload = uplinkMessage && isRecord(uplinkMessage.decoded_payload) - ? uplinkMessage.decoded_payload + const uplinkMessage = isRecord(data.uplink_message) + ? data.uplink_message : null; + const decodedPayload = + uplinkMessage && isRecord(uplinkMessage.decoded_payload) + ? uplinkMessage.decoded_payload + : null; return decodedPayload ?? {}; } function readConfirmationTime(payload: Record): string { const data = readApplicationUp(payload); - const uplinkMessage = isRecord(data.uplink_message) ? data.uplink_message : null; + const uplinkMessage = isRecord(data.uplink_message) + ? data.uplink_message + : null; return ( readString(data?.received_at) || @@ -73,8 +80,12 @@ function readConfirmationTime(payload: Record): string { function readConfirmationDevEui(payload: Record): string { const data = readApplicationUp(payload); - const endDeviceIds = isRecord(data.end_device_ids) ? data.end_device_ids : null; - const identifiers = Array.isArray(payload.identifiers) ? payload.identifiers : []; + const endDeviceIds = isRecord(data.end_device_ids) + ? data.end_device_ids + : null; + const identifiers = Array.isArray(payload.identifiers) + ? payload.identifiers + : []; for (const entry of identifiers) { if (!isRecord(entry)) { diff --git a/src/v1/relay/relay.controller.ts b/src/v1/relay/relay.controller.ts index 3c2ac5f..8603cab 100644 --- a/src/v1/relay/relay.controller.ts +++ b/src/v1/relay/relay.controller.ts @@ -92,11 +92,7 @@ export class RelayController { throw new UnauthorizedException('Missing bearer token'); } - return this.relayService.getLatestRelay( - req.user, - authHeader, - devEui, - ); + return this.relayService.getLatestRelay(req.user, authHeader, devEui); } @Patch(':dev_eui') @@ -143,7 +139,8 @@ export class RelayController { description: 'Missing or invalid bearer token.', }) @ApiOperation({ - summary: 'Turn a relay on for a fixed number of seconds, then let it revert', + summary: + 'Turn a relay on for a fixed number of seconds, then let it revert', }) pulseRelay( @Param('dev_eui') devEui: string, diff --git a/src/v1/relay/relay.module.ts b/src/v1/relay/relay.module.ts index 666270a..d9a37a3 100644 --- a/src/v1/relay/relay.module.ts +++ b/src/v1/relay/relay.module.ts @@ -10,4 +10,4 @@ import { RelayService } from './relay.service'; controllers: [RelayController], providers: [RelayCommandLockService, RelayService, ConfigService], }) -export class RelayModule { } +export class RelayModule {} diff --git a/src/v1/relay/relay.service.spec.ts b/src/v1/relay/relay.service.spec.ts index 5e91f30..df8858b 100644 --- a/src/v1/relay/relay.service.spec.ts +++ b/src/v1/relay/relay.service.spec.ts @@ -51,10 +51,11 @@ describe('RelayService', () => { } as unknown as ConfigService, new RelayCommandLockService(), { - getClient: jest.fn(() => - clientOverride ?? { - from: jest.fn(), - }, + getClient: jest.fn( + () => + clientOverride ?? { + from: jest.fn(), + }, ), } as unknown as SupabaseService, ); @@ -92,12 +93,10 @@ describe('RelayService', () => { it('returns the latest relay row for users who can read the device', async () => { const service = createService(); - jest - .spyOn(service as any, 'loadRelayDeviceContext') - .mockResolvedValue({ - ...deviceContext, - permissionLevel: 3, - }); + jest.spyOn(service as any, 'loadRelayDeviceContext').mockResolvedValue({ + ...deviceContext, + permissionLevel: 3, + }); jest .spyOn(service as any, 'findLatestRelayRow') .mockResolvedValue(relayRow); @@ -114,12 +113,10 @@ describe('RelayService', () => { it('returns the latest relay row for viewer-level users', async () => { const service = createService(); - jest - .spyOn(service as any, 'loadRelayDeviceContext') - .mockResolvedValue({ - ...deviceContext, - permissionLevel: 4, - }); + jest.spyOn(service as any, 'loadRelayDeviceContext').mockResolvedValue({ + ...deviceContext, + permissionLevel: 4, + }); jest .spyOn(service as any, 'findLatestRelayRow') .mockResolvedValue(relayRow); @@ -136,12 +133,10 @@ describe('RelayService', () => { it('rejects latest relay reads when the user has no access to the device', async () => { const service = createService(); - jest - .spyOn(service as any, 'loadRelayDeviceContext') - .mockResolvedValue({ - ...deviceContext, - permissionLevel: 5, - }); + jest.spyOn(service as any, 'loadRelayDeviceContext').mockResolvedValue({ + ...deviceContext, + permissionLevel: 5, + }); await expect( service.getLatestRelay( @@ -175,13 +170,15 @@ describe('RelayService', () => { .mockResolvedValue(relayRow); const originalFetch = global.fetch; - global.fetch = jest.fn(async () => - new Response(JSON.stringify({}), { - headers: { - 'content-type': 'application/json', - }, - status: 200, - })) as typeof fetch; + global.fetch = jest.fn( + async () => + new Response(JSON.stringify({}), { + headers: { + 'content-type': 'application/json', + }, + status: 200, + }), + ) as typeof fetch; await expect( service.updateRelay( @@ -286,9 +283,11 @@ describe('RelayService', () => { { durationSeconds: 60, relay: 1 }, ), ).rejects.toMatchObject({ - message: 'Timed relay pulse requires the target relay to currently be off', + message: + 'Timed relay pulse requires the target relay to currently be off', response: { - message: 'Timed relay pulse requires the target relay to currently be off', + message: + 'Timed relay pulse requires the target relay to currently be off', statusCode: 409, }, status: 409, diff --git a/src/v1/relay/relay.service.ts b/src/v1/relay/relay.service.ts index 5b00dbf..477f9b6 100644 --- a/src/v1/relay/relay.service.ts +++ b/src/v1/relay/relay.service.ts @@ -17,7 +17,11 @@ import { isCropwatchStaff, } from '../../supabase/supabase-token.helper'; import type { TableInsert, TableRow } from '../../v1/types/supabase'; -import { canManage, canRead, PermissionLevel } from '../common/permission-levels'; +import { + canManage, + canRead, + PermissionLevel, +} from '../common/permission-levels'; import { PulseRelayDto } from './dto/pulse-relay.dto'; import { UpdateRelayDto } from './dto/update-relay.dto'; import { @@ -40,10 +44,7 @@ import { mapTtiClientError, resolveTtiApplicationId, } from './tti-client'; -import { - isValidTtiDeviceId, - normalizeTtiDeviceId, -} from './tti-device-id'; +import { isValidTtiDeviceId, normalizeTtiDeviceId } from './tti-device-id'; type DeviceOwnerRow = TableRow<'cw_device_owners'>; type DeviceTypeRow = TableRow<'cw_device_type'>; @@ -144,7 +145,9 @@ export class RelayService { private readonly relayCommandLockService: RelayCommandLockService, private readonly supabaseService: SupabaseService, ) { - if (!readString(this.configService.get('PRIVATE_TTI_WEBHOOK_TOKEN'))) { + if ( + !readString(this.configService.get('PRIVATE_TTI_WEBHOOK_TOKEN')) + ) { this.logger.warn( 'PRIVATE_TTI_WEBHOOK_TOKEN is not set — the TTI relay webhook will reject all uplinks until it is configured', ); @@ -378,10 +381,7 @@ export class RelayService { authorizationHeader?: string, downlinkApiKeyHeader?: string, ) { - this.assertWebhookAuthorization( - authorizationHeader, - downlinkApiKeyHeader, - ); + this.assertWebhookAuthorization(authorizationHeader, downlinkApiKeyHeader); const confirmation = parseRelayConfirmation(payload); if (!confirmation) { @@ -582,7 +582,9 @@ export class RelayService { private readConfirmationTimeoutMs(): number { const parsed = Number( - this.configService.get('PRIVATE_TTI_RELAY_CONFIRMATION_TIMEOUT_MS'), + this.configService.get( + 'PRIVATE_TTI_RELAY_CONFIRMATION_TIMEOUT_MS', + ), ); if (Number.isFinite(parsed) && parsed >= 1000) { diff --git a/src/v1/relay/tti-client.ts b/src/v1/relay/tti-client.ts index cc701e6..c4e225f 100644 --- a/src/v1/relay/tti-client.ts +++ b/src/v1/relay/tti-client.ts @@ -1,4 +1,7 @@ -import { BadGatewayException, ServiceUnavailableException } from '@nestjs/common'; +import { + BadGatewayException, + ServiceUnavailableException, +} from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import type { TtiApplicationDownlink } from './relay-command-profile'; diff --git a/src/v1/relay/tti-device-id.ts b/src/v1/relay/tti-device-id.ts index 39a642f..5cd6b91 100644 --- a/src/v1/relay/tti-device-id.ts +++ b/src/v1/relay/tti-device-id.ts @@ -1,10 +1,7 @@ export const TTI_DEVICE_ID_MAX_LENGTH = 36; -export const TTI_DEVICE_ID_PATTERN = - /^[a-z0-9](?:[a-z0-9-]{0,34}[a-z0-9])?$/; +export const TTI_DEVICE_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,34}[a-z0-9])?$/; -export function normalizeTtiDeviceId( - value: string | null | undefined, -): string { +export function normalizeTtiDeviceId(value: string | null | undefined): string { return (value ?? '').trim().toLowerCase(); } diff --git a/src/v1/reports-new/dto/report-form-context.dto.ts b/src/v1/reports-new/dto/report-form-context.dto.ts index c7f9d86..67f6800 100644 --- a/src/v1/reports-new/dto/report-form-context.dto.ts +++ b/src/v1/reports-new/dto/report-form-context.dto.ts @@ -14,6 +14,10 @@ export class ReportFormContextDto { @ApiProperty({ type: () => CommunicationMethodDto, isArray: true }) communicationMethods: CommunicationMethodDto[]; - @ApiProperty({ type: () => ReportTemplateDto, nullable: true, required: false }) + @ApiProperty({ + type: () => ReportTemplateDto, + nullable: true, + required: false, + }) template: ReportTemplateDto | null; } diff --git a/src/v1/reports-new/dto/report-template.dto.ts b/src/v1/reports-new/dto/report-template.dto.ts index a27369f..d039833 100644 --- a/src/v1/reports-new/dto/report-template.dto.ts +++ b/src/v1/reports-new/dto/report-template.dto.ts @@ -18,7 +18,9 @@ export class ReportTemplateDto { @ApiProperty({ nullable: true, required: false }) deviceTypeId: number | null; - @ApiProperty({ description: 'Sampling interval in minutes used when building the report.' }) + @ApiProperty({ + description: 'Sampling interval in minutes used when building the report.', + }) dataPullInterval: number; @ApiProperty() @@ -39,6 +41,9 @@ export class ReportTemplateDto { @ApiProperty({ type: () => ReportTemplateAlertPointDto, isArray: true }) alertPoints: ReportTemplateAlertPointDto[]; - @ApiProperty({ type: () => ReportTemplateDataProcessingScheduleDto, isArray: true }) + @ApiProperty({ + type: () => ReportTemplateDataProcessingScheduleDto, + isArray: true, + }) dataProcessingSchedules: ReportTemplateDataProcessingScheduleDto[]; } diff --git a/src/v1/reports-new/dto/save-report-template.dto.ts b/src/v1/reports-new/dto/save-report-template.dto.ts index cdca286..1256f57 100644 --- a/src/v1/reports-new/dto/save-report-template.dto.ts +++ b/src/v1/reports-new/dto/save-report-template.dto.ts @@ -175,21 +175,33 @@ export class SaveReportTemplateDto { @IsString({ each: true }) devEuis: string[]; - @ApiProperty({ type: () => SaveReportTemplateScheduleDto, isArray: true, required: false }) + @ApiProperty({ + type: () => SaveReportTemplateScheduleDto, + isArray: true, + required: false, + }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => SaveReportTemplateScheduleDto) schedule?: SaveReportTemplateScheduleDto[]; - @ApiProperty({ type: () => SaveReportTemplateRecipientDto, isArray: true, required: false }) + @ApiProperty({ + type: () => SaveReportTemplateRecipientDto, + isArray: true, + required: false, + }) @IsOptional() @IsArray() @ValidateNested({ each: true }) @Type(() => SaveReportTemplateRecipientDto) recipients?: SaveReportTemplateRecipientDto[]; - @ApiProperty({ type: () => SaveReportTemplateAlertPointDto, isArray: true, required: false }) + @ApiProperty({ + type: () => SaveReportTemplateAlertPointDto, + isArray: true, + required: false, + }) @IsOptional() @IsArray() @ValidateNested({ each: true }) diff --git a/src/v1/reports-new/reports-new.controller.ts b/src/v1/reports-new/reports-new.controller.ts index 3279f0d..9c6c569 100644 --- a/src/v1/reports-new/reports-new.controller.ts +++ b/src/v1/reports-new/reports-new.controller.ts @@ -70,7 +70,8 @@ export class ReportsNewController { }) @ApiQuery({ name: 'templateId', - description: 'When provided, the matching report template is included in the response.', + description: + 'When provided, the matching report template is included in the response.', required: false, type: Number, }) @@ -133,7 +134,8 @@ export class ReportsNewController { @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: 'Creates a report template and assigns it to the listed devices.', + description: + 'Creates a report template and assigns it to the listed devices.', type: ReportTemplateDto, isArray: false, }) diff --git a/src/v1/reports-new/reports-new.service.ts b/src/v1/reports-new/reports-new.service.ts index 0c975a3..c893c1a 100644 --- a/src/v1/reports-new/reports-new.service.ts +++ b/src/v1/reports-new/reports-new.service.ts @@ -29,7 +29,9 @@ import { SaveReportTemplateDto } from './dto/save-report-template.dto'; type TemplateRow = TableRow<'cw_report_templates'>; type LocationJoin = { name: string | null }; -type DeviceLocationJoin = { cw_locations?: LocationJoin | LocationJoin[] | null }; +type DeviceLocationJoin = { + cw_locations?: LocationJoin | LocationJoin[] | null; +}; type AssignmentRow = TableRow<'cw_device_report_assignments'> & { cw_devices?: DeviceLocationJoin | DeviceLocationJoin[] | null; }; @@ -125,14 +127,18 @@ export class ReportsNewService { const client = this.supabaseService.getClient(accessToken); const { data: assignmentsData, error: assignmentsError } = await client .from('cw_device_report_assignments') - .select('created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))') + .select( + 'created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))', + ) .in( 'dev_eui', viewableDevices.map((device) => device.devEui), ); if (assignmentsError) { - throw new InternalServerErrorException('Failed to load report assignments'); + throw new InternalServerErrorException( + 'Failed to load report assignments', + ); } const assignments = (assignmentsData ?? []) as AssignmentRow[]; @@ -189,7 +195,9 @@ export class ReportsNewService { .maybeSingle(), client .from('cw_device_report_assignments') - .select('created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))') + .select( + 'created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))', + ) .eq('template_id', id) .in( 'dev_eui', @@ -201,7 +209,9 @@ export class ReportsNewService { throw new InternalServerErrorException('Failed to load report template'); } if (assignmentsResult.error) { - throw new InternalServerErrorException('Failed to load report assignments'); + throw new InternalServerErrorException( + 'Failed to load report assignments', + ); } if (!templateResult.data) { throw new NotFoundException('Report template not found'); @@ -266,7 +276,9 @@ export class ReportsNewService { .single(); if (templateError || !templateData) { - throw new InternalServerErrorException('Failed to create report template'); + throw new InternalServerErrorException( + 'Failed to create report template', + ); } try { @@ -316,7 +328,9 @@ export class ReportsNewService { .eq('id', id); if (updateError) { - throw new InternalServerErrorException('Failed to update report template'); + throw new InternalServerErrorException( + 'Failed to update report template', + ); } await this.replaceTemplateChildren(accessToken, id, normalized); @@ -349,7 +363,9 @@ export class ReportsNewService { .eq('id', id); if (error) { - throw new InternalServerErrorException('Failed to delete report template'); + throw new InternalServerErrorException( + 'Failed to delete report template', + ); } return { id }; @@ -371,7 +387,7 @@ export class ReportsNewService { ]); return { - devices: (devicesPage.data ?? []) as ReportFormContextDto['devices'], + devices: devicesPage.data ?? [], locations: (locations ?? []) as ReportFormContextDto['locations'], communicationMethods, template, @@ -434,21 +450,25 @@ export class ReportsNewService { }); if (error || !data) return [] as ReportTemplateHistoryItemDto[]; return data - .filter((item) => item.name && item.name !== '.emptyFolderPlaceholder') + .filter( + (item) => item.name && item.name !== '.emptyFolderPlaceholder', + ) .map( (item): ReportTemplateHistoryItemDto => ({ devEui, deviceName: deviceNames.get(devEui) ?? null, name: item.name, id: (item as { id?: string | null }).id ?? null, - createdAt: (item as { created_at?: string | null }).created_at ?? null, - updatedAt: (item as { updated_at?: string | null }).updated_at ?? null, + createdAt: + (item as { created_at?: string | null }).created_at ?? null, + updatedAt: + (item as { updated_at?: string | null }).updated_at ?? null, lastAccessedAt: - (item as { last_accessed_at?: string | null }).last_accessed_at ?? - null, + (item as { last_accessed_at?: string | null }) + .last_accessed_at ?? null, metadata: - (item as { metadata?: Record | null }).metadata ?? - null, + (item as { metadata?: Record | null }) + .metadata ?? null, }), ); }), @@ -547,7 +567,8 @@ export class ReportsNewService { const canView = isStaff || directOwner || - (permissionLevel != null && permissionLevel < PermissionLevel.DISABLED); + (permissionLevel != null && + permissionLevel < PermissionLevel.DISABLED); const canManage = isStaff || directOwner || @@ -603,7 +624,7 @@ export class ReportsNewService { throw new InternalServerErrorException('Failed to load report schedule'); } - return (data ?? []) as ScheduleRow[]; + return data ?? []; } private async loadRecipientsByTemplateIds( @@ -615,16 +636,16 @@ export class ReportsNewService { const { data, error } = await this.supabaseService .getClient(accessToken) .from('cw_report_template_recipients') - .select( - 'communication_method, created_at, email, id, name, template_id', - ) + .select('communication_method, created_at, email, id, name, template_id') .in('template_id', templateIds); if (error) { - throw new InternalServerErrorException('Failed to load report recipients'); + throw new InternalServerErrorException( + 'Failed to load report recipients', + ); } - return (data ?? []) as RecipientRow[]; + return data ?? []; } private async loadAlertPointsByTemplateIds( @@ -642,10 +663,12 @@ export class ReportsNewService { .in('template_id', templateIds); if (error) { - throw new InternalServerErrorException('Failed to load report alert points'); + throw new InternalServerErrorException( + 'Failed to load report alert points', + ); } - return (data ?? []) as AlertPointRow[]; + return data ?? []; } private async loadDataProcessingSchedulesByTemplateIds( @@ -668,7 +691,7 @@ export class ReportsNewService { ); } - return (data ?? []) as DataProcessingScheduleRow[]; + return data ?? []; } private async replaceTemplateChildren( @@ -689,7 +712,9 @@ export class ReportsNewService { .from('cw_device_report_assignments') .insert(assignments); if (assignmentsError) { - throw new InternalServerErrorException('Failed to save report assignments'); + throw new InternalServerErrorException( + 'Failed to save report assignments', + ); } if (payload.schedule.length > 0) { @@ -705,7 +730,9 @@ export class ReportsNewService { .from('cw_report_template_schedule') .insert(rows); if (error) { - throw new InternalServerErrorException('Failed to save report schedule'); + throw new InternalServerErrorException( + 'Failed to save report schedule', + ); } } @@ -720,7 +747,9 @@ export class ReportsNewService { .from('cw_report_template_recipients') .insert(rows); if (error) { - throw new InternalServerErrorException('Failed to save report recipients'); + throw new InternalServerErrorException( + 'Failed to save report recipients', + ); } } @@ -799,13 +828,19 @@ export class ReportsNewService { ]); if (assignments.error) { - throw new InternalServerErrorException('Failed to remove report assignments'); + throw new InternalServerErrorException( + 'Failed to remove report assignments', + ); } if (schedule.error) { - throw new InternalServerErrorException('Failed to remove report schedule'); + throw new InternalServerErrorException( + 'Failed to remove report schedule', + ); } if (recipients.error) { - throw new InternalServerErrorException('Failed to remove report recipients'); + throw new InternalServerErrorException( + 'Failed to remove report recipients', + ); } if (alertPoints.error) { throw new InternalServerErrorException( @@ -858,8 +893,15 @@ function buildReportTemplates(args: { dpSchedules: DataProcessingScheduleRow[]; devices: ManagedDevice[]; }): ReportTemplateDto[] { - const { templates, assignments, schedule, recipients, alertPoints, dpSchedules, devices } = - args; + const { + templates, + assignments, + schedule, + recipients, + alertPoints, + dpSchedules, + devices, + } = args; const devicesById = new Map(devices.map((device) => [device.devEui, device])); const assignmentsByTemplateId = groupBy( @@ -868,12 +910,19 @@ function buildReportTemplates(args: { ); const scheduleByTemplateId = groupBy(schedule, (row) => row.template_id); const recipientsByTemplateId = groupBy(recipients, (row) => row.template_id); - const alertPointsByTemplateId = groupBy(alertPoints, (row) => row.template_id); - const dpSchedulesByTemplateId = groupBy(dpSchedules, (row) => row.template_id); + const alertPointsByTemplateId = groupBy( + alertPoints, + (row) => row.template_id, + ); + const dpSchedulesByTemplateId = groupBy( + dpSchedules, + (row) => row.template_id, + ); return templates .map((template): ReportTemplateDto | null => { - const templateAssignments = assignmentsByTemplateId.get(template.id) ?? []; + const templateAssignments = + assignmentsByTemplateId.get(template.id) ?? []; if (templateAssignments.length === 0) return null; return { @@ -899,16 +948,18 @@ function buildReportTemplates(args: { }; }, ), - schedule: (scheduleByTemplateId.get(template.id) ?? []).map(mapSchedule), + schedule: (scheduleByTemplateId.get(template.id) ?? []).map( + mapSchedule, + ), recipients: (recipientsByTemplateId.get(template.id) ?? []).map( mapRecipient, ), alertPoints: (alertPointsByTemplateId.get(template.id) ?? []).map( mapAlertPoint, ), - dataProcessingSchedules: (dpSchedulesByTemplateId.get(template.id) ?? []).map( - mapDataProcessingSchedule, - ), + dataProcessingSchedules: ( + dpSchedulesByTemplateId.get(template.id) ?? [] + ).map(mapDataProcessingSchedule), }; }) .filter((report): report is ReportTemplateDto => report !== null) diff --git a/src/v1/rules-new/dto/rule-template-assignment.dto.ts b/src/v1/rules-new/dto/rule-template-assignment.dto.ts index bb67369..a7eaca1 100644 --- a/src/v1/rules-new/dto/rule-template-assignment.dto.ts +++ b/src/v1/rules-new/dto/rule-template-assignment.dto.ts @@ -26,6 +26,10 @@ export class RuleTemplateAssignmentDto { @ApiProperty({ nullable: true, required: false }) permissionLevel: number | null; - @ApiProperty({ nullable: true, required: false, type: () => RuleTemplateStateDto }) + @ApiProperty({ + nullable: true, + required: false, + type: () => RuleTemplateStateDto, + }) state: RuleTemplateStateDto | null; } diff --git a/src/v1/rules-new/rules-new.controller.ts b/src/v1/rules-new/rules-new.controller.ts index a6b4441..e650e86 100644 --- a/src/v1/rules-new/rules-new.controller.ts +++ b/src/v1/rules-new/rules-new.controller.ts @@ -34,7 +34,7 @@ export class RulesNewController { @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: "Lists every rule template visible to the current user.", + description: 'Lists every rule template visible to the current user.', type: RuleTemplateDto, isArray: true, }) @@ -51,7 +51,8 @@ export class RulesNewController { @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: 'Lists every action type a rule template action can reference.', + description: + 'Lists every action type a rule template action can reference.', type: RuleActionTypeDto, isArray: true, }) @@ -69,15 +70,13 @@ export class RulesNewController { }) @ApiQuery({ name: 'templateId', - description: 'When provided, the matching rule template is included in the response.', + description: + 'When provided, the matching rule template is included in the response.', required: false, type: Number, }) @Get('form-context') - getFormContext( - @Req() req, - @Query('templateId') templateId?: string, - ) { + getFormContext(@Req() req, @Query('templateId') templateId?: string) { const authHeader = req.headers?.authorization ?? ''; const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; @@ -143,7 +142,8 @@ export class RulesNewController { @UseGuards(JwtAuthGuard) @ApiOkResponse({ - description: 'Creates a rule template and assigns it to the listed devices.', + description: + 'Creates a rule template and assigns it to the listed devices.', type: RuleTemplateDto, isArray: false, }) diff --git a/src/v1/rules-new/rules-new.service.spec.ts b/src/v1/rules-new/rules-new.service.spec.ts index b4764f4..62c87b0 100644 --- a/src/v1/rules-new/rules-new.service.spec.ts +++ b/src/v1/rules-new/rules-new.service.spec.ts @@ -31,21 +31,36 @@ function buildQueryStub(handlers: { stub.select = jest.fn().mockReturnValue(stub); stub.insert = jest.fn().mockReturnValue({ select: jest.fn().mockReturnValue({ - single: jest.fn().mockResolvedValue(handlers.insertReturn ?? handlers.single ?? { data: null, error: null }), + single: jest + .fn() + .mockResolvedValue( + handlers.insertReturn ?? + handlers.single ?? { data: null, error: null }, + ), }), then: (resolve: (value: StubResult) => unknown) => resolve(handlers.insertReturn ?? { data: null, error: null }), }); stub.update = jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue(handlers.updateReturn ?? { data: null, error: null }), + eq: jest + .fn() + .mockResolvedValue(handlers.updateReturn ?? { data: null, error: null }), }); stub.delete = jest.fn().mockReturnValue({ - eq: jest.fn().mockResolvedValue(handlers.deleteReturn ?? { data: null, error: null }), + eq: jest + .fn() + .mockResolvedValue(handlers.deleteReturn ?? { data: null, error: null }), }); stub.eq = jest.fn().mockReturnValue(stub); stub.in = jest.fn().mockReturnValue(stub); - stub.single = jest.fn().mockResolvedValue(handlers.single ?? { data: null, error: null }); - stub.maybeSingle = jest.fn().mockResolvedValue(handlers.maybeSingle ?? handlers.single ?? { data: null, error: null }); + stub.single = jest + .fn() + .mockResolvedValue(handlers.single ?? { data: null, error: null }); + stub.maybeSingle = jest + .fn() + .mockResolvedValue( + handlers.maybeSingle ?? handlers.single ?? { data: null, error: null }, + ); stub.then = (resolve) => resolve(handlers.list ?? { data: [], error: null }); return stub as QueryStub; } @@ -117,9 +132,7 @@ describe('RulesNewService', () => { dev_eui: 'AA', name: 'Device A', user_id: 'someone-else', - cw_device_owners: [ - { user_id: 'user-1', permission_level: 4 }, - ], + cw_device_owners: [{ user_id: 'user-1', permission_level: 4 }], }, ], error: null, @@ -223,7 +236,10 @@ describe('RulesNewService', () => { describe('triggered rules', () => { const jwt = { sub: 'user-1', email: 'user@example.com' }; - const buildRule = (id: number, assignments: Array<{ devEui: string; isTriggered: boolean | null }>) => ({ + const buildRule = ( + id: number, + assignments: Array<{ devEui: string; isTriggered: boolean | null }>, + ) => ({ id, name: `Rule ${id}`, description: null, @@ -276,10 +292,12 @@ describe('RulesNewService', () => { it('findTriggeredCount reports triggered and total counts', async () => { const service = new RulesNewService({} as any, {} as any, {} as any); - jest.spyOn(service, 'findAll').mockResolvedValue([ - buildRule(1, [{ devEui: 'AA', isTriggered: true }]), - buildRule(2, [{ devEui: 'BB', isTriggered: false }]), - ]); + jest + .spyOn(service, 'findAll') + .mockResolvedValue([ + buildRule(1, [{ devEui: 'AA', isTriggered: true }]), + buildRule(2, [{ devEui: 'BB', isTriggered: false }]), + ]); await expect( service.findTriggeredCount(jwt, 'Bearer token-1'), diff --git a/src/v1/rules-new/rules-new.service.ts b/src/v1/rules-new/rules-new.service.ts index 66e97af..5e85bfc 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules-new/rules-new.service.ts @@ -80,7 +80,9 @@ export class RulesNewService { const client = this.supabaseService.getClient(accessToken); const { data: assignmentsData, error: assignmentsError } = await client .from('cw_device_rule_assignments') - .select('created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))') + .select( + 'created_at, dev_eui, id, is_active, template_id, cw_devices(cw_locations(name))', + ) .in( 'dev_eui', viewableDevices.map((device) => device.devEui), @@ -145,7 +147,9 @@ export class RulesNewService { ): Promise<{ count: number; triggered_count: number; total_count: number }> { const rules = await this.findAll(jwtPayload, authHeader); const triggeredCount = rules.filter((rule) => - rule.assignments.some((assignment) => assignment.state?.isTriggered === true), + rule.assignments.some( + (assignment) => assignment.state?.isTriggered === true, + ), ).length; return { @@ -214,7 +218,7 @@ export class RulesNewService { ]); const [rule] = buildRuleTemplates({ - templates: [templateResult.data as TemplateRow], + templates: [templateResult.data], assignments, criteria, actions, @@ -430,7 +434,8 @@ export class RulesNewService { const canView = isStaff || directOwner || - (permissionLevel != null && permissionLevel < PermissionLevel.DISABLED); + (permissionLevel != null && + permissionLevel < PermissionLevel.DISABLED); const canManage = isStaff || directOwner || @@ -463,7 +468,7 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule templates'); } - return (data ?? []) as TemplateRow[]; + return data ?? []; } private async loadCriteriaByTemplateIds( @@ -484,7 +489,7 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule criteria'); } - return (data ?? []) as CriterionRow[]; + return data ?? []; } private async loadActionsByTemplateIds( @@ -505,7 +510,7 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule actions'); } - return (data ?? []) as ActionRow[]; + return data ?? []; } async findAllActionTypes(authHeader: string): Promise { @@ -542,7 +547,7 @@ export class RulesNewService { ]); return { - devices: (devicesPage.data ?? []) as RuleFormContextDto['devices'], + devices: devicesPage.data ?? [], locations: (locations ?? []) as RuleFormContextDto['locations'], actionTypes, template, @@ -570,7 +575,7 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule state'); } - return (data ?? []) as StateRow[]; + return data ?? []; } private async replaceTemplateChildren( @@ -607,9 +612,7 @@ export class RulesNewService { ]); if (assignmentsResult.error) { - throw new InternalServerErrorException( - 'Failed to save rule assignments', - ); + throw new InternalServerErrorException('Failed to save rule assignments'); } if (criteriaResult.error) { throw new InternalServerErrorException('Failed to save rule criteria'); @@ -766,13 +769,15 @@ function mapCriterion(row: CriterionRow): RuleTemplateCriterionDto { function mapAction(row: ActionRow): RuleTemplateActionDto { const rawJoin = row.cw_rule_action_types; - const joined = Array.isArray(rawJoin) ? (rawJoin[0] ?? null) : (rawJoin ?? null); + const joined = Array.isArray(rawJoin) + ? (rawJoin[0] ?? null) + : (rawJoin ?? null); return { id: row.id, templateId: row.template_id, actionType: row.action_type, actionTypeName: joined?.name ?? null, - config: row.config as RuleTemplateActionDto['config'], + config: row.config, createdAt: row.created_at, }; } @@ -784,7 +789,9 @@ function unwrapJoin(raw: unknown): Record | null { // findAll embeds cw_devices(cw_locations(name)) on each assignment row. function readAssignmentLocationName(assignment: AssignmentRow): string | null { - const device = unwrapJoin((assignment as { cw_devices?: unknown }).cw_devices); + const device = unwrapJoin( + (assignment as { cw_devices?: unknown }).cw_devices, + ); const location = unwrapJoin(device?.cw_locations); const name = location?.name; return typeof name === 'string' && name.trim().length > 0 ? name : null; diff --git a/src/v1/traffic/dto/traffic-monthly-report.dto.ts b/src/v1/traffic/dto/traffic-monthly-report.dto.ts index 842f3ba..f129696 100644 --- a/src/v1/traffic/dto/traffic-monthly-report.dto.ts +++ b/src/v1/traffic/dto/traffic-monthly-report.dto.ts @@ -1,7 +1,10 @@ import { ApiProperty } from '@nestjs/swagger'; export class TrafficMonthlyReportDto { - @ApiProperty({ description: 'Date of the traffic day', example: '2026-03-01' }) + @ApiProperty({ + description: 'Date of the traffic day', + example: '2026-03-01', + }) traffic_day: string; @ApiProperty({ description: 'Total people count for the day', example: 1530 }) @@ -10,6 +13,9 @@ export class TrafficMonthlyReportDto { @ApiProperty({ description: 'Total bicycle count for the day', example: 590 }) total_bicycles: number; - @ApiProperty({ description: 'Total vehicle count (cars + trucks + buses) for the day', example: 3252 }) + @ApiProperty({ + description: 'Total vehicle count (cars + trucks + buses) for the day', + example: 3252, + }) total_vehicles: number; } diff --git a/src/v1/traffic/traffic.controller.ts b/src/v1/traffic/traffic.controller.ts index e24b5fd..9ac9bac 100644 --- a/src/v1/traffic/traffic.controller.ts +++ b/src/v1/traffic/traffic.controller.ts @@ -150,8 +150,18 @@ export class TrafficController { type: ErrorResponseDto, }) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) - @ApiQuery({ name: 'year', description: 'Report year (e.g. 2026)', type: Number, example: 2026 }) - @ApiQuery({ name: 'month', description: 'Report month (1-12)', type: Number, example: 3 }) + @ApiQuery({ + name: 'year', + description: 'Report year (e.g. 2026)', + type: Number, + example: 2026, + }) + @ApiQuery({ + name: 'month', + description: 'Report month (1-12)', + type: Number, + example: 3, + }) @ApiQuery({ name: 'timezone', required: false, diff --git a/src/v1/traffic/traffic.service.ts b/src/v1/traffic/traffic.service.ts index 8aaf4f4..e4b7928 100644 --- a/src/v1/traffic/traffic.service.ts +++ b/src/v1/traffic/traffic.service.ts @@ -51,14 +51,18 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { const { data, error } = await this.supabaseService .getClient() .from(this.tableName) - .select('traffic_hour, people_count, bicycle_count, car_count, truck_count, bus_count') + .select( + 'traffic_hour, people_count, bicycle_count, car_count, truck_count, bus_count', + ) .eq('dev_eui', devEui) .gte('traffic_hour', startUtc.toISOString()) .lt('traffic_hour', endUtc.toISOString()) .order('traffic_hour', { ascending: true }); if (error) { - throw new InternalServerErrorException('Failed to fetch monthly traffic report'); + throw new InternalServerErrorException( + 'Failed to fetch monthly traffic report', + ); } // Build a map of all days in the month initialised to zero @@ -81,7 +85,8 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { if (bucket) { bucket.total_people += row.people_count ?? 0; bucket.total_bicycles += row.bicycle_count ?? 0; - bucket.total_vehicles += (row.car_count ?? 0) + (row.truck_count ?? 0) + (row.bus_count ?? 0); + bucket.total_vehicles += + (row.car_count ?? 0) + (row.truck_count ?? 0) + (row.bus_count ?? 0); } } @@ -92,7 +97,12 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { * Converts a local midnight (year/month/day 00:00:00 in the given timezone) * to a UTC Date. */ - private localMidnightToUtc(year: number, month: number, day: number, timezone: string): Date { + private localMidnightToUtc( + year: number, + month: number, + day: number, + timezone: string, + ): Date { const guess = new Date(Date.UTC(year, month - 1, day)); const offsetMs = this.getTimezoneOffsetMs(guess, timezone); return new Date(Date.UTC(year, month - 1, day) - offsetMs); From b6f5f7840b9d1a00c312d3acd98cab5c50f46f31 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 11:53:42 +0900 Subject: [PATCH 03/11] chore: prune unused deps, honest CI gate, dedupe qlty config - remove @nestjs/websockets, @nestjs/platform-socket.io, swagger-ui-dist, source-map-support (zero references; @nestjs/swagger bundles its own UI) - rename npm-publish.yml -> ci.yml (it never published; it is the CI gate), add a lint step so the posted 'api: lint' status actually lints, and run on develop pushes + PRs too - drop duplicated entries in .qlty/qlty.toml Co-Authored-By: Claude Fable 5 --- .github/workflows/{npm-publish.yml => ci.yml} | 8 ++++ .qlty/qlty.toml | 6 +-- package.json | 4 -- pnpm-lock.yaml | 38 ++++++++++--------- 4 files changed, 30 insertions(+), 26 deletions(-) rename .github/workflows/{npm-publish.yml => ci.yml} (90%) diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/ci.yml similarity index 90% rename from .github/workflows/npm-publish.yml rename to .github/workflows/ci.yml index f459427..edf6161 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,11 @@ on: push: branches: - master + - develop + pull_request: + branches: + - master + - develop permissions: contents: read @@ -30,6 +35,9 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Lint + run: pnpm run lint:check + # Generate coverage (ensure coverage/lcov.info exists after this) - name: Test (with coverage) run: pnpm run test:cov diff --git a/.qlty/qlty.toml b/.qlty/qlty.toml index b035268..dd3970d 100644 --- a/.qlty/qlty.toml +++ b/.qlty/qlty.toml @@ -2,7 +2,5 @@ exclude_patterns = [ "**/database.types.ts", "**/swagger-ui-bundle.js", - "**/swagger-ui-standalone-preset.js", - "**/swagger-ui-standalone-preset.js", - "**/database.types.ts" -] \ No newline at end of file + "**/swagger-ui-standalone-preset.js" +] diff --git a/package.json b/package.json index 3eb5051..f032b2d 100644 --- a/package.json +++ b/package.json @@ -29,11 +29,9 @@ "@nestjs/mapped-types": "^2.1.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.19", - "@nestjs/platform-socket.io": "^11.1.19", "@nestjs/serve-static": "^5.0.5", "@nestjs/swagger": "^11.3.2", "@nestjs/throttler": "^6.5.0", - "@nestjs/websockets": "^11.1.19", "@polar-sh/sdk": "^0.48.1", "@rekog/mcp-nest": "^1.9.10", "@supabase/supabase-js": "^2.104.0", @@ -44,7 +42,6 @@ "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.2", - "swagger-ui-dist": "^5.32.4", "zod": "^4.4.3" }, "devDependencies": { @@ -63,7 +60,6 @@ "globals": "^16.5.0", "jest": "^30.3.0", "prettier": "^3.8.3", - "source-map-support": "^0.5.21", "supabase": "^2.93.0", "supertest": "^7.2.2", "ts-jest": "^29.4.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d42289f..eae37be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,9 +32,6 @@ importers: '@nestjs/platform-express': specifier: ^11.1.19 version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19) - '@nestjs/platform-socket.io': - specifier: ^11.1.19 - version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.19)(rxjs@7.8.2) '@nestjs/serve-static': specifier: ^5.0.5 version: 5.0.5(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(express@5.2.1) @@ -44,9 +41,6 @@ importers: '@nestjs/throttler': specifier: ^6.5.0 version: 6.5.0(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(reflect-metadata@0.2.2) - '@nestjs/websockets': - specifier: ^11.1.19 - version: 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.19)(@nestjs/platform-socket.io@11.1.19)(reflect-metadata@0.2.2)(rxjs@7.8.2) '@polar-sh/sdk': specifier: ^0.48.1 version: 0.48.1 @@ -77,9 +71,6 @@ importers: rxjs: specifier: ^7.8.2 version: 7.8.2 - swagger-ui-dist: - specifier: ^5.32.4 - version: 5.32.4 zod: specifier: ^4.4.3 version: 4.4.3 @@ -129,9 +120,6 @@ importers: prettier: specifier: ^3.8.3 version: 3.8.3 - source-map-support: - specifier: ^0.5.21 - version: 0.5.21 supabase: specifier: ^2.93.0 version: 2.93.0 @@ -4340,6 +4328,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true '@nestjs/schematics@11.1.0(chokidar@4.0.3)(prettier@3.8.3)(typescript@5.9.3)': dependencies: @@ -4402,6 +4391,7 @@ snapshots: tslib: 2.8.1 optionalDependencies: '@nestjs/platform-socket.io': 11.1.19(@nestjs/common@11.1.19(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.19)(rxjs@7.8.2) + optional: true '@noble/hashes@1.8.0': {} @@ -4455,7 +4445,8 @@ snapshots: dependencies: '@sinonjs/commons': 3.0.1 - '@socket.io/component-emitter@3.1.2': {} + '@socket.io/component-emitter@3.1.2': + optional: true '@stablelib/base64@1.0.1': {} @@ -4556,6 +4547,7 @@ snapshots: '@types/cors@2.8.19': dependencies: '@types/node': 22.19.17 + optional: true '@types/eslint-scope@3.7.7': dependencies: @@ -4889,6 +4881,7 @@ snapshots: dependencies: mime-types: 2.1.35 negotiator: 0.6.3 + optional: true accepts@2.0.0: dependencies: @@ -5041,7 +5034,8 @@ snapshots: base64-js@1.5.1: {} - base64id@2.0.0: {} + base64id@2.0.0: + optional: true base64url@3.0.1: {} @@ -5336,7 +5330,8 @@ snapshots: encodeurl@2.0.0: {} - engine.io-parser@5.2.3: {} + engine.io-parser@5.2.3: + optional: true engine.io@6.6.6: dependencies: @@ -5354,6 +5349,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true enhanced-resolve@5.20.1: dependencies: @@ -6412,7 +6408,8 @@ snapshots: natural-compare@1.4.0: {} - negotiator@0.6.3: {} + negotiator@0.6.3: + optional: true negotiator@1.0.0: {} @@ -6450,7 +6447,8 @@ snapshots: object-assign@4.1.1: {} - object-hash@3.0.0: {} + object-hash@3.0.0: + optional: true object-inspect@1.13.4: {} @@ -6785,6 +6783,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true socket.io-parser@4.2.6: dependencies: @@ -6792,6 +6791,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true socket.io@4.8.3: dependencies: @@ -6806,6 +6806,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true source-map-support@0.5.13: dependencies: @@ -7236,7 +7237,8 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.18.3: {} + ws@8.18.3: + optional: true ws@8.20.0: {} From 75388ebe29579098a64da6f4c7f973516e4fdf97 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 12:03:56 +0900 Subject: [PATCH 04/11] refactor: remove dead scaffolding and debug leftovers - delete the power module: it was a live nest scaffold whose GET /v1/power/:id returned 'This action returns a #1 power' in production (Swagger documented it) - delete empty alerts/ scaffold dirs and 9 scaffold-only spec files (single 'should be defined' tests against stubs) - remove remaining 'This action ...' stub methods from traffic, gateway, and soil services plus their commented-out CRUD routes and the orphaned create/update DTOs and entities they referenced - remove dead relay confirmation-wait path: waitForRelayConfirmation was never called (call site commented out); parseRelayConfirmation and readRelayRowTimestamp stay (live webhook path) - drop unused doubleCsrf import and the now-unused csrf-csrf dependency - rename 2 PascalCase DTO filenames to kebab-case - replace console.log('hit') / console.error with the injected Logger Route contract snapshot diff = /v1/power/{id} removed, nothing else. Co-Authored-By: Claude Fable 5 --- package.json | 1 - pnpm-lock.yaml | 10 --- src/app.controller.spec.ts | 25 ------ src/app.module.ts | 2 - src/main.ts | 1 - src/v1/air/air.controller.ts | 5 -- src/v1/air/dto/create-air.dto.ts | 3 - src/v1/air/dto/update-air.dto.ts | 3 - src/v1/air/entities/air.entity.ts | 1 - .../v1-route-input-contract.spec.ts.snap | 14 ---- src/v1/common/v1-route-input-contract.spec.ts | 16 ---- src/v1/devices/devices.controller.spec.ts | 35 -------- src/v1/devices/devices.controller.ts | 4 +- src/v1/devices/devices.service.ts | 9 +-- ... => update-device-name-group-local.dto.ts} | 0 ...dto.ts => update-device-permission.dto.ts} | 0 src/v1/gateway/dto/create-gateway.dto.ts | 67 --------------- src/v1/gateway/dto/update-gateway.dto.ts | 4 - src/v1/gateway/entities/gateway.entity.ts | 1 - src/v1/gateway/gateway.controller.ts | 21 ----- src/v1/gateway/gateway.service.ts | 16 ---- src/v1/locations/locations.controller.spec.ts | 37 --------- src/v1/power/dto/create-power.dto.ts | 3 - src/v1/power/dto/update-power.dto.ts | 3 - src/v1/power/entities/power.entity.ts | 1 - src/v1/power/power.controller.spec.ts | 20 ----- src/v1/power/power.controller.ts | 81 ------------------- src/v1/power/power.module.ts | 9 --- src/v1/power/power.service.spec.ts | 18 ----- src/v1/power/power.service.ts | 26 ------ src/v1/relay/relay-confirmation.spec.ts | 43 +--------- src/v1/relay/relay-confirmation.ts | 36 +-------- src/v1/relay/relay.service.spec.ts | 2 - src/v1/relay/relay.service.ts | 81 ------------------- src/v1/soil/dto/create-soil.dto.ts | 3 - src/v1/soil/dto/update-soil.dto.ts | 3 - src/v1/soil/entities/soil.entity.ts | 1 - src/v1/soil/soil.controller.spec.ts | 20 ----- src/v1/soil/soil.controller.ts | 11 --- src/v1/soil/soil.service.spec.ts | 16 ---- src/v1/soil/soil.service.ts | 6 -- src/v1/traffic/dto/create-traffic.dto.ts | 3 - src/v1/traffic/dto/update-traffic.dto.ts | 3 - src/v1/traffic/entities/traffic.entity.ts | 1 - src/v1/traffic/traffic.controller.spec.ts | 20 ----- src/v1/traffic/traffic.controller.ts | 6 -- src/v1/traffic/traffic.service.spec.ts | 18 ----- src/v1/traffic/traffic.service.ts | 18 ----- src/v1/water/dto/create-water.dto.ts | 3 - src/v1/water/dto/update-water.dto.ts | 3 - src/v1/water/entities/water.entity.ts | 1 - src/v1/water/water.controller.spec.ts | 20 ----- src/v1/water/water.controller.ts | 11 --- src/v1/water/water.service.spec.ts | 16 ---- 54 files changed, 8 insertions(+), 773 deletions(-) delete mode 100644 src/app.controller.spec.ts delete mode 100644 src/v1/air/dto/create-air.dto.ts delete mode 100644 src/v1/air/dto/update-air.dto.ts delete mode 100644 src/v1/air/entities/air.entity.ts delete mode 100644 src/v1/devices/devices.controller.spec.ts rename src/v1/devices/dto/{UpdateDeviceNameGroupLocal.dto.ts => update-device-name-group-local.dto.ts} (100%) rename src/v1/devices/dto/{UpdateDevicePermission.dto.ts => update-device-permission.dto.ts} (100%) delete mode 100644 src/v1/gateway/dto/create-gateway.dto.ts delete mode 100644 src/v1/gateway/dto/update-gateway.dto.ts delete mode 100644 src/v1/gateway/entities/gateway.entity.ts delete mode 100644 src/v1/locations/locations.controller.spec.ts delete mode 100644 src/v1/power/dto/create-power.dto.ts delete mode 100644 src/v1/power/dto/update-power.dto.ts delete mode 100644 src/v1/power/entities/power.entity.ts delete mode 100644 src/v1/power/power.controller.spec.ts delete mode 100644 src/v1/power/power.controller.ts delete mode 100644 src/v1/power/power.module.ts delete mode 100644 src/v1/power/power.service.spec.ts delete mode 100644 src/v1/power/power.service.ts delete mode 100644 src/v1/soil/dto/create-soil.dto.ts delete mode 100644 src/v1/soil/dto/update-soil.dto.ts delete mode 100644 src/v1/soil/entities/soil.entity.ts delete mode 100644 src/v1/soil/soil.controller.spec.ts delete mode 100644 src/v1/soil/soil.service.spec.ts delete mode 100644 src/v1/traffic/dto/create-traffic.dto.ts delete mode 100644 src/v1/traffic/dto/update-traffic.dto.ts delete mode 100644 src/v1/traffic/entities/traffic.entity.ts delete mode 100644 src/v1/traffic/traffic.controller.spec.ts delete mode 100644 src/v1/traffic/traffic.service.spec.ts delete mode 100644 src/v1/water/dto/create-water.dto.ts delete mode 100644 src/v1/water/dto/update-water.dto.ts delete mode 100644 src/v1/water/entities/water.entity.ts delete mode 100644 src/v1/water/water.controller.spec.ts delete mode 100644 src/v1/water/water.service.spec.ts diff --git a/package.json b/package.json index f032b2d..dd57fd9 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,6 @@ "@supabase/supabase-js": "^2.104.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.4", - "csrf-csrf": "^4.0.3", "helmet": "^8.1.0", "passport-jwt": "^4.0.1", "reflect-metadata": "^0.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eae37be..e5f9858 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,9 +56,6 @@ importers: class-validator: specifier: ^0.14.4 version: 0.14.4 - csrf-csrf: - specifier: ^4.0.3 - version: 4.0.3 helmet: specifier: ^8.1.0 version: 8.1.0 @@ -1690,9 +1687,6 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - csrf-csrf@4.0.3: - resolution: {integrity: sha512-DaygOzelL4Qo1pHwI9LPyZL+X2456/OzpT596kNeZGiTSqKVDOk/9PPJ+FjzZacjMUEusOHw3WJKe1RW4iUhrw==} - data-uri-to-buffer@4.0.1: resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} engines: {node: '>= 12'} @@ -5265,10 +5259,6 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - csrf-csrf@4.0.3: - dependencies: - http-errors: 2.0.1 - data-uri-to-buffer@4.0.1: {} debug@4.4.3: diff --git a/src/app.controller.spec.ts b/src/app.controller.spec.ts deleted file mode 100644 index 711ae70..0000000 --- a/src/app.controller.spec.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { AppController } from './app.controller'; -import { join } from 'path'; - -describe('AppController', () => { - let appController: AppController; - - beforeEach(async () => { - const app: TestingModule = await Test.createTestingModule({ - controllers: [AppController], - }).compile(); - - appController = app.get(AppController); - }); - - describe('root', () => { - it('should serve the API homepage (index.html)', () => { - const sendFile = jest.fn(); - appController.getHello({ sendFile } as any); - expect(sendFile).toHaveBeenCalledWith( - join(process.cwd(), 'static', 'index.html'), - ); - }); - }); -}); diff --git a/src/app.module.ts b/src/app.module.ts index b5740bf..376d96c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,7 +6,6 @@ import { ConfigModule } from '@nestjs/config'; import { AirModule } from './v1/air/air.module'; import { SoilModule } from './v1/soil/soil.module'; import { WaterModule } from './v1/water/water.module'; -import { PowerModule } from './v1/power/power.module'; import { TrafficModule } from './v1/traffic/traffic.module'; import { ServeStaticModule } from '@nestjs/serve-static'; import { join } from 'path'; @@ -29,7 +28,6 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; AirModule, SoilModule, WaterModule, - PowerModule, TrafficModule, ServeStaticModule.forRoot({ rootPath: join(process.cwd(), 'static'), diff --git a/src/main.ts b/src/main.ts index fdc221f..30346c6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,7 +6,6 @@ import helmet from 'helmet'; import { join } from 'path'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { STATUS_CODES } from 'http'; -import { doubleCsrf } from 'csrf-csrf'; import type { NextFunction, Request, Response } from 'express'; function getRequesterIp(req: Request): string { diff --git a/src/v1/air/air.controller.ts b/src/v1/air/air.controller.ts index 254e875..e760539 100644 --- a/src/v1/air/air.controller.ts +++ b/src/v1/air/air.controller.ts @@ -32,11 +32,6 @@ import { ErrorResponseDto } from '../common/dto/error-response.dto'; export class AirController { constructor(private readonly airService: AirService) {} - // @Post() - // create(@Body() createAirDto: CreateAirDto) { - // return this.airService.create(createAirDto); - // } - @Post('notes') @UseGuards(JwtAuthGuard) async createNote( diff --git a/src/v1/air/dto/create-air.dto.ts b/src/v1/air/dto/create-air.dto.ts deleted file mode 100644 index 1e13f28..0000000 --- a/src/v1/air/dto/create-air.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableInsert } from '../../types/supabase'; - -export type CreateAirDto = TableInsert<'cw_air_data'>; diff --git a/src/v1/air/dto/update-air.dto.ts b/src/v1/air/dto/update-air.dto.ts deleted file mode 100644 index 4579032..0000000 --- a/src/v1/air/dto/update-air.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableUpdate } from '../../types/supabase'; - -export type UpdateAirDto = TableUpdate<'cw_air_data'>; diff --git a/src/v1/air/entities/air.entity.ts b/src/v1/air/entities/air.entity.ts deleted file mode 100644 index 8c9625b..0000000 --- a/src/v1/air/entities/air.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Air {} diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index 6ea3ce9..017d5be 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -1076,20 +1076,6 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot ], }, }, - "/v1/power/{id}": { - "get": { - "parameters": [ - { - "in": "path", - "name": "id", - "required": true, - "schema": { - "type": "string", - }, - }, - ], - }, - }, "/v1/soil/{dev_eui}": { "get": { "parameters": [ diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index 8563dae..c5bc585 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -15,8 +15,6 @@ import { DevicesController } from '../devices/devices.controller'; import { DevicesService } from '../devices/devices.service'; import { LocationsController } from '../locations/locations.controller'; import { LocationsService } from '../locations/locations.service'; -import { PowerController } from '../power/power.controller'; -import { PowerService } from '../power/power.service'; import { SoilController } from '../soil/soil.controller'; import { SoilService } from '../soil/soil.service'; import { TrafficController } from '../traffic/traffic.controller'; @@ -362,7 +360,6 @@ describe('V1 Route Input Contracts', () => { 'updateUserPermissionLevel', 'removeLocationPermission', ]), - power: createMockedMethods(['findOne']), soil: createMockedMethods(['findOne']), traffic: createMockedMethods(['findOne']), water: createMockedMethods(['findOne']), @@ -374,7 +371,6 @@ describe('V1 Route Input Contracts', () => { AuthController, DevicesController, LocationsController, - PowerController, SoilController, TrafficController, WaterController, @@ -384,7 +380,6 @@ describe('V1 Route Input Contracts', () => { { provide: AuthService, useValue: serviceRegistry.auth }, { provide: DevicesService, useValue: serviceRegistry.devices }, { provide: LocationsService, useValue: serviceRegistry.locations }, - { provide: PowerService, useValue: serviceRegistry.power }, { provide: SoilService, useValue: serviceRegistry.soil }, { provide: TrafficService, useValue: serviceRegistry.traffic }, { provide: WaterService, useValue: serviceRegistry.water }, @@ -836,17 +831,6 @@ describe('V1 Route Input Contracts', () => { name: 'DELETE /v1/locations/:id/permission preserves permission_id query input', url: '/v1/locations/15/permission?permission_id=3', }, - { - expectedCall: { - args: [9], - method: 'findOne', - service: 'power', - }, - expectedStatus: 200, - method: 'get', - name: 'GET /v1/power/:id preserves numeric ids', - url: '/v1/power/9', - }, { auth: true, expectedCall: { diff --git a/src/v1/devices/devices.controller.spec.ts b/src/v1/devices/devices.controller.spec.ts deleted file mode 100644 index cda7b1d..0000000 --- a/src/v1/devices/devices.controller.spec.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { DevicesController } from './devices.controller'; -import { DevicesService } from './devices.service'; -import { SupabaseService } from '../../supabase/supabase.service'; -import { LocationsService } from '../locations/locations.service'; - -describe('DevicesController', () => { - let controller: DevicesController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [DevicesController], - providers: [ - DevicesService, - { - provide: SupabaseService, - useValue: { - getClient: () => null, - getAdminClient: () => null, - }, - }, - { - provide: LocationsService, - useValue: {}, - }, - ], - }).compile(); - - controller = module.get(DevicesController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/v1/devices/devices.controller.ts b/src/v1/devices/devices.controller.ts index 7dc1cf0..031b36b 100644 --- a/src/v1/devices/devices.controller.ts +++ b/src/v1/devices/devices.controller.ts @@ -27,8 +27,8 @@ import { } from '@nestjs/swagger'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; import { DeviceDto } from './dto/device.dto'; -import { UpdateDevicePermissionDto } from './dto/UpdateDevicePermission.dto'; -import { UpdateDeviceNameGroupLocalDto } from './dto/UpdateDeviceNameGroupLocal.dto'; +import { UpdateDevicePermissionDto } from './dto/update-device-permission.dto'; +import { UpdateDeviceNameGroupLocalDto } from './dto/update-device-name-group-local.dto'; import { ReplaceDeviceDto } from './dto/replace-device.dto'; @Controller({ path: 'devices', version: '1' }) diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index 80f57c3..2b4e342 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -431,7 +431,10 @@ export class DevicesService { const { data: device, error: deviceError } = await deviceQuery.single(); if (deviceError) { - console.error(deviceError); + this.logger.error( + `Failed to fetch device ${normalizedDevEui}`, + deviceError.message, + ); throw new InternalServerErrorException('Failed to fetch device'); } @@ -576,10 +579,6 @@ export class DevicesService { return null; } - if (deviceType.name === '[SEEED] DataLogger WaterLevel') { - console.log('hit'); - } - const location = Array.isArray(d.cw_locations) ? d.cw_locations[0] : d.cw_locations; diff --git a/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts b/src/v1/devices/dto/update-device-name-group-local.dto.ts similarity index 100% rename from src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts rename to src/v1/devices/dto/update-device-name-group-local.dto.ts diff --git a/src/v1/devices/dto/UpdateDevicePermission.dto.ts b/src/v1/devices/dto/update-device-permission.dto.ts similarity index 100% rename from src/v1/devices/dto/UpdateDevicePermission.dto.ts rename to src/v1/devices/dto/update-device-permission.dto.ts diff --git a/src/v1/gateway/dto/create-gateway.dto.ts b/src/v1/gateway/dto/create-gateway.dto.ts deleted file mode 100644 index 56184c6..0000000 --- a/src/v1/gateway/dto/create-gateway.dto.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { ApiProperty } from '@nestjs/swagger'; -import { - IsBoolean, - IsISO8601, - IsInt, - IsNotEmpty, - IsOptional, - IsString, - MaxLength, - Min, -} from 'class-validator'; -import { Type } from 'class-transformer'; -import type { TableInsert } from '../../types/supabase'; - -export class CreateGatewayDto implements TableInsert<'cw_gateways'> { - @ApiProperty({ - description: 'The external gateway identifier.', - example: 'cropwatch-gateway-001', - }) - @IsString() - @IsNotEmpty() - @MaxLength(120) - gateway_id: string; - - @ApiProperty({ - description: 'Gateway display name.', - example: 'North Greenhouse Gateway', - }) - @IsString() - @IsNotEmpty() - @MaxLength(120) - gateway_name: string; - - @ApiProperty({ - description: 'Whether the gateway is currently online.', - example: true, - }) - @IsBoolean() - is_online: boolean; - - @ApiProperty({ - required: false, - description: - 'Whether this gateway can be visible outside explicit ownership.', - example: false, - }) - @IsOptional() - @IsBoolean() - is_public?: boolean; - - @ApiProperty({ required: false, format: 'date-time' }) - @IsOptional() - @IsISO8601() - created_at?: string; - - @ApiProperty({ required: false, nullable: true, format: 'date-time' }) - @IsOptional() - @IsISO8601() - updated_at?: string | null; - - @ApiProperty({ required: false, description: 'Internal gateway row id.' }) - @IsOptional() - @Type(() => Number) - @IsInt() - @Min(1) - id?: number; -} diff --git a/src/v1/gateway/dto/update-gateway.dto.ts b/src/v1/gateway/dto/update-gateway.dto.ts deleted file mode 100644 index 2761006..0000000 --- a/src/v1/gateway/dto/update-gateway.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -import { PartialType } from '@nestjs/swagger'; -import { CreateGatewayDto } from './create-gateway.dto'; - -export class UpdateGatewayDto extends PartialType(CreateGatewayDto) {} diff --git a/src/v1/gateway/entities/gateway.entity.ts b/src/v1/gateway/entities/gateway.entity.ts deleted file mode 100644 index 1fa593b..0000000 --- a/src/v1/gateway/entities/gateway.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Gateway {} diff --git a/src/v1/gateway/gateway.controller.ts b/src/v1/gateway/gateway.controller.ts index 6809617..ffced52 100644 --- a/src/v1/gateway/gateway.controller.ts +++ b/src/v1/gateway/gateway.controller.ts @@ -1,18 +1,12 @@ import { BadRequestException, - Body, Controller, - Delete, Get, Param, - Patch, - Post, Req, UseGuards, } from '@nestjs/common'; import { GatewayService } from './gateway.service'; -import { CreateGatewayDto } from './dto/create-gateway.dto'; -import { UpdateGatewayDto } from './dto/update-gateway.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { ApiBearerAuth, @@ -32,11 +26,6 @@ import { ErrorResponseDto } from '../common/dto/error-response.dto'; export class GatewayController { constructor(private readonly gatewayService: GatewayService) {} - // @Post() - // create(@Body() createGatewayDto: CreateGatewayDto) { - // return this.gatewayService.create(createGatewayDto); - // } - @Get() @UseGuards(JwtAuthGuard) @ApiOperation({ @@ -88,14 +77,4 @@ export class GatewayController { return this.gatewayService.findOne(gatewayId, req.user); } - - // @Patch(':id') - // update(@Param('id') id: string, @Body() updateGatewayDto: UpdateGatewayDto) { - // return this.gatewayService.update(+id, updateGatewayDto); - // } - - // @Delete(':id') - // remove(@Param('id') id: string) { - // return this.gatewayService.remove(+id); - // } } diff --git a/src/v1/gateway/gateway.service.ts b/src/v1/gateway/gateway.service.ts index 0369e28..ed7d318 100644 --- a/src/v1/gateway/gateway.service.ts +++ b/src/v1/gateway/gateway.service.ts @@ -4,8 +4,6 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { CreateGatewayDto } from './dto/create-gateway.dto'; -import { UpdateGatewayDto } from './dto/update-gateway.dto'; import { SupabaseService } from '../../supabase/supabase.service'; import { getUserId } from '../../supabase/supabase-token.helper'; import type { TableRow } from '../types/supabase'; @@ -14,11 +12,6 @@ import type { TableRow } from '../types/supabase'; export class GatewayService { constructor(private readonly supabaseService: SupabaseService) {} - create(createGatewayDto: CreateGatewayDto) { - void createGatewayDto; - return 'This action adds a new gateway'; - } - async findAll(jwtPayload: any): Promise[]> { const client = this.supabaseService.getClient(); const userId = getUserId(jwtPayload); @@ -99,13 +92,4 @@ export class GatewayService { return data; } - - update(id: number, updateGatewayDto: UpdateGatewayDto) { - void updateGatewayDto; - return `This action updates a #${id} gateway`; - } - - remove(id: number) { - return `This action removes a #${id} gateway`; - } } diff --git a/src/v1/locations/locations.controller.spec.ts b/src/v1/locations/locations.controller.spec.ts deleted file mode 100644 index 1a007e3..0000000 --- a/src/v1/locations/locations.controller.spec.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { LocationsController } from './locations.controller'; -import { LocationsService } from './locations.service'; -import { SupabaseService } from '../../supabase/supabase.service'; -import { PaymentsService } from '../payments/payments.service'; - -describe('LocationsController', () => { - let controller: LocationsController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [LocationsController], - providers: [ - LocationsService, - { - provide: SupabaseService, - useValue: { - getClient: () => null, - getAdminClient: () => null, - }, - }, - { - provide: PaymentsService, - useValue: { - hasActiveBaseSubscription: jest.fn(async () => true), - }, - }, - ], - }).compile(); - - controller = module.get(LocationsController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/v1/power/dto/create-power.dto.ts b/src/v1/power/dto/create-power.dto.ts deleted file mode 100644 index f2f84e8..0000000 --- a/src/v1/power/dto/create-power.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableInsert } from '../../types/supabase'; - -export type CreatePowerDto = TableInsert<'cw_power_data'>; diff --git a/src/v1/power/dto/update-power.dto.ts b/src/v1/power/dto/update-power.dto.ts deleted file mode 100644 index 22f55da..0000000 --- a/src/v1/power/dto/update-power.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableUpdate } from '../../types/supabase'; - -export type UpdatePowerDto = TableUpdate<'cw_power_data'>; diff --git a/src/v1/power/entities/power.entity.ts b/src/v1/power/entities/power.entity.ts deleted file mode 100644 index e6ecaa3..0000000 --- a/src/v1/power/entities/power.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Power {} diff --git a/src/v1/power/power.controller.spec.ts b/src/v1/power/power.controller.spec.ts deleted file mode 100644 index cbe29d8..0000000 --- a/src/v1/power/power.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PowerController } from './power.controller'; -import { PowerService } from './power.service'; - -describe('PowerController', () => { - let controller: PowerController; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - controllers: [PowerController], - providers: [PowerService], - }).compile(); - - controller = module.get(PowerController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/v1/power/power.controller.ts b/src/v1/power/power.controller.ts deleted file mode 100644 index ccdf760..0000000 --- a/src/v1/power/power.controller.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { - Controller, - Get, - Post, - Body, - Patch, - Param, - Delete, - UseGuards, -} from '@nestjs/common'; -import { - ApiBadRequestResponse, - ApiBearerAuth, - ApiCreatedResponse, - ApiInternalServerErrorResponse, - ApiNotFoundResponse, - ApiOkResponse, -} from '@nestjs/swagger'; -import { PowerService } from './power.service'; -import type { CreatePowerDto } from './dto/create-power.dto'; -import type { UpdatePowerDto } from './dto/update-power.dto'; -import { ErrorResponseDto } from '../common/dto/error-response.dto'; -import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; - -@Controller({ path: 'power', version: '1' }) -@UseGuards(JwtAuthGuard) -@ApiBearerAuth() -export class PowerController { - constructor(private readonly powerService: PowerService) {} - - // @Post() - // @ApiCreatedResponse({ - // description: 'Power record created successfully.', - // schema: { type: 'string', example: 'This action adds a new power' }, - // }) - // @ApiBadRequestResponse({ - // description: 'Invalid power payload.', - // type: ErrorResponseDto, - // example: { statusCode: 400, error: 'Bad Request', message: 'Validation failed' }, - // }) - // @ApiInternalServerErrorResponse({ - // description: 'Failed to create power record.', - // type: ErrorResponseDto, - // example: { statusCode: 500, error: 'Internal Server Error', message: 'Failed to create power record' }, - // }) - // create(@Body() createPowerDto: CreatePowerDto) { - // return this.powerService.create(createPowerDto); - // } - - @Get(':id') - @ApiOkResponse({ - description: 'Power record returned successfully.', - schema: { type: 'string', example: 'This action returns a #1 power' }, - }) - @ApiBadRequestResponse({ - description: 'Invalid id.', - type: ErrorResponseDto, - example: { statusCode: 400, error: 'Bad Request', message: 'Invalid id' }, - }) - @ApiNotFoundResponse({ - description: 'Power record not found.', - type: ErrorResponseDto, - example: { - statusCode: 404, - error: 'Not Found', - message: 'Power record not found', - }, - }) - @ApiInternalServerErrorResponse({ - description: 'Failed to fetch power record.', - type: ErrorResponseDto, - example: { - statusCode: 500, - error: 'Internal Server Error', - message: 'Failed to fetch power record', - }, - }) - findOne(@Param('id') id: string) { - return this.powerService.findOne(+id); - } -} diff --git a/src/v1/power/power.module.ts b/src/v1/power/power.module.ts deleted file mode 100644 index 49b77fd..0000000 --- a/src/v1/power/power.module.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Module } from '@nestjs/common'; -import { PowerService } from './power.service'; -import { PowerController } from './power.controller'; - -@Module({ - controllers: [PowerController], - providers: [PowerService], -}) -export class PowerModule {} diff --git a/src/v1/power/power.service.spec.ts b/src/v1/power/power.service.spec.ts deleted file mode 100644 index 0058a60..0000000 --- a/src/v1/power/power.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { PowerService } from './power.service'; - -describe('PowerService', () => { - let service: PowerService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [PowerService], - }).compile(); - - service = module.get(PowerService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/v1/power/power.service.ts b/src/v1/power/power.service.ts deleted file mode 100644 index 4f7da80..0000000 --- a/src/v1/power/power.service.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { CreatePowerDto } from './dto/create-power.dto'; -import { UpdatePowerDto } from './dto/update-power.dto'; - -@Injectable() -export class PowerService { - create(createPowerDto: CreatePowerDto) { - return 'This action adds a new power'; - } - - findAll() { - return `This action returns all power`; - } - - findOne(id: number) { - return `This action returns a #${id} power`; - } - - update(id: number, updatePowerDto: UpdatePowerDto) { - return `This action updates a #${id} power`; - } - - remove(id: number) { - return `This action removes a #${id} power`; - } -} diff --git a/src/v1/relay/relay-confirmation.spec.ts b/src/v1/relay/relay-confirmation.spec.ts index f2a178e..bd08238 100644 --- a/src/v1/relay/relay-confirmation.spec.ts +++ b/src/v1/relay/relay-confirmation.spec.ts @@ -1,7 +1,4 @@ -import { - doesRelayRowConfirmTarget, - parseRelayConfirmation, -} from './relay-confirmation'; +import { parseRelayConfirmation } from './relay-confirmation'; describe('parseRelayConfirmation', () => { it('extracts relay states and confirmation metadata from a TTI uplink', () => { @@ -85,41 +82,3 @@ describe('parseRelayConfirmation', () => { ).toBeNull(); }); }); - -describe('doesRelayRowConfirmTarget', () => { - it('matches newer relay rows that confirm the requested state', () => { - expect( - doesRelayRowConfirmTarget( - { - created_at: '2026-04-05T02:35:00.834081749Z', - dev_eui: 'A8404194635A05FB', - id: 42, - last_update: '2026-04-05T02:35:00.834081749Z', - relay_1: true, - relay_2: false, - }, - 1, - 'on', - '2026-04-05T02:34:58.000000000Z', - ), - ).toBe(true); - }); - - it('rejects rows that are not newer than the command request', () => { - expect( - doesRelayRowConfirmTarget( - { - created_at: '2026-04-05T02:34:58.000000000Z', - dev_eui: 'A8404194635A05FB', - id: 42, - last_update: '2026-04-05T02:34:58.000000000Z', - relay_1: true, - relay_2: false, - }, - 1, - 'on', - '2026-04-05T02:34:58.000000000Z', - ), - ).toBe(false); - }); -}); diff --git a/src/v1/relay/relay-confirmation.ts b/src/v1/relay/relay-confirmation.ts index 1896c93..ff88ce7 100644 --- a/src/v1/relay/relay-confirmation.ts +++ b/src/v1/relay/relay-confirmation.ts @@ -1,9 +1,4 @@ -import type { - RelayConfirmation, - RelayDataRow, - RelayNumber, - RelayTargetState, -} from './relay.types'; +import type { RelayConfirmation, RelayDataRow } from './relay.types'; function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); @@ -149,32 +144,3 @@ export function readRelayRowTimestamp( return row.created_at || row.last_update || ''; } -export function compareIsoTimestamps(left: string, right: string): number { - const leftTime = Date.parse(left); - const rightTime = Date.parse(right); - - if (!Number.isNaN(leftTime) && !Number.isNaN(rightTime)) { - return leftTime - rightTime; - } - - return left.localeCompare(right); -} - -export function doesRelayRowConfirmTarget( - row: RelayDataRow, - relay: RelayNumber, - targetState: RelayTargetState, - baselineTime: string, -): boolean { - const rowTime = readRelayRowTimestamp(row); - if (!rowTime || compareIsoTimestamps(rowTime, baselineTime) <= 0) { - return false; - } - - const relayValue = relay === 1 ? row.relay_1 : row.relay_2; - if (relayValue === null) { - return false; - } - - return targetState === 'on' ? relayValue : !relayValue; -} diff --git a/src/v1/relay/relay.service.spec.ts b/src/v1/relay/relay.service.spec.ts index df8858b..766a968 100644 --- a/src/v1/relay/relay.service.spec.ts +++ b/src/v1/relay/relay.service.spec.ts @@ -158,8 +158,6 @@ describe('RelayService', () => { const service = createService({ PRIVATE_TTI_API_KEY: 'tti-secret', PRIVATE_TTI_BASE_URL: 'https://tti.example.com', - PRIVATE_TTI_RELAY_CONFIRMATION_POLL_MS: '250', - PRIVATE_TTI_RELAY_CONFIRMATION_TIMEOUT_MS: '1000', }); jest diff --git a/src/v1/relay/relay.service.ts b/src/v1/relay/relay.service.ts index 477f9b6..bae4065 100644 --- a/src/v1/relay/relay.service.ts +++ b/src/v1/relay/relay.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, ConflictException, ForbiddenException, - GatewayTimeoutException, Injectable, InternalServerErrorException, Logger, @@ -30,7 +29,6 @@ import { } from './relay-command-profile'; import { RelayCommandLockService } from './relay-command-lock.service'; import { - doesRelayRowConfirmTarget, parseRelayConfirmation, readRelayRowTimestamp, } from './relay-confirmation'; @@ -65,12 +63,6 @@ type DeviceRecord = DeviceRow & { cw_device_type?: DeviceTypeRow | DeviceTypeRow[] | null; }; -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - function readString(value: unknown): string { return typeof value === 'string' ? value.trim() : ''; } @@ -239,13 +231,6 @@ export class RelayService { downlinks: [buildRelayDownlink(relay, targetState, correlationIds)], }); - // const confirmedRow = await this.waitForRelayConfirmation( - // normalizedDevEui, - // relay, - // targetState, - // requestedAt, - // ); - return { confirmed: true, dev_eui: normalizedDevEui, @@ -255,10 +240,6 @@ export class RelayService { targetState, }; } catch (error) { - if (error instanceof GatewayTimeoutException) { - throw error; - } - if ( error instanceof BadRequestException || error instanceof ForbiddenException || @@ -355,10 +336,6 @@ export class RelayService { targetState: 'on', }; } catch (error) { - if (error instanceof GatewayTimeoutException) { - throw error; - } - if ( error instanceof BadRequestException || error instanceof ConflictException || @@ -548,64 +525,6 @@ export class RelayService { return (data as RelayRow | null) ?? null; } - private async waitForRelayConfirmation( - devEui: string, - relay: 1 | 2, - targetState: 'off' | 'on', - requestedAt: string, - ): Promise { - const timeoutMs = this.readConfirmationTimeoutMs(); - const pollIntervalMs = this.readConfirmationPollIntervalMs(); - const deadline = Date.now() + timeoutMs; - - while (Date.now() <= deadline) { - const latestRow = await this.findLatestRelayRow(devEui); - if ( - latestRow && - doesRelayRowConfirmTarget(latestRow, relay, targetState, requestedAt) - ) { - return latestRow; - } - - const remainingMs = deadline - Date.now(); - if (remainingMs <= 0) { - break; - } - - await sleep(Math.min(pollIntervalMs, remainingMs)); - } - - throw new GatewayTimeoutException( - `Timed out waiting for relay ${relay} confirmation from TTI`, - ); - } - - private readConfirmationTimeoutMs(): number { - const parsed = Number( - this.configService.get( - 'PRIVATE_TTI_RELAY_CONFIRMATION_TIMEOUT_MS', - ), - ); - - if (Number.isFinite(parsed) && parsed >= 1000) { - return parsed; - } - - return 35_000; - } - - private readConfirmationPollIntervalMs(): number { - const parsed = Number( - this.configService.get('PRIVATE_TTI_RELAY_CONFIRMATION_POLL_MS'), - ); - - if (Number.isFinite(parsed) && parsed >= 250) { - return parsed; - } - - return 1000; - } - private async persistRelayConfirmation( confirmation: RelayConfirmation, ): Promise { diff --git a/src/v1/soil/dto/create-soil.dto.ts b/src/v1/soil/dto/create-soil.dto.ts deleted file mode 100644 index a798e76..0000000 --- a/src/v1/soil/dto/create-soil.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableInsert } from '../../types/supabase'; - -export type CreateSoilDto = TableInsert<'cw_soil_data'>; diff --git a/src/v1/soil/dto/update-soil.dto.ts b/src/v1/soil/dto/update-soil.dto.ts deleted file mode 100644 index 9373b09..0000000 --- a/src/v1/soil/dto/update-soil.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableUpdate } from '../../types/supabase'; - -export type UpdateSoilDto = TableUpdate<'cw_soil_data'>; diff --git a/src/v1/soil/entities/soil.entity.ts b/src/v1/soil/entities/soil.entity.ts deleted file mode 100644 index c179c26..0000000 --- a/src/v1/soil/entities/soil.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Soil {} diff --git a/src/v1/soil/soil.controller.spec.ts b/src/v1/soil/soil.controller.spec.ts deleted file mode 100644 index e09b5ee..0000000 --- a/src/v1/soil/soil.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { SoilController } from './soil.controller'; -import { SoilService } from './soil.service'; -import { createTestingModuleWithCommonProviders } from '../common/test-helpers'; - -describe('SoilController', () => { - let controller: SoilController; - - beforeEach(async () => { - const module = await createTestingModuleWithCommonProviders( - [SoilService], - [SoilController], - ); - - controller = module.get(SoilController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/v1/soil/soil.controller.ts b/src/v1/soil/soil.controller.ts index fed0344..956fa90 100644 --- a/src/v1/soil/soil.controller.ts +++ b/src/v1/soil/soil.controller.ts @@ -2,19 +2,13 @@ import { BadRequestException, Controller, Get, - Post, - Body, - Patch, Param, - Delete, Query, UseGuards, Req, } from '@nestjs/common'; import { SoilService } from './soil.service'; import { SoilDataDto } from './dto/soil-data.dto'; -import type { CreateSoilDto } from './dto/create-soil.dto'; -import type { UpdateSoilDto } from './dto/update-soil.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { ApiBadRequestResponse, @@ -34,11 +28,6 @@ import { ErrorResponseDto } from '../common/dto/error-response.dto'; export class SoilController { constructor(private readonly soilService: SoilService) {} - // @Post() - // create(@Body() createSoilDto: CreateSoilDto) { - // return this.soilService.create(createSoilDto); - // } - @Get(':dev_eui') @UseGuards(JwtAuthGuard) @ApiOkResponse({ diff --git a/src/v1/soil/soil.service.spec.ts b/src/v1/soil/soil.service.spec.ts deleted file mode 100644 index bde475d..0000000 --- a/src/v1/soil/soil.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { SoilService } from './soil.service'; -import { createTestingModuleWithCommonProviders } from '../common/test-helpers'; - -describe('SoilService', () => { - let service: SoilService; - - beforeEach(async () => { - const module = await createTestingModuleWithCommonProviders([SoilService]); - - service = module.get(SoilService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/v1/soil/soil.service.ts b/src/v1/soil/soil.service.ts index a30b03b..f66f18e 100644 --- a/src/v1/soil/soil.service.ts +++ b/src/v1/soil/soil.service.ts @@ -1,7 +1,5 @@ import { Injectable } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; -import { CreateSoilDto } from './dto/create-soil.dto'; -import { UpdateSoilDto } from './dto/update-soil.dto'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { BaseDataService } from '../common/base-data.service'; @@ -13,8 +11,4 @@ export class SoilService extends BaseDataService<'cw_soil_data'> { ) { super(supabaseService, timezoneFormatter, 'cw_soil_data'); } - - create(createSoilDto: CreateSoilDto) { - return 'This action adds a new soil'; - } } diff --git a/src/v1/traffic/dto/create-traffic.dto.ts b/src/v1/traffic/dto/create-traffic.dto.ts deleted file mode 100644 index ba712bb..0000000 --- a/src/v1/traffic/dto/create-traffic.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableInsert } from '../../types/supabase'; - -export type CreateTrafficDto = TableInsert<'cw_traffic2'>; diff --git a/src/v1/traffic/dto/update-traffic.dto.ts b/src/v1/traffic/dto/update-traffic.dto.ts deleted file mode 100644 index 68c3c05..0000000 --- a/src/v1/traffic/dto/update-traffic.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableUpdate } from '../../types/supabase'; - -export type UpdateTrafficDto = TableUpdate<'cw_traffic2'>; diff --git a/src/v1/traffic/entities/traffic.entity.ts b/src/v1/traffic/entities/traffic.entity.ts deleted file mode 100644 index dd672b7..0000000 --- a/src/v1/traffic/entities/traffic.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Traffic {} diff --git a/src/v1/traffic/traffic.controller.spec.ts b/src/v1/traffic/traffic.controller.spec.ts deleted file mode 100644 index af07b6f..0000000 --- a/src/v1/traffic/traffic.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { TrafficController } from './traffic.controller'; -import { TrafficService } from './traffic.service'; -import { createTestingModuleWithCommonProviders } from '../common/test-helpers'; - -describe('TrafficController', () => { - let controller: TrafficController; - - beforeEach(async () => { - const module = await createTestingModuleWithCommonProviders( - [TrafficService], - [TrafficController], - ); - - controller = module.get(TrafficController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/v1/traffic/traffic.controller.ts b/src/v1/traffic/traffic.controller.ts index 9ac9bac..f11c775 100644 --- a/src/v1/traffic/traffic.controller.ts +++ b/src/v1/traffic/traffic.controller.ts @@ -2,11 +2,7 @@ import { BadRequestException, Controller, Get, - Post, - Body, - Patch, Param, - Delete, Query, UseGuards, Req, @@ -15,8 +11,6 @@ import { TrafficService } from './traffic.service'; import { TrafficDataDto } from './dto/traffic-data.dto'; import { TrafficReportDto } from './dto/traffic-report.dto'; import { TrafficMonthlyReportDto } from './dto/traffic-monthly-report.dto'; -import type { CreateTrafficDto } from './dto/create-traffic.dto'; -import type { UpdateTrafficDto } from './dto/update-traffic.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { ApiBadRequestResponse, diff --git a/src/v1/traffic/traffic.service.spec.ts b/src/v1/traffic/traffic.service.spec.ts deleted file mode 100644 index 0ed8c89..0000000 --- a/src/v1/traffic/traffic.service.spec.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { TrafficService } from './traffic.service'; -import { createTestingModuleWithCommonProviders } from '../common/test-helpers'; - -describe('TrafficService', () => { - let service: TrafficService; - - beforeEach(async () => { - const module = await createTestingModuleWithCommonProviders([ - TrafficService, - ]); - - service = module.get(TrafficService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); diff --git a/src/v1/traffic/traffic.service.ts b/src/v1/traffic/traffic.service.ts index e4b7928..3550901 100644 --- a/src/v1/traffic/traffic.service.ts +++ b/src/v1/traffic/traffic.service.ts @@ -1,7 +1,5 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; -import { CreateTrafficDto } from './dto/create-traffic.dto'; -import { UpdateTrafficDto } from './dto/update-traffic.dto'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { BaseDataService } from '../common/base-data.service'; import { TrafficMonthlyReportDto } from './dto/traffic-monthly-report.dto'; @@ -15,22 +13,6 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { super(supabaseService, timezoneFormatter, 'cw_traffic2'); } - create(createTrafficDto: CreateTrafficDto) { - return 'This action adds a new traffic'; - } - - findAll() { - return `This action returns all traffic`; - } - - update(id: number, updateTrafficDto: UpdateTrafficDto) { - return `This action updates a #${id} traffic`; - } - - remove(id: number) { - return `This action removes a #${id} traffic`; - } - async getMonthlyReport( devEui: string, year: number, diff --git a/src/v1/water/dto/create-water.dto.ts b/src/v1/water/dto/create-water.dto.ts deleted file mode 100644 index 7792982..0000000 --- a/src/v1/water/dto/create-water.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableInsert } from '../../types/supabase'; - -export type CreateWaterDto = TableInsert<'cw_water_data'>; diff --git a/src/v1/water/dto/update-water.dto.ts b/src/v1/water/dto/update-water.dto.ts deleted file mode 100644 index d20b74b..0000000 --- a/src/v1/water/dto/update-water.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { TableUpdate } from '../../types/supabase'; - -export type UpdateWaterDto = TableUpdate<'cw_water_data'>; diff --git a/src/v1/water/entities/water.entity.ts b/src/v1/water/entities/water.entity.ts deleted file mode 100644 index 150f332..0000000 --- a/src/v1/water/entities/water.entity.ts +++ /dev/null @@ -1 +0,0 @@ -export class Water {} diff --git a/src/v1/water/water.controller.spec.ts b/src/v1/water/water.controller.spec.ts deleted file mode 100644 index a349817..0000000 --- a/src/v1/water/water.controller.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { WaterController } from './water.controller'; -import { WaterService } from './water.service'; -import { createTestingModuleWithCommonProviders } from '../common/test-helpers'; - -describe('WaterController', () => { - let controller: WaterController; - - beforeEach(async () => { - const module = await createTestingModuleWithCommonProviders( - [WaterService], - [WaterController], - ); - - controller = module.get(WaterController); - }); - - it('should be defined', () => { - expect(controller).toBeDefined(); - }); -}); diff --git a/src/v1/water/water.controller.ts b/src/v1/water/water.controller.ts index 3aadfb8..73d3bba 100644 --- a/src/v1/water/water.controller.ts +++ b/src/v1/water/water.controller.ts @@ -2,19 +2,13 @@ import { BadRequestException, Controller, Get, - Post, - Body, - Patch, Param, - Delete, Query, UseGuards, Req, } from '@nestjs/common'; import { WaterService } from './water.service'; import { WaterDataDto } from './dto/water-data.dto'; -import type { CreateWaterDto } from './dto/create-water.dto'; -import type { UpdateWaterDto } from './dto/update-water.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { ApiBadRequestResponse, @@ -34,11 +28,6 @@ import { ErrorResponseDto } from '../common/dto/error-response.dto'; export class WaterController { constructor(private readonly waterService: WaterService) {} - // @Post() - // create(@Body() createWaterDto: CreateWaterDto) { - // return this.waterService.create(createWaterDto); - // } - @Get(':dev_eui') @UseGuards(JwtAuthGuard) @ApiOkResponse({ diff --git a/src/v1/water/water.service.spec.ts b/src/v1/water/water.service.spec.ts deleted file mode 100644 index 68c1aa9..0000000 --- a/src/v1/water/water.service.spec.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { WaterService } from './water.service'; -import { createTestingModuleWithCommonProviders } from '../common/test-helpers'; - -describe('WaterService', () => { - let service: WaterService; - - beforeEach(async () => { - const module = await createTestingModuleWithCommonProviders([WaterService]); - - service = module.get(WaterService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); -}); From e19e73f5d8d3b8ce5e7440228d0eeabe2e2cc025 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 18:21:57 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20typed=20auth=20foundation=20?= =?UTF-8?q?=E2=80=94=20AuthenticatedUser,=20@CurrentUser,=20strategy=20map?= =?UTF-8?q?ping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SupabaseStrategy.validate now maps verified JWT claims to a typed AuthenticatedUser { sub, email, isStaff } instead of returning the raw payload, and validates sub once at the boundary. isStaff is computed via the single isStaffEmail helper. Removes the no-op authenticate() override. Co-Authored-By: Claude Fable 5 --- src/v1/auth/authenticated-user.ts | 16 ++++++++++++ src/v1/auth/current-user.decorator.ts | 13 ++++++++++ src/v1/auth/strategies/supabase.strategy.ts | 27 ++++++++++++++++----- 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/v1/auth/authenticated-user.ts create mode 100644 src/v1/auth/current-user.decorator.ts diff --git a/src/v1/auth/authenticated-user.ts b/src/v1/auth/authenticated-user.ts new file mode 100644 index 0000000..2e7e503 --- /dev/null +++ b/src/v1/auth/authenticated-user.ts @@ -0,0 +1,16 @@ +/** + * The authenticated caller, as attached to `request.user` by + * {@link SupabaseStrategy.validate}. This is the only shape services + * should accept — never the raw JWT payload. + */ +export interface AuthenticatedUser { + /** Supabase user id (JWT `sub` claim, UUID). */ + sub: string; + /** Caller email, trimmed and lowercased; null when the token has none. */ + email: string | null; + /** + * True when the caller is CropWatch staff. Single source of truth for + * global read access — computed once at token validation. + */ + isStaff: boolean; +} diff --git a/src/v1/auth/current-user.decorator.ts b/src/v1/auth/current-user.decorator.ts new file mode 100644 index 0000000..999aa27 --- /dev/null +++ b/src/v1/auth/current-user.decorator.ts @@ -0,0 +1,13 @@ +import { createParamDecorator, ExecutionContext } from '@nestjs/common'; +import type { Request } from 'express'; +import type { AuthenticatedUser } from './authenticated-user'; + +/** + * Injects the authenticated caller (`request.user`) into a route handler. + * Only valid on routes protected by {@link JwtAuthGuard}. + */ +export const CurrentUser = createParamDecorator( + (_data: unknown, ctx: ExecutionContext): AuthenticatedUser => + ctx.switchToHttp().getRequest() + .user, +); diff --git a/src/v1/auth/strategies/supabase.strategy.ts b/src/v1/auth/strategies/supabase.strategy.ts index 8a3197b..f9baee4 100644 --- a/src/v1/auth/strategies/supabase.strategy.ts +++ b/src/v1/auth/strategies/supabase.strategy.ts @@ -1,7 +1,9 @@ -import { Injectable } from '@nestjs/common'; +import { Injectable, UnauthorizedException } from '@nestjs/common'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { ConfigService } from '@nestjs/config'; +import { isStaffEmail } from '../../common/owner-filter.helper'; +import type { AuthenticatedUser } from '../authenticated-user'; @Injectable() export class SupabaseStrategy extends PassportStrategy(Strategy) { @@ -13,11 +15,24 @@ export class SupabaseStrategy extends PassportStrategy(Strategy) { }); } - async validate(payload: any): Promise { - return payload; - } + /** + * Maps the verified Supabase JWT payload to the AuthenticatedUser shape + * attached to `request.user`. This is the single place raw JWT claims + * are read — everything downstream consumes AuthenticatedUser. + */ + validate(payload: unknown): AuthenticatedUser { + const claims = (payload ?? {}) as Record; + + const sub = typeof claims.sub === 'string' ? claims.sub.trim() : ''; + if (!sub) { + throw new UnauthorizedException('Invalid bearer token'); + } + + const email = + typeof claims.email === 'string' && claims.email.trim() + ? claims.email.trim().toLowerCase() + : null; - authenticate(req) { - super.authenticate(req); + return { sub, email, isStaff: isStaffEmail(email) }; } } From 7751fd6081dd3c1ba4efd3356652b763417d31fa Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 18:38:05 +0900 Subject: [PATCH 06/11] refactor: thread typed AuthenticatedUser through all modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the raw jwtPayload: any + authHeader plumbing (~90 sites) with the typed AuthenticatedUser from the passport strategy: - controllers take @CurrentUser() user instead of @Req() req and no longer forward req.headers.authorization; raw req.query reads became typed @Query params (now visible in Swagger) - services take user: AuthenticatedUser; getUserId(jwtPayload) -> user.sub, isCropwatchStaff(jwtPayload) -> user.isStaff, jwtPayload.email -> user.email - SupabaseService.getClient() loses its always-ignored accessToken param - supabase-token.helper.ts deleted (getUserId/getAccessToken/isCropwatchStaff all replaced); auth.service keeps one private readBearerToken() for the GoTrue email-change call, the single place a raw bearer is still needed - @UseGuards(JwtAuthGuard) hoisted to class level on the 10 controllers where every route is guarded (auth/payments/relay keep per-method guards for their public login/webhook routes) - dead jwtPayload.id fallback in air annotations removed (Supabase JWTs carry sub, never id); dead skip/take parsing in devices status route removed - mcp tools typed and their misleading 'RLS-scoped' comments corrected — the client is service-role; authorization happens in the services Contract snapshot diff: additions only (query params + auth header now documented); no route or arg semantics changed. Co-Authored-By: Claude Fable 5 --- src/supabase/supabase-token.helper.ts | 31 --- src/supabase/supabase.service.ts | 2 +- src/v1/air/air.controller.spec.ts | 12 +- src/v1/air/air.controller.ts | 24 ++- src/v1/air/air.service.spec.ts | 7 +- src/v1/air/air.service.ts | 31 +-- src/v1/auth/auth.controller.ts | 57 +++--- src/v1/auth/auth.service.ts | 59 +++--- .../v1-route-input-contract.spec.ts.snap | 90 +++++++-- src/v1/common/base-data.service.ts | 15 +- src/v1/common/v1-route-input-contract.spec.ts | 38 ++-- src/v1/dashboard/dashboard.controller.ts | 22 +-- src/v1/dashboard/dashboard.service.ts | 36 ++-- src/v1/devices/devices.controller.ts | 180 ++++++++--------- src/v1/devices/devices.service.spec.ts | 6 +- src/v1/devices/devices.service.ts | 148 ++++++-------- src/v1/gateway/gateway.controller.spec.ts | 26 +-- src/v1/gateway/gateway.controller.ts | 14 +- src/v1/gateway/gateway.service.spec.ts | 4 +- src/v1/gateway/gateway.service.ts | 10 +- src/v1/locations/locations.controller.ts | 72 +++---- src/v1/locations/locations.service.spec.ts | 8 +- src/v1/locations/locations.service.ts | 97 ++++----- src/v1/mcp/tools/devices.tools.ts | 20 +- src/v1/payments/payments.controller.ts | 57 +++--- src/v1/payments/payments.service.ts | 101 ++++------ src/v1/relay/relay.controller.spec.ts | 34 ++-- src/v1/relay/relay.controller.ts | 44 ++--- src/v1/relay/relay.service.spec.ts | 21 +- src/v1/relay/relay.service.ts | 33 ++-- src/v1/reports-new/reports-new.controller.ts | 68 ++----- src/v1/reports-new/reports-new.service.ts | 186 ++++++++---------- src/v1/rules-new/rules-new.controller.ts | 74 +++---- src/v1/rules-new/rules-new.service.spec.ts | 15 +- src/v1/rules-new/rules-new.service.ts | 186 ++++++++---------- src/v1/soil/soil.controller.ts | 9 +- src/v1/traffic/traffic.controller.ts | 14 +- src/v1/traffic/traffic.service.ts | 5 +- src/v1/water/water.controller.ts | 9 +- 39 files changed, 771 insertions(+), 1094 deletions(-) delete mode 100644 src/supabase/supabase-token.helper.ts diff --git a/src/supabase/supabase-token.helper.ts b/src/supabase/supabase-token.helper.ts deleted file mode 100644 index 25d9618..0000000 --- a/src/supabase/supabase-token.helper.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { UnauthorizedException } from '@nestjs/common'; -/********************************************************************* - * - * Private functions to handle common tasks such as extracting user ID from JWT payload, - * - ********************************************************************/ - -export function getUserId(jwtPayload: any): string { - const userId = jwtPayload?.sub; - if (typeof userId !== 'string' || !userId.trim()) { - throw new UnauthorizedException('Invalid bearer token'); - } - return userId; -} - -export function 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; -} - -export function isCropwatchStaff(jwtPayload: any): boolean { - const email = jwtPayload?.email; - return ( - typeof email === 'string' && - email.trim().toLowerCase().endsWith('@cropwatch.io') - ); -} diff --git a/src/supabase/supabase.service.ts b/src/supabase/supabase.service.ts index 240a0f8..a07811a 100644 --- a/src/supabase/supabase.service.ts +++ b/src/supabase/supabase.service.ts @@ -12,7 +12,7 @@ export class SupabaseService { // Data operations run through the service-role client so API authorization // is enforced in Nest instead of via Supabase RLS. - getClient(_accessToken?: string): SupabaseClient { + getClient(): SupabaseClient { return this.adminClient; } diff --git a/src/v1/air/air.controller.spec.ts b/src/v1/air/air.controller.spec.ts index 33272a9..bac2752 100644 --- a/src/v1/air/air.controller.spec.ts +++ b/src/v1/air/air.controller.spec.ts @@ -41,17 +41,17 @@ describe('AirController', () => { note: 'stable reading', title: 'Daily review', }; - const req = { - user: { - sub: 'user-123', - }, + const user = { + sub: 'user-123', + email: 'user@example.com', + isStaff: false, }; const expected = { data: { ...dto, id: 1 }, error: null }; mockAirService.createNote.mockResolvedValue(expected); - await expect(controller.createNote(dto, req)).resolves.toEqual(expected); - expect(mockAirService.createNote).toHaveBeenCalledWith(dto, req.user); + await expect(controller.createNote(dto, user)).resolves.toEqual(expected); + expect(mockAirService.createNote).toHaveBeenCalledWith(dto, user); }); it('accepts a valid payload under the global validation pipe settings', async () => { diff --git a/src/v1/air/air.controller.ts b/src/v1/air/air.controller.ts index e760539..84fda03 100644 --- a/src/v1/air/air.controller.ts +++ b/src/v1/air/air.controller.ts @@ -7,7 +7,6 @@ import { Param, Query, UseGuards, - Req, Delete, } from '@nestjs/common'; import { AirService } from './air.service'; @@ -25,41 +24,40 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'air', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class AirController { constructor(private readonly airService: AirService) {} @Post('notes') - @UseGuards(JwtAuthGuard) async createNote( @Body() createAirNoteDto: CreateAirAnnotationDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - return this.airService.createNote(createAirNoteDto, req.user); + return this.airService.createNote(createAirNoteDto, user); } @Get('notes/:dev_eui/month/:month/year/:year') - @UseGuards(JwtAuthGuard) async findAll( @Param('dev_eui') devEui: string, @Param('month') month: string, @Param('year') year: string, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - return this.airService.findAllNotes(devEui, month, year, req.user); + return this.airService.findAllNotes(devEui, month, year, user); } @Delete('notes/:note_id') - @UseGuards(JwtAuthGuard) - async deleteNote(@Param('note_id') noteId: number, @Req() req) { - return this.airService.deleteNote(noteId, req.user); + async deleteNote(@Param('note_id') noteId: number, @CurrentUser() user: AuthenticatedUser) { + return this.airService.deleteNote(noteId, user); } @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Air data returned successfully.', type: AirDataDto, @@ -119,7 +117,7 @@ export class AirController { }) findOne( @Param('dev_eui') devEui: string, - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Query('start') start?: string, @Query('end') end?: string, @Query('timezone') timezone?: string, @@ -147,7 +145,7 @@ export class AirController { devEui, startDate, endDate, - req.user, + user, timezone, ); } diff --git a/src/v1/air/air.service.spec.ts b/src/v1/air/air.service.spec.ts index a5dabde..7bff4de 100644 --- a/src/v1/air/air.service.spec.ts +++ b/src/v1/air/air.service.spec.ts @@ -77,8 +77,9 @@ describe('AirService', () => { describe('createNote', () => { it('resolves millisecond timestamps to the canonical air reading before insert', async () => { - const jwtPayload = { + const user = { email: 'user-123@example.com', + isStaff: false, sub: 'user-123', }; const dto: CreateAirAnnotationDto = { @@ -125,7 +126,7 @@ describe('AirService', () => { throw new Error(`Unexpected table ${table}`); }); - await expect(service.createNote(dto, jwtPayload)).resolves.toEqual({ + await expect(service.createNote(dto, user)).resolves.toEqual({ created_at: resolvedCreatedAt, created_by: 'user-123@example.com', dev_eui: '2CF7F1C073800102', @@ -137,7 +138,7 @@ describe('AirService', () => { expect((service as any).assertDeviceAccess).toHaveBeenCalledWith( '2CF7F1C073800102', - jwtPayload, + user, ); expect(exactMatchBuilder.eq).toHaveBeenNthCalledWith( 1, diff --git a/src/v1/air/air.service.ts b/src/v1/air/air.service.ts index b65e542..90c0ed3 100644 --- a/src/v1/air/air.service.ts +++ b/src/v1/air/air.service.ts @@ -2,12 +2,12 @@ import { BadRequestException, Injectable, InternalServerErrorException, - UnauthorizedException, } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { BaseDataService } from '../common/base-data.service'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Injectable() export class AirService extends BaseDataService<'cw_air_data'> { @@ -22,13 +22,13 @@ export class AirService extends BaseDataService<'cw_air_data'> { devEui: string, month: string, year: string, - jwtPayload: any, + user: AuthenticatedUser, ): Promise { const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); } - await this.assertDeviceAccess(normalizedDevEui, jwtPayload); + await this.assertDeviceAccess(normalizedDevEui, user); const client = this.supabaseService.getClient(); const startOfMonth = new Date( @@ -60,14 +60,14 @@ export class AirService extends BaseDataService<'cw_air_data'> { return data; } - async createNote(createAirNoteDto: CreateAirAnnotationDto, jwtPayload: any) { + async createNote(createAirNoteDto: CreateAirAnnotationDto, user: AuthenticatedUser) { const normalizedDevEui = createAirNoteDto.dev_eui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); } - const createdBy = jwtPayload?.email?.trim(); + const createdBy = user.email?.trim(); - await this.assertDeviceAccess(normalizedDevEui, jwtPayload); + await this.assertDeviceAccess(normalizedDevEui, user); const client = this.supabaseService.getClient(); const resolvedCreatedAt = await this.resolveAnnotationCreatedAt( client, @@ -93,22 +93,7 @@ export class AirService extends BaseDataService<'cw_air_data'> { return data; } - private resolveAnnotationAuthorId(jwtPayload: any): string { - const subject = - typeof jwtPayload?.sub === 'string' - ? jwtPayload.sub.trim() - : typeof jwtPayload?.id === 'string' - ? jwtPayload.id.trim() - : ''; - - if (!subject) { - throw new UnauthorizedException('Authenticated user is required'); - } - - return subject; - } - - async deleteNote(noteId: number, jwtPayload: any) { + async deleteNote(noteId: number, user: AuthenticatedUser) { const client = this.supabaseService.getClient(); const { data: existingNote, error: fetchError } = await client .from('cw_air_annotations') @@ -124,7 +109,7 @@ export class AirService extends BaseDataService<'cw_air_data'> { throw new BadRequestException('Air annotation not found'); } - await this.assertDeviceAccess(existingNote.dev_eui, jwtPayload); + await this.assertDeviceAccess(existingNote.dev_eui, user); const { error: deleteError } = await client .from('cw_air_annotations') diff --git a/src/v1/auth/auth.controller.ts b/src/v1/auth/auth.controller.ts index 1cf7c09..9145972 100644 --- a/src/v1/auth/auth.controller.ts +++ b/src/v1/auth/auth.controller.ts @@ -2,13 +2,15 @@ import { Body, Controller, Get, + Headers, Patch, Post, - Req, UseGuards, } from '@nestjs/common'; import { Throttle } from '@nestjs/throttler'; import { JwtAuthGuard } from './guards/jwt.auth.guard'; +import { CurrentUser } from './current-user.decorator'; +import type { AuthenticatedUser } from './authenticated-user'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -49,8 +51,8 @@ export class AuthController { message: 'Unauthorized', }, }) - async protected(@Req() req) { - return req.user; + async protected(@CurrentUser() user: AuthenticatedUser) { + return user; } @Get('user-profile') @@ -68,12 +70,8 @@ export class AuthController { message: 'Unauthorized', }, }) - async getUserProfile(@Req() req) { - return this.authService.getUserProfile( - req.user, - req.headers?.authorization, - req.user, - ); + async getUserProfile(@CurrentUser() user: AuthenticatedUser) { + return this.authService.getUserProfile(user); } @Patch('user-profile') @@ -104,12 +102,11 @@ export class AuthController { description: 'Failed to update user profile.', type: ErrorResponseDto, }) - async updateUserProfile(@Body() body: UpdateUserProfileDto, @Req() req) { - return this.authService.updateUserProfile( - body, - req.headers?.authorization, - req.user, - ); + async updateUserProfile( + @Body() body: UpdateUserProfileDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.authService.updateUserProfile(body, user); } @Patch('email') @@ -138,12 +135,12 @@ export class AuthController { description: 'Failed to start email change.', type: ErrorResponseDto, }) - async updateEmail(@Body() body: UpdateEmailDto, @Req() req) { - return this.authService.updateEmail( - req.headers?.authorization, - body.email, - req.user, - ); + async updateEmail( + @Body() body: UpdateEmailDto, + @CurrentUser() user: AuthenticatedUser, + @Headers('authorization') authHeader?: string, + ) { + return this.authService.updateEmail(authHeader, body.email, user); } @Get('preferences') @@ -163,11 +160,8 @@ export class AuthController { description: 'Failed to read preferences.', type: ErrorResponseDto, }) - async getPreferences(@Req() req) { - return this.authService.getPreferences( - req.user, - req.headers?.authorization, - ); + async getPreferences(@CurrentUser() user: AuthenticatedUser) { + return this.authService.getPreferences(user); } @Patch('preferences') @@ -189,12 +183,11 @@ export class AuthController { description: 'Failed to update preferences.', type: ErrorResponseDto, }) - async updatePreferences(@Body() body: UpdatePreferencesDto, @Req() req) { - return this.authService.updatePreferences( - body, - req.headers?.authorization, - req.user, - ); + async updatePreferences( + @Body() body: UpdatePreferencesDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.authService.updatePreferences(body, user); } @Throttle({ default: { limit: 2, ttl: 60000 } }) diff --git a/src/v1/auth/auth.service.ts b/src/v1/auth/auth.service.ts index e9c8cbc..b24b07d 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -8,10 +8,7 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SupabaseService } from '../../supabase/supabase.service'; -import { - getAccessToken, - getUserId, -} from '../../supabase/supabase-token.helper'; +import type { AuthenticatedUser } from './authenticated-user'; import { UpdateUserProfileDto } from './dto/update-user-profile.dto'; import { UpdatePreferencesDto } from './dto/update-preferences.dto'; @@ -86,10 +83,9 @@ export class AuthService { result, }; } - async getUserProfile(user: any, authHeader: string, jwtPayload: any) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); + async getUserProfile(user: AuthenticatedUser) { + const client = this.supabaseService.getClient(); + const userId = user.sub; const { data, error } = await client .from('profiles') .select('*') @@ -105,12 +101,10 @@ export class AuthService { async updateUserProfile( updateDto: UpdateUserProfileDto, - authHeader: string, - jwtPayload: any, + user: AuthenticatedUser, ) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; const normalized: UpdateUserProfileDto = {}; if (updateDto.full_name !== undefined) { @@ -182,14 +176,17 @@ export class AuthService { * the CropWatch /auth/confirm route). profiles.email is synced by the * on_auth_user_email_updated DB trigger after confirmation. */ - async updateEmail(authHeader: string, newEmail: string, jwtPayload: any) { - const accessToken = getAccessToken(authHeader); + async updateEmail( + authHeader: string | undefined, + newEmail: string, + user: AuthenticatedUser, + ) { + // GoTrue needs the caller's own bearer token (see fetch below), so this + // is the one place the raw Authorization header is still parsed. + const accessToken = this.readBearerToken(authHeader); // Corporate CropWatch accounts are locked to their email of record. - const currentEmail = (jwtPayload?.email ?? '') - .toString() - .trim() - .toLowerCase(); + const currentEmail = user.email ?? ''; if ( RESTRICTED_EMAIL_CHANGE_DOMAINS.some((domain) => currentEmail.endsWith(domain), @@ -251,10 +248,9 @@ export class AuthService { } /** Read the caller's preferences, creating an empty row on first access. */ - async getPreferences(jwtPayload: any, authHeader: string) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); + async getPreferences(user: AuthenticatedUser) { + const client = this.supabaseService.getClient(); + const userId = user.sub; const { data, error } = await client .from('profile_preferences') @@ -285,12 +281,10 @@ export class AuthService { /** Patch the caller's preferences (get-or-create + merge in a single upsert). */ async updatePreferences( updateDto: UpdatePreferencesDto, - authHeader: string, - jwtPayload: any, + user: AuthenticatedUser, ) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; const patch: UpdatePreferencesDto = {}; for (const key of PREFERENCE_KEYS) { @@ -320,4 +314,13 @@ export class AuthService { return data; } + + private readBearerToken(authHeader: string | undefined): string { + const rawHeader = authHeader?.trim() ?? ''; + const [scheme, token] = rawHeader.split(' '); + if (scheme?.toLowerCase() !== 'bearer' || !token) { + throw new UnauthorizedException('Missing bearer token'); + } + return token; + } } diff --git a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap index 017d5be..a903dec 100644 --- a/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap +++ b/src/v1/common/__snapshots__/v1-route-input-contract.spec.ts.snap @@ -521,6 +521,16 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, "/v1/auth/email": { "patch": { + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": true, + "schema": { + "type": "string", + }, + }, + ], "requestBody": { "content": { "application/json": { @@ -598,31 +608,41 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "in": "query", "name": "group", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "location", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "name", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "skip", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "take", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, ], }, @@ -640,37 +660,49 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "in": "query", "name": "group-by-device-group", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "location", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "locationGroup", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "name", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "skip", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, { "in": "query", "name": "take", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, ], }, @@ -763,6 +795,22 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "required": false, "schema": {}, }, + { + "in": "query", + "name": "skip", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "take", + "required": true, + "schema": { + "type": "string", + }, + }, ], }, }, @@ -804,6 +852,14 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "type": "string", }, }, + { + "in": "query", + "name": "skip", + "required": true, + "schema": { + "type": "string", + }, + }, { "in": "query", "name": "start", @@ -813,6 +869,14 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "type": "string", }, }, + { + "in": "query", + "name": "take", + "required": true, + "schema": { + "type": "string", + }, + }, ], }, }, @@ -899,7 +963,9 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "in": "query", "name": "name", "required": false, - "schema": {}, + "schema": { + "type": "string", + }, }, ], }, diff --git a/src/v1/common/base-data.service.ts b/src/v1/common/base-data.service.ts index 9b98dd3..a4b23d0 100644 --- a/src/v1/common/base-data.service.ts +++ b/src/v1/common/base-data.service.ts @@ -7,11 +7,8 @@ import { import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from './timezone-formatter.service'; import { TableRow, TableName } from '../types/supabase'; -import { - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { READ_EXCLUSIVE_CEILING } from './permission-levels'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; /** * Base service class for common data fetching operations across different data types @@ -36,7 +33,7 @@ export abstract class BaseDataService { devEui: string, startDate: Date, endDate: Date, - jwtPayload: any, + user: AuthenticatedUser, timezone?: string, ): Promise[]> { const normalizedDevEui = devEui?.trim(); @@ -48,7 +45,7 @@ export abstract class BaseDataService { this.timezoneFormatter.assertValidTimeZone(normalizedTimeZone); } - await this.assertDeviceAccess(normalizedDevEui, jwtPayload); + await this.assertDeviceAccess(normalizedDevEui, user); const { data, error } = await this.supabaseService .getClient() @@ -77,10 +74,10 @@ export abstract class BaseDataService { protected async assertDeviceAccess( devEui: string, - jwtPayload: any, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isGlobalUser = user.isStaff; let query = this.supabaseService .getClient() .from('cw_devices') diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index c5bc585..c783258 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -53,7 +53,7 @@ type RejectionCase = { const AUTH_HEADER = 'Bearer test-token'; const MOCK_USER = { email: 'user@example.com', - id: 'user-123', + isStaff: false, sub: 'user-123', }; const ISO_DATETIME_PLACEHOLDER = ''; @@ -506,7 +506,6 @@ describe('V1 Route Input Contracts', () => { expectedCall: { args: [ MOCK_USER, - AUTH_HEADER, 5, 1000, 'Field Group', @@ -524,7 +523,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER], + args: [MOCK_USER], method: 'findAllStatus', service: 'devices', }, @@ -536,7 +535,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER], + args: [MOCK_USER], method: 'findAllDeviceGroups', service: 'devices', }, @@ -552,7 +551,6 @@ describe('V1 Route Input Contracts', () => { MOCK_USER, 7, 11, - AUTH_HEADER, 'Irrigation', 'West Node', 'Field 7', @@ -569,7 +567,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 42, AUTH_HEADER], + args: [MOCK_USER, 42], method: 'findAllDevicesInLocation', service: 'devices', }, @@ -581,7 +579,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 'DEV-001', AUTH_HEADER], + args: [MOCK_USER, 'DEV-001'], method: 'findOne', service: 'devices', }, @@ -593,7 +591,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 'DEV-001', 3, 9, AUTH_HEADER], + args: [MOCK_USER, 'DEV-001', 3, 9], method: 'findData', service: 'devices', }, @@ -605,7 +603,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 'DEV-001', 'user@example.com', 2, AUTH_HEADER], + args: [MOCK_USER, 'DEV-001', 'user@example.com', 2], method: 'updatePermissionLevel', service: 'devices', }, @@ -625,7 +623,6 @@ describe('V1 Route Input Contracts', () => { args: [ MOCK_USER, 'DEV-001', - AUTH_HEADER, '2026-01-01T00:00:00.000Z', '2026-01-02T12:00:00.000Z', 2, @@ -642,7 +639,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 'DEV-001', AUTH_HEADER], + args: [MOCK_USER, 'DEV-001'], method: 'findLatestData', service: 'devices', }, @@ -654,7 +651,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 'DEV-001', AUTH_HEADER, true], + args: [MOCK_USER, 'DEV-001', true], method: 'findLatestData', service: 'devices', }, @@ -672,7 +669,6 @@ describe('V1 Route Input Contracts', () => { 'Renamed Device', 'Zone A', 54, - AUTH_HEADER, ], method: 'updateDevice', service: 'devices', @@ -690,7 +686,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [{ name: 'North Field' }, MOCK_USER, AUTH_HEADER], + args: [{ name: 'North Field' }, MOCK_USER], method: 'create', service: 'locations', }, @@ -705,7 +701,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER, undefined], + args: [MOCK_USER, undefined], method: 'findAll', service: 'locations', }, @@ -717,7 +713,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER], + args: [MOCK_USER], method: 'findAllLocationGroups', service: 'locations', }, @@ -729,7 +725,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [15, MOCK_USER, AUTH_HEADER], + args: [15, MOCK_USER], method: 'findOne', service: 'locations', }, @@ -741,7 +737,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [15, { name: 'North Field' }, MOCK_USER, AUTH_HEADER], + args: [15, { name: 'North Field' }, MOCK_USER], method: 'update', service: 'locations', }, @@ -766,7 +762,6 @@ describe('V1 Route Input Contracts', () => { 3, true, MOCK_USER, - AUTH_HEADER, ], method: 'createLocationPermission', service: 'locations', @@ -784,7 +779,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [15, { admin_user_id: 'admin-1' }, true, MOCK_USER, AUTH_HEADER], + args: [15, { admin_user_id: 'admin-1' }, true, MOCK_USER], method: 'updateLocationPermission', service: 'locations', }, @@ -804,7 +799,6 @@ describe('V1 Route Input Contracts', () => { { extra: 'keep-me', permission_level: 3, user_id: 'user-456' }, true, MOCK_USER, - AUTH_HEADER, ], method: 'updateUserPermissionLevel', service: 'locations', @@ -822,7 +816,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [15, 3, MOCK_USER, AUTH_HEADER], + args: [15, 3, MOCK_USER], method: 'removeLocationPermission', service: 'locations', }, diff --git a/src/v1/dashboard/dashboard.controller.ts b/src/v1/dashboard/dashboard.controller.ts index 006cc6f..73045e8 100644 --- a/src/v1/dashboard/dashboard.controller.ts +++ b/src/v1/dashboard/dashboard.controller.ts @@ -3,7 +3,6 @@ import { Get, Param, Query, - Req, Res, UseGuards, } from '@nestjs/common'; @@ -18,15 +17,17 @@ import type { Response } from 'express'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { DashboardService } from './dashboard.service'; import { DashboardQuery } from './dashboard.types'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'dashboard', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class DashboardController { constructor(private readonly dashboardService: DashboardService) {} @Get('devices') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Paginated list of devices the user can see, with latest primary/secondary readings', @@ -38,7 +39,7 @@ export class DashboardController { @ApiQuery({ name: 'location', required: false }) @ApiQuery({ name: 'locationGroup', required: false }) async getDevices( - @Req() req: any, + @CurrentUser() user: AuthenticatedUser, @Query() q: Record, ) { const query: DashboardQuery = { @@ -50,14 +51,12 @@ export class DashboardController { locationGroup: q.locationGroup?.trim() || undefined, }; return this.dashboardService.getDevices( - req.user, - req.headers.authorization, + user, query, ); } @Get('locations') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Paginated list of locations the user can see, each with its devices and latest readings', @@ -69,7 +68,7 @@ export class DashboardController { @ApiQuery({ name: 'location', required: false }) @ApiQuery({ name: 'locationGroup', required: false }) async getLocations( - @Req() req: any, + @CurrentUser() user: AuthenticatedUser, @Query() q: Record, ) { const query: DashboardQuery = { @@ -81,28 +80,25 @@ export class DashboardController { locationGroup: q.locationGroup?.trim() || undefined, }; return this.dashboardService.getLocations( - req.user, - req.headers.authorization, + user, query, ); } @Get('devices/:dev_eui/latest') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Latest full data row for a single device (all columns from its data_table_v2)', }) @ApiParam({ name: 'dev_eui', required: true }) async getLatest( - @Req() req: any, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Res({ passthrough: true }) res: Response, ) { const row = await this.dashboardService.getLatest( - req.user, + user, devEui, - req.headers.authorization, ); if (row === null) { res.status(204); diff --git a/src/v1/dashboard/dashboard.service.ts b/src/v1/dashboard/dashboard.service.ts index a7e6f89..ab54051 100644 --- a/src/v1/dashboard/dashboard.service.ts +++ b/src/v1/dashboard/dashboard.service.ts @@ -6,11 +6,6 @@ import { NotFoundException, } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { READ_EXCLUSIVE_CEILING } from '../common/permission-levels'; import { sanitizeOrFilterTerm } from '../common/postgrest-filter.helper'; import { @@ -21,6 +16,7 @@ import { DashboardRow, isDashboardDataTable, } from './dashboard.types'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Injectable() export class DashboardService { @@ -29,14 +25,12 @@ export class DashboardService { constructor(private readonly supabaseService: SupabaseService) {} async getDevices( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, query: DashboardQuery, ): Promise { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const skip = Math.max(0, query.skip ?? 0); const take = Math.min(Math.max(1, query.take ?? 50), 200); @@ -115,14 +109,12 @@ export class DashboardService { } async getLocations( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, query: DashboardQuery, ): Promise { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const skip = Math.max(0, query.skip ?? 0); const take = Math.min(Math.max(1, query.take ?? 20), 100); @@ -286,14 +278,12 @@ export class DashboardService { } async getLatest( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, - authHeader: string, ): Promise | null> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalized = devEui?.trim(); if (!normalized) { diff --git a/src/v1/devices/devices.controller.ts b/src/v1/devices/devices.controller.ts index 031b36b..27b9804 100644 --- a/src/v1/devices/devices.controller.ts +++ b/src/v1/devices/devices.controller.ts @@ -8,7 +8,7 @@ import { Param, Patch, Post, - Req, + Query, UseGuards, } from '@nestjs/common'; import { DevicesService } from './devices.service'; @@ -30,15 +30,17 @@ import { DeviceDto } from './dto/device.dto'; import { UpdateDevicePermissionDto } from './dto/update-device-permission.dto'; import { UpdateDeviceNameGroupLocalDto } from './dto/update-device-name-group-local.dto'; import { ReplaceDeviceDto } from './dto/replace-device.dto'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'devices', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class DevicesController { constructor(private readonly devicesService: DevicesService) {} @Get() - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Current all of the user's authenticated devices returned when run successfully.", @@ -93,75 +95,63 @@ export class DevicesController { description: 'Filter by device location', required: false, }) - findAll(@Req() req) { - const parsedSkip = parseInt(req.query.skip, 10); - const parsedTake = parseInt(req.query.take, 10); + findAll( + @CurrentUser() user: AuthenticatedUser, + @Query('skip') rawSkip?: string, + @Query('take') rawTake?: string, + @Query('group') searchGroup?: string, + @Query('name') searchName?: string, + @Query('location') searchLocation?: string, + ) { + const parsedSkip = parseInt(rawSkip ?? '', 10); + const parsedTake = parseInt(rawTake ?? '', 10); const skip = Number.isNaN(parsedSkip) ? 0 : parsedSkip; const take = Math.min(Number.isNaN(parsedTake) ? 100 : parsedTake, 1000); - const searchGroup = req.query.group ? String(req.query.group) : undefined; - const searchName = req.query.name ? String(req.query.name) : undefined; - const searchLocation = req.query.location - ? String(req.query.location) - : undefined; return this.devicesService.findAll( - req.user, - req.headers.authorization, + user, skip, take, - searchGroup, - searchName, - searchLocation, + searchGroup || undefined, + searchName || undefined, + searchLocation || undefined, ); } @Get('status') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Returns online vs offline devices for the authenticated user', description: ` Returns the online vs offline status of all devices for the authenticated user.`, }) - findAllDeviceStatus(@Req() req) { - const parsedSkip = parseInt(req.query.skip, 10); - const parsedTake = parseInt(req.query.take, 10); - const skip = Number.isNaN(parsedSkip) ? 0 : parsedSkip; - const take = Number.isNaN(parsedTake) ? undefined : parsedTake; - return this.devicesService.findAllStatus( - req.user, - req.headers.authorization, - ); + findAllDeviceStatus(@CurrentUser() user: AuthenticatedUser) { + return this.devicesService.findAllStatus(user); } @Get('groups') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Returns device groups for the authenticated user', description: ` Returns the device groups for the authenticated user.`, }) - findAllDeviceGroups(@Req() req) { + findAllDeviceGroups(@CurrentUser() user: AuthenticatedUser) { return this.devicesService.findAllDeviceGroups( - req.user, - req.headers.authorization, + user, ); } @Get('device-types') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Returns device types for the authenticated user', description: ` Returns the device types for the authenticated user.`, }) - findAllDeviceTypes(@Req() req) { + findAllDeviceTypes(@CurrentUser() user: AuthenticatedUser) { return this.devicesService.findAllDeviceTypes( - req.user, - req.headers.authorization, + user, ); } @Get('latest-primary-data') - @UseGuards(JwtAuthGuard) @ApiQuery({ name: 'skip', description: 'Number of records to skip for pagination', @@ -197,33 +187,29 @@ export class DevicesController { description: ` Returns the latest, 2 primary data values from the table record for all devices.`, }) - allLatestPrimaryData(@Req() req) { - const skip = parseInt(req.query.skip, 10) || 0; - const take = parseInt(req.query.take, 10) || 10; - const searchDeviceGroup = req.query['group-by-device-group'] - ? String(req.query['group-by-device-group']) - : undefined; - const locationGroup = req.query.locationGroup - ? String(req.query.locationGroup) - : undefined; - const searchName = req.query.name ? String(req.query.name) : undefined; - const searchLocation = req.query.location - ? String(req.query.location) - : undefined; + allLatestPrimaryData( + @CurrentUser() user: AuthenticatedUser, + @Query('skip') rawSkip?: string, + @Query('take') rawTake?: string, + @Query('group-by-device-group') searchDeviceGroup?: string, + @Query('locationGroup') locationGroup?: string, + @Query('name') searchName?: string, + @Query('location') searchLocation?: string, + ) { + const skip = parseInt(rawSkip ?? '', 10) || 0; + const take = parseInt(rawTake ?? '', 10) || 10; return this.devicesService.findAllLatestData( - req.user, + user, skip, take, - req.headers.authorization, - searchDeviceGroup, - searchName, - searchLocation, - locationGroup, + searchDeviceGroup || undefined, + searchName || undefined, + searchLocation || undefined, + locationGroup || undefined, ); } @Get('location/:location_id') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'location_id', description: 'Location ID', @@ -236,16 +222,14 @@ export class DevicesController { description: ` Returns the latest, 2 primary data values from the table record for all devices in a specific location.`, }) - allDevicesInLocation(@Req() req, @Param('location_id') locationId: number) { + allDevicesInLocation(@CurrentUser() user: AuthenticatedUser, @Param('location_id') locationId: number) { return this.devicesService.findAllDevicesInLocation( - req.user, + user, locationId, - req.headers.authorization, ); } @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Current user's device returned successfully.", type: DeviceDto, @@ -292,19 +276,17 @@ export class DevicesController { description: ` Returns a specific device for the authenticated user.`, }) - findOne(@Req() req, @Param('dev_eui') devEui: string) { + findOne(@CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } return this.devicesService.findOne( - req.user, + user, devEui, - req.headers.authorization, ); } @Get(':dev_eui/data') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiParam({ name: 'skip (0)', @@ -321,23 +303,21 @@ export class DevicesController { description: ` Returns the latest, data from the table record for a device paginated.`, }) - data(@Req() req, @Param('dev_eui') devEui: string) { + data( + @CurrentUser() user: AuthenticatedUser, + @Param('dev_eui') devEui: string, + @Query('skip') rawSkip?: string, + @Query('take') rawTake?: string, + ) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - const skip = parseInt(req.query.skip, 10) || 0; - const take = parseInt(req.query.take, 10) || 144; - return this.devicesService.findData( - req.user, - devEui, - skip, - take, - req.headers.authorization, - ); + const skip = parseInt(rawSkip ?? '', 10) || 0; + const take = parseInt(rawTake ?? '', 10) || 144; + return this.devicesService.findData(user, devEui, skip, take); } @Get(':dev_eui/data-within-range') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiQuery({ name: 'start', @@ -388,20 +368,25 @@ export class DevicesController { Within the time window, the skip/take will be applied to the filtered data for pagination. For example, if there are 100 records in the time window and skip=10 and take=20, records 11-30 will be returned. Defaults to the last 24 hours if no range is provided.`, }) - dataWithinRange(@Req() req, @Param('dev_eui') devEui: string) { + dataWithinRange( + @CurrentUser() user: AuthenticatedUser, + @Param('dev_eui') devEui: string, + @Query('skip') rawSkip?: string, + @Query('take') rawTake?: string, + @Query('start') rawStart?: string, + @Query('end') rawEnd?: string, + ) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - const skip = parseInt(req.query.skip, 10) || 0; - const take = parseInt(req.query.take, 10) || 144; + const skip = parseInt(rawSkip ?? '', 10) || 0; + const take = parseInt(rawTake ?? '', 10) || 144; const start = - req.query.start || - new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - const end = req.query.end || new Date().toISOString(); + rawStart || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const end = rawEnd || new Date().toISOString(); return this.devicesService.findDataWithinRange( - req.user, + user, devEui, - req.headers.authorization, start, end, skip, @@ -410,46 +395,41 @@ export class DevicesController { } @Get(':dev_eui/latest-data') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Get the 1 latest FULL data value for a device', description: ` Returns the full latest data record for a device.`, }) - latestData(@Req() req, @Param('dev_eui') devEui: string) { + latestData(@CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } return this.devicesService.findLatestData( - req.user, + user, devEui, - req.headers.authorization, ); } @Get(':dev_eui/latest-primary-data') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Get the latest primary data for a device', description: ` Returns the latest, 2 primary data values from the table record for a device.`, }) - latestPrimaryData(@Req() req, @Param('dev_eui') devEui: string) { + latestPrimaryData(@CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } return this.devicesService.findLatestData( - req.user, + user, devEui, - req.headers.authorization, true, ); } @Post(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Create a new device for the authenticated user', @@ -460,7 +440,7 @@ export class DevicesController { `, }) create( - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() _body?: unknown, ) { @@ -470,7 +450,6 @@ export class DevicesController { } @Post(':dev_eui/replace') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: @@ -482,7 +461,7 @@ export class DevicesController { `, }) replace( - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: ReplaceDeviceDto, ) { @@ -496,10 +475,9 @@ export class DevicesController { const replacementDevice: ReplaceDeviceDto = body; const insertResult = this.devicesService.replaceDevice( - req.user, + user, normalizedDevEui, replacementDevice, - req.headers.authorization, ); if (!insertResult) { throw new InternalServerErrorException( @@ -510,7 +488,6 @@ export class DevicesController { } @Patch(':dev_eui/permission-level') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Update the permission level for a device', @@ -521,7 +498,7 @@ export class DevicesController { `, }) updatePermissionLevel( - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: UpdateDevicePermissionDto, ) { @@ -542,16 +519,14 @@ export class DevicesController { throw new BadRequestException('Permission Level is required'); } return this.devicesService.updatePermissionLevel( - req.user, + user, normalizedDevEui, body.targetUserEmail, body.permissionLevel, - req.headers.authorization, ); } @Patch(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Update a device', @@ -560,7 +535,7 @@ export class DevicesController { `, }) updateDevice( - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: UpdateDeviceNameGroupLocalDto, ) { @@ -574,12 +549,11 @@ export class DevicesController { throw new BadRequestException('Device location is required'); } return this.devicesService.updateDevice( - req.user, + user, devEui, body.name, body.group ?? null, body.location_id, - req.headers.authorization, ); } } diff --git a/src/v1/devices/devices.service.spec.ts b/src/v1/devices/devices.service.spec.ts index c27e007..5f50884 100644 --- a/src/v1/devices/devices.service.spec.ts +++ b/src/v1/devices/devices.service.spec.ts @@ -131,7 +131,7 @@ describe('DevicesService', () => { ); const result = await latestDataService.findAllLatestData( - { sub: 'user-1' }, + { sub: 'user-1', email: null, isStaff: false }, 0, 25, 'Bearer test-token', @@ -187,7 +187,7 @@ describe('DevicesService', () => { await expect( deviceService.findOne( - { sub: 'staff-1', email: 'staff@cropwatch.io' }, + { sub: 'staff-1', email: 'staff@cropwatch.io', isStaff: true }, 'DEV-001', 'Bearer test-token', ), @@ -201,7 +201,7 @@ describe('DevicesService', () => { }); describe('updateDevice location moves', () => { - const jwt = { sub: 'mover-1', email: 'mover@example.com' }; + const jwt = { sub: 'mover-1', email: 'mover@example.com', isStaff: false }; function createPermissionCheckBuilder(deviceRow: unknown) { return { diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index 2b4e342..1c6b1e8 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -8,11 +8,6 @@ import { } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { LocationsService } from '../locations/locations.service'; import { sanitizeOrFilterTerm } from '../common/postgrest-filter.helper'; import { CreateDeviceDto } from './dto/create-device.dto'; @@ -21,6 +16,7 @@ import { PermissionLevel, READ_EXCLUSIVE_CEILING, } from '../common/permission-levels'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; export interface PagedDevicesResponse { total?: number; @@ -39,18 +35,16 @@ export class DevicesService { ) {} async findAll( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, skip: number = 0, take?: number, searchGroup?: string, searchName?: string, searchLocation?: string, ): Promise>> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; let devicesQuery = client.from('cw_devices').select( ` @@ -105,14 +99,12 @@ export class DevicesService { } async findOne( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, - authHeader: string, ): Promise> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -149,13 +141,11 @@ export class DevicesService { } public async findAllStatus( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ online: number; offline: number }> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; let query = client .from('cw_devices') @@ -205,13 +195,11 @@ export class DevicesService { } public async findAllDeviceGroups( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ group: string | null; count: number }[]> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; let query = client .from('cw_devices') @@ -247,12 +235,10 @@ export class DevicesService { } public async findAllDeviceTypes( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ type: string | null; count: number }[]> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; const { data: types, error } = await client .from('cw_device_type') @@ -270,20 +256,17 @@ export class DevicesService { } public async findData( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, skip: number = 0, take: number = 144, - authHeader: string, ): Promise> { this.logger.log( `findData called: devEui=${devEui}, skip=${skip}, take=${take}`, ); - - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { this.logger.warn('findData: dev_eui is empty or missing'); @@ -402,9 +385,8 @@ export class DevicesService { } public async findDataWithinRange( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, - authHeader: string, start: Date | string = new Date( Date.now() - 24 * 60 * 60 * 1000, ).toISOString(), @@ -412,10 +394,9 @@ export class DevicesService { skip: number = 0, take: number = 144, ): Promise> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -488,19 +469,17 @@ export class DevicesService { } public async findAllLatestData( - jwtPayload: any, + user: AuthenticatedUser, skip: number = 0, take: number = 10, - authHeader: string, searchGroup?: string, searchName?: string, searchLocation?: string, locationGroup?: string, ): Promise> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const hasLocationFilter = typeof searchLocation === 'string' && searchLocation.trim().length > 0; const locationIdFilter = hasLocationFilter @@ -656,14 +635,12 @@ export class DevicesService { } public async findAllDevicesInLocation( - jwtPayload: any, + user: AuthenticatedUser, locationId: number, - authHeader: string, ): Promise[]> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; let query = client .from('cw_devices') @@ -688,15 +665,13 @@ export class DevicesService { } public async findLatestData( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, - authHeader: string, primaryAndSecondaryOnly = false, ) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -775,15 +750,13 @@ export class DevicesService { } public async createDevice( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, device: CreateDeviceDto, - authHeader: string, ) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -795,8 +768,7 @@ export class DevicesService { } const location = await this.locationsService.findOne( device.location_id, - jwtPayload, - authHeader, + user, ); if (!location) { throw new BadRequestException('Invalid location'); @@ -874,16 +846,14 @@ export class DevicesService { } async replaceDevice( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, newDevice: CreateDeviceDto, - authHeader: string, ) { // Ensure user has access to the device they want to replace - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -962,16 +932,14 @@ export class DevicesService { } async updatePermissionLevel( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, targetUserEmail: string, permissionLevel: number, - authHeader: string, ) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -1037,17 +1005,15 @@ export class DevicesService { } async updateDevice( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, name: string, group: string | null, location_id: number, - authHeader: string, ) { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); diff --git a/src/v1/gateway/gateway.controller.spec.ts b/src/v1/gateway/gateway.controller.spec.ts index 51fee67..9b93ef9 100644 --- a/src/v1/gateway/gateway.controller.spec.ts +++ b/src/v1/gateway/gateway.controller.spec.ts @@ -24,33 +24,23 @@ describe('GatewayController', () => { expect(controller).toBeDefined(); }); - it('passes gateway id and authenticated request data to the service', () => { + it('passes gateway id and authenticated user to the service', () => { + const user = { sub: 'user-123', email: null, isStaff: false }; const gateway = { gateway_id: 'gw-001' }; gatewayService.findOne.mockReturnValue(gateway); - expect( - controller.findOne('gw-001', { - user: { sub: 'user-123' }, - headers: { authorization: 'Bearer access-token' }, - }), - ).toBe(gateway); + expect(controller.findOne('gw-001', user)).toBe(gateway); - expect(gatewayService.findOne).toHaveBeenCalledWith('gw-001', { - sub: 'user-123', - }); + expect(gatewayService.findOne).toHaveBeenCalledWith('gw-001', user); }); - it('passes authenticated request data to find all gateways', () => { + it('passes the authenticated user to find all gateways', () => { + const user = { sub: 'user-123', email: null, isStaff: false }; const gateways = [{ gateway_id: 'gw-001' }]; gatewayService.findAll.mockReturnValue(gateways); - expect( - controller.findAll({ - user: { sub: 'user-123' }, - headers: { authorization: 'Bearer access-token' }, - }), - ).toBe(gateways); + expect(controller.findAll(user)).toBe(gateways); - expect(gatewayService.findAll).toHaveBeenCalledWith({ sub: 'user-123' }); + expect(gatewayService.findAll).toHaveBeenCalledWith(user); }); }); diff --git a/src/v1/gateway/gateway.controller.ts b/src/v1/gateway/gateway.controller.ts index ffced52..c5cc067 100644 --- a/src/v1/gateway/gateway.controller.ts +++ b/src/v1/gateway/gateway.controller.ts @@ -3,7 +3,6 @@ import { Controller, Get, Param, - Req, UseGuards, } from '@nestjs/common'; import { GatewayService } from './gateway.service'; @@ -19,15 +18,17 @@ import { } from '@nestjs/swagger'; import { GatewayDto } from './dto/gateway.dto'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') @Controller({ path: 'gateway', version: '1' }) +@UseGuards(JwtAuthGuard) export class GatewayController { constructor(private readonly gatewayService: GatewayService) {} @Get() - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get gateways for the authenticated user', description: @@ -42,12 +43,11 @@ export class GatewayController { description: 'Missing or invalid bearer token.', type: ErrorResponseDto, }) - findAll(@Req() req) { - return this.gatewayService.findAll(req.user); + findAll(@CurrentUser() user: AuthenticatedUser) { + return this.gatewayService.findAll(user); } @Get(':gatewayId') - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get a gateway for the authenticated user', description: @@ -70,11 +70,11 @@ export class GatewayController { 'Gateway not found or not accessible to the authenticated user.', type: ErrorResponseDto, }) - findOne(@Param('gatewayId') gatewayId: string, @Req() req) { + findOne(@Param('gatewayId') gatewayId: string, @CurrentUser() user: AuthenticatedUser) { if (!gatewayId?.trim()) { throw new BadRequestException('gateway_id is required'); } - return this.gatewayService.findOne(gatewayId, req.user); + return this.gatewayService.findOne(gatewayId, user); } } diff --git a/src/v1/gateway/gateway.service.spec.ts b/src/v1/gateway/gateway.service.spec.ts index 4d2bff7..2f14138 100644 --- a/src/v1/gateway/gateway.service.spec.ts +++ b/src/v1/gateway/gateway.service.spec.ts @@ -82,7 +82,7 @@ describe('GatewayService', () => { supabaseService.getClient.mockReturnValue(client); await expect( - service.findOne(' gw-001 ', { sub: 'user-123' }), + service.findOne(' gw-001 ', { sub: 'user-123', email: null, isStaff: false }), ).resolves.toEqual(gateway); expect(supabaseService.getClient).toHaveBeenCalledWith(); @@ -135,7 +135,7 @@ describe('GatewayService', () => { ); supabaseService.getClient.mockReturnValue(client); - await expect(service.findAll({ sub: 'user-123' })).resolves.toEqual( + await expect(service.findAll({ sub: 'user-123', email: null, isStaff: false })).resolves.toEqual( expectedGateways, ); diff --git a/src/v1/gateway/gateway.service.ts b/src/v1/gateway/gateway.service.ts index ed7d318..acb4109 100644 --- a/src/v1/gateway/gateway.service.ts +++ b/src/v1/gateway/gateway.service.ts @@ -5,16 +5,16 @@ import { NotFoundException, } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; -import { getUserId } from '../../supabase/supabase-token.helper'; import type { TableRow } from '../types/supabase'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Injectable() export class GatewayService { constructor(private readonly supabaseService: SupabaseService) {} - async findAll(jwtPayload: any): Promise[]> { + async findAll(user: AuthenticatedUser): Promise[]> { const client = this.supabaseService.getClient(); - const userId = getUserId(jwtPayload); + const userId = user.sub; const { data: ownedGateways, error: ownedGatewaysError } = await client .from('cw_gateways') @@ -53,7 +53,7 @@ export class GatewayService { async findOne( gatewayIdentifier: string, - jwtPayload: any, + user: AuthenticatedUser, ): Promise> { const normalizedGatewayIdentifier = gatewayIdentifier?.trim(); if (!normalizedGatewayIdentifier) { @@ -61,7 +61,7 @@ export class GatewayService { } const client = this.supabaseService.getClient(); - const userId = getUserId(jwtPayload); + const userId = user.sub; const query = client .from('cw_gateways') diff --git a/src/v1/locations/locations.controller.ts b/src/v1/locations/locations.controller.ts index b3ae151..7286c3f 100644 --- a/src/v1/locations/locations.controller.ts +++ b/src/v1/locations/locations.controller.ts @@ -8,7 +8,6 @@ import { Delete, Query, UseGuards, - Req, BadRequestException, } from '@nestjs/common'; import { LocationsService } from './locations.service'; @@ -30,25 +29,23 @@ import { MAX_PERMISSION_LEVEL, MIN_PERMISSION_LEVEL, } from '../common/permission-levels'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'locations', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class LocationsController { constructor(private readonly locationsService: LocationsService) {} @Post() - @UseGuards(JwtAuthGuard) - create(@Body() createLocationDto: CreateLocationDto, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; + create(@Body() createLocationDto: CreateLocationDto, @CurrentUser() user: AuthenticatedUser) { return this.locationsService.create( createLocationDto, - req.user, - authHeader, + user, ); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Current all of the user's rules configurations.", type: LocationDto, @@ -60,54 +57,45 @@ export class LocationsController { required: false, }) @Get() - findAll(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - const searchName = req.query.name ? String(req.query.name) : undefined; - return this.locationsService.findAll(req.user, authHeader, searchName); + findAll( + @CurrentUser() user: AuthenticatedUser, + @Query('name') searchName?: string, + ) { + return this.locationsService.findAll(user, searchName || undefined); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Current all of the user's location groups.", type: String, isArray: true, }) @Get('groups') - findLocationGroups(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.locationsService.findAllLocationGroups(req.user, authHeader); + findLocationGroups(@CurrentUser() user: AuthenticatedUser) { + return this.locationsService.findAllLocationGroups(user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Get a user's location configuration by ID.", type: LocationDto, isArray: false, }) @Get(':id') - findOne(@Param('id') id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.locationsService.findOne(id, req.user, authHeader); + findOne(@Param('id') id: number, @CurrentUser() user: AuthenticatedUser) { + return this.locationsService.findOne(id, user); } @Patch(':id') - @UseGuards(JwtAuthGuard) update( @Param('id') id: string, @Body() updateLocationDto: UpdateLocationDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; return this.locationsService.update( +id, updateLocationDto, - req.user, - authHeader, + user, ); } @Post(':id/permission') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'ID of the location to update permissions for', @@ -136,9 +124,8 @@ export class LocationsController { @Query('newUserEmail') newUserEmail: string | undefined, @Query('permission_level') permissionLevel: number, @Query('applyToAllDevices') applyToAllDevices: string = 'false', - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; const locationId = Number.parseInt(id, 10); const normalizedNewUserEmail = createLocationOwnerDto.user_email?.trim() || newUserEmail?.trim(); @@ -175,13 +162,11 @@ export class LocationsController { }, permissionLevel, applyToAllDevicesFlag, - req.user, - authHeader, + user, ); } @Patch(':id/permission') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'ID of the location to update permissions for', @@ -202,21 +187,18 @@ export class LocationsController { @Param('id') id: string, @Body() updateLocationOwnerDto: UpdateLocationOwnerDto, @Query('applyToAllDevices') applyToAllDevices: string = 'false', - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; const applyToAllDevicesFlag = applyToAllDevices === 'true'; return this.locationsService.updateLocationPermission( +id, updateLocationOwnerDto, applyToAllDevicesFlag, - req.user, - authHeader, + user, ); } @Patch(':id/permission-level') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'ID of the location to update permissions for', @@ -237,38 +219,32 @@ export class LocationsController { @Param('id') id: string, @Body() updateLocationUserPermissionLevelDto: any, @Query('applyToAllDevices') applyToAllDevices: string = 'false', - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; const applyToAllDevicesFlag = applyToAllDevices === 'true'; return this.locationsService.updateUserPermissionLevel( +id, updateLocationUserPermissionLevelDto, applyToAllDevicesFlag, - req.user, - authHeader, + user, ); } @Delete(':id/permission') - @UseGuards(JwtAuthGuard) remove( @Param('id') id: number, @Query('permission_id') permissionId: number, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { if (!id || !permissionId) { throw new BadRequestException( 'Location ID and Permission ID are required', ); } - - const authHeader = req.headers?.authorization ?? ''; return this.locationsService.removeLocationPermission( id, permissionId, - req.user, - authHeader, + user, ); } } diff --git a/src/v1/locations/locations.service.spec.ts b/src/v1/locations/locations.service.spec.ts index 2d71241..7881475 100644 --- a/src/v1/locations/locations.service.spec.ts +++ b/src/v1/locations/locations.service.spec.ts @@ -64,7 +64,7 @@ describe('LocationsService', () => { const service = createService(client); await expect( - service.findOne(123, { sub: 'user-1' }, 'Bearer token-1'), + service.findOne(123, { sub: 'user-1', email: null, isStaff: false }), ).rejects.toBeInstanceOf(NotFoundException); expect(locationQuery.maybeSingle).toHaveBeenCalledTimes(1); }); @@ -90,7 +90,7 @@ describe('LocationsService', () => { await expect( service.findOne( 83, - { sub: 'staff-1', email: 'staff@cropwatch.io' }, + { sub: 'staff-1', email: 'staff@cropwatch.io', isStaff: true }, 'Bearer token-1', ), ).resolves.toEqual({ @@ -130,7 +130,7 @@ describe('LocationsService', () => { await expect( service.findOne( 84, - { sub: 'user-1', email: 'farmer@example.com' }, + { sub: 'user-1', email: 'farmer@example.com', isStaff: false }, 'Bearer token-1', ), ).resolves.toEqual({ @@ -175,7 +175,7 @@ describe('LocationsService', () => { is_active: true, }, true, - { sub: 'admin-1' }, + { sub: 'admin-1', email: null, isStaff: false }, 'Bearer token-1', ), ).rejects.toBeInstanceOf(InternalServerErrorException); diff --git a/src/v1/locations/locations.service.ts b/src/v1/locations/locations.service.ts index ec548e4..36c5fa4 100644 --- a/src/v1/locations/locations.service.ts +++ b/src/v1/locations/locations.service.ts @@ -13,11 +13,6 @@ import { SupabaseService } from '../../supabase/supabase.service'; import { PaymentsService } from '../payments/payments.service'; import { error, group } from 'console'; import { LocationDto } from './dto/location.dto'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { UpdateLocationOwnerDto } from './dto/update-location-owner.dto'; import { MANAGE_CEILING, @@ -25,6 +20,7 @@ import { READ_EXCLUSIVE_CEILING, } from '../common/permission-levels'; import { filterStaffOwnerRows } from '../common/owner-filter.helper'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Injectable() export class LocationsService { @@ -35,20 +31,17 @@ export class LocationsService { async create( createLocationDto: CreateLocationDto, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); + const userId = user.sub; + const client = this.supabaseService.getClient(); // Creating a location requires an active base subscription. CropWatch staff // are exempt, mirroring the rest of the permission model. if ( - !isCropwatchStaff(jwtPayload) && + !user.isStaff && !(await this.paymentsService.hasActiveBaseSubscription( - jwtPayload, - authHeader, + user, )) ) { throw new ForbiddenException( @@ -97,11 +90,10 @@ export class LocationsService { return locationData; } - async findAll(jwtPayload: any, authHeader: string, searchName?: string) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async findAll(user: AuthenticatedUser, searchName?: string) { + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; let query = client.from('cw_locations').select(` *, @@ -124,11 +116,10 @@ export class LocationsService { return data; } - async findOne(id: number, jwtPayload: any, authHeader: string) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async findOne(id: number, user: AuthenticatedUser) { + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; let query = client .from('cw_locations') @@ -163,13 +154,11 @@ export class LocationsService { async update( id: number, updateLocationDto: UpdateLocationDto, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; // check if you have permission to update location permissions let permissionQuery = client @@ -226,13 +215,11 @@ export class LocationsService { } async findAllLocationGroups( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; let query = client .from('cw_locations') @@ -265,13 +252,11 @@ export class LocationsService { createLocationOwnerDto: CreateLocationOwnerDto, permissionLevel: number, applyPermissionToAllDevices: boolean, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; // check if you have permission to update location permissions let permissionQuery = client @@ -368,13 +353,11 @@ export class LocationsService { id: number, updateLocationOwnerDto: UpdateLocationOwnerDto, applyPermissionToAllDevices: boolean, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; // check if you have permission to update location permissions let permissionQuery = client @@ -460,13 +443,11 @@ export class LocationsService { id: number, updateLocationOwnerDto: any, applyPermissionToAllDevices: boolean, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; const email = updateLocationOwnerDto.email; const permission_level = updateLocationOwnerDto.permission_level; @@ -532,13 +513,11 @@ export class LocationsService { async removeLocationPermission( location_id: number, permissionId: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; // Check if current user has permissions to remove another user's permissions from the location let permissionQuery = client diff --git a/src/v1/mcp/tools/devices.tools.ts b/src/v1/mcp/tools/devices.tools.ts index 635bc8b..5dc51e3 100644 --- a/src/v1/mcp/tools/devices.tools.ts +++ b/src/v1/mcp/tools/devices.tools.ts @@ -3,12 +3,13 @@ import { Injectable } from '@nestjs/common'; import { Tool, type Context } from '@rekog/mcp-nest'; import { z } from 'zod'; import { DevicesService } from '../../devices/devices.service'; +import type { AuthenticatedUser } from '../../auth/authenticated-user'; -// The MCP module applies JwtAuthGuard, which puts the Supabase JWT payload on -// `request.user`. The domain services also need the raw Authorization header so -// their Supabase client stays RLS-scoped to the caller — same contract the REST -// controllers use (`devicesService.findAll(req.user, req.headers.authorization, ...)`). -type AuthedRequest = Request & { user: any }; +// The MCP module applies JwtAuthGuard, which puts the AuthenticatedUser on +// `request.user`. Note: the Supabase client is the service-role client and +// bypasses RLS — authorization is enforced in the domain services (the same +// owner/staff scoping the REST controllers rely on). +type AuthedRequest = Request & { user: AuthenticatedUser }; /** * Read-only MCP tools over device data. @@ -70,10 +71,8 @@ export class DeviceMcpTools { _context: Context, request: AuthedRequest, ) { - const authHeader = request.headers.authorization ?? ''; const result = await this.devicesService.findAll( request.user, - authHeader, skip ?? 0, take ?? 100, group, @@ -102,12 +101,7 @@ export class DeviceMcpTools { }, }) async getDevice({ dev_eui }, _context: Context, request: AuthedRequest) { - const authHeader = request.headers.authorization ?? ''; - const device = await this.devicesService.findOne( - request.user, - dev_eui, - authHeader, - ); + const device = await this.devicesService.findOne(request.user, dev_eui); return this.json(device); } diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index 41bc244..28c6e05 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -20,6 +20,8 @@ import { ApiSecurity, } from '@nestjs/swagger'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; import { PaymentsService } from './payments.service'; import { CreateBaseCheckoutDto } from './dto/create-base-checkout.dto'; import { CreateDeviceCheckoutDto } from './dto/create-device-checkout.dto'; @@ -46,20 +48,18 @@ export class PaymentsController { @ApiOperation({ summary: 'Get the full billing overview (base sub, device seats, licenses)', }) - getState(@Req() req) { + getState(@CurrentUser() user: AuthenticatedUser) { return this.paymentsService.getState( - req.user, - req.headers?.authorization ?? '', + user, ); } @Get('licenses') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: "List the user's device licenses" }) - getLicenses(@Req() req) { + getLicenses(@CurrentUser() user: AuthenticatedUser) { return this.paymentsService.getLicenses( - req.user, - req.headers?.authorization ?? '', + user, ); } @@ -68,10 +68,9 @@ export class PaymentsController { @ApiOperation({ summary: 'Create a hosted checkout for the base subscription', }) - createBaseCheckout(@Body() dto: CreateBaseCheckoutDto, @Req() req) { + createBaseCheckout(@Body() dto: CreateBaseCheckoutDto, @CurrentUser() user: AuthenticatedUser) { return this.paymentsService.createBaseCheckout( - req.user, - req.headers?.authorization ?? '', + user, dto.discountId ?? null, ); } @@ -81,10 +80,9 @@ export class PaymentsController { @ApiOperation({ summary: 'Create a hosted checkout for device licenses (seats)', }) - createDeviceCheckout(@Body() dto: CreateDeviceCheckoutDto, @Req() req) { + createDeviceCheckout(@Body() dto: CreateDeviceCheckoutDto, @CurrentUser() user: AuthenticatedUser) { return this.paymentsService.createDeviceCheckout( - req.user, - req.headers?.authorization ?? '', + user, dto.quantity, ); } @@ -92,10 +90,9 @@ export class PaymentsController { @Patch('subscriptions/device/seats') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Change the number of device licenses (seats)' }) - changeDeviceSeats(@Body() dto: ChangeSeatsDto, @Req() req) { + changeDeviceSeats(@Body() dto: ChangeSeatsDto, @CurrentUser() user: AuthenticatedUser) { return this.paymentsService.changeDeviceSeats( - req.user, - req.headers?.authorization ?? '', + user, dto.seats, ); } @@ -107,11 +104,10 @@ export class PaymentsController { assignLicense( @Param('id') id: string, @Body() dto: AssignLicenseDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { return this.paymentsService.assignLicense( - req.user, - req.headers?.authorization ?? '', + user, this.parseId(id), dto.devEui, ); @@ -124,11 +120,10 @@ export class PaymentsController { moveLicense( @Param('id') id: string, @Body() dto: MoveLicenseDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { return this.paymentsService.moveLicense( - req.user, - req.headers?.authorization ?? '', + user, this.parseId(id), dto.devEui, ); @@ -138,10 +133,9 @@ export class PaymentsController { @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) @ApiOperation({ summary: 'Unassign a license from its device' }) - unassignLicense(@Param('id') id: string, @Req() req) { + unassignLicense(@Param('id') id: string, @CurrentUser() user: AuthenticatedUser) { return this.paymentsService.unassignLicense( - req.user, - req.headers?.authorization ?? '', + user, this.parseId(id), ); } @@ -152,10 +146,9 @@ export class PaymentsController { @ApiOperation({ summary: 'Cancel an unassigned license (reduce the paid seat count by one)', }) - cancelLicense(@Param('id') id: string, @Req() req) { + cancelLicense(@Param('id') id: string, @CurrentUser() user: AuthenticatedUser) { return this.paymentsService.cancelLicense( - req.user, - req.headers?.authorization ?? '', + user, this.parseId(id), ); } @@ -163,20 +156,18 @@ export class PaymentsController { @Post('portal') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Open the Polar customer billing portal' }) - openPortal(@Req() req) { + openPortal(@CurrentUser() user: AuthenticatedUser) { return this.paymentsService.openPortal( - req.user, - req.headers?.authorization ?? '', + user, ); } @Delete('subscriptions/base') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Cancel the base subscription' }) - cancelBase(@Body() dto: CancelBaseDto, @Req() req) { + cancelBase(@Body() dto: CancelBaseDto, @CurrentUser() user: AuthenticatedUser) { return this.paymentsService.cancelBaseSubscription( - req.user, - req.headers?.authorization ?? '', + user, dto.atPeriodEnd ?? true, ); } diff --git a/src/v1/payments/payments.service.ts b/src/v1/payments/payments.service.ts index 6e185e3..b23eaa3 100644 --- a/src/v1/payments/payments.service.ts +++ b/src/v1/payments/payments.service.ts @@ -10,11 +10,6 @@ import { } from '@nestjs/common'; import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { MANAGE_CEILING } from '../common/permission-levels'; import type { TableInsert, TableRow } from '../types/supabase'; import { @@ -27,6 +22,7 @@ import { BillingProductsResponse, SubscriptionStateResponse, } from './payments.types'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; type BillingCustomerRow = TableRow<'billing_customers'>; @@ -60,11 +56,10 @@ export class PaymentsService { } async getState( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); await this.ensureBillingCustomer(client, userId); @@ -113,11 +108,10 @@ export class PaymentsService { } async getLicenses( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); return this.fetchLicenses(client, userId); } @@ -128,10 +122,9 @@ export class PaymentsService { * a legitimately-subscribed user. */ async hasActiveBaseSubscription( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); + const userId = user.sub; try { const subscriptions = await this.polarService.listSubscriptions(userId); const baseSub = this.pickSubscription( @@ -143,7 +136,7 @@ export class PaymentsService { this.logger.warn( `Base-subscription check fell back to cache for ${userId}: ${error}`, ); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const client = this.supabaseService.getClient(); const { data } = await client .from('billing_customers') .select('base_status') @@ -161,12 +154,11 @@ export class PaymentsService { // --------------------------------------------------------------------------- async createBaseCheckout( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, discountId?: string | null, ): Promise<{ checkoutUrl: string }> { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); await this.ensureBillingCustomer(client, userId); const subscriptions = await this.listSubscriptionsSafe(userId); @@ -181,19 +173,18 @@ export class PaymentsService { const checkoutUrl = await this.polarService.createCheckout({ productId: this.requireProductId(this.polarService.baseProductId, 'base'), externalCustomerId: userId, - customerEmail: this.readEmail(jwtPayload), + customerEmail: this.readEmail(user), discountId: discountId ?? null, }); return { checkoutUrl }; } async createDeviceCheckout( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, quantity: number, ): Promise<{ checkoutUrl: string }> { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); await this.ensureBillingCustomer(client, userId); const subscriptions = await this.listSubscriptionsSafe(userId); @@ -213,7 +204,7 @@ export class PaymentsService { 'device', ), externalCustomerId: userId, - customerEmail: this.readEmail(jwtPayload), + customerEmail: this.readEmail(user), // Informational: the seat count is confirmed by Polar (webhook / getState // reconcile). Honoured on the hosted checkout's seat selector. metadata: { seats: quantity }, @@ -222,12 +213,11 @@ export class PaymentsService { } async changeDeviceSeats( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, seats: number, ): Promise<{ seats: number }> { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); const subscriptions = await this.listSubscriptionsSafe(userId); const deviceSub = this.pickSubscription( @@ -261,11 +251,9 @@ export class PaymentsService { } async openPortal( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ portalUrl: string }> { - const userId = getUserId(jwtPayload); - getAccessToken(authHeader); // validate bearer token shape + const userId = user.sub; try { const portalUrl = await this.polarService.createPortalSession(userId); return { portalUrl }; @@ -278,12 +266,11 @@ export class PaymentsService { } async cancelBaseSubscription( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, atPeriodEnd: boolean, ): Promise<{ status: string }> { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); const subscriptions = await this.listSubscriptionsSafe(userId); const baseSub = this.pickSubscription( @@ -322,14 +309,13 @@ export class PaymentsService { // --------------------------------------------------------------------------- async assignLicense( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, licenseId: number, devEui: string, ): Promise { - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const isGlobalUser = user.isStaff; + const client = this.supabaseService.getClient(); const license = await this.loadOwnedLicense(client, userId, licenseId); if (license.status === 'assigned' && license.dev_eui) { @@ -345,14 +331,13 @@ export class PaymentsService { } async moveLicense( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, licenseId: number, devEui: string, ): Promise { - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const isGlobalUser = user.isStaff; + const client = this.supabaseService.getClient(); await this.loadOwnedLicense(client, userId, licenseId); await this.assertDeviceManageable(client, userId, isGlobalUser, devEui); @@ -362,12 +347,11 @@ export class PaymentsService { } async unassignLicense( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, licenseId: number, ): Promise { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); await this.loadOwnedLicense(client, userId, licenseId); @@ -393,12 +377,11 @@ export class PaymentsService { * Polar's seat minimum is 1). Assigned licenses must be unassigned first. */ async cancelLicense( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, licenseId: number, ): Promise<{ canceled: boolean }> { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + const userId = user.sub; + const client = this.supabaseService.getClient(); const license = await this.loadOwnedLicense(client, userId, licenseId); if (license.dev_eui || license.status === 'assigned') { @@ -778,8 +761,8 @@ export class PaymentsService { return productId; } - private readEmail(jwtPayload: any): string | null { - const email = jwtPayload?.email; + private readEmail(user: AuthenticatedUser): string | null { + const email = user.email; return typeof email === 'string' && email.trim() ? email.trim() : null; } diff --git a/src/v1/relay/relay.controller.spec.ts b/src/v1/relay/relay.controller.spec.ts index 610af50..7db08cf 100644 --- a/src/v1/relay/relay.controller.spec.ts +++ b/src/v1/relay/relay.controller.spec.ts @@ -47,34 +47,25 @@ describe('RelayController', () => { }); it('forwards latest relay lookups to the relay service', () => { - const req = { - headers: { - authorization: 'Bearer test-token', - }, - user: { - email: 'user@example.com', - sub: 'user-1', - }, + const user = { + email: 'user@example.com', + isStaff: false, + sub: 'user-1', }; - controller.getLatestRelay('A8404194635A05FB', req); + controller.getLatestRelay('A8404194635A05FB', user); expect(relayService.getLatestRelay).toHaveBeenCalledWith( - req.user, - 'Bearer test-token', + user, 'A8404194635A05FB', ); }); it('forwards timed relay pulse requests to the relay service', () => { - const req = { - headers: { - authorization: 'Bearer test-token', - }, - user: { - email: 'user@example.com', - sub: 'user-1', - }, + const user = { + email: 'user@example.com', + isStaff: false, + sub: 'user-1', }; controller.pulseRelay( @@ -83,12 +74,11 @@ describe('RelayController', () => { durationSeconds: 60, relay: 1, }, - req, + user, ); expect(relayService.pulseRelay).toHaveBeenCalledWith( - req.user, - 'Bearer test-token', + user, 'A8404194635A05FB', { durationSeconds: 60, diff --git a/src/v1/relay/relay.controller.ts b/src/v1/relay/relay.controller.ts index 8603cab..e7aec49 100644 --- a/src/v1/relay/relay.controller.ts +++ b/src/v1/relay/relay.controller.ts @@ -9,8 +9,6 @@ import { Param, Patch, Post, - Req, - UnauthorizedException, UseGuards, } from '@nestjs/common'; import { @@ -27,6 +25,8 @@ import { } from '@nestjs/swagger'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; import { JwtAuthGuard } from '../../v1/auth/guards/jwt.auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; import { PulseRelayDto } from './dto/pulse-relay.dto'; import { UpdateRelayDto } from './dto/update-relay.dto'; import { RelayService } from './relay.service'; @@ -82,17 +82,15 @@ export class RelayController { @ApiOperation({ summary: 'Get the latest relay values for a device', }) - getLatestRelay(@Param('dev_eui') devEui: string, @Req() req) { + getLatestRelay( + @Param('dev_eui') devEui: string, + @CurrentUser() user: AuthenticatedUser, + ) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - const authHeader = req.headers?.authorization; - if (!authHeader) { - throw new UnauthorizedException('Missing bearer token'); - } - - return this.relayService.getLatestRelay(req.user, authHeader, devEui); + return this.relayService.getLatestRelay(user, devEui); } @Patch(':dev_eui') @@ -112,19 +110,9 @@ export class RelayController { updateRelay( @Param('dev_eui') devEui: string, @Body() updateRelayDto: UpdateRelayDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization; - if (!authHeader) { - throw new UnauthorizedException('Missing bearer token'); - } - - return this.relayService.updateRelay( - req.user, - authHeader, - devEui, - updateRelayDto, - ); + return this.relayService.updateRelay(user, devEui, updateRelayDto); } @Post(':dev_eui/pulse') @@ -145,19 +133,9 @@ export class RelayController { pulseRelay( @Param('dev_eui') devEui: string, @Body() pulseRelayDto: PulseRelayDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization; - if (!authHeader) { - throw new UnauthorizedException('Missing bearer token'); - } - - return this.relayService.pulseRelay( - req.user, - authHeader, - devEui, - pulseRelayDto, - ); + return this.relayService.pulseRelay(user, devEui, pulseRelayDto); } @Post('tti/up') diff --git a/src/v1/relay/relay.service.spec.ts b/src/v1/relay/relay.service.spec.ts index 766a968..1dc3029 100644 --- a/src/v1/relay/relay.service.spec.ts +++ b/src/v1/relay/relay.service.spec.ts @@ -79,8 +79,7 @@ describe('RelayService', () => { await expect( service.updateRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'a8404194635a05fb', { relay: 1, targetState: 'on' }, ), @@ -103,8 +102,7 @@ describe('RelayService', () => { await expect( service.getLatestRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'a8404194635a05fb', ), ).resolves.toEqual(relayRow); @@ -123,8 +121,7 @@ describe('RelayService', () => { await expect( service.getLatestRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'a8404194635a05fb', ), ).resolves.toEqual(relayRow); @@ -140,8 +137,7 @@ describe('RelayService', () => { await expect( service.getLatestRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'A8404194635A05FB', ), ).rejects.toMatchObject({ @@ -180,8 +176,7 @@ describe('RelayService', () => { await expect( service.updateRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'A8404194635A05FB', { relay: 1, targetState: 'on' }, ), @@ -242,8 +237,7 @@ describe('RelayService', () => { await expect( service.pulseRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'A8404194635A05FB', { durationSeconds: 1, relay: 1 }, ), @@ -275,8 +269,7 @@ describe('RelayService', () => { await expect( service.pulseRelay( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'A8404194635A05FB', { durationSeconds: 60, relay: 1 }, ), diff --git a/src/v1/relay/relay.service.ts b/src/v1/relay/relay.service.ts index bae4065..e21aaef 100644 --- a/src/v1/relay/relay.service.ts +++ b/src/v1/relay/relay.service.ts @@ -10,11 +10,6 @@ import { } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { SupabaseService } from '../../supabase/supabase.service'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import type { TableInsert, TableRow } from '../../v1/types/supabase'; import { canManage, @@ -43,6 +38,7 @@ import { resolveTtiApplicationId, } from './tti-client'; import { isValidTtiDeviceId, normalizeTtiDeviceId } from './tti-device-id'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; type DeviceOwnerRow = TableRow<'cw_device_owners'>; type DeviceTypeRow = TableRow<'cw_device_type'>; @@ -146,15 +142,14 @@ export class RelayService { } } - async getLatestRelay(jwtPayload: any, authHeader: string, devEui: string) { + async getLatestRelay(user: AuthenticatedUser, devEui: string) { const normalizedDevEui = normalizeDevEui(devEui); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); } const context = await this.loadRelayDeviceContext( - jwtPayload, - authHeader, + user, normalizedDevEui, ); if (!canRead(context.permissionLevel)) { @@ -170,8 +165,7 @@ export class RelayService { } async updateRelay( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, devEui: string, updateRelayDto: UpdateRelayDto, ) { @@ -182,8 +176,7 @@ export class RelayService { const { relay, targetState } = updateRelayDto; const context = await this.loadRelayDeviceContext( - jwtPayload, - authHeader, + user, normalizedDevEui, ); if (!canManage(context.permissionLevel)) { @@ -257,8 +250,7 @@ export class RelayService { } async pulseRelay( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, devEui: string, pulseRelayDto: PulseRelayDto, ) { @@ -269,8 +261,7 @@ export class RelayService { const { durationSeconds, relay } = pulseRelayDto; const context = await this.loadRelayDeviceContext( - jwtPayload, - authHeader, + user, normalizedDevEui, ); if (!canManage(context.permissionLevel)) { @@ -402,14 +393,12 @@ export class RelayService { } private async loadRelayDeviceContext( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, devEui: string, ): Promise { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); - const isGlobalUser = isCropwatchStaff(jwtPayload); + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; const { data, error } = await client .from('cw_devices') diff --git a/src/v1/reports-new/reports-new.controller.ts b/src/v1/reports-new/reports-new.controller.ts index 9c6c569..b22a9bf 100644 --- a/src/v1/reports-new/reports-new.controller.ts +++ b/src/v1/reports-new/reports-new.controller.ts @@ -8,7 +8,6 @@ import { Patch, Post, Query, - Req, UseGuards, } from '@nestjs/common'; import { @@ -25,14 +24,15 @@ import { ReportTemplateDto } from './dto/report-template.dto'; import { ReportTemplateHistoryItemDto } from './dto/report-template-history-item.dto'; import { SaveReportTemplateDto } from './dto/save-report-template.dto'; import { ReportsNewService } from './reports-new.service'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') @Controller({ path: 'reports-new', version: '1' }) +@UseGuards(JwtAuthGuard) export class ReportsNewController { constructor(private readonly reportsNewService: ReportsNewService) {} - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists every report template visible to the current user.', type: ReportTemplateDto, @@ -44,12 +44,9 @@ export class ReportsNewController { required: false, }) @Get() - findAll(@Req() req, @Query('search') search?: string) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.findAll(req.user, authHeader, search); + findAll(@CurrentUser() user: AuthenticatedUser, @Query('search') search?: string) { + return this.reportsNewService.findAll(user, search); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists every communication method a report template recipient can use.', @@ -57,12 +54,9 @@ export class ReportsNewController { isArray: true, }) @Get('communication-methods') - findAllCommunicationMethods(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.findAllCommunicationMethods(authHeader); + findAllCommunicationMethods(@CurrentUser() user: AuthenticatedUser) { + return this.reportsNewService.findAllCommunicationMethods(); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Bundled data needed to render the reports-new create/edit form: devices (with cw_locations join), locations, communication methods, and optionally a template.', @@ -76,14 +70,11 @@ export class ReportsNewController { type: Number, }) @Get('form-context') - getFormContext(@Req() req, @Query('templateId') templateId?: string) { - const authHeader = req.headers?.authorization ?? ''; + getFormContext(@CurrentUser() user: AuthenticatedUser, @Query('templateId') templateId?: string) { const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; - return this.reportsNewService.getFormContext(req.user, authHeader, id); + return this.reportsNewService.getFormContext(user, id); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Returns a signed URL to download a generated report PDF for a device the user can view.', @@ -96,18 +87,14 @@ export class ReportsNewController { download( @Param('dev_eui') devEui: string, @Param('reportName') reportName: string, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; return this.reportsNewService.getDownloadUrl( devEui, reportName, - req.user, - authHeader, + user, ); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Lists generated report PDFs across the template's assigned devices, newest first.", @@ -115,24 +102,18 @@ export class ReportsNewController { isArray: true, }) @Get(':id/history') - findHistory(@Param('id', ParseIntPipe) id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.getHistory(id, req.user, authHeader); + findHistory(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + return this.reportsNewService.getHistory(id, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Returns a single report template the user can view.', type: ReportTemplateDto, isArray: false, }) @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.findOne(id, req.user, authHeader); + findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + return this.reportsNewService.findOne(id, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Creates a report template and assigns it to the listed devices.', @@ -141,12 +122,9 @@ export class ReportsNewController { }) @ApiBody({ type: SaveReportTemplateDto }) @Post() - create(@Body() body: SaveReportTemplateDto, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.create(body, req.user, authHeader); + create(@Body() body: SaveReportTemplateDto, @CurrentUser() user: AuthenticatedUser) { + return this.reportsNewService.create(body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Replaces a report template with the provided configuration.', type: ReportTemplateDto, @@ -157,13 +135,10 @@ export class ReportsNewController { update( @Param('id', ParseIntPipe) id: number, @Body() body: SaveReportTemplateDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.update(id, body, req.user, authHeader); + return this.reportsNewService.update(id, body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Deletes a report template the user manages.', schema: { @@ -172,8 +147,7 @@ export class ReportsNewController { }, }) @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.remove(id, req.user, authHeader); + remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + return this.reportsNewService.remove(id, user); } } diff --git a/src/v1/reports-new/reports-new.service.ts b/src/v1/reports-new/reports-new.service.ts index c893c1a..af65459 100644 --- a/src/v1/reports-new/reports-new.service.ts +++ b/src/v1/reports-new/reports-new.service.ts @@ -7,11 +7,6 @@ import { UnauthorizedException, } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import type { TableRow } from '../types/supabase'; import { MANAGE_CEILING, PermissionLevel } from '../common/permission-levels'; import { DevicesService } from '../devices/devices.service'; @@ -26,6 +21,7 @@ import { ReportTemplateRecipientDto } from './dto/report-template-recipient.dto' import { ReportTemplateScheduleDto } from './dto/report-template-schedule.dto'; import { ReportTemplateDto } from './dto/report-template.dto'; import { SaveReportTemplateDto } from './dto/save-report-template.dto'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; type TemplateRow = TableRow<'cw_report_templates'>; type LocationJoin = { name: string | null }; @@ -112,19 +108,17 @@ export class ReportsNewService { ) {} async findAll( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, searchTerm?: string, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) return []; - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { data: assignmentsData, error: assignmentsError } = await client .from('cw_device_report_assignments') .select( @@ -147,11 +141,11 @@ export class ReportsNewService { const [templates, schedule, recipients, alertPoints, dpSchedules] = await Promise.all([ - this.loadTemplatesByIds(accessToken, templateIds), - this.loadScheduleByTemplateIds(accessToken, templateIds), - this.loadRecipientsByTemplateIds(accessToken, templateIds), - this.loadAlertPointsByTemplateIds(accessToken, templateIds), - this.loadDataProcessingSchedulesByTemplateIds(accessToken, templateIds), + this.loadTemplatesByIds(templateIds), + this.loadScheduleByTemplateIds(templateIds), + this.loadRecipientsByTemplateIds(templateIds), + this.loadAlertPointsByTemplateIds(templateIds), + this.loadDataProcessingSchedulesByTemplateIds(templateIds), ]); const reports = buildReportTemplates({ @@ -171,20 +165,18 @@ export class ReportsNewService { async findOne( id: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) { throw new NotFoundException('Report template not found'); } - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const [templateResult, assignmentsResult] = await Promise.all([ client .from('cw_report_templates') @@ -223,10 +215,10 @@ export class ReportsNewService { } const [schedule, recipients, alertPoints, dpSchedules] = await Promise.all([ - this.loadScheduleByTemplateIds(accessToken, [id]), - this.loadRecipientsByTemplateIds(accessToken, [id]), - this.loadAlertPointsByTemplateIds(accessToken, [id]), - this.loadDataProcessingSchedulesByTemplateIds(accessToken, [id]), + this.loadScheduleByTemplateIds([id]), + this.loadRecipientsByTemplateIds([id]), + this.loadAlertPointsByTemplateIds([id]), + this.loadDataProcessingSchedulesByTemplateIds([id]), ]); const [report] = buildReportTemplates({ @@ -248,18 +240,16 @@ export class ReportsNewService { async create( payload: SaveReportTemplateDto, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; const normalized = normalizeSaveRequest(payload); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); assertDevicesCanBeManaged(devices, normalized.devEuis); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { data: templateData, error: templateError } = await client .from('cw_report_templates') .insert({ @@ -283,31 +273,28 @@ export class ReportsNewService { try { await this.replaceTemplateChildren( - accessToken, templateData.id, normalized, ); } catch (error) { - await this.deleteTemplateBestEffort(accessToken, templateData.id); + await this.deleteTemplateBestEffort(templateData.id); throw error; } - return this.findOne(templateData.id, jwtPayload, authHeader); + return this.findOne(templateData.id, user); } async update( id: number, payload: SaveReportTemplateDto, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; const normalized = normalizeSaveRequest(payload); - const existing = await this.findOne(id, jwtPayload, authHeader); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const existing = await this.findOne(id, user); + const devices = await this.listManagedDevices(userId, isStaff); const allDevEuis = uniqueValues([ ...existing.assignments.map((assignment) => assignment.devEui), @@ -315,7 +302,7 @@ export class ReportsNewService { ]); assertDevicesCanBeManaged(devices, allDevEuis); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { error: updateError } = await client .from('cw_report_templates') .update({ @@ -333,30 +320,28 @@ export class ReportsNewService { ); } - await this.replaceTemplateChildren(accessToken, id, normalized); + await this.replaceTemplateChildren(id, normalized); - return this.findOne(id, jwtPayload, authHeader); + return this.findOne(id, user); } async remove( id: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ id: number }> { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; - const existing = await this.findOne(id, jwtPayload, authHeader); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const existing = await this.findOne(id, user); + const devices = await this.listManagedDevices(userId, isStaff); assertDevicesCanBeManaged( devices, existing.assignments.map((assignment) => assignment.devEui), ); - await this.deleteTemplateChildren(accessToken, id); + await this.deleteTemplateChildren(id); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { error } = await client .from('cw_report_templates') .delete() @@ -372,17 +357,16 @@ export class ReportsNewService { } async getFormContext( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, templateId?: number, ): Promise { const [devicesPage, locations, communicationMethods, template] = await Promise.all([ - this.devicesService.findAll(jwtPayload, authHeader), - this.locationsService.findAll(jwtPayload, authHeader), - this.findAllCommunicationMethods(authHeader), + this.devicesService.findAll(user), + this.locationsService.findAll(user), + this.findAllCommunicationMethods(), typeof templateId === 'number' - ? this.findOne(templateId, jwtPayload, authHeader) + ? this.findOne(templateId, user) : Promise.resolve(null), ]); @@ -394,12 +378,9 @@ export class ReportsNewService { }; } - async findAllCommunicationMethods( - authHeader: string, - ): Promise { - const accessToken = getAccessToken(authHeader); + async findAllCommunicationMethods(): Promise { const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('communication_methods') .select('communication_method_id, name, is_active') .eq('is_active', true) @@ -420,14 +401,12 @@ export class ReportsNewService { async getHistory( id: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { // Reuse findOne so a hidden or non-existent template returns 404 instead of // an empty list. - const template = await this.findOne(id, jwtPayload, authHeader); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); + const template = await this.findOne(id, user); + const client = this.supabaseService.getClient(); const devEuis = uniqueValues( template.assignments.map((assignment) => assignment.devEui), @@ -480,12 +459,10 @@ export class ReportsNewService { async getDownloadUrl( devEui: string, reportName: string, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ url: string }> { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; const normalizedDevEui = devEui?.trim(); const normalizedName = reportName?.trim(); @@ -508,7 +485,7 @@ export class ReportsNewService { ? normalizedName : `${normalizedName}.pdf`; - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); const device = devices.find((entry) => entry.devEui === normalizedDevEui); if (!device || !device.canView) { throw new UnauthorizedException( @@ -518,7 +495,7 @@ export class ReportsNewService { const storageClient = this.supabaseService.getAdminClient() ?? - this.supabaseService.getClient(accessToken); + this.supabaseService.getClient(); const { data, error } = await storageClient.storage .from(STORAGE_BUCKET) .createSignedUrl(`${normalizedDevEui}/${resolvedName}`, 60, { @@ -536,10 +513,9 @@ export class ReportsNewService { private async listManagedDevices( userId: string, - accessToken: string, - isStaff: boolean, + isStaff: boolean, ): Promise { - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { data, error } = await client .from('cw_devices') .select('dev_eui, name, user_id, cw_device_owners(*)'); @@ -586,13 +562,12 @@ export class ReportsNewService { } private async loadTemplatesByIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_report_templates') .select( 'created_at, data_pull_interval, description, device_type_id, id, is_active, name', @@ -607,13 +582,12 @@ export class ReportsNewService { } private async loadScheduleByTemplateIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_report_template_schedule') .select( 'created_at, end_of_day, end_of_month, end_of_week, id, is_active, template_id, utc_offset', @@ -628,13 +602,12 @@ export class ReportsNewService { } private async loadRecipientsByTemplateIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_report_template_recipients') .select('communication_method, created_at, email, id, name, template_id') .in('template_id', templateIds); @@ -649,13 +622,12 @@ export class ReportsNewService { } private async loadAlertPointsByTemplateIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_report_template_alert_points') .select( 'created_at, data_point_key, hex_color, id, max, min, name, operator, template_id, value', @@ -672,13 +644,12 @@ export class ReportsNewService { } private async loadDataProcessingSchedulesByTemplateIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_report_template_data_processing_schedules') .select( 'created_at, crosses_midnight, day_of_week, end_time, id, is_enabled, rule_type, start_time, template_id, timezone, updated_at, valid_from, valid_to', @@ -695,13 +666,12 @@ export class ReportsNewService { } private async replaceTemplateChildren( - accessToken: string, - templateId: number, + templateId: number, payload: NormalizedSaveRequest, ): Promise { - await this.deleteTemplateChildren(accessToken, templateId); + await this.deleteTemplateChildren(templateId); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const assignments = payload.devEuis.map((devEui) => ({ dev_eui: devEui, @@ -799,10 +769,9 @@ export class ReportsNewService { } private async deleteTemplateChildren( - accessToken: string, - templateId: number, + templateId: number, ): Promise { - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const [assignments, schedule, recipients, alertPoints, dpSchedules] = await Promise.all([ client @@ -855,13 +824,12 @@ export class ReportsNewService { } private async deleteTemplateBestEffort( - accessToken: string, - templateId: number, + templateId: number, ): Promise { try { - await this.deleteTemplateChildren(accessToken, templateId); + await this.deleteTemplateChildren(templateId); await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_report_templates') .delete() .eq('id', templateId); diff --git a/src/v1/rules-new/rules-new.controller.ts b/src/v1/rules-new/rules-new.controller.ts index e650e86..7ce2e98 100644 --- a/src/v1/rules-new/rules-new.controller.ts +++ b/src/v1/rules-new/rules-new.controller.ts @@ -8,7 +8,6 @@ import { Patch, Post, Query, - Req, UseGuards, } from '@nestjs/common'; import { @@ -25,14 +24,15 @@ import { RuleTemplateDto } from './dto/rule-template.dto'; import { RuleTriggerLogDto } from './dto/rule-trigger-log.dto'; import { SaveRuleTemplateDto } from './dto/save-rule-template.dto'; import { RulesNewService } from './rules-new.service'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') @Controller({ path: 'rules-new', version: '1' }) +@UseGuards(JwtAuthGuard) export class RulesNewController { constructor(private readonly rulesNewService: RulesNewService) {} - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists every rule template visible to the current user.', type: RuleTemplateDto, @@ -44,12 +44,9 @@ export class RulesNewController { required: false, }) @Get() - findAll(@Req() req, @Query('search') search?: string) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.findAll(req.user, authHeader, search); + findAll(@CurrentUser() user: AuthenticatedUser, @Query('search') search?: string) { + return this.rulesNewService.findAll(user, search); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists every action type a rule template action can reference.', @@ -57,12 +54,9 @@ export class RulesNewController { isArray: true, }) @Get('action-types') - findAllActionTypes(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.findAllActionTypes(authHeader); + findAllActionTypes(@CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.findAllActionTypes(); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Bundled data needed to render the rules-new create/edit form: devices (with cw_locations join), locations, action types, and optionally a template.', @@ -76,14 +70,11 @@ export class RulesNewController { type: Number, }) @Get('form-context') - getFormContext(@Req() req, @Query('templateId') templateId?: string) { - const authHeader = req.headers?.authorization ?? ''; + getFormContext(@CurrentUser() user: AuthenticatedUser, @Query('templateId') templateId?: string) { const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; - return this.rulesNewService.getFormContext(req.user, authHeader, id); + return this.rulesNewService.getFormContext(user, id); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists rule templates that are currently triggered on at least one device the user can view; assignments are narrowed to the triggered ones.', @@ -91,12 +82,9 @@ export class RulesNewController { isArray: true, }) @Get('triggered') - findAllTriggered(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.findAllTriggered(req.user, authHeader); + findAllTriggered(@CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.findAllTriggered(user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Counts of currently-triggered rule templates and of all visible rule templates.', @@ -110,12 +98,9 @@ export class RulesNewController { }, }) @Get('triggered/count') - findTriggeredCount(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.findTriggeredCount(req.user, authHeader); + findTriggeredCount(@CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.findTriggeredCount(user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists the trigger/reset history for a rule template, newest first.', @@ -123,24 +108,18 @@ export class RulesNewController { isArray: true, }) @Get(':id/history') - findHistory(@Param('id', ParseIntPipe) id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.getHistory(id, req.user, authHeader); + findHistory(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.getHistory(id, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Returns a single rule template the user can view.', type: RuleTemplateDto, isArray: false, }) @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.findOne(id, req.user, authHeader); + findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.findOne(id, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Creates a rule template and assigns it to the listed devices.', @@ -149,12 +128,9 @@ export class RulesNewController { }) @ApiBody({ type: SaveRuleTemplateDto }) @Post() - create(@Body() body: SaveRuleTemplateDto, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.create(body, req.user, authHeader); + create(@Body() body: SaveRuleTemplateDto, @CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.create(body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Replaces a rule template with the provided configuration.', type: RuleTemplateDto, @@ -165,13 +141,10 @@ export class RulesNewController { update( @Param('id', ParseIntPipe) id: number, @Body() body: SaveRuleTemplateDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.update(id, body, req.user, authHeader); + return this.rulesNewService.update(id, body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Deletes a rule template the user manages.', schema: { @@ -180,8 +153,7 @@ export class RulesNewController { }, }) @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number, @Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.remove(id, req.user, authHeader); + remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + return this.rulesNewService.remove(id, user); } } diff --git a/src/v1/rules-new/rules-new.service.spec.ts b/src/v1/rules-new/rules-new.service.spec.ts index 62c87b0..8b2427d 100644 --- a/src/v1/rules-new/rules-new.service.spec.ts +++ b/src/v1/rules-new/rules-new.service.spec.ts @@ -116,8 +116,7 @@ describe('RulesNewService', () => { await expect( serviceWithClient.findAll( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, ), ).resolves.toEqual([]); @@ -172,8 +171,7 @@ describe('RulesNewService', () => { }, ], }, - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, ), ).rejects.toBeInstanceOf(ForbiddenException); }); @@ -227,14 +225,13 @@ describe('RulesNewService', () => { await expect( serviceWithClient.findOne( 1, - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, ), ).rejects.toBeInstanceOf(NotFoundException); }); describe('triggered rules', () => { - const jwt = { sub: 'user-1', email: 'user@example.com' }; + const jwt = { sub: 'user-1', email: 'user@example.com', isStaff: false }; const buildRule = ( id: number, @@ -282,7 +279,7 @@ describe('RulesNewService', () => { buildRule(3, [{ devEui: 'DD', isTriggered: null }]), ]); - const triggered = await service.findAllTriggered(jwt, 'Bearer token-1'); + const triggered = await service.findAllTriggered(jwt); expect(triggered).toHaveLength(1); expect(triggered[0].id).toBe(1); @@ -300,7 +297,7 @@ describe('RulesNewService', () => { ]); await expect( - service.findTriggeredCount(jwt, 'Bearer token-1'), + service.findTriggeredCount(jwt), ).resolves.toEqual({ count: 1, triggered_count: 1, total_count: 2 }); }); }); diff --git a/src/v1/rules-new/rules-new.service.ts b/src/v1/rules-new/rules-new.service.ts index 5e85bfc..da01193 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules-new/rules-new.service.ts @@ -6,11 +6,6 @@ import { NotFoundException, } from '@nestjs/common'; import { SupabaseService } from '../../supabase/supabase.service'; -import { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import type { TableRow } from '../types/supabase'; import { MANAGE_CEILING, PermissionLevel } from '../common/permission-levels'; import { DevicesService } from '../devices/devices.service'; @@ -24,6 +19,7 @@ import { RuleTemplateStateDto } from './dto/rule-template-state.dto'; import { RuleTemplateDto } from './dto/rule-template.dto'; import { RuleTriggerLogDto } from './dto/rule-trigger-log.dto'; import { SaveRuleTemplateDto } from './dto/save-rule-template.dto'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; type TemplateRow = TableRow<'cw_rule_templates'>; type TriggerLogRow = TableRow<'cw_rule_trigger_log'>; @@ -65,19 +61,17 @@ export class RulesNewService { ) {} async findAll( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, searchTerm?: string, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) return []; - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { data: assignmentsData, error: assignmentsError } = await client .from('cw_device_rule_assignments') .select( @@ -97,11 +91,10 @@ export class RulesNewService { if (templateIds.length === 0) return []; const [templates, criteria, actions, states] = await Promise.all([ - this.loadTemplatesByIds(accessToken, templateIds), - this.loadCriteriaByTemplateIds(accessToken, templateIds), - this.loadActionsByTemplateIds(accessToken, templateIds), + this.loadTemplatesByIds(templateIds), + this.loadCriteriaByTemplateIds(templateIds), + this.loadActionsByTemplateIds(templateIds), this.loadStates( - accessToken, templateIds, assignments.map((row) => row.dev_eui), ), @@ -127,10 +120,9 @@ export class RulesNewService { * inherited from findAll (device read scope). */ async findAllTriggered( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const rules = await this.findAll(jwtPayload, authHeader); + const rules = await this.findAll(user); return rules .map((rule) => ({ ...rule, @@ -142,10 +134,9 @@ export class RulesNewService { } async findTriggeredCount( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ count: number; triggered_count: number; total_count: number }> { - const rules = await this.findAll(jwtPayload, authHeader); + const rules = await this.findAll(user); const triggeredCount = rules.filter((rule) => rule.assignments.some( (assignment) => assignment.state?.isTriggered === true, @@ -162,20 +153,18 @@ export class RulesNewService { async findOne( id: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) { throw new NotFoundException('Rule template not found'); } - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const [templateResult, assignmentsResult] = await Promise.all([ client .from('cw_rule_templates') @@ -208,10 +197,9 @@ export class RulesNewService { } const [criteria, actions, states] = await Promise.all([ - this.loadCriteriaByTemplateIds(accessToken, [id]), - this.loadActionsByTemplateIds(accessToken, [id]), + this.loadCriteriaByTemplateIds([id]), + this.loadActionsByTemplateIds([id]), this.loadStates( - accessToken, [id], assignments.map((row) => row.dev_eui), ), @@ -235,25 +223,23 @@ export class RulesNewService { async getHistory( id: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; // Reuse findOne so a hidden or non-existent template returns 404 instead of // an empty list. - await this.findOne(id, jwtPayload, authHeader); + await this.findOne(id, user); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); const viewableDevices = devices.filter((device) => device.canView); const deviceNames = new Map( devices.map((device) => [device.devEui, device.name]), ); const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_trigger_log') .select( 'created_at, dev_eui, id, reset_at, reset_value, template_id, triggered_at, triggered_value', @@ -287,18 +273,16 @@ export class RulesNewService { async create( payload: SaveRuleTemplateDto, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; const normalized = normalizeSaveRequest(payload); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const devices = await this.listManagedDevices(userId, isStaff); assertDevicesCanBeManaged(devices, normalized.devEuis); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { data: templateData, error: templateError } = await client .from('cw_rule_templates') .insert({ @@ -316,31 +300,28 @@ export class RulesNewService { try { await this.replaceTemplateChildren( - accessToken, templateData.id, normalized, ); } catch (error) { - await this.deleteTemplateBestEffort(accessToken, templateData.id); + await this.deleteTemplateBestEffort(templateData.id); throw error; } - return this.findOne(templateData.id, jwtPayload, authHeader); + return this.findOne(templateData.id, user); } async update( id: number, payload: SaveRuleTemplateDto, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; const normalized = normalizeSaveRequest(payload); - const existing = await this.findOne(id, jwtPayload, authHeader); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const existing = await this.findOne(id, user); + const devices = await this.listManagedDevices(userId, isStaff); const allDevEuis = uniqueValues([ ...existing.assignments.map((assignment) => assignment.devEui), @@ -348,7 +329,7 @@ export class RulesNewService { ]); assertDevicesCanBeManaged(devices, allDevEuis); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { error: updateError } = await client .from('cw_rule_templates') .update({ @@ -363,32 +344,30 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to update rule template'); } - await this.replaceTemplateChildren(accessToken, id, normalized); - await this.deleteTemplateState(accessToken, id); + await this.replaceTemplateChildren(id, normalized); + await this.deleteTemplateState(id); - return this.findOne(id, jwtPayload, authHeader); + return this.findOne(id, user); } async remove( id: number, - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, ): Promise<{ id: number }> { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + const userId = user.sub; + const isStaff = user.isStaff; - const existing = await this.findOne(id, jwtPayload, authHeader); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + const existing = await this.findOne(id, user); + const devices = await this.listManagedDevices(userId, isStaff); assertDevicesCanBeManaged( devices, existing.assignments.map((assignment) => assignment.devEui), ); - await this.deleteTemplateState(accessToken, id); - await this.deleteTemplateChildren(accessToken, id); + await this.deleteTemplateState(id); + await this.deleteTemplateChildren(id); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { error } = await client .from('cw_rule_templates') .delete() @@ -403,10 +382,9 @@ export class RulesNewService { private async listManagedDevices( userId: string, - accessToken: string, - isStaff: boolean, + isStaff: boolean, ): Promise { - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const { data, error } = await client .from('cw_devices') .select('dev_eui, name, user_id, cw_device_owners(*)'); @@ -453,13 +431,12 @@ export class RulesNewService { } private async loadTemplatesByIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_templates') .select('created_at, description, device_type_id, id, is_active, name') .in('id', templateIds); @@ -472,13 +449,12 @@ export class RulesNewService { } private async loadCriteriaByTemplateIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_template_criteria') .select( 'created_at, id, operator, reset_value, subject, template_id, trigger_value', @@ -493,13 +469,12 @@ export class RulesNewService { } private async loadActionsByTemplateIds( - accessToken: string, - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_template_actions') .select( 'action_type, config, created_at, id, template_id, cw_rule_action_types(id, name)', @@ -513,10 +488,9 @@ export class RulesNewService { return data ?? []; } - async findAllActionTypes(authHeader: string): Promise { - const accessToken = getAccessToken(authHeader); + async findAllActionTypes(): Promise { const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_action_types') .select('created_at, id, name') .order('name', { ascending: true }); @@ -533,16 +507,15 @@ export class RulesNewService { } async getFormContext( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, templateId?: number, ): Promise { const [devicesPage, locations, actionTypes, template] = await Promise.all([ - this.devicesService.findAll(jwtPayload, authHeader), - this.locationsService.findAll(jwtPayload, authHeader), - this.findAllActionTypes(authHeader), + this.devicesService.findAll(user), + this.locationsService.findAll(user), + this.findAllActionTypes(), typeof templateId === 'number' - ? this.findOne(templateId, jwtPayload, authHeader) + ? this.findOne(templateId, user) : Promise.resolve(null), ]); @@ -555,15 +528,14 @@ export class RulesNewService { } private async loadStates( - accessToken: string, - templateIds: number[], + templateIds: number[], devEuis: string[], ): Promise { const uniqueDevEuis = uniqueValues(devEuis); if (templateIds.length === 0 || uniqueDevEuis.length === 0) return []; const { data, error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_state') .select( 'dev_eui, id, is_triggered, last_reset_at, last_triggered_at, template_id', @@ -579,13 +551,12 @@ export class RulesNewService { } private async replaceTemplateChildren( - accessToken: string, - templateId: number, + templateId: number, payload: NormalizedSaveRequest, ): Promise { - await this.deleteTemplateChildren(accessToken, templateId); + await this.deleteTemplateChildren(templateId); - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const assignments = payload.devEuis.map((devEui) => ({ dev_eui: devEui, template_id: templateId, @@ -623,10 +594,9 @@ export class RulesNewService { } private async deleteTemplateChildren( - accessToken: string, - templateId: number, + templateId: number, ): Promise { - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ client @@ -657,11 +627,10 @@ export class RulesNewService { } private async deleteTemplateState( - accessToken: string, - templateId: number, + templateId: number, ): Promise { const { error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_state') .delete() .eq('template_id', templateId); @@ -672,13 +641,12 @@ export class RulesNewService { } private async deleteTemplateBestEffort( - accessToken: string, - templateId: number, + templateId: number, ): Promise { try { - await this.deleteTemplateChildren(accessToken, templateId); + await this.deleteTemplateChildren(templateId); await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_templates') .delete() .eq('id', templateId); diff --git a/src/v1/soil/soil.controller.ts b/src/v1/soil/soil.controller.ts index 956fa90..e7af5c3 100644 --- a/src/v1/soil/soil.controller.ts +++ b/src/v1/soil/soil.controller.ts @@ -5,7 +5,6 @@ import { Param, Query, UseGuards, - Req, } from '@nestjs/common'; import { SoilService } from './soil.service'; import { SoilDataDto } from './dto/soil-data.dto'; @@ -21,15 +20,17 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'soil', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class SoilController { constructor(private readonly soilService: SoilService) {} @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Soil data returned successfully.', type: SoilDataDto, @@ -89,7 +90,7 @@ export class SoilController { }) findOne( @Param('dev_eui') devEui: string, - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Query('start') start?: string, @Query('end') end?: string, @Query('timezone') timezone?: string, @@ -117,7 +118,7 @@ export class SoilController { devEui, startDate, endDate, - req.user, + user, timezone, ); } diff --git a/src/v1/traffic/traffic.controller.ts b/src/v1/traffic/traffic.controller.ts index f11c775..b2d85e9 100644 --- a/src/v1/traffic/traffic.controller.ts +++ b/src/v1/traffic/traffic.controller.ts @@ -5,7 +5,6 @@ import { Param, Query, UseGuards, - Req, } from '@nestjs/common'; import { TrafficService } from './traffic.service'; import { TrafficDataDto } from './dto/traffic-data.dto'; @@ -23,15 +22,17 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'traffic', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class TrafficController { constructor(private readonly trafficService: TrafficService) {} @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Traffic data returned successfully.', type: TrafficDataDto, @@ -91,7 +92,7 @@ export class TrafficController { }) findOne( @Param('dev_eui') devEui: string, - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Query('start') start?: string, @Query('end') end?: string, @Query('timezone') timezone?: string, @@ -119,13 +120,12 @@ export class TrafficController { devEui, startDate, endDate, - req.user, + user, timezone, ); } @Get(':dev_eui/monthly') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Monthly traffic report returned successfully.', type: TrafficMonthlyReportDto, @@ -166,7 +166,7 @@ export class TrafficController { getMonthlyReport( @Param('dev_eui') devEui: string, @Query() query: TrafficReportDto, - @Req() req, + @CurrentUser() user: AuthenticatedUser, ) { if (!devEui) { throw new BadRequestException('dev_eui is required'); @@ -175,7 +175,7 @@ export class TrafficController { devEui, query.year, query.month, - req.user, + user, query.timezone, ); } diff --git a/src/v1/traffic/traffic.service.ts b/src/v1/traffic/traffic.service.ts index 3550901..fc48aed 100644 --- a/src/v1/traffic/traffic.service.ts +++ b/src/v1/traffic/traffic.service.ts @@ -3,6 +3,7 @@ import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { BaseDataService } from '../common/base-data.service'; import { TrafficMonthlyReportDto } from './dto/traffic-monthly-report.dto'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Injectable() export class TrafficService extends BaseDataService<'cw_traffic2'> { @@ -17,12 +18,12 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { devEui: string, year: number, month: number, - jwtPayload: any, + user: AuthenticatedUser, timezone: string = 'Asia/Tokyo', ): Promise { const tz = timezone || 'Asia/Tokyo'; this.timezoneFormatter.assertValidTimeZone(tz); - await this.assertDeviceAccess(devEui, jwtPayload); + await this.assertDeviceAccess(devEui, user); // Compute month boundaries as UTC timestamps corresponding to local midnight const startUtc = this.localMidnightToUtc(year, month, 1, tz); diff --git a/src/v1/water/water.controller.ts b/src/v1/water/water.controller.ts index 73d3bba..93098d7 100644 --- a/src/v1/water/water.controller.ts +++ b/src/v1/water/water.controller.ts @@ -5,7 +5,6 @@ import { Param, Query, UseGuards, - Req, } from '@nestjs/common'; import { WaterService } from './water.service'; import { WaterDataDto } from './dto/water-data.dto'; @@ -21,15 +20,17 @@ import { ApiUnauthorizedResponse, } from '@nestjs/swagger'; import { ErrorResponseDto } from '../common/dto/error-response.dto'; +import { CurrentUser } from '../auth/current-user.decorator'; +import type { AuthenticatedUser } from '../auth/authenticated-user'; @Controller({ path: 'water', version: '1' }) @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') +@UseGuards(JwtAuthGuard) export class WaterController { constructor(private readonly waterService: WaterService) {} @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Water data returned successfully.', type: WaterDataDto, @@ -89,7 +90,7 @@ export class WaterController { }) findOne( @Param('dev_eui') devEui: string, - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Query('start') start?: string, @Query('end') end?: string, @Query('timezone') timezone?: string, @@ -117,7 +118,7 @@ export class WaterController { devEui, startDate, endDate, - req.user, + user, timezone, ); } From 086d3c54a419525295aa6b442517a7f83bb16efc Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 18:38:53 +0900 Subject: [PATCH 07/11] style: eslint --fix after typed-auth refactor Co-Authored-By: Claude Fable 5 --- src/v1/air/air.controller.ts | 13 ++-- src/v1/air/air.service.ts | 5 +- src/v1/auth/current-user.decorator.ts | 3 +- src/v1/common/v1-route-input-contract.spec.ts | 17 +---- src/v1/dashboard/dashboard.controller.ts | 24 ++----- src/v1/devices/devices.controller.ts | 49 ++++++------- src/v1/gateway/gateway.controller.ts | 5 +- src/v1/gateway/gateway.service.spec.ts | 12 ++-- src/v1/locations/locations.controller.ts | 16 ++--- src/v1/locations/locations.service.ts | 13 +--- src/v1/payments/payments.controller.ts | 68 +++++++++---------- src/v1/payments/payments.service.ts | 16 ++--- src/v1/relay/relay-confirmation.ts | 1 - src/v1/relay/relay.service.ts | 15 +--- src/v1/reports-new/reports-new.controller.ts | 36 +++++++--- src/v1/reports-new/reports-new.service.ts | 35 ++++------ src/v1/rules-new/rules-new.controller.ts | 30 ++++++-- src/v1/rules-new/rules-new.service.spec.ts | 25 ++++--- src/v1/rules-new/rules-new.service.ts | 43 ++++-------- src/v1/soil/soil.controller.ts | 8 +-- 20 files changed, 188 insertions(+), 246 deletions(-) diff --git a/src/v1/air/air.controller.ts b/src/v1/air/air.controller.ts index 84fda03..9d8923f 100644 --- a/src/v1/air/air.controller.ts +++ b/src/v1/air/air.controller.ts @@ -53,7 +53,10 @@ export class AirController { } @Delete('notes/:note_id') - async deleteNote(@Param('note_id') noteId: number, @CurrentUser() user: AuthenticatedUser) { + async deleteNote( + @Param('note_id') noteId: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.airService.deleteNote(noteId, user); } @@ -141,12 +144,6 @@ export class AirController { throw new BadRequestException('start must be before end'); } - return this.airService.findOne( - devEui, - startDate, - endDate, - user, - timezone, - ); + return this.airService.findOne(devEui, startDate, endDate, user, timezone); } } diff --git a/src/v1/air/air.service.ts b/src/v1/air/air.service.ts index 90c0ed3..7704508 100644 --- a/src/v1/air/air.service.ts +++ b/src/v1/air/air.service.ts @@ -60,7 +60,10 @@ export class AirService extends BaseDataService<'cw_air_data'> { return data; } - async createNote(createAirNoteDto: CreateAirAnnotationDto, user: AuthenticatedUser) { + async createNote( + createAirNoteDto: CreateAirAnnotationDto, + user: AuthenticatedUser, + ) { const normalizedDevEui = createAirNoteDto.dev_eui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); diff --git a/src/v1/auth/current-user.decorator.ts b/src/v1/auth/current-user.decorator.ts index 999aa27..236451d 100644 --- a/src/v1/auth/current-user.decorator.ts +++ b/src/v1/auth/current-user.decorator.ts @@ -8,6 +8,5 @@ import type { AuthenticatedUser } from './authenticated-user'; */ export const CurrentUser = createParamDecorator( (_data: unknown, ctx: ExecutionContext): AuthenticatedUser => - ctx.switchToHttp().getRequest() - .user, + ctx.switchToHttp().getRequest().user, ); diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index c783258..5cb9574 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -504,14 +504,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [ - MOCK_USER, - 5, - 1000, - 'Field Group', - 'North Node', - 'Greenhouse', - ], + args: [MOCK_USER, 5, 1000, 'Field Group', 'North Node', 'Greenhouse'], method: 'findAll', service: 'devices', }, @@ -663,13 +656,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [ - MOCK_USER, - 'DEV-001', - 'Renamed Device', - 'Zone A', - 54, - ], + args: [MOCK_USER, 'DEV-001', 'Renamed Device', 'Zone A', 54], method: 'updateDevice', service: 'devices', }, diff --git a/src/v1/dashboard/dashboard.controller.ts b/src/v1/dashboard/dashboard.controller.ts index 73045e8..3254cd5 100644 --- a/src/v1/dashboard/dashboard.controller.ts +++ b/src/v1/dashboard/dashboard.controller.ts @@ -1,11 +1,4 @@ -import { - Controller, - Get, - Param, - Query, - Res, - UseGuards, -} from '@nestjs/common'; +import { Controller, Get, Param, Query, Res, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -50,10 +43,7 @@ export class DashboardController { location: q.location?.trim() || undefined, locationGroup: q.locationGroup?.trim() || undefined, }; - return this.dashboardService.getDevices( - user, - query, - ); + return this.dashboardService.getDevices(user, query); } @Get('locations') @@ -79,10 +69,7 @@ export class DashboardController { location: q.location?.trim() || undefined, locationGroup: q.locationGroup?.trim() || undefined, }; - return this.dashboardService.getLocations( - user, - query, - ); + return this.dashboardService.getLocations(user, query); } @Get('devices/:dev_eui/latest') @@ -96,10 +83,7 @@ export class DashboardController { @Param('dev_eui') devEui: string, @Res({ passthrough: true }) res: Response, ) { - const row = await this.dashboardService.getLatest( - user, - devEui, - ); + const row = await this.dashboardService.getLatest(user, devEui); if (row === null) { res.status(204); return; diff --git a/src/v1/devices/devices.controller.ts b/src/v1/devices/devices.controller.ts index 27b9804..4626b09 100644 --- a/src/v1/devices/devices.controller.ts +++ b/src/v1/devices/devices.controller.ts @@ -134,9 +134,7 @@ export class DevicesController { Returns the device groups for the authenticated user.`, }) findAllDeviceGroups(@CurrentUser() user: AuthenticatedUser) { - return this.devicesService.findAllDeviceGroups( - user, - ); + return this.devicesService.findAllDeviceGroups(user); } @Get('device-types') @@ -146,9 +144,7 @@ export class DevicesController { Returns the device types for the authenticated user.`, }) findAllDeviceTypes(@CurrentUser() user: AuthenticatedUser) { - return this.devicesService.findAllDeviceTypes( - user, - ); + return this.devicesService.findAllDeviceTypes(user); } @Get('latest-primary-data') @@ -222,11 +218,11 @@ export class DevicesController { description: ` Returns the latest, 2 primary data values from the table record for all devices in a specific location.`, }) - allDevicesInLocation(@CurrentUser() user: AuthenticatedUser, @Param('location_id') locationId: number) { - return this.devicesService.findAllDevicesInLocation( - user, - locationId, - ); + allDevicesInLocation( + @CurrentUser() user: AuthenticatedUser, + @Param('location_id') locationId: number, + ) { + return this.devicesService.findAllDevicesInLocation(user, locationId); } @Get(':dev_eui') @@ -276,14 +272,14 @@ export class DevicesController { description: ` Returns a specific device for the authenticated user.`, }) - findOne(@CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string) { + findOne( + @CurrentUser() user: AuthenticatedUser, + @Param('dev_eui') devEui: string, + ) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - return this.devicesService.findOne( - user, - devEui, - ); + return this.devicesService.findOne(user, devEui); } @Get(':dev_eui/data') @@ -401,14 +397,14 @@ export class DevicesController { description: ` Returns the full latest data record for a device.`, }) - latestData(@CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string) { + latestData( + @CurrentUser() user: AuthenticatedUser, + @Param('dev_eui') devEui: string, + ) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - return this.devicesService.findLatestData( - user, - devEui, - ); + return this.devicesService.findLatestData(user, devEui); } @Get(':dev_eui/latest-primary-data') @@ -418,15 +414,14 @@ export class DevicesController { description: ` Returns the latest, 2 primary data values from the table record for a device.`, }) - latestPrimaryData(@CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string) { + latestPrimaryData( + @CurrentUser() user: AuthenticatedUser, + @Param('dev_eui') devEui: string, + ) { if (!devEui?.trim()) { throw new BadRequestException('dev_eui is required'); } - return this.devicesService.findLatestData( - user, - devEui, - true, - ); + return this.devicesService.findLatestData(user, devEui, true); } @Post(':dev_eui') diff --git a/src/v1/gateway/gateway.controller.ts b/src/v1/gateway/gateway.controller.ts index c5cc067..8ba3019 100644 --- a/src/v1/gateway/gateway.controller.ts +++ b/src/v1/gateway/gateway.controller.ts @@ -70,7 +70,10 @@ export class GatewayController { 'Gateway not found or not accessible to the authenticated user.', type: ErrorResponseDto, }) - findOne(@Param('gatewayId') gatewayId: string, @CurrentUser() user: AuthenticatedUser) { + findOne( + @Param('gatewayId') gatewayId: string, + @CurrentUser() user: AuthenticatedUser, + ) { if (!gatewayId?.trim()) { throw new BadRequestException('gateway_id is required'); } diff --git a/src/v1/gateway/gateway.service.spec.ts b/src/v1/gateway/gateway.service.spec.ts index 2f14138..7d38dcf 100644 --- a/src/v1/gateway/gateway.service.spec.ts +++ b/src/v1/gateway/gateway.service.spec.ts @@ -82,7 +82,11 @@ describe('GatewayService', () => { supabaseService.getClient.mockReturnValue(client); await expect( - service.findOne(' gw-001 ', { sub: 'user-123', email: null, isStaff: false }), + service.findOne(' gw-001 ', { + sub: 'user-123', + email: null, + isStaff: false, + }), ).resolves.toEqual(gateway); expect(supabaseService.getClient).toHaveBeenCalledWith(); @@ -135,9 +139,9 @@ describe('GatewayService', () => { ); supabaseService.getClient.mockReturnValue(client); - await expect(service.findAll({ sub: 'user-123', email: null, isStaff: false })).resolves.toEqual( - expectedGateways, - ); + await expect( + service.findAll({ sub: 'user-123', email: null, isStaff: false }), + ).resolves.toEqual(expectedGateways); expect(supabaseService.getClient).toHaveBeenCalledWith(); expect(client.from).toHaveBeenNthCalledWith(1, 'cw_gateways'); diff --git a/src/v1/locations/locations.controller.ts b/src/v1/locations/locations.controller.ts index 7286c3f..8e64c35 100644 --- a/src/v1/locations/locations.controller.ts +++ b/src/v1/locations/locations.controller.ts @@ -40,11 +40,11 @@ export class LocationsController { constructor(private readonly locationsService: LocationsService) {} @Post() - create(@Body() createLocationDto: CreateLocationDto, @CurrentUser() user: AuthenticatedUser) { - return this.locationsService.create( - createLocationDto, - user, - ); + create( + @Body() createLocationDto: CreateLocationDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.locationsService.create(createLocationDto, user); } @ApiOkResponse({ description: "Current all of the user's rules configurations.", @@ -88,11 +88,7 @@ export class LocationsController { @Body() updateLocationDto: UpdateLocationDto, @CurrentUser() user: AuthenticatedUser, ) { - return this.locationsService.update( - +id, - updateLocationDto, - user, - ); + return this.locationsService.update(+id, updateLocationDto, user); } @Post(':id/permission') diff --git a/src/v1/locations/locations.service.ts b/src/v1/locations/locations.service.ts index 36c5fa4..db862f8 100644 --- a/src/v1/locations/locations.service.ts +++ b/src/v1/locations/locations.service.ts @@ -29,10 +29,7 @@ export class LocationsService { private readonly paymentsService: PaymentsService, ) {} - async create( - createLocationDto: CreateLocationDto, - user: AuthenticatedUser, - ) { + async create(createLocationDto: CreateLocationDto, user: AuthenticatedUser) { const userId = user.sub; const client = this.supabaseService.getClient(); @@ -40,9 +37,7 @@ export class LocationsService { // are exempt, mirroring the rest of the permission model. if ( !user.isStaff && - !(await this.paymentsService.hasActiveBaseSubscription( - user, - )) + !(await this.paymentsService.hasActiveBaseSubscription(user)) ) { throw new ForbiddenException( 'An active base subscription is required to create a location.', @@ -214,9 +209,7 @@ export class LocationsService { return data; } - async findAllLocationGroups( - user: AuthenticatedUser, - ): Promise { + async findAllLocationGroups(user: AuthenticatedUser): Promise { const userId = user.sub; const client = this.supabaseService.getClient(); const isGlobalUser = user.isStaff; diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index 28c6e05..5b54167 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -49,18 +49,14 @@ export class PaymentsController { summary: 'Get the full billing overview (base sub, device seats, licenses)', }) getState(@CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.getState( - user, - ); + return this.paymentsService.getState(user); } @Get('licenses') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: "List the user's device licenses" }) getLicenses(@CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.getLicenses( - user, - ); + return this.paymentsService.getLicenses(user); } @Post('subscriptions/base/checkout') @@ -68,7 +64,10 @@ export class PaymentsController { @ApiOperation({ summary: 'Create a hosted checkout for the base subscription', }) - createBaseCheckout(@Body() dto: CreateBaseCheckoutDto, @CurrentUser() user: AuthenticatedUser) { + createBaseCheckout( + @Body() dto: CreateBaseCheckoutDto, + @CurrentUser() user: AuthenticatedUser, + ) { return this.paymentsService.createBaseCheckout( user, dto.discountId ?? null, @@ -80,21 +79,21 @@ export class PaymentsController { @ApiOperation({ summary: 'Create a hosted checkout for device licenses (seats)', }) - createDeviceCheckout(@Body() dto: CreateDeviceCheckoutDto, @CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.createDeviceCheckout( - user, - dto.quantity, - ); + createDeviceCheckout( + @Body() dto: CreateDeviceCheckoutDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.createDeviceCheckout(user, dto.quantity); } @Patch('subscriptions/device/seats') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Change the number of device licenses (seats)' }) - changeDeviceSeats(@Body() dto: ChangeSeatsDto, @CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.changeDeviceSeats( - user, - dto.seats, - ); + changeDeviceSeats( + @Body() dto: ChangeSeatsDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.changeDeviceSeats(user, dto.seats); } @Post('licenses/:id/assign') @@ -122,22 +121,18 @@ export class PaymentsController { @Body() dto: MoveLicenseDto, @CurrentUser() user: AuthenticatedUser, ) { - return this.paymentsService.moveLicense( - user, - this.parseId(id), - dto.devEui, - ); + return this.paymentsService.moveLicense(user, this.parseId(id), dto.devEui); } @Post('licenses/:id/unassign') @UseGuards(JwtAuthGuard) @ApiParam({ name: 'id', description: 'License id', type: Number }) @ApiOperation({ summary: 'Unassign a license from its device' }) - unassignLicense(@Param('id') id: string, @CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.unassignLicense( - user, - this.parseId(id), - ); + unassignLicense( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.unassignLicense(user, this.parseId(id)); } @Post('licenses/:id/cancel') @@ -146,26 +141,27 @@ export class PaymentsController { @ApiOperation({ summary: 'Cancel an unassigned license (reduce the paid seat count by one)', }) - cancelLicense(@Param('id') id: string, @CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.cancelLicense( - user, - this.parseId(id), - ); + cancelLicense( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.cancelLicense(user, this.parseId(id)); } @Post('portal') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Open the Polar customer billing portal' }) openPortal(@CurrentUser() user: AuthenticatedUser) { - return this.paymentsService.openPortal( - user, - ); + return this.paymentsService.openPortal(user); } @Delete('subscriptions/base') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Cancel the base subscription' }) - cancelBase(@Body() dto: CancelBaseDto, @CurrentUser() user: AuthenticatedUser) { + cancelBase( + @Body() dto: CancelBaseDto, + @CurrentUser() user: AuthenticatedUser, + ) { return this.paymentsService.cancelBaseSubscription( user, dto.atPeriodEnd ?? true, diff --git a/src/v1/payments/payments.service.ts b/src/v1/payments/payments.service.ts index b23eaa3..9e8017c 100644 --- a/src/v1/payments/payments.service.ts +++ b/src/v1/payments/payments.service.ts @@ -55,9 +55,7 @@ export class PaymentsService { }; } - async getState( - user: AuthenticatedUser, - ): Promise { + async getState(user: AuthenticatedUser): Promise { const userId = user.sub; const client = this.supabaseService.getClient(); @@ -107,9 +105,7 @@ export class PaymentsService { }; } - async getLicenses( - user: AuthenticatedUser, - ): Promise { + async getLicenses(user: AuthenticatedUser): Promise { const userId = user.sub; const client = this.supabaseService.getClient(); return this.fetchLicenses(client, userId); @@ -121,9 +117,7 @@ export class PaymentsService { * cached `billing_customers.base_status` so a transient outage doesn't block * a legitimately-subscribed user. */ - async hasActiveBaseSubscription( - user: AuthenticatedUser, - ): Promise { + async hasActiveBaseSubscription(user: AuthenticatedUser): Promise { const userId = user.sub; try { const subscriptions = await this.polarService.listSubscriptions(userId); @@ -250,9 +244,7 @@ export class PaymentsService { return { seats }; } - async openPortal( - user: AuthenticatedUser, - ): Promise<{ portalUrl: string }> { + async openPortal(user: AuthenticatedUser): Promise<{ portalUrl: string }> { const userId = user.sub; try { const portalUrl = await this.polarService.createPortalSession(userId); diff --git a/src/v1/relay/relay-confirmation.ts b/src/v1/relay/relay-confirmation.ts index ff88ce7..619434a 100644 --- a/src/v1/relay/relay-confirmation.ts +++ b/src/v1/relay/relay-confirmation.ts @@ -143,4 +143,3 @@ export function readRelayRowTimestamp( return row.created_at || row.last_update || ''; } - diff --git a/src/v1/relay/relay.service.ts b/src/v1/relay/relay.service.ts index e21aaef..fac9e8b 100644 --- a/src/v1/relay/relay.service.ts +++ b/src/v1/relay/relay.service.ts @@ -148,10 +148,7 @@ export class RelayService { throw new BadRequestException('dev_eui is required'); } - const context = await this.loadRelayDeviceContext( - user, - normalizedDevEui, - ); + const context = await this.loadRelayDeviceContext(user, normalizedDevEui); if (!canRead(context.permissionLevel)) { throw new NotFoundException('Device not found'); } @@ -175,10 +172,7 @@ export class RelayService { } const { relay, targetState } = updateRelayDto; - const context = await this.loadRelayDeviceContext( - user, - normalizedDevEui, - ); + const context = await this.loadRelayDeviceContext(user, normalizedDevEui); if (!canManage(context.permissionLevel)) { throw new ForbiddenException( 'You do not have permission to control this relay', @@ -260,10 +254,7 @@ export class RelayService { } const { durationSeconds, relay } = pulseRelayDto; - const context = await this.loadRelayDeviceContext( - user, - normalizedDevEui, - ); + const context = await this.loadRelayDeviceContext(user, normalizedDevEui); if (!canManage(context.permissionLevel)) { throw new ForbiddenException( 'You do not have permission to control this relay', diff --git a/src/v1/reports-new/reports-new.controller.ts b/src/v1/reports-new/reports-new.controller.ts index b22a9bf..f48bb6f 100644 --- a/src/v1/reports-new/reports-new.controller.ts +++ b/src/v1/reports-new/reports-new.controller.ts @@ -44,7 +44,10 @@ export class ReportsNewController { required: false, }) @Get() - findAll(@CurrentUser() user: AuthenticatedUser, @Query('search') search?: string) { + findAll( + @CurrentUser() user: AuthenticatedUser, + @Query('search') search?: string, + ) { return this.reportsNewService.findAll(user, search); } @ApiOkResponse({ @@ -70,7 +73,10 @@ export class ReportsNewController { type: Number, }) @Get('form-context') - getFormContext(@CurrentUser() user: AuthenticatedUser, @Query('templateId') templateId?: string) { + getFormContext( + @CurrentUser() user: AuthenticatedUser, + @Query('templateId') templateId?: string, + ) { const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; return this.reportsNewService.getFormContext(user, id); @@ -89,11 +95,7 @@ export class ReportsNewController { @Param('reportName') reportName: string, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.getDownloadUrl( - devEui, - reportName, - user, - ); + return this.reportsNewService.getDownloadUrl(devEui, reportName, user); } @ApiOkResponse({ description: @@ -102,7 +104,10 @@ export class ReportsNewController { isArray: true, }) @Get(':id/history') - findHistory(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + findHistory( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.reportsNewService.getHistory(id, user); } @ApiOkResponse({ @@ -111,7 +116,10 @@ export class ReportsNewController { isArray: false, }) @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + findOne( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.reportsNewService.findOne(id, user); } @ApiOkResponse({ @@ -122,7 +130,10 @@ export class ReportsNewController { }) @ApiBody({ type: SaveReportTemplateDto }) @Post() - create(@Body() body: SaveReportTemplateDto, @CurrentUser() user: AuthenticatedUser) { + create( + @Body() body: SaveReportTemplateDto, + @CurrentUser() user: AuthenticatedUser, + ) { return this.reportsNewService.create(body, user); } @ApiOkResponse({ @@ -147,7 +158,10 @@ export class ReportsNewController { }, }) @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + remove( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.reportsNewService.remove(id, user); } } diff --git a/src/v1/reports-new/reports-new.service.ts b/src/v1/reports-new/reports-new.service.ts index af65459..9a17348 100644 --- a/src/v1/reports-new/reports-new.service.ts +++ b/src/v1/reports-new/reports-new.service.ts @@ -272,10 +272,7 @@ export class ReportsNewService { } try { - await this.replaceTemplateChildren( - templateData.id, - normalized, - ); + await this.replaceTemplateChildren(templateData.id, normalized); } catch (error) { await this.deleteTemplateBestEffort(templateData.id); throw error; @@ -325,10 +322,7 @@ export class ReportsNewService { return this.findOne(id, user); } - async remove( - id: number, - user: AuthenticatedUser, - ): Promise<{ id: number }> { + async remove(id: number, user: AuthenticatedUser): Promise<{ id: number }> { const userId = user.sub; const isStaff = user.isStaff; @@ -494,8 +488,7 @@ export class ReportsNewService { } const storageClient = - this.supabaseService.getAdminClient() ?? - this.supabaseService.getClient(); + this.supabaseService.getAdminClient() ?? this.supabaseService.getClient(); const { data, error } = await storageClient.storage .from(STORAGE_BUCKET) .createSignedUrl(`${normalizedDevEui}/${resolvedName}`, 60, { @@ -513,7 +506,7 @@ export class ReportsNewService { private async listManagedDevices( userId: string, - isStaff: boolean, + isStaff: boolean, ): Promise { const client = this.supabaseService.getClient(); const { data, error } = await client @@ -562,7 +555,7 @@ export class ReportsNewService { } private async loadTemplatesByIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -582,7 +575,7 @@ export class ReportsNewService { } private async loadScheduleByTemplateIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -602,7 +595,7 @@ export class ReportsNewService { } private async loadRecipientsByTemplateIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -622,7 +615,7 @@ export class ReportsNewService { } private async loadAlertPointsByTemplateIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -644,7 +637,7 @@ export class ReportsNewService { } private async loadDataProcessingSchedulesByTemplateIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -666,7 +659,7 @@ export class ReportsNewService { } private async replaceTemplateChildren( - templateId: number, + templateId: number, payload: NormalizedSaveRequest, ): Promise { await this.deleteTemplateChildren(templateId); @@ -768,9 +761,7 @@ export class ReportsNewService { } } - private async deleteTemplateChildren( - templateId: number, - ): Promise { + private async deleteTemplateChildren(templateId: number): Promise { const client = this.supabaseService.getClient(); const [assignments, schedule, recipients, alertPoints, dpSchedules] = await Promise.all([ @@ -823,9 +814,7 @@ export class ReportsNewService { } } - private async deleteTemplateBestEffort( - templateId: number, - ): Promise { + private async deleteTemplateBestEffort(templateId: number): Promise { try { await this.deleteTemplateChildren(templateId); await this.supabaseService diff --git a/src/v1/rules-new/rules-new.controller.ts b/src/v1/rules-new/rules-new.controller.ts index 7ce2e98..9547554 100644 --- a/src/v1/rules-new/rules-new.controller.ts +++ b/src/v1/rules-new/rules-new.controller.ts @@ -44,7 +44,10 @@ export class RulesNewController { required: false, }) @Get() - findAll(@CurrentUser() user: AuthenticatedUser, @Query('search') search?: string) { + findAll( + @CurrentUser() user: AuthenticatedUser, + @Query('search') search?: string, + ) { return this.rulesNewService.findAll(user, search); } @ApiOkResponse({ @@ -70,7 +73,10 @@ export class RulesNewController { type: Number, }) @Get('form-context') - getFormContext(@CurrentUser() user: AuthenticatedUser, @Query('templateId') templateId?: string) { + getFormContext( + @CurrentUser() user: AuthenticatedUser, + @Query('templateId') templateId?: string, + ) { const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; return this.rulesNewService.getFormContext(user, id); @@ -108,7 +114,10 @@ export class RulesNewController { isArray: true, }) @Get(':id/history') - findHistory(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + findHistory( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.rulesNewService.getHistory(id, user); } @ApiOkResponse({ @@ -117,7 +126,10 @@ export class RulesNewController { isArray: false, }) @Get(':id') - findOne(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + findOne( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.rulesNewService.findOne(id, user); } @ApiOkResponse({ @@ -128,7 +140,10 @@ export class RulesNewController { }) @ApiBody({ type: SaveRuleTemplateDto }) @Post() - create(@Body() body: SaveRuleTemplateDto, @CurrentUser() user: AuthenticatedUser) { + create( + @Body() body: SaveRuleTemplateDto, + @CurrentUser() user: AuthenticatedUser, + ) { return this.rulesNewService.create(body, user); } @ApiOkResponse({ @@ -153,7 +168,10 @@ export class RulesNewController { }, }) @Delete(':id') - remove(@Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser) { + remove( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { return this.rulesNewService.remove(id, user); } } diff --git a/src/v1/rules-new/rules-new.service.spec.ts b/src/v1/rules-new/rules-new.service.spec.ts index 8b2427d..6108ff8 100644 --- a/src/v1/rules-new/rules-new.service.spec.ts +++ b/src/v1/rules-new/rules-new.service.spec.ts @@ -115,9 +115,11 @@ describe('RulesNewService', () => { ); await expect( - serviceWithClient.findAll( - { sub: 'user-1', email: 'user@example.com', isStaff: false }, - ), + serviceWithClient.findAll({ + sub: 'user-1', + email: 'user@example.com', + isStaff: false, + }), ).resolves.toEqual([]); expect(client.from).toHaveBeenCalledWith('cw_devices'); @@ -223,10 +225,11 @@ describe('RulesNewService', () => { ); await expect( - serviceWithClient.findOne( - 1, - { sub: 'user-1', email: 'user@example.com', isStaff: false }, - ), + serviceWithClient.findOne(1, { + sub: 'user-1', + email: 'user@example.com', + isStaff: false, + }), ).rejects.toBeInstanceOf(NotFoundException); }); @@ -296,9 +299,11 @@ describe('RulesNewService', () => { buildRule(2, [{ devEui: 'BB', isTriggered: false }]), ]); - await expect( - service.findTriggeredCount(jwt), - ).resolves.toEqual({ count: 1, triggered_count: 1, total_count: 2 }); + await expect(service.findTriggeredCount(jwt)).resolves.toEqual({ + count: 1, + triggered_count: 1, + total_count: 2, + }); }); }); }); diff --git a/src/v1/rules-new/rules-new.service.ts b/src/v1/rules-new/rules-new.service.ts index da01193..4832203 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules-new/rules-new.service.ts @@ -119,9 +119,7 @@ export class RulesNewService { * triggered, narrowed to those triggered assignments. Visibility scoping is * inherited from findAll (device read scope). */ - async findAllTriggered( - user: AuthenticatedUser, - ): Promise { + async findAllTriggered(user: AuthenticatedUser): Promise { const rules = await this.findAll(user); return rules .map((rule) => ({ @@ -151,10 +149,7 @@ export class RulesNewService { }; } - async findOne( - id: number, - user: AuthenticatedUser, - ): Promise { + async findOne(id: number, user: AuthenticatedUser): Promise { const userId = user.sub; const isStaff = user.isStaff; @@ -299,10 +294,7 @@ export class RulesNewService { } try { - await this.replaceTemplateChildren( - templateData.id, - normalized, - ); + await this.replaceTemplateChildren(templateData.id, normalized); } catch (error) { await this.deleteTemplateBestEffort(templateData.id); throw error; @@ -350,10 +342,7 @@ export class RulesNewService { return this.findOne(id, user); } - async remove( - id: number, - user: AuthenticatedUser, - ): Promise<{ id: number }> { + async remove(id: number, user: AuthenticatedUser): Promise<{ id: number }> { const userId = user.sub; const isStaff = user.isStaff; @@ -382,7 +371,7 @@ export class RulesNewService { private async listManagedDevices( userId: string, - isStaff: boolean, + isStaff: boolean, ): Promise { const client = this.supabaseService.getClient(); const { data, error } = await client @@ -431,7 +420,7 @@ export class RulesNewService { } private async loadTemplatesByIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -449,7 +438,7 @@ export class RulesNewService { } private async loadCriteriaByTemplateIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -469,7 +458,7 @@ export class RulesNewService { } private async loadActionsByTemplateIds( - templateIds: number[], + templateIds: number[], ): Promise { if (templateIds.length === 0) return []; @@ -528,7 +517,7 @@ export class RulesNewService { } private async loadStates( - templateIds: number[], + templateIds: number[], devEuis: string[], ): Promise { const uniqueDevEuis = uniqueValues(devEuis); @@ -551,7 +540,7 @@ export class RulesNewService { } private async replaceTemplateChildren( - templateId: number, + templateId: number, payload: NormalizedSaveRequest, ): Promise { await this.deleteTemplateChildren(templateId); @@ -593,9 +582,7 @@ export class RulesNewService { } } - private async deleteTemplateChildren( - templateId: number, - ): Promise { + private async deleteTemplateChildren(templateId: number): Promise { const client = this.supabaseService.getClient(); const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ @@ -626,9 +613,7 @@ export class RulesNewService { } } - private async deleteTemplateState( - templateId: number, - ): Promise { + private async deleteTemplateState(templateId: number): Promise { const { error } = await this.supabaseService .getClient() .from('cw_rule_state') @@ -640,9 +625,7 @@ export class RulesNewService { } } - private async deleteTemplateBestEffort( - templateId: number, - ): Promise { + private async deleteTemplateBestEffort(templateId: number): Promise { try { await this.deleteTemplateChildren(templateId); await this.supabaseService diff --git a/src/v1/soil/soil.controller.ts b/src/v1/soil/soil.controller.ts index e7af5c3..91f118b 100644 --- a/src/v1/soil/soil.controller.ts +++ b/src/v1/soil/soil.controller.ts @@ -114,12 +114,6 @@ export class SoilController { throw new BadRequestException('start must be before end'); } - return this.soilService.findOne( - devEui, - startDate, - endDate, - user, - timezone, - ); + return this.soilService.findOne(devEui, startDate, endDate, user, timezone); } } From 427e51fdde14223a17c39deb6d490c074c8a1e79 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 18:43:01 +0900 Subject: [PATCH 08/11] build: enable TypeScript strict mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Full strict on (noImplicitAny, strictBindCallApply, noFallthroughCasesInSwitch included); strictPropertyInitialization stays off — DTOs are hydrated by class-transformer/ValidationPipe, not constructors. Adds @types/passport-jwt, fails fast when PRIVATE_SUPABASE_JWT_SECRET is missing, and types the MCP tool params and the Polar webhook rawBody request. tsc --noEmit clean; 121/121 tests green. Co-Authored-By: Claude Fable 5 --- package.json | 1 + pnpm-lock.yaml | 26 +++++++++++++++++++++ src/v1/auth/strategies/supabase.strategy.ts | 8 +++++-- src/v1/mcp/tools/devices.tools.ts | 20 ++++++++++++++-- src/v1/payments/payments.controller.ts | 4 +++- tsconfig.json | 8 +++---- 6 files changed, 58 insertions(+), 9 deletions(-) diff --git a/package.json b/package.json index dd57fd9..db9471a 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@types/express": "^5.0.6", "@types/jest": "^30.0.0", "@types/node": "^22.19.17", + "@types/passport-jwt": "^4.0.1", "@types/supertest": "^6.0.3", "eslint": "^9.39.4", "eslint-config-prettier": "^10.1.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5f9858..4438417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,9 @@ importers: '@types/node': specifier: ^22.19.17 version: 22.19.17 + '@types/passport-jwt': + specifier: ^4.0.1 + version: 4.0.1 '@types/supertest': specifier: ^6.0.3 version: 6.0.3 @@ -1036,6 +1039,15 @@ packages: '@types/node@22.19.17': resolution: {integrity: sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==} + '@types/passport-jwt@4.0.1': + resolution: {integrity: sha512-Y0Ykz6nWP4jpxgEUYq8NoVZeCQPo1ZndJLfapI249g1jHChvRfZRO/LS3tqu26YgAS/laI1qx98sYGz0IalRXQ==} + + '@types/passport-strategy@0.2.38': + resolution: {integrity: sha512-GC6eMqqojOooq993Tmnmp7AUTbbQSgilyvpCYQjT+H6JfG/g6RGc7nXEniZlp0zyKJ0WUdOiZWLBZft9Yug1uA==} + + '@types/passport@1.0.17': + resolution: {integrity: sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==} + '@types/qs@6.15.0': resolution: {integrity: sha512-JawvT8iBVWpzTrz3EGw9BTQFg3BQNmwERdKE22vlTxawwtbyUSlMppvZYKLZzB5zgACXdXxbD3m1bXaMqP/9ow==} @@ -4600,6 +4612,20 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/passport-jwt@4.0.1': + dependencies: + '@types/jsonwebtoken': 9.0.10 + '@types/passport-strategy': 0.2.38 + + '@types/passport-strategy@0.2.38': + dependencies: + '@types/express': 5.0.6 + '@types/passport': 1.0.17 + + '@types/passport@1.0.17': + dependencies: + '@types/express': 5.0.6 + '@types/qs@6.15.0': {} '@types/range-parser@1.2.7': {} diff --git a/src/v1/auth/strategies/supabase.strategy.ts b/src/v1/auth/strategies/supabase.strategy.ts index f9baee4..8a3abd9 100644 --- a/src/v1/auth/strategies/supabase.strategy.ts +++ b/src/v1/auth/strategies/supabase.strategy.ts @@ -7,11 +7,15 @@ import type { AuthenticatedUser } from '../authenticated-user'; @Injectable() export class SupabaseStrategy extends PassportStrategy(Strategy) { - public constructor(private readonly configService: ConfigService) { + public constructor(configService: ConfigService) { + const secret = configService.get('PRIVATE_SUPABASE_JWT_SECRET'); + if (!secret) { + throw new Error('PRIVATE_SUPABASE_JWT_SECRET is not configured'); + } super({ jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), ignoreExpiration: false, - secretOrKey: configService.get('PRIVATE_SUPABASE_JWT_SECRET'), + secretOrKey: secret, }); } diff --git a/src/v1/mcp/tools/devices.tools.ts b/src/v1/mcp/tools/devices.tools.ts index 5dc51e3..03e8758 100644 --- a/src/v1/mcp/tools/devices.tools.ts +++ b/src/v1/mcp/tools/devices.tools.ts @@ -67,7 +67,19 @@ export class DeviceMcpTools { }, }) async listDevices( - { skip, take, name, group, location }, + { + skip, + take, + name, + group, + location, + }: { + skip?: number; + take?: number; + name?: string; + group?: string; + location?: string; + }, _context: Context, request: AuthedRequest, ) { @@ -100,7 +112,11 @@ export class DeviceMcpTools { openWorldHint: false, }, }) - async getDevice({ dev_eui }, _context: Context, request: AuthedRequest) { + async getDevice( + { dev_eui }: { dev_eui: string }, + _context: Context, + request: AuthedRequest, + ) { const device = await this.devicesService.findOne(request.user, dev_eui); return this.json(device); } diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index 5b54167..2617451 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -12,6 +12,8 @@ import { Req, UseGuards, } from '@nestjs/common'; +import type { RawBodyRequest } from '@nestjs/common'; +import type { Request } from 'express'; import { ApiBearerAuth, ApiOkResponse, @@ -173,7 +175,7 @@ export class PaymentsController { @ApiOperation({ summary: 'Receive Polar webhook events (signature-verified)', }) - handleWebhook(@Req() req) { + handleWebhook(@Req() req: RawBodyRequest) { const rawBody: Buffer | undefined = req.rawBody; if (!rawBody) { throw new BadRequestException('Missing webhook body'); diff --git a/tsconfig.json b/tsconfig.json index aba29b0..5b59069 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,10 +16,10 @@ "baseUrl": "./", "incremental": true, "skipLibCheck": true, - "strictNullChecks": true, + "strict": true, + // DTOs are hydrated by class-transformer/ValidationPipe, not constructors + "strictPropertyInitialization": false, "forceConsistentCasingInFileNames": true, - "noImplicitAny": false, - "strictBindCallApply": false, - "noFallthroughCasesInSwitch": false + "noFallthroughCasesInSwitch": true } } From 25974ff8ecd5b6652e14c2e3a48d73a8aaa1d78c Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 19:04:29 +0900 Subject: [PATCH 09/11] refactor: type all Supabase query paths; eslint any/floating-promises now error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Burn down the ~430 no-unsafe-* lint errors by typing every Supabase query result against the generated Database types (TableRow<...> aliases, cast once at the awaited result — the relay.service idiom, now applied across devices, dashboard, locations, gateway, air, auth, payments, relay, rules, reports, traffic, main.ts, and the supabase module providers, which now create SupabaseClient). Notable co-fixes: - devices/dashboard scope helpers are now generic over the query builder instead of any (this alone had been poisoning ~35 downstream sites) - devices.controller replace() awaited (its !insertResult check was testing an unawaited promise — dead branch); create() keeps its dev_eui swagger param via an underscore placeholder - gateway findAll return type corrected to the 6-field summary it actually returns; air findAllNotes returns typed annotation rows - eslint: no-explicit-any and no-floating-promises are errors now (any allowed in *.spec.ts/test-helpers for mocks); argsIgnorePattern ^_ lint:check: 0 errors / 6 warnings (was 772 problems); tsc strict clean; 121/121 tests green. Co-Authored-By: Claude Fable 5 --- eslint.config.mjs | 17 +- src/main.ts | 23 ++- src/supabase/supabase.module.ts | 9 +- src/utils/gitCommit.ts | 8 +- src/v1/air/air.controller.spec.ts | 2 +- src/v1/air/air.service.spec.ts | 20 +- src/v1/air/air.service.ts | 26 ++- src/v1/auth/auth.controller.ts | 2 +- src/v1/auth/auth.service.ts | 32 +-- src/v1/common/base-data.service.ts | 5 +- src/v1/common/timezone-formatter.service.ts | 2 +- src/v1/common/v1-route-input-contract.spec.ts | 5 +- src/v1/dashboard/dashboard.service.ts | 89 +++++++-- src/v1/devices/devices.controller.ts | 14 +- src/v1/devices/devices.service.spec.ts | 10 +- src/v1/devices/devices.service.ts | 183 +++++++++++------- src/v1/devices/dto/replace-device.dto.ts | 2 +- src/v1/gateway/gateway.service.ts | 45 +++-- src/v1/locations/locations.controller.ts | 2 +- src/v1/locations/locations.service.spec.ts | 37 +++- src/v1/locations/locations.service.ts | 82 +++++--- src/v1/payments/payments.controller.ts | 1 - src/v1/payments/payments.service.ts | 40 ++-- src/v1/relay/relay.controller.spec.ts | 6 +- src/v1/relay/relay.service.spec.ts | 38 ++-- src/v1/relay/relay.service.ts | 28 +-- src/v1/relay/tti-client.spec.ts | 29 +-- src/v1/reports-new/reports-new.controller.ts | 2 +- src/v1/reports-new/reports-new.service.ts | 19 +- src/v1/rules-new/rules-new.controller.ts | 2 +- src/v1/rules-new/rules-new.service.spec.ts | 24 ++- src/v1/rules-new/rules-new.service.ts | 18 +- src/v1/traffic/traffic.service.ts | 22 ++- 33 files changed, 573 insertions(+), 271 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 4e9f827..c342f5e 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -26,10 +26,21 @@ export default tseslint.config( }, { rules: { - '@typescript-eslint/no-explicit-any': 'off', - '@typescript-eslint/no-floating-promises': 'warn', + '@typescript-eslint/no-explicit-any': 'error', + '@typescript-eslint/no-floating-promises': 'error', '@typescript-eslint/no-unsafe-argument': 'warn', - "prettier/prettier": ["error", { endOfLine: "auto" }], + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, + ], + 'prettier/prettier': ['error', { endOfLine: 'auto' }], + }, + }, + { + // Test files may use `any` for mocks and private-member access. + files: ['**/*.spec.ts', 'src/v1/common/test-helpers.ts'], + rules: { + '@typescript-eslint/no-explicit-any': 'off', }, }, ); diff --git a/src/main.ts b/src/main.ts index 30346c6..15e917b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,12 +1,16 @@ import { NestFactory } from '@nestjs/core'; -import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; +import { + SwaggerModule, + DocumentBuilder, + type OpenAPIObject, +} from '@nestjs/swagger'; import { AppModule } from './app.module'; import { getCommit } from './utils/gitCommit'; import helmet from 'helmet'; import { join } from 'path'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; import { STATUS_CODES } from 'http'; -import type { NextFunction, Request, Response } from 'express'; +import type { Express, NextFunction, Request, Response } from 'express'; function getRequesterIp(req: Request): string { const forwardedFor = req.headers['x-forwarded-for']; @@ -27,7 +31,8 @@ async function bootstrap() { // the Polar webhook route can verify the signature over the unmodified payload. const app = await NestFactory.create(AppModule, { rawBody: true }); const logger = new Logger('NestApplication'); - const expressApp = app.getHttpAdapter().getInstance(); + // The Express adapter's getInstance() is typed `any`; pin it once here. + const expressApp = app.getHttpAdapter().getInstance() as Express; expressApp.set('trust proxy', true); app.enableCors(); @@ -118,7 +123,7 @@ Developer notes: const documentFactory = () => SwaggerModule.createDocument(app, config); const fullDoc = SwaggerModule.createDocument(app, config); - function filterByPrefix(doc: any, prefix: string) { + function filterByPrefix(doc: OpenAPIObject, prefix: string): OpenAPIObject { return { ...doc, paths: Object.fromEntries( @@ -131,8 +136,12 @@ Developer notes: const v2Doc = filterByPrefix(fullDoc, '/v2'); // register raw JSON endpoints on the underlying Express instance - expressApp.get('/docs-json-v1', (_req: any, res: any) => res.json(v1Doc)); - expressApp.get('/docs-json-v2', (_req: any, res: any) => res.json(v2Doc)); + expressApp.get('/docs-json-v1', (_req: Request, res: Response) => + res.json(v1Doc), + ); + expressApp.get('/docs-json-v2', (_req: Request, res: Response) => + res.json(v2Doc), + ); SwaggerModule.setup('docs', app, documentFactory, { explorer: true, @@ -170,4 +179,4 @@ Developer notes: ); await app.listen(process.env.PORT ?? 3000); } -bootstrap(); +void bootstrap(); diff --git a/src/supabase/supabase.module.ts b/src/supabase/supabase.module.ts index a82039d..f778761 100644 --- a/src/supabase/supabase.module.ts +++ b/src/supabase/supabase.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { createClient, SupabaseClient } from '@supabase/supabase-js'; +import type { Database } from '../../database.types'; import { SUPABASE_ADMIN_CLIENT, SUPABASE_CLIENT } from './supabase.constants'; import { SupabaseService } from './supabase.service'; @@ -10,7 +11,7 @@ import { SupabaseService } from './supabase.service'; { provide: SUPABASE_CLIENT, inject: [ConfigService], - useFactory: (configService: ConfigService): SupabaseClient => { + useFactory: (configService: ConfigService): SupabaseClient => { const url = configService.get('PRIVATE_SUPABASE_URL'); const anonKey = configService.get('PRIVATE_SUPABASE_ANON_KEY'); if (!url || !anonKey) { @@ -18,7 +19,7 @@ import { SupabaseService } from './supabase.service'; 'PRIVATE_SUPABASE_URL and PRIVATE_SUPABASE_ANON_KEY are required', ); } - return createClient(url, anonKey, { + return createClient(url, anonKey, { auth: { autoRefreshToken: false, persistSession: false }, }); }, @@ -26,7 +27,7 @@ import { SupabaseService } from './supabase.service'; { provide: SUPABASE_ADMIN_CLIENT, inject: [ConfigService], - useFactory: (configService: ConfigService): SupabaseClient => { + useFactory: (configService: ConfigService): SupabaseClient => { const url = configService.get('PRIVATE_SUPABASE_URL'); const serviceRoleKey = configService.get( 'PRIVATE_SUPABASE_SERVICE_ROLE_KEY', @@ -36,7 +37,7 @@ import { SupabaseService } from './supabase.service'; 'PRIVATE_SUPABASE_URL and PRIVATE_SUPABASE_SERVICE_ROLE_KEY are required', ); } - return createClient(url, serviceRoleKey, { + return createClient(url, serviceRoleKey, { auth: { autoRefreshToken: false, persistSession: false }, }); }, diff --git a/src/utils/gitCommit.ts b/src/utils/gitCommit.ts index 84de54e..1b340ae 100644 --- a/src/utils/gitCommit.ts +++ b/src/utils/gitCommit.ts @@ -14,7 +14,9 @@ export function getCommit(): string { }); const sha = out.toString().trim(); if (sha) return sha; - } catch (_) {} + } catch { + /* git binary unavailable; fall through to reading .git directly */ + } try { const gitDir = join(process.cwd(), '.git'); @@ -43,7 +45,9 @@ export function getCommit(): string { } else if (/^[0-9a-f]{40}$/.test(head)) { return head.substring(0, 7); } - } catch (_) {} + } catch { + /* optional .git lookup failed; fall through to 'unknown' */ + } return 'unknown'; } diff --git a/src/v1/air/air.controller.spec.ts b/src/v1/air/air.controller.spec.ts index bac2752..86b2ab2 100644 --- a/src/v1/air/air.controller.spec.ts +++ b/src/v1/air/air.controller.spec.ts @@ -68,7 +68,7 @@ describe('AirController', () => { title: 'Daily review', }; - const result = await pipe.transform(payload, { + const result: unknown = await pipe.transform(payload, { type: 'body', metatype: CreateAirAnnotationDto, data: '', diff --git a/src/v1/air/air.service.spec.ts b/src/v1/air/air.service.spec.ts index 7bff4de..772f819 100644 --- a/src/v1/air/air.service.spec.ts +++ b/src/v1/air/air.service.spec.ts @@ -1,6 +1,8 @@ import { BadRequestException } from '@nestjs/common'; import { AirService } from './air.service'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { TimezoneFormatterService } from '../common/timezone-formatter.service'; function createExactMatchBuilder(response: { data: { created_at: string } | null; @@ -61,9 +63,15 @@ describe('AirService', () => { mockSupabaseService = { getClient: jest.fn(() => client), }; - service = new AirService(mockSupabaseService as any, {} as any); + service = new AirService( + mockSupabaseService as unknown as SupabaseService, + {} as TimezoneFormatterService, + ); jest - .spyOn(service as any, 'assertDeviceAccess') + .spyOn( + service as unknown as { assertDeviceAccess: () => Promise }, + 'assertDeviceAccess', + ) .mockResolvedValue(undefined); }); @@ -136,10 +144,10 @@ describe('AirService', () => { title: 'Shift review', }); - expect((service as any).assertDeviceAccess).toHaveBeenCalledWith( - '2CF7F1C073800102', - user, - ); + expect( + (service as unknown as { assertDeviceAccess: jest.Mock }) + .assertDeviceAccess, + ).toHaveBeenCalledWith('2CF7F1C073800102', user); expect(exactMatchBuilder.eq).toHaveBeenNthCalledWith( 1, 'dev_eui', diff --git a/src/v1/air/air.service.ts b/src/v1/air/air.service.ts index 7704508..50c1af7 100644 --- a/src/v1/air/air.service.ts +++ b/src/v1/air/air.service.ts @@ -3,12 +3,18 @@ import { Injectable, InternalServerErrorException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { BaseDataService } from '../common/base-data.service'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; +import type { TableRow } from '../types/supabase'; import type { AuthenticatedUser } from '../auth/authenticated-user'; +type AirAnnotationRow = TableRow<'cw_air_annotations'>; +type AirDataTimestampRow = Pick, 'created_at'>; +type QueryResult = { data: T | null; error: PostgrestError | null }; + @Injectable() export class AirService extends BaseDataService<'cw_air_data'> { constructor( @@ -23,7 +29,7 @@ export class AirService extends BaseDataService<'cw_air_data'> { month: string, year: string, user: AuthenticatedUser, - ): Promise { + ): Promise { const normalizedDevEui = devEui?.trim(); if (!normalizedDevEui) { throw new BadRequestException('dev_eui is required'); @@ -57,7 +63,7 @@ export class AirService extends BaseDataService<'cw_air_data'> { if (error) { throw new InternalServerErrorException('Failed to fetch air annotations'); } - return data; + return (data ?? []) as AirAnnotationRow[]; } async createNote( @@ -78,7 +84,7 @@ export class AirService extends BaseDataService<'cw_air_data'> { createAirNoteDto.created_at, ); - const { data, error } = await client + const { data, error } = (await client .from('cw_air_annotations') .insert({ ...createAirNoteDto, @@ -87,7 +93,7 @@ export class AirService extends BaseDataService<'cw_air_data'> { dev_eui: normalizedDevEui, }) .select('*') - .single(); + .single()) as QueryResult; if (error) { throw new BadRequestException('Failed to create air annotation'); @@ -98,11 +104,11 @@ export class AirService extends BaseDataService<'cw_air_data'> { async deleteNote(noteId: number, user: AuthenticatedUser) { const client = this.supabaseService.getClient(); - const { data: existingNote, error: fetchError } = await client + const { data: existingNote, error: fetchError } = (await client .from('cw_air_annotations') .select('*') .eq('id', noteId) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (fetchError) { throw new InternalServerErrorException('Failed to fetch air annotation'); @@ -131,12 +137,12 @@ export class AirService extends BaseDataService<'cw_air_data'> { devEui: string, requestedCreatedAt: string, ): Promise { - const { data: exactMatch, error: exactMatchError } = await client + const { data: exactMatch, error: exactMatchError } = (await client .from('cw_air_data') .select('created_at') .eq('dev_eui', devEui) .eq('created_at', requestedCreatedAt) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (exactMatchError) { throw new InternalServerErrorException( @@ -150,14 +156,14 @@ export class AirService extends BaseDataService<'cw_air_data'> { const { rangeEnd, rangeStart } = this.getTimestampResolutionWindow(requestedCreatedAt); - const { data: matches, error: matchError } = await client + const { data: matches, error: matchError } = (await client .from('cw_air_data') .select('created_at') .eq('dev_eui', devEui) .gte('created_at', rangeStart) .lt('created_at', rangeEnd) .order('created_at', { ascending: true }) - .limit(2); + .limit(2)) as QueryResult; if (matchError) { throw new InternalServerErrorException( diff --git a/src/v1/auth/auth.controller.ts b/src/v1/auth/auth.controller.ts index 9145972..6366706 100644 --- a/src/v1/auth/auth.controller.ts +++ b/src/v1/auth/auth.controller.ts @@ -51,7 +51,7 @@ export class AuthController { message: 'Unauthorized', }, }) - async protected(@CurrentUser() user: AuthenticatedUser) { + protected(@CurrentUser() user: AuthenticatedUser) { return user; } diff --git a/src/v1/auth/auth.service.ts b/src/v1/auth/auth.service.ts index b24b07d..8de4210 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -7,7 +7,9 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; +import type { TableRow } from '../types/supabase'; import type { AuthenticatedUser } from './authenticated-user'; import { UpdateUserProfileDto } from './dto/update-user-profile.dto'; import { UpdatePreferencesDto } from './dto/update-preferences.dto'; @@ -16,6 +18,12 @@ import { UpdatePreferencesDto } from './dto/update-preferences.dto'; // change their email address (checked against the caller's current email). const RESTRICTED_EMAIL_CHANGE_DOMAINS = ['@cropwatch.io', '@cropwatch.co.jp']; +type ProfileRow = TableRow<'profiles'>; +type PreferencesRow = TableRow<'profile_preferences'>; + +/** Shape of a PostgREST single/maybeSingle response from the untyped client. */ +type QueryResult = { data: T | null; error: PostgrestError | null }; + const PREFERENCE_KEYS = [ 'theme', 'temperature_unit', @@ -86,11 +94,11 @@ export class AuthService { async getUserProfile(user: AuthenticatedUser) { const client = this.supabaseService.getClient(); const userId = user.sub; - const { data, error } = await client + const { data, error } = (await client .from('profiles') .select('*') .eq('id', userId) - .single(); + .single()) as QueryResult; if (error) { throw new BadRequestException('Failed to fetch user profile'); @@ -132,13 +140,13 @@ export class AuthService { typeof normalized.username === 'string' && normalized.username.length > 0 ) { - const { data: existing, error: lookupError } = await client + const { data: existing, error: lookupError } = (await client .from('profiles') .select('id') .eq('username', normalized.username) .neq('id', userId) .limit(1) - .maybeSingle(); + .maybeSingle()) as QueryResult>; if (lookupError) { throw new InternalServerErrorException( @@ -151,12 +159,12 @@ export class AuthService { } } - const { data, error } = await client + const { data, error } = (await client .from('profiles') .update(normalized) .eq('id', userId) .select('*') - .single(); + .single()) as QueryResult; if (error) { // Postgres unique-violation, in case the DB has a constraint we bypassed above. @@ -252,11 +260,11 @@ export class AuthService { const client = this.supabaseService.getClient(); const userId = user.sub; - const { data, error } = await client + const { data, error } = (await client .from('profile_preferences') .select('*') .eq('user_id', userId) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to read preferences'); } @@ -264,14 +272,14 @@ export class AuthService { return data; } - const { data: inserted, error: insertError } = await client + const { data: inserted, error: insertError } = (await client .from('profile_preferences') .upsert( { user_id: userId }, { onConflict: 'user_id', ignoreDuplicates: false }, ) .select('*') - .single(); + .single()) as QueryResult; if (insertError || !inserted) { throw new InternalServerErrorException('Failed to create preferences'); } @@ -299,14 +307,14 @@ export class AuthService { throw new BadRequestException('No preference fields provided to update'); } - const { data, error } = await client + const { data, error } = (await client .from('profile_preferences') .upsert( { user_id: userId, ...patch, updated_at: new Date().toISOString() }, { onConflict: 'user_id' }, ) .select('*') - .single(); + .single()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to update preferences'); diff --git a/src/v1/common/base-data.service.ts b/src/v1/common/base-data.service.ts index a4b23d0..3198389 100644 --- a/src/v1/common/base-data.service.ts +++ b/src/v1/common/base-data.service.ts @@ -62,10 +62,11 @@ export abstract class BaseDataService { ); } - return (data ?? []).map((row) => ({ + const rows = (data ?? []) as (TableRow & { created_at: string })[]; + + return rows.map((row) => ({ ...row, created_at: this.timezoneFormatter.formatTimestamp( - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument row.created_at, normalizedTimeZone, ), diff --git a/src/v1/common/timezone-formatter.service.ts b/src/v1/common/timezone-formatter.service.ts index ebd0c72..4687cb1 100644 --- a/src/v1/common/timezone-formatter.service.ts +++ b/src/v1/common/timezone-formatter.service.ts @@ -6,7 +6,7 @@ export class TimezoneFormatterService { assertValidTimeZone(timeZone: string): void { try { new Intl.DateTimeFormat('en-US', { timeZone }).format(new Date()); - } catch (error) { + } catch { throw new BadRequestException('timezone must be a valid IANA time zone'); } } diff --git a/src/v1/common/v1-route-input-contract.spec.ts b/src/v1/common/v1-route-input-contract.spec.ts index 5cb9574..d22b968 100644 --- a/src/v1/common/v1-route-input-contract.spec.ts +++ b/src/v1/common/v1-route-input-contract.spec.ts @@ -1048,12 +1048,13 @@ describe('V1 Route Input Contracts', () => { expect(response.status).toBe(testCase.expectedStatus); if (testCase.expectedMessage !== undefined) { + const body = response.body as { message: string | string[] }; if (Array.isArray(testCase.expectedMessage)) { - expect(response.body.message).toEqual( + expect(body.message).toEqual( expect.arrayContaining(testCase.expectedMessage), ); } else { - expect(response.body.message).toBe(testCase.expectedMessage); + expect(body.message).toBe(testCase.expectedMessage); } } diff --git a/src/v1/dashboard/dashboard.service.ts b/src/v1/dashboard/dashboard.service.ts index ab54051..85f2746 100644 --- a/src/v1/dashboard/dashboard.service.ts +++ b/src/v1/dashboard/dashboard.service.ts @@ -5,9 +5,11 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import { READ_EXCLUSIVE_CEILING } from '../common/permission-levels'; import { sanitizeOrFilterTerm } from '../common/postgrest-filter.helper'; +import type { TableRow } from '../types/supabase'; import { DashboardLocationGroup, DashboardLocationPage, @@ -18,6 +20,40 @@ import { } from './dashboard.types'; import type { AuthenticatedUser } from '../auth/authenticated-user'; +type DeviceRow = TableRow<'cw_devices'>; +type DeviceTypeRow = TableRow<'cw_device_type'>; +type LocationRow = TableRow<'cw_locations'>; + +type LocationJoin = Pick; +type DeviceTypeJoin = Pick< + DeviceTypeRow, + | 'id' + | 'name' + | 'data_table_v2' + | 'primary_data_v2' + | 'secondary_data_v2' + | 'default_upload_interval' +>; + +/** Columns selected by the device queries in getDevices/getLocations. */ +type DashboardDeviceRecord = Pick< + DeviceRow, + | 'dev_eui' + | 'name' + | 'group' + | 'upload_interval' + | 'last_data_updated_at' + | 'error_status' +> & { + cw_device_type: DeviceTypeJoin | DeviceTypeJoin[] | null; + cw_locations: LocationJoin | LocationJoin[] | null; +}; + +/** Columns selected by the location-listing query in getLocations. */ +type DeviceLocationRecord = Pick & { + cw_locations: LocationJoin | LocationJoin[] | null; +}; + @Injectable() export class DashboardService { private readonly logger = new Logger(DashboardService.name); @@ -94,7 +130,7 @@ export class DashboardService { ); } - const devices = data ?? []; + const devices = (data ?? []) as DashboardDeviceRecord[]; const rows = await Promise.all( devices.map((d) => this.buildRow(client, d)), @@ -171,15 +207,15 @@ export class DashboardService { } | null; const uniqueLocs = new Map(); let hasNoLocationBucket = false; - for (const d of locData ?? []) { - const locId = (d as any).location_id; + for (const d of (locData ?? []) as DeviceLocationRecord[]) { + const locId = d.location_id; if (locId == null) { hasNoLocationBucket = true; continue; } const key = String(locId); if (uniqueLocs.has(key)) continue; - const rawLoc = (d as any).cw_locations; + const rawLoc = d.cw_locations; const loc = Array.isArray(rawLoc) ? rawLoc[0] : rawLoc; uniqueLocs.set(key, { location_id: locId, @@ -252,7 +288,11 @@ export class DashboardService { } const rows = ( - await Promise.all((devices ?? []).map((d) => this.buildRow(client, d))) + await Promise.all( + ((devices ?? []) as DashboardDeviceRecord[]).map((d) => + this.buildRow(client, d), + ), + ) ).filter((r): r is DashboardRow => r !== null); // Bucket devices into the page's location slots, preserving page order. @@ -312,9 +352,15 @@ export class DashboardService { throw new NotFoundException('Device not found'); } - const deviceType = Array.isArray(device.cw_device_type) - ? device.cw_device_type[0] - : device.cw_device_type; + const deviceRecord = device as Pick & { + cw_device_type: + | Pick + | Pick[] + | null; + }; + const deviceType = Array.isArray(deviceRecord.cw_device_type) + ? deviceRecord.cw_device_type[0] + : deviceRecord.cw_device_type; const table = deviceType?.data_table_v2; if (!isDashboardDataTable(table)) { @@ -323,13 +369,16 @@ export class DashboardService { ); } - const { data: latest, error: latestError } = await client + const { data: latest, error: latestError } = (await client .from(table) .select('*') .eq('dev_eui', normalized) .order('created_at', { ascending: false }) .limit(1) - .maybeSingle(); + .maybeSingle()) as { + data: Record | null; + error: PostgrestError | null; + }; if (latestError) { this.logger.error( @@ -338,12 +387,12 @@ export class DashboardService { throw new InternalServerErrorException('Failed to fetch latest data'); } - return (latest as Record | null) ?? null; + return latest ?? null; } private async buildRow( client: ReturnType, - d: any, + d: DashboardDeviceRecord, ): Promise { const deviceType = Array.isArray(d.cw_device_type) ? d.cw_device_type[0] @@ -449,8 +498,8 @@ export class DashboardService { return []; } - return (data ?? []) - .map((l: any) => l.location_id) + return ((data ?? []) as Pick[]) + .map((l) => l.location_id) .filter((id: unknown): id is number => typeof id === 'number'); } @@ -467,11 +516,13 @@ export class DashboardService { return parts.join(','); } - private applyDeviceReadScope( - query: any, - userId: string, - isGlobalUser: boolean, - ) { + private applyDeviceReadScope< + Q extends { + eq(column: string, value: unknown): Q; + lt(column: string, value: unknown): Q; + or(filters: string): Q; + }, + >(query: Q, userId: string, isGlobalUser: boolean): Q { if (isGlobalUser) { return query; } diff --git a/src/v1/devices/devices.controller.ts b/src/v1/devices/devices.controller.ts index 4626b09..539de05 100644 --- a/src/v1/devices/devices.controller.ts +++ b/src/v1/devices/devices.controller.ts @@ -143,8 +143,8 @@ export class DevicesController { description: ` Returns the device types for the authenticated user.`, }) - findAllDeviceTypes(@CurrentUser() user: AuthenticatedUser) { - return this.devicesService.findAllDeviceTypes(user); + findAllDeviceTypes() { + return this.devicesService.findAllDeviceTypes(); } @Get('latest-primary-data') @@ -434,11 +434,7 @@ export class DevicesController { Please contact support if you would like this feature to be prioritized. `, }) - create( - @CurrentUser() user: AuthenticatedUser, - @Param('dev_eui') devEui: string, - @Body() _body?: unknown, - ) { + create(@Param('dev_eui') _devEui: string) { throw new NotImplementedException( 'Device creation is not yet implemented. Please contact support if you would like this feature to be prioritized.', ); @@ -455,7 +451,7 @@ export class DevicesController { Please contact support if you would like this feature to be prioritized. `, }) - replace( + async replace( @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: ReplaceDeviceDto, @@ -469,7 +465,7 @@ export class DevicesController { const replacementDevice: ReplaceDeviceDto = body; - const insertResult = this.devicesService.replaceDevice( + const insertResult = await this.devicesService.replaceDevice( user, normalizedDevEui, replacementDevice, diff --git a/src/v1/devices/devices.service.spec.ts b/src/v1/devices/devices.service.spec.ts index 5f50884..1036d74 100644 --- a/src/v1/devices/devices.service.spec.ts +++ b/src/v1/devices/devices.service.spec.ts @@ -242,9 +242,17 @@ describe('DevicesService', () => { }; } + type DeviceOwnerInsert = { + dev_eui: string; + user_id: string; + permission_level: number; + }; + function createInsertBuilder() { return { - insert: jest.fn().mockResolvedValue({ error: null }), + insert: jest + .fn, [rows: DeviceOwnerInsert[]]>() + .mockResolvedValue({ error: null }), }; } diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index 1c6b1e8..7df19dc 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -6,6 +6,7 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; import { LocationsService } from '../locations/locations.service'; @@ -18,6 +19,59 @@ import { } from '../common/permission-levels'; import type { AuthenticatedUser } from '../auth/authenticated-user'; +type DeviceRow = TableRow<'cw_devices'>; +type DeviceOwnerRow = TableRow<'cw_device_owners'>; +type DeviceTypeRow = TableRow<'cw_device_type'>; +type LocationRow = TableRow<'cw_locations'>; +type LocationOwnerRow = TableRow<'cw_location_owners'>; + +type LocationJoin = Pick; + +/** Device row plus the relations embedded by the select strings below. */ +type DeviceRecord = DeviceRow & { + /** Not present in the generated cw_devices Row type; read defensively by findLatestData. */ + created_at?: string | null; + cw_device_owners?: DeviceOwnerRow[]; + cw_locations?: LocationJoin | LocationJoin[] | null; + cw_device_type?: DeviceTypeRow | DeviceTypeRow[] | null; +}; + +type LatestDeviceTypeJoin = Pick< + DeviceTypeRow, + | 'name' + | 'default_upload_interval' + | 'primary_data_v2' + | 'secondary_data_v2' + | 'data_table_v2' +>; + +/** Columns selected by findAllLatestData. */ +type LatestDeviceRecord = Pick< + DeviceRow, + 'dev_eui' | 'name' | 'group' | 'location_id' | 'last_data_updated_at' +> & { + cw_device_type: LatestDeviceTypeJoin | LatestDeviceTypeJoin[] | null; + cw_locations: LocationJoin | LocationJoin[] | null; +}; + +/** Row from a per-device-type data table (cw_air_data, ...) — columns vary by table. */ +type SensorDataRow = Record; + +type SingleResult = { data: T | null; error: PostgrestError | null }; +type ListResult = { + data: T[] | null; + count?: number | null; + error: PostgrestError | null; +}; + +/** Structural shape of the PostgREST filter builder methods the scope helpers use. */ +interface DeviceScopeQuery { + eq(column: string, value: unknown): Q; + lt(column: string, value: unknown): Q; + lte(column: string, value: unknown): Q; + or(filters: string): Q; +} + export interface PagedDevicesResponse { total?: number; skip: number; @@ -41,7 +95,7 @@ export class DevicesService { searchGroup?: string, searchName?: string, searchLocation?: string, - ): Promise>> { + ): Promise> { const client = this.supabaseService.getClient(); const userId = user.sub; const isGlobalUser = user.isStaff; @@ -87,21 +141,22 @@ export class DevicesService { throw new InternalServerErrorException('Failed to fetch devices'); } + const rows = (data ?? []) as DeviceRecord[]; const responseTake = - typeof take === 'number' ? take : (count ?? data?.length ?? 0); + typeof take === 'number' ? take : (count ?? rows.length); return { total: count ?? 0, skip, take: responseTake, - data: data ?? [], + data: rows, }; } async findOne( user: AuthenticatedUser, devEui: string, - ): Promise> { + ): Promise { const client = this.supabaseService.getClient(); const userId = user.sub; const isGlobalUser = user.isStaff; @@ -125,9 +180,9 @@ export class DevicesService { query = this.applyDeviceReadScope(query, userId, isGlobalUser); - const { data, error } = await query + const { data, error } = (await query .order('name', { ascending: true }) - .single(); + .single()) as SingleResult; if (error) { throw new InternalServerErrorException('Failed to fetch device'); @@ -218,7 +273,8 @@ export class DevicesService { throw new NotFoundException('No device groups found'); } - const groupCounts = groups.reduce( + const groupRows = groups as Pick[]; + const groupCounts = groupRows.reduce( (acc, item) => { const existing = acc.find((g) => g.group === item.group); if (existing) { @@ -234,11 +290,8 @@ export class DevicesService { return groupCounts; } - public async findAllDeviceTypes( - user: AuthenticatedUser, - ): Promise<{ type: string | null; count: number }[]> { + public async findAllDeviceTypes(): Promise { const client = this.supabaseService.getClient(); - const userId = user.sub; const { data: types, error } = await client .from('cw_device_type') @@ -252,7 +305,7 @@ export class DevicesService { throw new NotFoundException('No device types found'); } - return types; + return types as DeviceTypeRow[]; } public async findData( @@ -260,7 +313,7 @@ export class DevicesService { devEui: string, skip: number = 0, take: number = 144, - ): Promise> { + ): Promise> { this.logger.log( `findData called: devEui=${devEui}, skip=${skip}, take=${take}`, ); @@ -289,7 +342,8 @@ export class DevicesService { deviceQuery = this.applyDeviceReadScope(deviceQuery, userId, isGlobalUser); - const { data: device, error: deviceError } = await deviceQuery.single(); + const { data: device, error: deviceError } = + (await deviceQuery.single()) as SingleResult; if (deviceError) { this.logger.error( @@ -309,11 +363,11 @@ export class DevicesService { this.logger.debug( `findData: fetching device type id=${device.type} for devEui=${normalizedDevEui}`, ); - const { data: deviceType, error: deviceTypeError } = await client + const { data: deviceType, error: deviceTypeError } = (await client .from('cw_device_type') .select('*') .eq('id', device.type) - .maybeSingle(); + .maybeSingle()) as SingleResult; if (deviceTypeError) { this.logger.error( @@ -350,13 +404,13 @@ export class DevicesService { this.logger.debug( `findData: querying table=${dataTable}, select=${getAnnotations}, range=${skip}-${skip + take - 1}`, ); - const { data: latestData, error: dataError } = await client + const { data: latestData, error: dataError } = (await client .from(dataTable) .select(`${getAnnotations}`) .eq('dev_eui', normalizedDevEui) .order('created_at', { ascending: false }) .range(skip, skip + take - 1) - .limit(take); + .limit(take)) as ListResult; if (dataError) { this.logger.error( @@ -393,7 +447,7 @@ export class DevicesService { end: Date | string = new Date().toISOString(), skip: number = 0, take: number = 144, - ): Promise> { + ): Promise> { const client = this.supabaseService.getClient(); const userId = user.sub; const isGlobalUser = user.isStaff; @@ -409,7 +463,8 @@ export class DevicesService { deviceQuery = this.applyDeviceReadScope(deviceQuery, userId, isGlobalUser); - const { data: device, error: deviceError } = await deviceQuery.single(); + const { data: device, error: deviceError } = + (await deviceQuery.single()) as SingleResult; if (deviceError) { this.logger.error( @@ -423,11 +478,11 @@ export class DevicesService { throw new NotFoundException('Device not found'); } - const { data: deviceType, error: deviceTypeError } = await client + const { data: deviceType, error: deviceTypeError } = (await client .from('cw_device_type') .select('*') .eq('id', device.type) - .maybeSingle(); + .maybeSingle()) as SingleResult; if (deviceTypeError) { throw new InternalServerErrorException('Failed to fetch device type'); @@ -440,18 +495,14 @@ export class DevicesService { const startDate = new Date(start).toISOString(); const endDate = new Date(end).toISOString(); - const { - data: latestData, - count, - error: dataError, - } = await client + const { data: latestData, error: dataError } = (await client .from(deviceType.data_table_v2) .select('*') .eq('dev_eui', normalizedDevEui) .gte('created_at', startDate) .lte('created_at', endDate) .order('created_at', { ascending: false }) - .range(skip, skip + take - 1); + .range(skip, skip + take - 1)) as ListResult; if (dataError) { throw new InternalServerErrorException('Failed to fetch Data'); @@ -476,7 +527,7 @@ export class DevicesService { searchName?: string, searchLocation?: string, locationGroup?: string, - ): Promise> { + ): Promise>> { const client = this.supabaseService.getClient(); const userId = user.sub; const isGlobalUser = user.isStaff; @@ -485,9 +536,6 @@ export class DevicesService { const locationIdFilter = hasLocationFilter ? Number(searchLocation) : undefined; - const countLocationSelect = hasLocationFilter - ? 'cw_locations!inner(location_id, name, group)' - : 'cw_locations(location_id, name, group)'; const dataLocationSelect = hasLocationFilter ? 'cw_locations!inner(location_id, name, group)' : 'cw_locations(location_id, name, group)'; @@ -545,9 +593,10 @@ export class DevicesService { throw new NotFoundException('No devices found'); } + const deviceRows = device as LatestDeviceRecord[]; const devicesWithLatestData = ( await Promise.all( - device.map(async (d) => { + deviceRows.map(async (d) => { const deviceType = Array.isArray(d.cw_device_type) ? d.cw_device_type[0] : d.cw_device_type; @@ -637,7 +686,7 @@ export class DevicesService { public async findAllDevicesInLocation( user: AuthenticatedUser, locationId: number, - ): Promise[]> { + ): Promise { const client = this.supabaseService.getClient(); const userId = user.sub; const isGlobalUser = user.isStaff; @@ -661,7 +710,7 @@ export class DevicesService { throw new NotFoundException('No devices found for this location'); } - return devices; + return devices as DeviceRecord[]; } public async findLatestData( @@ -684,7 +733,8 @@ export class DevicesService { deviceQuery = this.applyDeviceReadScope(deviceQuery, userId, isGlobalUser); - const { data: device, error: deviceError } = await deviceQuery.single(); + const { data: device, error: deviceError } = + (await deviceQuery.single()) as SingleResult; if (deviceError) { throw new InternalServerErrorException('Failed to fetch device'); @@ -694,11 +744,11 @@ export class DevicesService { throw new NotFoundException('Device not found'); } - const { data: deviceType, error: deviceTypeError } = await client + const { data: deviceType, error: deviceTypeError } = (await client .from('cw_device_type') .select('*') .eq('id', device.type) - .maybeSingle(); + .maybeSingle()) as SingleResult; if (deviceTypeError) { throw new InternalServerErrorException('Failed to fetch device type'); @@ -708,13 +758,13 @@ export class DevicesService { throw new NotFoundException('Device type not found'); } - const { data: latestData, error: dataError } = await client + const { data: latestData, error: dataError } = (await client .from(deviceType.data_table_v2) .select('*') .eq('dev_eui', normalizedDevEui) .order('created_at', { ascending: false }) .limit(1) - .maybeSingle(); + .maybeSingle()) as SingleResult; if (dataError) { throw new InternalServerErrorException('Failed to fetch latest data'); @@ -766,10 +816,10 @@ export class DevicesService { if (!device.location_id) { throw new BadRequestException('location_id is required'); } - const location = await this.locationsService.findOne( + const location = (await this.locationsService.findOne( device.location_id, user, - ); + )) as LocationRow | null; if (!location) { throw new BadRequestException('Invalid location'); } @@ -780,7 +830,7 @@ export class DevicesService { } // create the device - const { data: createdDeviceData, error: createDeviceError } = await client + const { data: createdDeviceData, error: createDeviceError } = (await client .from('cw_devices') .insert({ dev_eui: normalizedDevEui, @@ -791,7 +841,7 @@ export class DevicesService { user_id: userId, }) .select('*') - .single(); + .single()) as SingleResult; if (createDeviceError) { throw new InternalServerErrorException('Failed to create device'); @@ -872,7 +922,7 @@ export class DevicesService { ); const { data: device, error: deviceError } = - await existingDeviceQuery.single(); + (await existingDeviceQuery.single()) as SingleResult; if (deviceError) { throw new InternalServerErrorException('Failed to fetch device'); @@ -896,7 +946,7 @@ export class DevicesService { ); const { data: newDeviceData, error: newDeviceError } = - await newDeviceQuery.single(); + (await newDeviceQuery.single()) as SingleResult; if (newDeviceError) { throw new InternalServerErrorException('Failed to fetch new device'); @@ -907,7 +957,7 @@ export class DevicesService { } // We have access to both devices, let's now update the existing device with the new device. - const { data: updatedDeviceData, error: updateDeviceError } = await client + const { data: updatedDeviceData, error: updateDeviceError } = (await client .from('cw_devices') .update({ dev_eui: newDevice.dev_eui, @@ -918,7 +968,7 @@ export class DevicesService { }) .eq('dev_eui', normalizedDevEui) .select('*') - .single(); + .single()) as SingleResult; if (updateDeviceError) { throw new InternalServerErrorException('Failed to update device'); @@ -965,7 +1015,7 @@ export class DevicesService { ); const { data: RequestingUserHasPermission, error: deviceError } = - await permissionQuery.single(); + (await permissionQuery.single()) as SingleResult; if (!RequestingUserHasPermission || deviceError) { throw new UnauthorizedException( @@ -988,12 +1038,12 @@ export class DevicesService { } // do the thing - const { data, error } = await client + const { data, error } = (await client .from('cw_device_owners') .update({ permission_level: permissionLevel }) .eq('dev_eui', devEui) .eq('user_id', targetUser.id) - .select('*'); + .select('*')) as ListResult; if (!data || error) { throw new BadRequestException( @@ -1042,7 +1092,7 @@ export class DevicesService { ); const { data: RequestingUserHasPermission, error: deviceError } = - await permissionQuery.single(); + (await permissionQuery.single()) as SingleResult; if (!RequestingUserHasPermission || deviceError) { throw new UnauthorizedException( @@ -1050,10 +1100,7 @@ export class DevicesService { ); } - const currentDevice = RequestingUserHasPermission as { - location_id: number | null; - user_id: string | null; - }; + const currentDevice = RequestingUserHasPermission; const isMovingLocation = Number(currentDevice.location_id) !== Number(location_id); @@ -1083,7 +1130,11 @@ export class DevicesService { } const { data: destination, error: destinationError } = - await destinationQuery.maybeSingle(); + (await destinationQuery.maybeSingle()) as SingleResult< + Pick & { + cw_location_owners: Pick[] | null; + } + >; if (destinationError) { throw new InternalServerErrorException( @@ -1110,11 +1161,11 @@ export class DevicesService { updatePayload.user_id = destinationOwnerId; } - const { data, error } = await client + const { data, error } = (await client .from('cw_devices') .update(updatePayload) .eq('dev_eui', devEui) - .select('*'); + .select('*')) as ListResult; if (!data || error) { throw new BadRequestException('Failed to update device'); @@ -1141,7 +1192,7 @@ export class DevicesService { * — ownership via cw_devices.user_id is implicit and outranks all levels. */ private async resetDevicePermissionsForMove( - client: any, + client: ReturnType, devEui: string, moverId: string, destinationOwnerId: string | null, @@ -1186,11 +1237,11 @@ export class DevicesService { } } - private applyDeviceReadScope( - query: any, + private applyDeviceReadScope>( + query: Q, userId: string, isGlobalUser: boolean, - ) { + ): Q { if (isGlobalUser) { return query; } @@ -1201,12 +1252,12 @@ export class DevicesService { .or(`user_id.eq.${userId},owner_match.not.is.null`); } - private applyDeviceManageScope( - query: any, + private applyDeviceManageScope>( + query: Q, userId: string, isGlobalUser: boolean, maxPermissionLevel: number, - ) { + ): Q { if (isGlobalUser) { return query; } diff --git a/src/v1/devices/dto/replace-device.dto.ts b/src/v1/devices/dto/replace-device.dto.ts index b541f75..4719cd1 100644 --- a/src/v1/devices/dto/replace-device.dto.ts +++ b/src/v1/devices/dto/replace-device.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { ApiProperty } from '@nestjs/swagger'; import { IsNotEmpty, IsString } from 'class-validator'; export class ReplaceDeviceDto { diff --git a/src/v1/gateway/gateway.service.ts b/src/v1/gateway/gateway.service.ts index acb4109..f4f69b2 100644 --- a/src/v1/gateway/gateway.service.ts +++ b/src/v1/gateway/gateway.service.ts @@ -4,15 +4,32 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; import type { AuthenticatedUser } from '../auth/authenticated-user'; +type GatewayRow = TableRow<'cw_gateways'>; +type GatewayOwnerRow = TableRow<'cw_gateways_owners'>; +type GatewayRecord = GatewayRow & { + cw_gateways_owners?: GatewayOwnerRow[]; +}; +type GatewayListItem = Pick< + GatewayRow, + | 'id' + | 'gateway_id' + | 'is_online' + | 'is_public' + | 'gateway_name' + | 'updated_at' +>; +type QueryResult = { data: T | null; error: PostgrestError | null }; + @Injectable() export class GatewayService { constructor(private readonly supabaseService: SupabaseService) {} - async findAll(user: AuthenticatedUser): Promise[]> { + async findAll(user: AuthenticatedUser): Promise { const client = this.supabaseService.getClient(); const userId = user.sub; @@ -30,12 +47,13 @@ export class GatewayService { throw new InternalServerErrorException('Failed to fetch gateways'); } - const ownedGatewayIds = new Set( - ownedGateways?.map((og) => og.gateway_id) ?? [], - ); + const ownedRows = (ownedGateways ?? []) as GatewayRecord[]; + const publicRows = (publicGateways ?? []) as GatewayRow[]; + + const ownedGatewayIds = new Set(ownedRows.map((og) => og.gateway_id)); - const allGateways = [ - ...(ownedGateways ?? []).map((og) => ({ + const allGateways: GatewayListItem[] = [ + ...ownedRows.map((og) => ({ id: og.id, gateway_id: og.gateway_id, is_online: og.is_online, @@ -43,18 +61,16 @@ export class GatewayService { gateway_name: og.gateway_name, updated_at: og.updated_at, })), - ...(publicGateways ?? []).filter( - (pg) => !ownedGatewayIds.has(pg.gateway_id), - ), + ...publicRows.filter((pg) => !ownedGatewayIds.has(pg.gateway_id)), ]; - return allGateways ?? []; + return allGateways; } async findOne( gatewayIdentifier: string, user: AuthenticatedUser, - ): Promise> { + ): Promise { const normalizedGatewayIdentifier = gatewayIdentifier?.trim(); if (!normalizedGatewayIdentifier) { throw new BadRequestException('gateway_id is required'); @@ -73,7 +89,8 @@ export class GatewayService { ) .eq('gateway_id', normalizedGatewayIdentifier); - const { data, error } = await query.maybeSingle(); + const { data, error } = + (await query.maybeSingle()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to fetch gateway'); @@ -83,9 +100,7 @@ export class GatewayService { throw new NotFoundException('Gateway not found'); } - const isOwner = data.cw_gateways_owners?.some( - (o: any) => o.user_id === userId, - ); + const isOwner = data.cw_gateways_owners?.some((o) => o.user_id === userId); if (!data.is_public && !isOwner) { throw new NotFoundException('Gateway not found'); } diff --git a/src/v1/locations/locations.controller.ts b/src/v1/locations/locations.controller.ts index 8e64c35..c154910 100644 --- a/src/v1/locations/locations.controller.ts +++ b/src/v1/locations/locations.controller.ts @@ -213,7 +213,7 @@ export class LocationsController { }) async updateUserPermissionLevel( @Param('id') id: string, - @Body() updateLocationUserPermissionLevelDto: any, + @Body() updateLocationUserPermissionLevelDto: unknown, @Query('applyToAllDevices') applyToAllDevices: string = 'false', @CurrentUser() user: AuthenticatedUser, ) { diff --git a/src/v1/locations/locations.service.spec.ts b/src/v1/locations/locations.service.spec.ts index 7881475..e218205 100644 --- a/src/v1/locations/locations.service.spec.ts +++ b/src/v1/locations/locations.service.spec.ts @@ -7,10 +7,27 @@ import { SupabaseService } from '../../supabase/supabase.service'; import { PaymentsService } from '../payments/payments.service'; describe('LocationsService', () => { - type QueryResult = { data: any; error: any }; + type QueryResult = { data: unknown; error: unknown }; + + type QueryBuilder = { + data: unknown; + error: unknown; + select: jest.Mock; + eq: jest.Mock; + gt: jest.Mock; + lt: jest.Mock; + lte: jest.Mock; + or: jest.Mock; + order: jest.Mock; + maybeSingle: jest.Mock; + single: jest.Mock; + upsert: jest.Mock; + insert: jest.Mock; + delete: jest.Mock; + }; - const createBuilder = (result: QueryResult) => { - const builder: any = { + const createBuilder = (result: QueryResult): QueryBuilder => { + const builder: QueryBuilder = { data: result.data, error: result.error, select: jest.fn(() => builder), @@ -20,8 +37,8 @@ describe('LocationsService', () => { lte: jest.fn(() => builder), or: jest.fn(() => builder), order: jest.fn(() => builder), - maybeSingle: jest.fn(async () => result), - single: jest.fn(async () => result), + maybeSingle: jest.fn(() => Promise.resolve(result)), + single: jest.fn(() => Promise.resolve(result)), upsert: jest.fn(() => builder), insert: jest.fn(() => builder), delete: jest.fn(() => builder), @@ -29,24 +46,24 @@ describe('LocationsService', () => { return builder; }; - const createClient = (queues: Record) => ({ - from: jest.fn((table: string) => { + const createClient = (queues: Record) => ({ + from: jest.fn((table: string): QueryBuilder => { const tableQueue = queues[table]; if (!tableQueue || tableQueue.length === 0) { throw new Error(`No mock builder available for table: ${table}`); } - return tableQueue.shift(); + return tableQueue.shift() as QueryBuilder; }), }); - const createService = (client: any) => + const createService = (client: ReturnType) => new LocationsService( { getClient: jest.fn(() => client), getAdminClient: jest.fn(), } as unknown as SupabaseService, { - hasActiveBaseSubscription: jest.fn(async () => true), + hasActiveBaseSubscription: jest.fn(() => Promise.resolve(true)), } as unknown as PaymentsService, ); diff --git a/src/v1/locations/locations.service.ts b/src/v1/locations/locations.service.ts index db862f8..6d82d86 100644 --- a/src/v1/locations/locations.service.ts +++ b/src/v1/locations/locations.service.ts @@ -3,15 +3,14 @@ import { Injectable, InternalServerErrorException, NotFoundException, - NotImplementedException, UnauthorizedException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { CreateLocationDto } from './dto/create-location.dto'; import { CreateLocationOwnerDto } from './dto/create-location-owner.dto'; import { UpdateLocationDto } from './dto/update-location.dto'; import { SupabaseService } from '../../supabase/supabase.service'; import { PaymentsService } from '../payments/payments.service'; -import { error, group } from 'console'; import { LocationDto } from './dto/location.dto'; import { UpdateLocationOwnerDto } from './dto/update-location-owner.dto'; import { @@ -20,8 +19,31 @@ import { READ_EXCLUSIVE_CEILING, } from '../common/permission-levels'; import { filterStaffOwnerRows } from '../common/owner-filter.helper'; +import type { TableRow } from '../types/supabase'; import type { AuthenticatedUser } from '../auth/authenticated-user'; +type LocationRow = TableRow<'cw_locations'>; +type LocationOwnerRow = TableRow<'cw_location_owners'>; +type OwnerProfile = Pick, 'id' | 'full_name' | 'email'>; +type LocationOwnerWithProfile = LocationOwnerRow & { + profiles?: OwnerProfile | OwnerProfile[] | null; +}; +type LocationRecord = LocationRow & { + cw_location_owners: LocationOwnerWithProfile[]; +}; +type QueryResult = { data: T | null; error: PostgrestError | null }; + +/** + * Structural constraint for the Supabase query builders the scope helpers + * accept: the helpers only chain `.eq`, `.lt`/`.lte`, and `.or`. + */ +interface LocationScopeQuery { + eq(column: string, value: unknown): Q; + lt(column: string, value: unknown): Q; + lte(column: string, value: unknown): Q; + or(filters: string): Q; +} + @Injectable() export class LocationsService { constructor( @@ -46,14 +68,14 @@ export class LocationsService { createLocationDto.owner_id = userId; // Ensure the owner_id is set to the authenticated user - const { data: locationData, error: locationError } = await client + const { data: locationData, error: locationError } = (await client .from('cw_locations') .insert({ ...createLocationDto, owner_id: userId, }) .select('*') - .single(); + .single()) as QueryResult; if (locationError) { throw new InternalServerErrorException('Failed to create location'); @@ -70,7 +92,7 @@ export class LocationsService { description: null, }; - const { data: ownerData, error: ownerError } = await client + const { error: ownerError } = await client .from('cw_location_owners') .insert({ ...locationOwnerObject, @@ -108,7 +130,7 @@ export class LocationsService { throw new InternalServerErrorException('Failed to fetch locations'); } - return data; + return (data ?? []) as LocationRecord[]; } async findOne(id: number, user: AuthenticatedUser) { @@ -125,9 +147,9 @@ export class LocationsService { query = this.applyLocationReadScope(query, userId, isGlobalUser); - const { data, error } = await query + const { data, error } = (await query .order('name', { ascending: true }) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to fetch location'); @@ -172,7 +194,7 @@ export class LocationsService { isGlobalUser, ); const { data: locationCurrentPermission, error: locationPermissionError } = - await permissionQuery.maybeSingle(); + (await permissionQuery.maybeSingle()) as QueryResult; if (locationPermissionError) throw new InternalServerErrorException( 'Failed to fetch location permissions', @@ -194,7 +216,9 @@ export class LocationsService { updateQuery = updateQuery.eq('owner_id', userId); } - const { data, error } = await updateQuery.select('*').single(); + const { data, error } = (await updateQuery + .select('*') + .single()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to update location'); @@ -233,8 +257,9 @@ export class LocationsService { throw new InternalServerErrorException('Failed to fetch location groups'); } + const rows = (data ?? []) as Pick[]; const uniqueGroupArray = Array.from( - new Set(data.map((item) => item.group)), + new Set(rows.map((item) => item.group)), ).filter((group) => group !== null); return uniqueGroupArray; @@ -268,7 +293,7 @@ export class LocationsService { isGlobalUser, ); const { data: locationCurrentPermission, error: locationPermissionError } = - await permissionQuery.maybeSingle(); + (await permissionQuery.maybeSingle()) as QueryResult; if (locationPermissionError) throw new InternalServerErrorException( 'Failed to fetch location permissions', @@ -369,7 +394,7 @@ export class LocationsService { isGlobalUser, ); const { data: locationCurrentPermission, error: locationPermissionError } = - await permissionQuery.maybeSingle(); + (await permissionQuery.maybeSingle()) as QueryResult; if (locationPermissionError) throw new InternalServerErrorException( 'Failed to fetch location permissions', @@ -434,7 +459,7 @@ export class LocationsService { async updateUserPermissionLevel( id: number, - updateLocationOwnerDto: any, + updateLocationOwnerDto: unknown, applyPermissionToAllDevices: boolean, user: AuthenticatedUser, ) { @@ -442,9 +467,11 @@ export class LocationsService { const client = this.supabaseService.getClient(); const isGlobalUser = user.isStaff; - const email = updateLocationOwnerDto.email; - const permission_level = updateLocationOwnerDto.permission_level; - const location_id = updateLocationOwnerDto.location_id; + const { email, permission_level, location_id } = updateLocationOwnerDto as { + email: string; + permission_level: number | null; + location_id: number; + }; // check if you have permission to update location permissions let permissionQuery = client @@ -463,7 +490,7 @@ export class LocationsService { isGlobalUser, ); const { data: locationCurrentPermission, error: locationPermissionError } = - await permissionQuery.maybeSingle(); + (await permissionQuery.maybeSingle()) as QueryResult; if (locationPermissionError) throw new InternalServerErrorException( 'Failed to fetch location permissions', @@ -528,7 +555,8 @@ export class LocationsService { userId, isGlobalUser, ); - const { data: requestingUser, error } = await permissionQuery.maybeSingle(); + const { data: requestingUser, error } = + (await permissionQuery.maybeSingle()) as QueryResult; if (error) { throw new InternalServerErrorException( @@ -545,12 +573,12 @@ export class LocationsService { const { data: locationPermissionRecord, error: locationPermissionRecordError, - } = await client + } = (await client .from('cw_location_owners') .select('*') .eq('id', permissionId) .eq('location_id', location_id) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (locationPermissionRecordError) throw new InternalServerErrorException( 'Failed to fetch location permission record', @@ -600,11 +628,11 @@ export class LocationsService { }; } - private applyLocationReadScope( - query: any, + private applyLocationReadScope>( + query: Q, userId: string, isGlobalUser: boolean, - ) { + ): Q { if (isGlobalUser) { return query; } @@ -615,11 +643,11 @@ export class LocationsService { .or(`owner_id.eq.${userId},owner_match.not.is.null`); } - private applyLocationManageScope( - query: any, + private applyLocationManageScope>( + query: Q, userId: string, isGlobalUser: boolean, - ) { + ): Q { if (isGlobalUser) { return query; } diff --git a/src/v1/payments/payments.controller.ts b/src/v1/payments/payments.controller.ts index 2617451..5a159d5 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -16,7 +16,6 @@ import type { RawBodyRequest } from '@nestjs/common'; import type { Request } from 'express'; import { ApiBearerAuth, - ApiOkResponse, ApiOperation, ApiParam, ApiSecurity, diff --git a/src/v1/payments/payments.service.ts b/src/v1/payments/payments.service.ts index 9e8017c..c4b0eca 100644 --- a/src/v1/payments/payments.service.ts +++ b/src/v1/payments/payments.service.ts @@ -8,7 +8,7 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; -import { SupabaseClient } from '@supabase/supabase-js'; +import { SupabaseClient, type PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import { MANAGE_CEILING } from '../common/permission-levels'; import type { TableInsert, TableRow } from '../types/supabase'; @@ -25,6 +25,14 @@ import { import type { AuthenticatedUser } from '../auth/authenticated-user'; type BillingCustomerRow = TableRow<'billing_customers'>; +type DeviceLicenseRow = TableRow<'device_licenses'>; +type LicenseSeatRow = Pick< + DeviceLicenseRow, + 'id' | 'seat_index' | 'status' | 'dev_eui' +>; + +/** Shape of a PostgREST response from the untyped Supabase client. */ +type QueryResult = { data: T | null; error: PostgrestError | null }; const ACTIVE_SUBSCRIPTION_STATUSES = ['active', 'trialing', 'past_due']; @@ -128,14 +136,14 @@ export class PaymentsService { return !!baseSub && ACTIVE_SUBSCRIPTION_STATUSES.includes(baseSub.status); } catch (error) { this.logger.warn( - `Base-subscription check fell back to cache for ${userId}: ${error}`, + `Base-subscription check fell back to cache for ${userId}: ${String(error)}`, ); const client = this.supabaseService.getClient(); - const { data } = await client + const { data } = (await client .from('billing_customers') .select('base_status') .eq('user_id', userId) - .maybeSingle(); + .maybeSingle()) as QueryResult>; return ( !!data?.base_status && ACTIVE_SUBSCRIPTION_STATUSES.includes(data.base_status) @@ -250,7 +258,9 @@ export class PaymentsService { const portalUrl = await this.polarService.createPortalSession(userId); return { portalUrl }; } catch (error) { - this.logger.warn(`Failed to open Polar portal for ${userId}: ${error}`); + this.logger.warn( + `Failed to open Polar portal for ${userId}: ${String(error)}`, + ); throw new BadRequestException( 'No billing account yet. Subscribe before opening the billing portal.', ); @@ -555,11 +565,13 @@ export class PaymentsService { subscriptionId: string, targetSeats: number, ): Promise { - const { data, error } = await client + const { data, error } = (await client .from('device_licenses') .select('id, seat_index, status, dev_eui') .eq('user_id', userId) - .order('seat_index', { ascending: true }); + .order('seat_index', { ascending: true })) as QueryResult< + LicenseSeatRow[] + >; if (error) { throw new InternalServerErrorException('Failed to read device licenses'); } @@ -625,11 +637,11 @@ export class PaymentsService { client: SupabaseClient, userId: string, ): Promise { - const { data, error } = await client + const { data, error } = (await client .from('billing_customers') .select('*') .eq('user_id', userId) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to read billing customer'); } @@ -637,14 +649,14 @@ export class PaymentsService { return data; } - const { data: inserted, error: insertError } = await client + const { data: inserted, error: insertError } = (await client .from('billing_customers') .upsert( { user_id: userId }, { onConflict: 'user_id', ignoreDuplicates: false }, ) .select('*') - .single(); + .single()) as QueryResult; if (insertError || !inserted) { throw new InternalServerErrorException( 'Failed to create billing customer', @@ -716,7 +728,7 @@ export class PaymentsService { return await this.polarService.listSubscriptions(userId); } catch (error) { this.logger.warn( - `Failed to list Polar subscriptions for ${userId}: ${error}`, + `Failed to list Polar subscriptions for ${userId}: ${String(error)}`, ); return []; } @@ -763,12 +775,12 @@ export class PaymentsService { userId: string, licenseId: number, ): Promise> { - const { data, error } = await client + const { data, error } = (await client .from('device_licenses') .select('*') .eq('id', licenseId) .eq('user_id', userId) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (error) { throw new InternalServerErrorException('Failed to read license'); } diff --git a/src/v1/relay/relay.controller.spec.ts b/src/v1/relay/relay.controller.spec.ts index 7db08cf..bbfae5c 100644 --- a/src/v1/relay/relay.controller.spec.ts +++ b/src/v1/relay/relay.controller.spec.ts @@ -37,7 +37,7 @@ describe('RelayController', () => { }); it('forwards the TTI downlink API key header to the relay service', () => { - controller.handleTtiUp({ uplink_message: {} }, undefined, 'tti-token'); + void controller.handleTtiUp({ uplink_message: {} }, undefined, 'tti-token'); expect(relayService.handleTtiUp).toHaveBeenCalledWith( { uplink_message: {} }, @@ -53,7 +53,7 @@ describe('RelayController', () => { sub: 'user-1', }; - controller.getLatestRelay('A8404194635A05FB', user); + void controller.getLatestRelay('A8404194635A05FB', user); expect(relayService.getLatestRelay).toHaveBeenCalledWith( user, @@ -68,7 +68,7 @@ describe('RelayController', () => { sub: 'user-1', }; - controller.pulseRelay( + void controller.pulseRelay( 'A8404194635A05FB', { durationSeconds: 60, diff --git a/src/v1/relay/relay.service.spec.ts b/src/v1/relay/relay.service.spec.ts index 1dc3029..b999c8c 100644 --- a/src/v1/relay/relay.service.spec.ts +++ b/src/v1/relay/relay.service.spec.ts @@ -164,14 +164,15 @@ describe('RelayService', () => { .mockResolvedValue(relayRow); const originalFetch = global.fetch; - global.fetch = jest.fn( - async () => + global.fetch = jest.fn(() => + Promise.resolve( new Response(JSON.stringify({}), { headers: { 'content-type': 'application/json', }, status: 200, }), + ), ) as typeof fetch; await expect( @@ -208,9 +209,9 @@ describe('RelayService', () => { }); const originalFetch = global.fetch; - global.fetch = jest.fn(async (_input, init) => { + global.fetch = jest.fn((_input: RequestInfo | URL, init?: RequestInit) => { expect(init?.method).toBe('POST'); - expect(JSON.parse(String(init?.body))).toEqual({ + expect(JSON.parse(init?.body as string)).toEqual({ downlinks: [ { confirmed: false, @@ -219,7 +220,7 @@ describe('RelayService', () => { 'cropwatch:kind:pulse', 'cropwatch:target:on', 'cropwatch:duration_ms:1000', - ]), + ]) as string[], f_port: 2, frm_payload: 'BQERA+g=', priority: 'NORMAL', @@ -227,12 +228,14 @@ describe('RelayService', () => { ], }); - return new Response(JSON.stringify({}), { - headers: { - 'content-type': 'application/json', - }, - status: 200, - }); + return Promise.resolve( + new Response(JSON.stringify({}), { + headers: { + 'content-type': 'application/json', + }, + status: 200, + }), + ); }) as typeof fetch; await expect( @@ -386,8 +389,19 @@ describe('RelayService', () => { .spyOn(service as any, 'findLatestRelayRow') .mockResolvedValue(relayRow); + const persistRelayConfirmation = ( + service as unknown as { + persistRelayConfirmation: (confirmation: { + devEui: string; + receivedAt: string; + relay1?: boolean; + relay2?: boolean; + }) => Promise; + } + ).persistRelayConfirmation.bind(service); + await expect( - (service as any).persistRelayConfirmation({ + persistRelayConfirmation({ devEui: 'A8404194635A05FB', receivedAt: '2026-04-05T04:23:55.360223162Z', relay1: true, diff --git a/src/v1/relay/relay.service.ts b/src/v1/relay/relay.service.ts index fac9e8b..45fb2e7 100644 --- a/src/v1/relay/relay.service.ts +++ b/src/v1/relay/relay.service.ts @@ -9,6 +9,7 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableInsert, TableRow } from '../../v1/types/supabase'; import { @@ -47,6 +48,9 @@ type DeviceRow = TableRow<'cw_devices'>; type RelayRow = TableRow<'cw_relay_data'>; type RelayInsert = TableInsert<'cw_relay_data'>; +/** Shape of a PostgREST response from the untyped Supabase client. */ +type QueryResult = { data: T | null; error: PostgrestError | null }; + type RelayDeviceContext = { applicationId: string; device: DeviceRow; @@ -391,11 +395,11 @@ export class RelayService { const userId = user.sub; const isGlobalUser = user.isStaff; - const { data, error } = await client + const { data, error } = (await client .from('cw_devices') .select('*, cw_device_owners(*), cw_device_type(*)') .eq('dev_eui', devEui) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (error) { this.logger.error( @@ -409,7 +413,7 @@ export class RelayService { throw new NotFoundException('Device not found'); } - const device = data as DeviceRecord; + const device = data; const deviceId = normalizeTtiDeviceId(device.tti_name); if (!deviceId || !isValidTtiDeviceId(deviceId)) { throw new BadRequestException('Device is missing a valid TTI device id'); @@ -457,11 +461,11 @@ export class RelayService { } if (device.location_id) { - const { data, error } = await client + const { data, error } = (await client .from('cw_location_owners') .select('*') .eq('location_id', device.location_id) - .eq('user_id', userId); + .eq('user_id', userId)) as QueryResult; if (error) { this.logger.error( @@ -473,7 +477,7 @@ export class RelayService { ); } - for (const owner of (data ?? []) as LocationOwnerRow[]) { + for (const owner of data ?? []) { permissionLevels.push(readPermissionLevel(owner.permission_level)); } } @@ -485,14 +489,14 @@ export class RelayService { private async findLatestRelayRow(devEui: string): Promise { const client = this.supabaseService.getClient(); - const { data, error } = await client + const { data, error } = (await client .from('cw_relay_data') .select('*') .eq('dev_eui', devEui) .order('created_at', { ascending: false }) .order('id', { ascending: false }) .limit(1) - .maybeSingle(); + .maybeSingle()) as QueryResult; if (error) { this.logger.error( @@ -502,7 +506,7 @@ export class RelayService { throw new InternalServerErrorException('Failed to fetch relay data'); } - return (data as RelayRow | null) ?? null; + return data ?? null; } private async persistRelayConfirmation( @@ -519,13 +523,13 @@ export class RelayService { relay_2: confirmation.relay2 ?? latestRow?.relay_2 ?? null, }; - const { data, error } = await client + const { data, error } = (await client .from('cw_relay_data') .upsert(mergedRow, { onConflict: 'dev_eui', }) .select('*') - .single(); + .single()) as QueryResult; if (error || !data) { this.logger.error( @@ -537,6 +541,6 @@ export class RelayService { ); } - return data as RelayRow; + return data; } } diff --git a/src/v1/relay/tti-client.spec.ts b/src/v1/relay/tti-client.spec.ts index 894925e..bd71743 100644 --- a/src/v1/relay/tti-client.spec.ts +++ b/src/v1/relay/tti-client.spec.ts @@ -47,10 +47,15 @@ describe('createTtiClient', () => { it('targets the TTI downlink replace endpoint with bearer auth and the queued payload', async () => { let requestUrl = ''; let requestInit: RequestInit | undefined; - const fetchFn = (async (input: RequestInfo | URL, init?: RequestInit) => { - requestUrl = String(input); + const fetchFn = ((input: RequestInfo | URL, init?: RequestInit) => { + requestUrl = + typeof input === 'string' + ? input + : input instanceof URL + ? input.href + : input.url; requestInit = init; - return createJsonResponse({}); + return Promise.resolve(createJsonResponse({})); }) as typeof fetch; const client = createTtiClient(configService, fetchFn); @@ -67,7 +72,7 @@ describe('createTtiClient', () => { expect(new Headers(requestInit?.headers).get('authorization')).toBe( 'Bearer tti-secret', ); - expect(JSON.parse(String(requestInit?.body))).toEqual({ + expect(JSON.parse(requestInit?.body as string)).toEqual({ downlinks: [ { confirmed: false, @@ -81,13 +86,15 @@ describe('createTtiClient', () => { }); it('surfaces TTI error responses as structured client errors', async () => { - const fetchFn = (async () => - createJsonResponse( - { - message: - 'error:pkg/auth/rights:no_application_rights (no rights for application `dragino-lt-22222@cropwatch`)', - }, - 403, + const fetchFn = (() => + Promise.resolve( + createJsonResponse( + { + message: + 'error:pkg/auth/rights:no_application_rights (no rights for application `dragino-lt-22222@cropwatch`)', + }, + 403, + ), )) as typeof fetch; const client = createTtiClient(configService, fetchFn); diff --git a/src/v1/reports-new/reports-new.controller.ts b/src/v1/reports-new/reports-new.controller.ts index f48bb6f..82aa561 100644 --- a/src/v1/reports-new/reports-new.controller.ts +++ b/src/v1/reports-new/reports-new.controller.ts @@ -57,7 +57,7 @@ export class ReportsNewController { isArray: true, }) @Get('communication-methods') - findAllCommunicationMethods(@CurrentUser() user: AuthenticatedUser) { + findAllCommunicationMethods() { return this.reportsNewService.findAllCommunicationMethods(); } @ApiOkResponse({ diff --git a/src/v1/reports-new/reports-new.service.ts b/src/v1/reports-new/reports-new.service.ts index 9a17348..ecdf411 100644 --- a/src/v1/reports-new/reports-new.service.ts +++ b/src/v1/reports-new/reports-new.service.ts @@ -6,6 +6,7 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; import { MANAGE_CEILING, PermissionLevel } from '../common/permission-levels'; @@ -250,7 +251,7 @@ export class ReportsNewService { assertDevicesCanBeManaged(devices, normalized.devEuis); const client = this.supabaseService.getClient(); - const { data: templateData, error: templateError } = await client + const { data: templateData, error: templateError } = (await client .from('cw_report_templates') .insert({ name: normalized.name, @@ -263,7 +264,19 @@ export class ReportsNewService { .select( 'created_at, data_pull_interval, description, device_type_id, id, is_active, name', ) - .single(); + .single()) as { + data: Pick< + TemplateRow, + | 'created_at' + | 'data_pull_interval' + | 'description' + | 'device_type_id' + | 'id' + | 'is_active' + | 'name' + > | null; + error: PostgrestError | null; + }; if (templateError || !templateData) { throw new InternalServerErrorException( @@ -366,7 +379,7 @@ export class ReportsNewService { return { devices: devicesPage.data ?? [], - locations: (locations ?? []) as ReportFormContextDto['locations'], + locations: locations ?? [], communicationMethods, template, }; diff --git a/src/v1/rules-new/rules-new.controller.ts b/src/v1/rules-new/rules-new.controller.ts index 9547554..43b76a5 100644 --- a/src/v1/rules-new/rules-new.controller.ts +++ b/src/v1/rules-new/rules-new.controller.ts @@ -57,7 +57,7 @@ export class RulesNewController { isArray: true, }) @Get('action-types') - findAllActionTypes(@CurrentUser() user: AuthenticatedUser) { + findAllActionTypes() { return this.rulesNewService.findAllActionTypes(); } @ApiOkResponse({ diff --git a/src/v1/rules-new/rules-new.service.spec.ts b/src/v1/rules-new/rules-new.service.spec.ts index 6108ff8..1c589cd 100644 --- a/src/v1/rules-new/rules-new.service.spec.ts +++ b/src/v1/rules-new/rules-new.service.spec.ts @@ -110,8 +110,8 @@ describe('RulesNewService', () => { getClient: jest.fn(() => client), getAdminClient: jest.fn(), } as unknown as SupabaseService, - {} as any, - {} as any, + {} as unknown as DevicesService, + {} as unknown as LocationsService, ); await expect( @@ -149,8 +149,8 @@ describe('RulesNewService', () => { getClient: jest.fn(() => client), getAdminClient: jest.fn(), } as unknown as SupabaseService, - {} as any, - {} as any, + {} as unknown as DevicesService, + {} as unknown as LocationsService, ); await expect( @@ -220,8 +220,8 @@ describe('RulesNewService', () => { getClient: jest.fn(() => ({ from: fromMock })), getAdminClient: jest.fn(), } as unknown as SupabaseService, - {} as any, - {} as any, + {} as unknown as DevicesService, + {} as unknown as LocationsService, ); await expect( @@ -272,7 +272,11 @@ describe('RulesNewService', () => { }); it('findAllTriggered returns only templates with triggered assignments, narrowed to those assignments', async () => { - const service = new RulesNewService({} as any, {} as any, {} as any); + const service = new RulesNewService( + {} as unknown as SupabaseService, + {} as unknown as DevicesService, + {} as unknown as LocationsService, + ); jest.spyOn(service, 'findAll').mockResolvedValue([ buildRule(1, [ { devEui: 'AA', isTriggered: true }, @@ -291,7 +295,11 @@ describe('RulesNewService', () => { }); it('findTriggeredCount reports triggered and total counts', async () => { - const service = new RulesNewService({} as any, {} as any, {} as any); + const service = new RulesNewService( + {} as unknown as SupabaseService, + {} as unknown as DevicesService, + {} as unknown as LocationsService, + ); jest .spyOn(service, 'findAll') .mockResolvedValue([ diff --git a/src/v1/rules-new/rules-new.service.ts b/src/v1/rules-new/rules-new.service.ts index 4832203..bd1cde4 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules-new/rules-new.service.ts @@ -5,6 +5,7 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; import { MANAGE_CEILING, PermissionLevel } from '../common/permission-levels'; @@ -278,7 +279,7 @@ export class RulesNewService { assertDevicesCanBeManaged(devices, normalized.devEuis); const client = this.supabaseService.getClient(); - const { data: templateData, error: templateError } = await client + const { data: templateData, error: templateError } = (await client .from('cw_rule_templates') .insert({ name: normalized.name, @@ -287,7 +288,18 @@ export class RulesNewService { is_active: normalized.isActive, }) .select('created_at, description, device_type_id, id, is_active, name') - .single(); + .single()) as { + data: Pick< + TemplateRow, + | 'created_at' + | 'description' + | 'device_type_id' + | 'id' + | 'is_active' + | 'name' + > | null; + error: PostgrestError | null; + }; if (templateError || !templateData) { throw new InternalServerErrorException('Failed to create rule template'); @@ -510,7 +522,7 @@ export class RulesNewService { return { devices: devicesPage.data ?? [], - locations: (locations ?? []) as RuleFormContextDto['locations'], + locations: locations ?? [], actionTypes, template, }; diff --git a/src/v1/traffic/traffic.service.ts b/src/v1/traffic/traffic.service.ts index fc48aed..a331214 100644 --- a/src/v1/traffic/traffic.service.ts +++ b/src/v1/traffic/traffic.service.ts @@ -1,10 +1,22 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; import { BaseDataService } from '../common/base-data.service'; +import type { TableRow } from '../types/supabase'; import { TrafficMonthlyReportDto } from './dto/traffic-monthly-report.dto'; import type { AuthenticatedUser } from '../auth/authenticated-user'; +type TrafficHourRow = Pick< + TableRow<'cw_traffic2'>, + | 'traffic_hour' + | 'people_count' + | 'bicycle_count' + | 'car_count' + | 'truck_count' + | 'bus_count' +>; + @Injectable() export class TrafficService extends BaseDataService<'cw_traffic2'> { constructor( @@ -31,7 +43,7 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { const nextYear = month === 12 ? year + 1 : year; const endUtc = this.localMidnightToUtc(nextYear, nextMonth, 1, tz); - const { data, error } = await this.supabaseService + const { data, error } = (await this.supabaseService .getClient() .from(this.tableName) .select( @@ -40,7 +52,10 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { .eq('dev_eui', devEui) .gte('traffic_hour', startUtc.toISOString()) .lt('traffic_hour', endUtc.toISOString()) - .order('traffic_hour', { ascending: true }); + .order('traffic_hour', { ascending: true })) as { + data: TrafficHourRow[] | null; + error: PostgrestError | null; + }; if (error) { throw new InternalServerErrorException( @@ -63,6 +78,9 @@ export class TrafficService extends BaseDataService<'cw_traffic2'> { // Aggregate each row into its local date bucket for (const row of data ?? []) { + // traffic_hour is nullable in the schema, but the gte/lt filters above + // exclude null rows; skip defensively to keep the types honest. + if (!row.traffic_hour) continue; const localDate = this.toLocalDateString(row.traffic_hour, tz); const bucket = dayMap.get(localDate); if (bucket) { From 26afbfbba02df38b232b51ace830d29ffbccf32e Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 22:23:48 +0900 Subject: [PATCH 10/11] refactor: rename rules-new/reports-new to rules/reports; extract shared template helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hard cutover: routes are now /v1/rules and /v1/reports (frontend switched in the same window; PUBLIC_*_ENDPOINT env overrides remain the rollback lever). Classes and files renamed to match (RulesService, ReportsService, ...). Dedup between the two near-parallel modules: - common/managed-devices.helper.ts — shared ManagedDevice + listManagedDevices (was duplicated verbatim in both services) - common/collection.helpers.ts — uniqueValues, groupBy, and a generic matchesSearch over the shared searchable-template shape Co-Authored-By: Claude Fable 5 --- src/app.module.ts | 8 +- src/v1/common/collection.helpers.ts | 43 ++++++ src/v1/common/managed-devices.helper.ts | 70 +++++++++ .../dto/communication-method.dto.ts | 0 .../dto/report-form-context.dto.ts | 0 .../dto/report-template-alert-point.dto.ts | 0 .../dto/report-template-assignment.dto.ts | 0 ...t-template-data-processing-schedule.dto.ts | 0 .../dto/report-template-history-item.dto.ts | 0 .../dto/report-template-recipient.dto.ts | 0 .../dto/report-template-schedule.dto.ts | 0 .../dto/report-template.dto.ts | 0 .../dto/save-report-template.dto.ts | 0 .../reports.controller.ts} | 28 ++-- .../reports.module.ts} | 10 +- .../reports.service.ts} | 136 ++++++------------ .../dto/rule-action-type.dto.ts | 0 .../dto/rule-form-context.dto.ts | 0 .../dto/rule-template-action.dto.ts | 0 .../dto/rule-template-assignment.dto.ts | 0 .../dto/rule-template-criterion.dto.ts | 0 .../dto/rule-template-state.dto.ts | 0 .../dto/rule-template.dto.ts | 0 .../dto/rule-trigger-log.dto.ts | 0 .../dto/save-rule-template.dto.ts | 0 .../entities/rule-template.entity.ts | 0 .../rules.controller.spec.ts} | 14 +- .../rules.controller.ts} | 30 ++-- .../rules.module.ts} | 10 +- .../rules.service.spec.ts} | 20 +-- .../rules.service.ts} | 136 ++++++------------ 31 files changed, 253 insertions(+), 252 deletions(-) create mode 100644 src/v1/common/collection.helpers.ts create mode 100644 src/v1/common/managed-devices.helper.ts rename src/v1/{reports-new => reports}/dto/communication-method.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-form-context.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template-alert-point.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template-assignment.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template-data-processing-schedule.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template-history-item.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template-recipient.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template-schedule.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/report-template.dto.ts (100%) rename src/v1/{reports-new => reports}/dto/save-report-template.dto.ts (100%) rename src/v1/{reports-new/reports-new.controller.ts => reports/reports.controller.ts} (81%) rename src/v1/{rules-new/rules-new.module.ts => reports/reports.module.ts} (58%) rename src/v1/{reports-new/reports-new.service.ts => reports/reports.service.ts} (91%) rename src/v1/{rules-new => rules}/dto/rule-action-type.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-form-context.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-template-action.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-template-assignment.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-template-criterion.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-template-state.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-template.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/rule-trigger-log.dto.ts (100%) rename src/v1/{rules-new => rules}/dto/save-rule-template.dto.ts (100%) rename src/v1/{rules-new => rules}/entities/rule-template.entity.ts (100%) rename src/v1/{rules-new/rules-new.controller.spec.ts => rules/rules.controller.spec.ts} (80%) rename src/v1/{rules-new/rules-new.controller.ts => rules/rules.controller.ts} (82%) rename src/v1/{reports-new/reports-new.module.ts => rules/rules.module.ts} (57%) rename src/v1/{rules-new/rules-new.service.spec.ts => rules/rules.service.spec.ts} (94%) rename src/v1/{rules-new/rules-new.service.ts => rules/rules.service.ts} (88%) diff --git a/src/app.module.ts b/src/app.module.ts index 376d96c..0126dc8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,8 +12,8 @@ import { join } from 'path'; import { ThrottlerGuard, ThrottlerModule } from '@nestjs/throttler'; import { APP_GUARD } from '@nestjs/core'; import { DevicesModule } from './v1/devices/devices.module'; -import { RulesNewModule } from './v1/rules-new/rules-new.module'; -import { ReportsNewModule } from './v1/reports-new/reports-new.module'; +import { RulesModule } from './v1/rules/rules.module'; +import { ReportsModule } from './v1/reports/reports.module'; import { LocationsModule } from './v1/locations/locations.module'; import { RelayModule } from './v1/relay/relay.module'; import { GatewayModule } from './v1/gateway/gateway.module'; @@ -49,8 +49,8 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; }, ]), DevicesModule, - RulesNewModule, - ReportsNewModule, + RulesModule, + ReportsModule, LocationsModule, RelayModule, GatewayModule, diff --git a/src/v1/common/collection.helpers.ts b/src/v1/common/collection.helpers.ts new file mode 100644 index 0000000..80810cf --- /dev/null +++ b/src/v1/common/collection.helpers.ts @@ -0,0 +1,43 @@ +/** Deduplicates primitive values, preserving first-seen order. */ +export function uniqueValues(values: T[]): T[] { + return [...new Set(values)]; +} + +/** Groups items into a Map keyed by the given selector. */ +export function groupBy( + items: T[], + key: (item: T) => TKey, +): Map { + const result = new Map(); + for (const item of items) { + const groupKey = key(item); + const existing = result.get(groupKey); + if (existing) { + existing.push(item); + } else { + result.set(groupKey, [item]); + } + } + return result; +} + +/** The searchable shape shared by rule and report templates. */ +interface SearchableTemplate { + name: string; + description?: string | null; + assignments: Array<{ deviceName?: string | null; devEui: string }>; +} + +/** Case-insensitive search over a template's name, description, and devices. */ +export function matchesSearch( + template: SearchableTemplate, + search: string, +): boolean { + const deviceText = template.assignments + .map((assignment) => `${assignment.deviceName ?? ''} ${assignment.devEui}`) + .join(' '); + return [template.name, template.description ?? '', deviceText] + .join(' ') + .toLowerCase() + .includes(search); +} diff --git a/src/v1/common/managed-devices.helper.ts b/src/v1/common/managed-devices.helper.ts new file mode 100644 index 0000000..f38387b --- /dev/null +++ b/src/v1/common/managed-devices.helper.ts @@ -0,0 +1,70 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import type { SupabaseService } from '../../supabase/supabase.service'; +import type { TableRow } from '../types/supabase'; +import { MANAGE_CEILING, PermissionLevel } from './permission-levels'; + +type DeviceRow = TableRow<'cw_devices'>; +type DeviceOwnerRow = TableRow<'cw_device_owners'>; + +/** A device with the caller's effective permissions resolved. */ +export interface ManagedDevice { + devEui: string; + name: string | null; + permissionLevel: number | null; + canView: boolean; + canManage: boolean; +} + +/** + * Lists every device with the caller's effective view/manage permissions, + * resolved from direct ownership and cw_device_owners entries. Staff can + * view and manage everything. + */ +export async function listManagedDevices( + client: ReturnType, + userId: string, + isStaff: boolean, +): Promise { + const { data, error } = await client + .from('cw_devices') + .select('dev_eui, name, user_id, cw_device_owners(*)'); + + if (error) { + throw new InternalServerErrorException('Failed to load devices'); + } + + const rows = (data ?? []) as Array< + Pick & { + cw_device_owners?: DeviceOwnerRow[] | null; + } + >; + + return rows + .map((row): ManagedDevice => { + const owners = Array.isArray(row.cw_device_owners) + ? row.cw_device_owners + : []; + const ownEntry = owners.find((entry) => entry.user_id === userId); + const directOwner = row.user_id === userId; + const permissionLevel = directOwner + ? PermissionLevel.ADMIN + : (ownEntry?.permission_level ?? null); + const canView = + isStaff || + directOwner || + (permissionLevel != null && permissionLevel < PermissionLevel.DISABLED); + const canManage = + isStaff || + directOwner || + (permissionLevel != null && permissionLevel <= MANAGE_CEILING); + + return { + devEui: row.dev_eui, + name: row.name?.trim() ? row.name : null, + permissionLevel, + canView, + canManage, + }; + }) + .filter((device) => device.devEui.length > 0); +} diff --git a/src/v1/reports-new/dto/communication-method.dto.ts b/src/v1/reports/dto/communication-method.dto.ts similarity index 100% rename from src/v1/reports-new/dto/communication-method.dto.ts rename to src/v1/reports/dto/communication-method.dto.ts diff --git a/src/v1/reports-new/dto/report-form-context.dto.ts b/src/v1/reports/dto/report-form-context.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-form-context.dto.ts rename to src/v1/reports/dto/report-form-context.dto.ts diff --git a/src/v1/reports-new/dto/report-template-alert-point.dto.ts b/src/v1/reports/dto/report-template-alert-point.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template-alert-point.dto.ts rename to src/v1/reports/dto/report-template-alert-point.dto.ts diff --git a/src/v1/reports-new/dto/report-template-assignment.dto.ts b/src/v1/reports/dto/report-template-assignment.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template-assignment.dto.ts rename to src/v1/reports/dto/report-template-assignment.dto.ts diff --git a/src/v1/reports-new/dto/report-template-data-processing-schedule.dto.ts b/src/v1/reports/dto/report-template-data-processing-schedule.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template-data-processing-schedule.dto.ts rename to src/v1/reports/dto/report-template-data-processing-schedule.dto.ts diff --git a/src/v1/reports-new/dto/report-template-history-item.dto.ts b/src/v1/reports/dto/report-template-history-item.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template-history-item.dto.ts rename to src/v1/reports/dto/report-template-history-item.dto.ts diff --git a/src/v1/reports-new/dto/report-template-recipient.dto.ts b/src/v1/reports/dto/report-template-recipient.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template-recipient.dto.ts rename to src/v1/reports/dto/report-template-recipient.dto.ts diff --git a/src/v1/reports-new/dto/report-template-schedule.dto.ts b/src/v1/reports/dto/report-template-schedule.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template-schedule.dto.ts rename to src/v1/reports/dto/report-template-schedule.dto.ts diff --git a/src/v1/reports-new/dto/report-template.dto.ts b/src/v1/reports/dto/report-template.dto.ts similarity index 100% rename from src/v1/reports-new/dto/report-template.dto.ts rename to src/v1/reports/dto/report-template.dto.ts diff --git a/src/v1/reports-new/dto/save-report-template.dto.ts b/src/v1/reports/dto/save-report-template.dto.ts similarity index 100% rename from src/v1/reports-new/dto/save-report-template.dto.ts rename to src/v1/reports/dto/save-report-template.dto.ts diff --git a/src/v1/reports-new/reports-new.controller.ts b/src/v1/reports/reports.controller.ts similarity index 81% rename from src/v1/reports-new/reports-new.controller.ts rename to src/v1/reports/reports.controller.ts index 82aa561..4950a4d 100644 --- a/src/v1/reports-new/reports-new.controller.ts +++ b/src/v1/reports/reports.controller.ts @@ -23,16 +23,16 @@ import { ReportFormContextDto } from './dto/report-form-context.dto'; import { ReportTemplateDto } from './dto/report-template.dto'; import { ReportTemplateHistoryItemDto } from './dto/report-template-history-item.dto'; import { SaveReportTemplateDto } from './dto/save-report-template.dto'; -import { ReportsNewService } from './reports-new.service'; +import { ReportsService } from './reports.service'; import { CurrentUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/authenticated-user'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') -@Controller({ path: 'reports-new', version: '1' }) +@Controller({ path: 'reports', version: '1' }) @UseGuards(JwtAuthGuard) -export class ReportsNewController { - constructor(private readonly reportsNewService: ReportsNewService) {} +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} @ApiOkResponse({ description: 'Lists every report template visible to the current user.', type: ReportTemplateDto, @@ -48,7 +48,7 @@ export class ReportsNewController { @CurrentUser() user: AuthenticatedUser, @Query('search') search?: string, ) { - return this.reportsNewService.findAll(user, search); + return this.reportsService.findAll(user, search); } @ApiOkResponse({ description: @@ -58,11 +58,11 @@ export class ReportsNewController { }) @Get('communication-methods') findAllCommunicationMethods() { - return this.reportsNewService.findAllCommunicationMethods(); + return this.reportsService.findAllCommunicationMethods(); } @ApiOkResponse({ description: - 'Bundled data needed to render the reports-new create/edit form: devices (with cw_locations join), locations, communication methods, and optionally a template.', + 'Bundled data needed to render the reports create/edit form: devices (with cw_locations join), locations, communication methods, and optionally a template.', type: ReportFormContextDto, }) @ApiQuery({ @@ -79,7 +79,7 @@ export class ReportsNewController { ) { const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; - return this.reportsNewService.getFormContext(user, id); + return this.reportsService.getFormContext(user, id); } @ApiOkResponse({ description: @@ -95,7 +95,7 @@ export class ReportsNewController { @Param('reportName') reportName: string, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.getDownloadUrl(devEui, reportName, user); + return this.reportsService.getDownloadUrl(devEui, reportName, user); } @ApiOkResponse({ description: @@ -108,7 +108,7 @@ export class ReportsNewController { @Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.getHistory(id, user); + return this.reportsService.getHistory(id, user); } @ApiOkResponse({ description: 'Returns a single report template the user can view.', @@ -120,7 +120,7 @@ export class ReportsNewController { @Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.findOne(id, user); + return this.reportsService.findOne(id, user); } @ApiOkResponse({ description: @@ -134,7 +134,7 @@ export class ReportsNewController { @Body() body: SaveReportTemplateDto, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.create(body, user); + return this.reportsService.create(body, user); } @ApiOkResponse({ description: 'Replaces a report template with the provided configuration.', @@ -148,7 +148,7 @@ export class ReportsNewController { @Body() body: SaveReportTemplateDto, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.update(id, body, user); + return this.reportsService.update(id, body, user); } @ApiOkResponse({ description: 'Deletes a report template the user manages.', @@ -162,6 +162,6 @@ export class ReportsNewController { @Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser, ) { - return this.reportsNewService.remove(id, user); + return this.reportsService.remove(id, user); } } diff --git a/src/v1/rules-new/rules-new.module.ts b/src/v1/reports/reports.module.ts similarity index 58% rename from src/v1/rules-new/rules-new.module.ts rename to src/v1/reports/reports.module.ts index ab45284..4863089 100644 --- a/src/v1/rules-new/rules-new.module.ts +++ b/src/v1/reports/reports.module.ts @@ -2,12 +2,12 @@ import { Module } from '@nestjs/common'; import { SupabaseModule } from '../../supabase/supabase.module'; import { DevicesModule } from '../devices/devices.module'; import { LocationsModule } from '../locations/locations.module'; -import { RulesNewController } from './rules-new.controller'; -import { RulesNewService } from './rules-new.service'; +import { ReportsController } from './reports.controller'; +import { ReportsService } from './reports.service'; @Module({ imports: [SupabaseModule, DevicesModule, LocationsModule], - controllers: [RulesNewController], - providers: [RulesNewService], + controllers: [ReportsController], + providers: [ReportsService], }) -export class RulesNewModule {} +export class ReportsModule {} diff --git a/src/v1/reports-new/reports-new.service.ts b/src/v1/reports/reports.service.ts similarity index 91% rename from src/v1/reports-new/reports-new.service.ts rename to src/v1/reports/reports.service.ts index ecdf411..7617b5e 100644 --- a/src/v1/reports-new/reports-new.service.ts +++ b/src/v1/reports/reports.service.ts @@ -9,7 +9,15 @@ import { import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; -import { MANAGE_CEILING, PermissionLevel } from '../common/permission-levels'; +import { + listManagedDevices, + type ManagedDevice, +} from '../common/managed-devices.helper'; +import { + groupBy, + matchesSearch, + uniqueValues, +} from '../common/collection.helpers'; import { DevicesService } from '../devices/devices.service'; import { LocationsService } from '../locations/locations.service'; import { CommunicationMethodDto } from './dto/communication-method.dto'; @@ -38,19 +46,9 @@ type AlertPointRow = TableRow<'cw_report_template_alert_points'>; type DataProcessingScheduleRow = TableRow<'cw_report_template_data_processing_schedules'>; type CommunicationMethodRow = TableRow<'communication_methods'>; -type DeviceRow = TableRow<'cw_devices'>; -type DeviceOwnerRow = TableRow<'cw_device_owners'>; const STORAGE_BUCKET = 'Reports'; -interface ManagedDevice { - devEui: string; - name: string | null; - permissionLevel: number | null; - canView: boolean; - canManage: boolean; -} - interface NormalizedScheduleRow { endOfDay: boolean; endOfWeek: boolean; @@ -101,7 +99,7 @@ interface NormalizedSaveRequest { } @Injectable() -export class ReportsNewService { +export class ReportsService { constructor( private readonly supabaseService: SupabaseService, private readonly devicesService: DevicesService, @@ -115,7 +113,11 @@ export class ReportsNewService { const userId = user.sub; const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) return []; @@ -171,7 +173,11 @@ export class ReportsNewService { const userId = user.sub; const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) { throw new NotFoundException('Report template not found'); @@ -247,7 +253,11 @@ export class ReportsNewService { const isStaff = user.isStaff; const normalized = normalizeSaveRequest(payload); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); assertDevicesCanBeManaged(devices, normalized.devEuis); const client = this.supabaseService.getClient(); @@ -304,7 +314,11 @@ export class ReportsNewService { const normalized = normalizeSaveRequest(payload); const existing = await this.findOne(id, user); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const allDevEuis = uniqueValues([ ...existing.assignments.map((assignment) => assignment.devEui), @@ -340,7 +354,11 @@ export class ReportsNewService { const isStaff = user.isStaff; const existing = await this.findOne(id, user); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); assertDevicesCanBeManaged( devices, existing.assignments.map((assignment) => assignment.devEui), @@ -492,7 +510,11 @@ export class ReportsNewService { ? normalizedName : `${normalizedName}.pdf`; - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const device = devices.find((entry) => entry.devEui === normalizedDevEui); if (!device || !device.canView) { throw new UnauthorizedException( @@ -517,56 +539,6 @@ export class ReportsNewService { return { url: data.signedUrl }; } - private async listManagedDevices( - userId: string, - isStaff: boolean, - ): Promise { - const client = this.supabaseService.getClient(); - const { data, error } = await client - .from('cw_devices') - .select('dev_eui, name, user_id, cw_device_owners(*)'); - - if (error) { - throw new InternalServerErrorException('Failed to load devices'); - } - - const rows = (data ?? []) as Array< - Pick & { - cw_device_owners?: DeviceOwnerRow[] | null; - } - >; - - return rows - .map((row): ManagedDevice => { - const owners = Array.isArray(row.cw_device_owners) - ? row.cw_device_owners - : []; - const ownEntry = owners.find((entry) => entry.user_id === userId); - const directOwner = row.user_id === userId; - const permissionLevel = directOwner - ? PermissionLevel.ADMIN - : (ownEntry?.permission_level ?? null); - const canView = - isStaff || - directOwner || - (permissionLevel != null && - permissionLevel < PermissionLevel.DISABLED); - const canManage = - isStaff || - directOwner || - (permissionLevel != null && permissionLevel <= MANAGE_CEILING); - - return { - devEui: row.dev_eui, - name: row.name?.trim() ? row.name : null, - permissionLevel, - canView, - canManage, - }; - }) - .filter((device) => device.devEui.length > 0); - } - private async loadTemplatesByIds( templateIds: number[], ): Promise { @@ -1131,16 +1103,6 @@ function assertDevicesCanBeManaged( } } -function matchesSearch(report: ReportTemplateDto, search: string): boolean { - const deviceText = report.assignments - .map((assignment) => `${assignment.deviceName ?? ''} ${assignment.devEui}`) - .join(' '); - return [report.name, report.description ?? '', deviceText] - .join(' ') - .toLowerCase() - .includes(search); -} - function trimOrNull(value: string | null | undefined): string | null { if (typeof value !== 'string') return null; const trimmed = value.trim(); @@ -1150,21 +1112,3 @@ function trimOrNull(value: string | null | undefined): string | null { function numberOrNull(value: number | null | undefined): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } - -function groupBy(items: T[], key: (item: T) => TKey): Map { - const result = new Map(); - for (const item of items) { - const groupKey = key(item); - const existing = result.get(groupKey); - if (existing) { - existing.push(item); - } else { - result.set(groupKey, [item]); - } - } - return result; -} - -function uniqueValues(values: T[]): T[] { - return [...new Set(values)]; -} diff --git a/src/v1/rules-new/dto/rule-action-type.dto.ts b/src/v1/rules/dto/rule-action-type.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-action-type.dto.ts rename to src/v1/rules/dto/rule-action-type.dto.ts diff --git a/src/v1/rules-new/dto/rule-form-context.dto.ts b/src/v1/rules/dto/rule-form-context.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-form-context.dto.ts rename to src/v1/rules/dto/rule-form-context.dto.ts diff --git a/src/v1/rules-new/dto/rule-template-action.dto.ts b/src/v1/rules/dto/rule-template-action.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-template-action.dto.ts rename to src/v1/rules/dto/rule-template-action.dto.ts diff --git a/src/v1/rules-new/dto/rule-template-assignment.dto.ts b/src/v1/rules/dto/rule-template-assignment.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-template-assignment.dto.ts rename to src/v1/rules/dto/rule-template-assignment.dto.ts diff --git a/src/v1/rules-new/dto/rule-template-criterion.dto.ts b/src/v1/rules/dto/rule-template-criterion.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-template-criterion.dto.ts rename to src/v1/rules/dto/rule-template-criterion.dto.ts diff --git a/src/v1/rules-new/dto/rule-template-state.dto.ts b/src/v1/rules/dto/rule-template-state.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-template-state.dto.ts rename to src/v1/rules/dto/rule-template-state.dto.ts diff --git a/src/v1/rules-new/dto/rule-template.dto.ts b/src/v1/rules/dto/rule-template.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-template.dto.ts rename to src/v1/rules/dto/rule-template.dto.ts diff --git a/src/v1/rules-new/dto/rule-trigger-log.dto.ts b/src/v1/rules/dto/rule-trigger-log.dto.ts similarity index 100% rename from src/v1/rules-new/dto/rule-trigger-log.dto.ts rename to src/v1/rules/dto/rule-trigger-log.dto.ts diff --git a/src/v1/rules-new/dto/save-rule-template.dto.ts b/src/v1/rules/dto/save-rule-template.dto.ts similarity index 100% rename from src/v1/rules-new/dto/save-rule-template.dto.ts rename to src/v1/rules/dto/save-rule-template.dto.ts diff --git a/src/v1/rules-new/entities/rule-template.entity.ts b/src/v1/rules/entities/rule-template.entity.ts similarity index 100% rename from src/v1/rules-new/entities/rule-template.entity.ts rename to src/v1/rules/entities/rule-template.entity.ts diff --git a/src/v1/rules-new/rules-new.controller.spec.ts b/src/v1/rules/rules.controller.spec.ts similarity index 80% rename from src/v1/rules-new/rules-new.controller.spec.ts rename to src/v1/rules/rules.controller.spec.ts index 817c1d5..d725619 100644 --- a/src/v1/rules-new/rules-new.controller.spec.ts +++ b/src/v1/rules/rules.controller.spec.ts @@ -1,24 +1,24 @@ import { ValidationPipe } from '@nestjs/common'; import { Test, TestingModule } from '@nestjs/testing'; import { SaveRuleTemplateDto } from './dto/save-rule-template.dto'; -import { RulesNewController } from './rules-new.controller'; -import { RulesNewService } from './rules-new.service'; +import { RulesController } from './rules.controller'; +import { RulesService } from './rules.service'; -describe('RulesNewController', () => { - let controller: RulesNewController; +describe('RulesController', () => { + let controller: RulesController; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - controllers: [RulesNewController], + controllers: [RulesController], providers: [ { - provide: RulesNewService, + provide: RulesService, useValue: {}, }, ], }).compile(); - controller = module.get(RulesNewController); + controller = module.get(RulesController); }); it('should be defined', () => { diff --git a/src/v1/rules-new/rules-new.controller.ts b/src/v1/rules/rules.controller.ts similarity index 82% rename from src/v1/rules-new/rules-new.controller.ts rename to src/v1/rules/rules.controller.ts index 43b76a5..44fe6d5 100644 --- a/src/v1/rules-new/rules-new.controller.ts +++ b/src/v1/rules/rules.controller.ts @@ -23,16 +23,16 @@ import { RuleFormContextDto } from './dto/rule-form-context.dto'; import { RuleTemplateDto } from './dto/rule-template.dto'; import { RuleTriggerLogDto } from './dto/rule-trigger-log.dto'; import { SaveRuleTemplateDto } from './dto/save-rule-template.dto'; -import { RulesNewService } from './rules-new.service'; +import { RulesService } from './rules.service'; import { CurrentUser } from '../auth/current-user.decorator'; import type { AuthenticatedUser } from '../auth/authenticated-user'; @ApiBearerAuth('bearerAuth') @ApiSecurity('apiKey') -@Controller({ path: 'rules-new', version: '1' }) +@Controller({ path: 'rules', version: '1' }) @UseGuards(JwtAuthGuard) -export class RulesNewController { - constructor(private readonly rulesNewService: RulesNewService) {} +export class RulesController { + constructor(private readonly rulesService: RulesService) {} @ApiOkResponse({ description: 'Lists every rule template visible to the current user.', type: RuleTemplateDto, @@ -48,7 +48,7 @@ export class RulesNewController { @CurrentUser() user: AuthenticatedUser, @Query('search') search?: string, ) { - return this.rulesNewService.findAll(user, search); + return this.rulesService.findAll(user, search); } @ApiOkResponse({ description: @@ -58,11 +58,11 @@ export class RulesNewController { }) @Get('action-types') findAllActionTypes() { - return this.rulesNewService.findAllActionTypes(); + return this.rulesService.findAllActionTypes(); } @ApiOkResponse({ description: - 'Bundled data needed to render the rules-new create/edit form: devices (with cw_locations join), locations, action types, and optionally a template.', + 'Bundled data needed to render the rules create/edit form: devices (with cw_locations join), locations, action types, and optionally a template.', type: RuleFormContextDto, }) @ApiQuery({ @@ -79,7 +79,7 @@ export class RulesNewController { ) { const parsed = templateId !== undefined ? Number(templateId) : NaN; const id = Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; - return this.rulesNewService.getFormContext(user, id); + return this.rulesService.getFormContext(user, id); } @ApiOkResponse({ description: @@ -89,7 +89,7 @@ export class RulesNewController { }) @Get('triggered') findAllTriggered(@CurrentUser() user: AuthenticatedUser) { - return this.rulesNewService.findAllTriggered(user); + return this.rulesService.findAllTriggered(user); } @ApiOkResponse({ description: @@ -105,7 +105,7 @@ export class RulesNewController { }) @Get('triggered/count') findTriggeredCount(@CurrentUser() user: AuthenticatedUser) { - return this.rulesNewService.findTriggeredCount(user); + return this.rulesService.findTriggeredCount(user); } @ApiOkResponse({ description: @@ -118,7 +118,7 @@ export class RulesNewController { @Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser, ) { - return this.rulesNewService.getHistory(id, user); + return this.rulesService.getHistory(id, user); } @ApiOkResponse({ description: 'Returns a single rule template the user can view.', @@ -130,7 +130,7 @@ export class RulesNewController { @Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser, ) { - return this.rulesNewService.findOne(id, user); + return this.rulesService.findOne(id, user); } @ApiOkResponse({ description: @@ -144,7 +144,7 @@ export class RulesNewController { @Body() body: SaveRuleTemplateDto, @CurrentUser() user: AuthenticatedUser, ) { - return this.rulesNewService.create(body, user); + return this.rulesService.create(body, user); } @ApiOkResponse({ description: 'Replaces a rule template with the provided configuration.', @@ -158,7 +158,7 @@ export class RulesNewController { @Body() body: SaveRuleTemplateDto, @CurrentUser() user: AuthenticatedUser, ) { - return this.rulesNewService.update(id, body, user); + return this.rulesService.update(id, body, user); } @ApiOkResponse({ description: 'Deletes a rule template the user manages.', @@ -172,6 +172,6 @@ export class RulesNewController { @Param('id', ParseIntPipe) id: number, @CurrentUser() user: AuthenticatedUser, ) { - return this.rulesNewService.remove(id, user); + return this.rulesService.remove(id, user); } } diff --git a/src/v1/reports-new/reports-new.module.ts b/src/v1/rules/rules.module.ts similarity index 57% rename from src/v1/reports-new/reports-new.module.ts rename to src/v1/rules/rules.module.ts index 5294cc5..d0d2ec0 100644 --- a/src/v1/reports-new/reports-new.module.ts +++ b/src/v1/rules/rules.module.ts @@ -2,12 +2,12 @@ import { Module } from '@nestjs/common'; import { SupabaseModule } from '../../supabase/supabase.module'; import { DevicesModule } from '../devices/devices.module'; import { LocationsModule } from '../locations/locations.module'; -import { ReportsNewController } from './reports-new.controller'; -import { ReportsNewService } from './reports-new.service'; +import { RulesController } from './rules.controller'; +import { RulesService } from './rules.service'; @Module({ imports: [SupabaseModule, DevicesModule, LocationsModule], - controllers: [ReportsNewController], - providers: [ReportsNewService], + controllers: [RulesController], + providers: [RulesService], }) -export class ReportsNewModule {} +export class RulesModule {} diff --git a/src/v1/rules-new/rules-new.service.spec.ts b/src/v1/rules/rules.service.spec.ts similarity index 94% rename from src/v1/rules-new/rules-new.service.spec.ts rename to src/v1/rules/rules.service.spec.ts index 1c589cd..32826ab 100644 --- a/src/v1/rules-new/rules-new.service.spec.ts +++ b/src/v1/rules/rules.service.spec.ts @@ -3,7 +3,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { SupabaseService } from '../../supabase/supabase.service'; import { DevicesService } from '../devices/devices.service'; import { LocationsService } from '../locations/locations.service'; -import { RulesNewService } from './rules-new.service'; +import { RulesService } from './rules.service'; type StubResult = { data: unknown; error: unknown }; @@ -65,13 +65,13 @@ function buildQueryStub(handlers: { return stub as QueryStub; } -describe('RulesNewService', () => { - let service: RulesNewService; +describe('RulesService', () => { + let service: RulesService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - RulesNewService, + RulesService, { provide: SupabaseService, useValue: { @@ -90,7 +90,7 @@ describe('RulesNewService', () => { ], }).compile(); - service = module.get(RulesNewService); + service = module.get(RulesService); }); it('should be defined', () => { @@ -105,7 +105,7 @@ describe('RulesNewService', () => { from: jest.fn(() => devicesQuery), }; - const serviceWithClient = new RulesNewService( + const serviceWithClient = new RulesService( { getClient: jest.fn(() => client), getAdminClient: jest.fn(), @@ -144,7 +144,7 @@ describe('RulesNewService', () => { from: jest.fn(() => devicesQuery), }; - const serviceWithClient = new RulesNewService( + const serviceWithClient = new RulesService( { getClient: jest.fn(() => client), getAdminClient: jest.fn(), @@ -215,7 +215,7 @@ describe('RulesNewService', () => { .mockImplementationOnce(() => templateQuery) .mockImplementationOnce(() => assignmentsQuery); - const serviceWithClient = new RulesNewService( + const serviceWithClient = new RulesService( { getClient: jest.fn(() => ({ from: fromMock })), getAdminClient: jest.fn(), @@ -272,7 +272,7 @@ describe('RulesNewService', () => { }); it('findAllTriggered returns only templates with triggered assignments, narrowed to those assignments', async () => { - const service = new RulesNewService( + const service = new RulesService( {} as unknown as SupabaseService, {} as unknown as DevicesService, {} as unknown as LocationsService, @@ -295,7 +295,7 @@ describe('RulesNewService', () => { }); it('findTriggeredCount reports triggered and total counts', async () => { - const service = new RulesNewService( + const service = new RulesService( {} as unknown as SupabaseService, {} as unknown as DevicesService, {} as unknown as LocationsService, diff --git a/src/v1/rules-new/rules-new.service.ts b/src/v1/rules/rules.service.ts similarity index 88% rename from src/v1/rules-new/rules-new.service.ts rename to src/v1/rules/rules.service.ts index bd1cde4..ef3eeef 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules/rules.service.ts @@ -8,7 +8,15 @@ import { import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; import type { TableRow } from '../types/supabase'; -import { MANAGE_CEILING, PermissionLevel } from '../common/permission-levels'; +import { + listManagedDevices, + type ManagedDevice, +} from '../common/managed-devices.helper'; +import { + groupBy, + matchesSearch, + uniqueValues, +} from '../common/collection.helpers'; import { DevicesService } from '../devices/devices.service'; import { LocationsService } from '../locations/locations.service'; import { RuleActionTypeDto } from './dto/rule-action-type.dto'; @@ -32,16 +40,6 @@ type ActionRow = TableRow<'cw_rule_template_actions'> & { cw_rule_action_types?: ActionTypeJoin | ActionTypeJoin[] | null; }; type StateRow = TableRow<'cw_rule_state'>; -type DeviceRow = TableRow<'cw_devices'>; -type DeviceOwnerRow = TableRow<'cw_device_owners'>; - -interface ManagedDevice { - devEui: string; - name: string | null; - permissionLevel: number | null; - canView: boolean; - canManage: boolean; -} interface NormalizedSaveRequest { name: string; @@ -54,7 +52,7 @@ interface NormalizedSaveRequest { } @Injectable() -export class RulesNewService { +export class RulesService { constructor( private readonly supabaseService: SupabaseService, private readonly devicesService: DevicesService, @@ -68,7 +66,11 @@ export class RulesNewService { const userId = user.sub; const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) return []; @@ -154,7 +156,11 @@ export class RulesNewService { const userId = user.sub; const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const viewableDevices = devices.filter((device) => device.canView); if (viewableDevices.length === 0) { throw new NotFoundException('Rule template not found'); @@ -228,7 +234,11 @@ export class RulesNewService { // an empty list. await this.findOne(id, user); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const viewableDevices = devices.filter((device) => device.canView); const deviceNames = new Map( devices.map((device) => [device.devEui, device.name]), @@ -275,7 +285,11 @@ export class RulesNewService { const isStaff = user.isStaff; const normalized = normalizeSaveRequest(payload); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); assertDevicesCanBeManaged(devices, normalized.devEuis); const client = this.supabaseService.getClient(); @@ -325,7 +339,11 @@ export class RulesNewService { const normalized = normalizeSaveRequest(payload); const existing = await this.findOne(id, user); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const allDevEuis = uniqueValues([ ...existing.assignments.map((assignment) => assignment.devEui), @@ -359,7 +377,11 @@ export class RulesNewService { const isStaff = user.isStaff; const existing = await this.findOne(id, user); - const devices = await this.listManagedDevices(userId, isStaff); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); assertDevicesCanBeManaged( devices, existing.assignments.map((assignment) => assignment.devEui), @@ -381,56 +403,6 @@ export class RulesNewService { return { id }; } - private async listManagedDevices( - userId: string, - isStaff: boolean, - ): Promise { - const client = this.supabaseService.getClient(); - const { data, error } = await client - .from('cw_devices') - .select('dev_eui, name, user_id, cw_device_owners(*)'); - - if (error) { - throw new InternalServerErrorException('Failed to load devices'); - } - - const rows = (data ?? []) as Array< - Pick & { - cw_device_owners?: DeviceOwnerRow[] | null; - } - >; - - return rows - .map((row): ManagedDevice => { - const owners = Array.isArray(row.cw_device_owners) - ? row.cw_device_owners - : []; - const ownEntry = owners.find((entry) => entry.user_id === userId); - const directOwner = row.user_id === userId; - const permissionLevel = directOwner - ? PermissionLevel.ADMIN - : (ownEntry?.permission_level ?? null); - const canView = - isStaff || - directOwner || - (permissionLevel != null && - permissionLevel < PermissionLevel.DISABLED); - const canManage = - isStaff || - directOwner || - (permissionLevel != null && permissionLevel <= MANAGE_CEILING); - - return { - devEui: row.dev_eui, - name: row.name?.trim() ? row.name : null, - permissionLevel, - canView, - canManage, - }; - }) - .filter((device) => device.devEui.length > 0); - } - private async loadTemplatesByIds( templateIds: number[], ): Promise { @@ -860,31 +832,3 @@ function assertDevicesCanBeManaged( ); } } - -function matchesSearch(rule: RuleTemplateDto, search: string): boolean { - const deviceText = rule.assignments - .map((assignment) => `${assignment.deviceName ?? ''} ${assignment.devEui}`) - .join(' '); - return [rule.name, rule.description ?? '', deviceText] - .join(' ') - .toLowerCase() - .includes(search); -} - -function groupBy(items: T[], key: (item: T) => TKey): Map { - const result = new Map(); - for (const item of items) { - const groupKey = key(item); - const existing = result.get(groupKey); - if (existing) { - existing.push(item); - } else { - result.set(groupKey, [item]); - } - } - return result; -} - -function uniqueValues(values: T[]): T[] { - return [...new Set(values)]; -} From bd34594bab1733b4a0f8a2e66954993b169ef2e3 Mon Sep 17 00:00:00 2001 From: Kevin Cantrell Date: Thu, 2 Jul 2026 22:26:25 +0900 Subject: [PATCH 11/11] refactor: shared time-series range parsing + global exception filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - common/timeseries-range.helper.ts replaces the byte-identical 13-line start/end parsing block in the air, soil, water, and traffic controllers (behavior preserved exactly — same defaults, same error messages) - AllExceptionsFilter logs every 5xx and every non-HttpException with its underlying cause before responding; previously unhandled errors surfaced as opaque 500s with no server-side trace Co-Authored-By: Claude Fable 5 --- src/main.ts | 2 + src/v1/air/air.controller.ts | 16 +----- .../common/filters/all-exceptions.filter.ts | 55 +++++++++++++++++++ src/v1/common/timeseries-range.helper.ts | 33 +++++++++++ src/v1/soil/soil.controller.ts | 16 +----- src/v1/traffic/traffic.controller.ts | 16 +----- src/v1/water/water.controller.ts | 16 +----- 7 files changed, 98 insertions(+), 56 deletions(-) create mode 100644 src/v1/common/filters/all-exceptions.filter.ts create mode 100644 src/v1/common/timeseries-range.helper.ts diff --git a/src/main.ts b/src/main.ts index 15e917b..1cb3f61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,6 +9,7 @@ import { getCommit } from './utils/gitCommit'; import helmet from 'helmet'; import { join } from 'path'; import { Logger, ValidationPipe, VersioningType } from '@nestjs/common'; +import { AllExceptionsFilter } from './v1/common/filters/all-exceptions.filter'; import { STATUS_CODES } from 'http'; import type { Express, NextFunction, Request, Response } from 'express'; @@ -74,6 +75,7 @@ async function bootstrap() { transform: true, }), ); + app.useGlobalFilters(new AllExceptionsFilter()); app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1', diff --git a/src/v1/air/air.controller.ts b/src/v1/air/air.controller.ts index 9d8923f..49cbf9f 100644 --- a/src/v1/air/air.controller.ts +++ b/src/v1/air/air.controller.ts @@ -13,6 +13,7 @@ import { AirService } from './air.service'; import { AirDataDto } from './dto/air-data.dto'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -129,20 +130,7 @@ export class AirController { throw new BadRequestException('dev_eui is required'); } - const endDate = end ? new Date(end) : new Date(); - if (Number.isNaN(endDate.getTime())) { - throw new BadRequestException('end must be a valid date/time'); - } - - const startDate = start - ? new Date(start) - : new Date(endDate.getTime() - 24 * 60 * 60 * 1000); - if (Number.isNaN(startDate.getTime())) { - throw new BadRequestException('start must be a valid date/time'); - } - if (startDate > endDate) { - throw new BadRequestException('start must be before end'); - } + const { startDate, endDate } = parseTimeseriesRange(start, end); return this.airService.findOne(devEui, startDate, endDate, user, timezone); } diff --git a/src/v1/common/filters/all-exceptions.filter.ts b/src/v1/common/filters/all-exceptions.filter.ts new file mode 100644 index 0000000..3afbf4c --- /dev/null +++ b/src/v1/common/filters/all-exceptions.filter.ts @@ -0,0 +1,55 @@ +import { + ArgumentsHost, + Catch, + ExceptionFilter, + HttpException, + HttpStatus, + Logger, +} from '@nestjs/common'; +import type { Request, Response } from 'express'; + +/** + * Global exception filter. Preserves Nest's response shapes for + * HttpExceptions, but ensures every 5xx (and every non-HttpException, + * which previously surfaced as an opaque 500) is logged server-side + * with its underlying cause. + */ +@Catch() +export class AllExceptionsFilter implements ExceptionFilter { + private readonly logger = new Logger(AllExceptionsFilter.name); + + catch(exception: unknown, host: ArgumentsHost): void { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const request = ctx.getRequest(); + + if (exception instanceof HttpException) { + const status = exception.getStatus(); + if (status >= 500) { + this.logger.error( + `${request.method} ${request.originalUrl} -> ${status}: ${exception.message}`, + exception.stack, + ); + } + const body = exception.getResponse(); + response + .status(status) + .json( + typeof body === 'string' + ? { statusCode: status, message: body } + : body, + ); + return; + } + + this.logger.error( + `${request.method} ${request.originalUrl} -> unhandled exception`, + exception instanceof Error ? exception.stack : String(exception), + ); + response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ + statusCode: HttpStatus.INTERNAL_SERVER_ERROR, + error: 'Internal Server Error', + message: 'Internal server error', + }); + } +} diff --git a/src/v1/common/timeseries-range.helper.ts b/src/v1/common/timeseries-range.helper.ts new file mode 100644 index 0000000..18d7ba2 --- /dev/null +++ b/src/v1/common/timeseries-range.helper.ts @@ -0,0 +1,33 @@ +import { BadRequestException } from '@nestjs/common'; + +export interface TimeseriesRange { + startDate: Date; + endDate: Date; +} + +/** + * Parses the start/end query params shared by the time-series endpoints + * (air, soil, water, traffic). end defaults to now; start defaults to + * 24 hours before end. + */ +export function parseTimeseriesRange( + start?: string, + end?: string, +): TimeseriesRange { + const endDate = end ? new Date(end) : new Date(); + if (Number.isNaN(endDate.getTime())) { + throw new BadRequestException('end must be a valid date/time'); + } + + const startDate = start + ? new Date(start) + : new Date(endDate.getTime() - 24 * 60 * 60 * 1000); + if (Number.isNaN(startDate.getTime())) { + throw new BadRequestException('start must be a valid date/time'); + } + if (startDate > endDate) { + throw new BadRequestException('start must be before end'); + } + + return { startDate, endDate }; +} diff --git a/src/v1/soil/soil.controller.ts b/src/v1/soil/soil.controller.ts index 91f118b..9d9db11 100644 --- a/src/v1/soil/soil.controller.ts +++ b/src/v1/soil/soil.controller.ts @@ -9,6 +9,7 @@ import { import { SoilService } from './soil.service'; import { SoilDataDto } from './dto/soil-data.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -99,20 +100,7 @@ export class SoilController { throw new BadRequestException('dev_eui is required'); } - const endDate = end ? new Date(end) : new Date(); - if (Number.isNaN(endDate.getTime())) { - throw new BadRequestException('end must be a valid date/time'); - } - - const startDate = start - ? new Date(start) - : new Date(endDate.getTime() - 24 * 60 * 60 * 1000); - if (Number.isNaN(startDate.getTime())) { - throw new BadRequestException('start must be a valid date/time'); - } - if (startDate > endDate) { - throw new BadRequestException('start must be before end'); - } + const { startDate, endDate } = parseTimeseriesRange(start, end); return this.soilService.findOne(devEui, startDate, endDate, user, timezone); } diff --git a/src/v1/traffic/traffic.controller.ts b/src/v1/traffic/traffic.controller.ts index b2d85e9..17f302f 100644 --- a/src/v1/traffic/traffic.controller.ts +++ b/src/v1/traffic/traffic.controller.ts @@ -11,6 +11,7 @@ import { TrafficDataDto } from './dto/traffic-data.dto'; import { TrafficReportDto } from './dto/traffic-report.dto'; import { TrafficMonthlyReportDto } from './dto/traffic-monthly-report.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -101,20 +102,7 @@ export class TrafficController { throw new BadRequestException('dev_eui is required'); } - const endDate = end ? new Date(end) : new Date(); - if (Number.isNaN(endDate.getTime())) { - throw new BadRequestException('end must be a valid date/time'); - } - - const startDate = start - ? new Date(start) - : new Date(endDate.getTime() - 24 * 60 * 60 * 1000); - if (Number.isNaN(startDate.getTime())) { - throw new BadRequestException('start must be a valid date/time'); - } - if (startDate > endDate) { - throw new BadRequestException('start must be before end'); - } + const { startDate, endDate } = parseTimeseriesRange(start, end); return this.trafficService.findOne( devEui, diff --git a/src/v1/water/water.controller.ts b/src/v1/water/water.controller.ts index 93098d7..7f04b72 100644 --- a/src/v1/water/water.controller.ts +++ b/src/v1/water/water.controller.ts @@ -9,6 +9,7 @@ import { import { WaterService } from './water.service'; import { WaterDataDto } from './dto/water-data.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; +import { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -99,20 +100,7 @@ export class WaterController { throw new BadRequestException('dev_eui is required'); } - const endDate = end ? new Date(end) : new Date(); - if (Number.isNaN(endDate.getTime())) { - throw new BadRequestException('end must be a valid date/time'); - } - - const startDate = start - ? new Date(start) - : new Date(endDate.getTime() - 24 * 60 * 60 * 1000); - if (Number.isNaN(startDate.getTime())) { - throw new BadRequestException('start must be a valid date/time'); - } - if (startDate > endDate) { - throw new BadRequestException('start must be before end'); - } + const { startDate, endDate } = parseTimeseriesRange(start, end); return this.waterService.findOne( devEui,