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/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/package.json b/package.json index 5d609f8..db9471a 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", @@ -28,22 +29,18 @@ "@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", "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", "rxjs": "^7.8.2", - "swagger-ui-dist": "^5.32.4", "zod": "^4.4.3" }, "devDependencies": { @@ -55,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", @@ -62,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..4438417 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 @@ -62,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 @@ -77,9 +68,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 @@ -108,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 @@ -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 @@ -1051,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==} @@ -1702,9 +1699,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'} @@ -4340,6 +4334,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 +4397,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 +4451,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 +4553,7 @@ snapshots: '@types/cors@2.8.19': dependencies: '@types/node': 22.19.17 + optional: true '@types/eslint-scope@3.7.7': dependencies: @@ -4614,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': {} @@ -4889,6 +4901,7 @@ snapshots: dependencies: mime-types: 2.1.35 negotiator: 0.6.3 + optional: true accepts@2.0.0: dependencies: @@ -5041,7 +5054,8 @@ snapshots: base64-js@1.5.1: {} - base64id@2.0.0: {} + base64id@2.0.0: + optional: true base64url@3.0.1: {} @@ -5271,10 +5285,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: @@ -5336,7 +5346,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 +5365,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true enhanced-resolve@5.20.1: dependencies: @@ -6412,7 +6424,8 @@ snapshots: natural-compare@1.4.0: {} - negotiator@0.6.3: {} + negotiator@0.6.3: + optional: true negotiator@1.0.0: {} @@ -6450,7 +6463,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 +6799,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true socket.io-parser@4.2.6: dependencies: @@ -6792,6 +6807,7 @@ snapshots: debug: 4.4.3 transitivePeerDependencies: - supports-color + optional: true socket.io@4.8.3: dependencies: @@ -6806,6 +6822,7 @@ snapshots: - bufferutil - supports-color - utf-8-validate + optional: true source-map-support@0.5.13: dependencies: @@ -7236,7 +7253,8 @@ snapshots: dependencies: signal-exit: 4.1.0 - ws@8.18.3: {} + ws@8.18.3: + optional: true ws@8.20.0: {} 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.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/app.module.ts b/src/app.module.ts index b5740bf..0126dc8 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -6,15 +6,14 @@ 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'; 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'; @@ -29,7 +28,6 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; AirModule, SoilModule, WaterModule, - PowerModule, TrafficModule, ServeStaticModule.forRoot({ rootPath: join(process.cwd(), 'static'), @@ -51,8 +49,8 @@ import { CropwatchMcpModule } from './v1/mcp/mcp.module'; }, ]), DevicesModule, - RulesNewModule, - ReportsNewModule, + RulesModule, + ReportsModule, LocationsModule, RelayModule, GatewayModule, diff --git a/src/main.ts b/src/main.ts index 3ee391c..1cb3f61 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,13 +1,17 @@ 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 { AllExceptionsFilter } from './v1/common/filters/all-exceptions.filter'; import { STATUS_CODES } from 'http'; -import { doubleCsrf } from 'csrf-csrf'; -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']; @@ -28,7 +32,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() as any; + // 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(); @@ -63,11 +68,14 @@ 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.useGlobalFilters(new AllExceptionsFilter()); app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1', @@ -99,9 +107,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', @@ -111,11 +125,11 @@ 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( - Object.entries(doc.paths).filter(([path]) => path.startsWith(prefix)) + Object.entries(doc.paths).filter(([path]) => path.startsWith(prefix)), ), }; } @@ -124,8 +138,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, @@ -163,4 +181,4 @@ Developer notes: ); await app.listen(process.env.PORT ?? 3000); } -bootstrap(); +void bootstrap(); diff --git a/src/supabase/supabase-token.helper.ts b/src/supabase/supabase-token.helper.ts deleted file mode 100644 index 12d12e0..0000000 --- a/src/supabase/supabase-token.helper.ts +++ /dev/null @@ -1,28 +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.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/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/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 33272a9..86b2ab2 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 () => { @@ -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.controller.ts b/src/v1/air/air.controller.ts index 254e875..49cbf9f 100644 --- a/src/v1/air/air.controller.ts +++ b/src/v1/air/air.controller.ts @@ -7,13 +7,13 @@ import { Param, Query, UseGuards, - Req, Delete, } from '@nestjs/common'; 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, @@ -25,46 +25,43 @@ 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() - // create(@Body() createAirDto: CreateAirDto) { - // return this.airService.create(createAirDto); - // } - @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, @@ -124,7 +121,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, @@ -133,27 +130,8 @@ 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, - req.user, - timezone, - ); + return this.airService.findOne(devEui, startDate, endDate, user, timezone); } } diff --git a/src/v1/air/air.service.spec.ts b/src/v1/air/air.service.spec.ts index f7974d6..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); }); @@ -77,8 +85,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,9 +134,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,10 +144,10 @@ describe('AirService', () => { title: 'Shift review', }); - expect((service as any).assertDeviceAccess).toHaveBeenCalledWith( - '2CF7F1C073800102', - jwtPayload, - ); + 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 b65e542..50c1af7 100644 --- a/src/v1/air/air.service.ts +++ b/src/v1/air/air.service.ts @@ -2,12 +2,18 @@ import { BadRequestException, Injectable, InternalServerErrorException, - UnauthorizedException, } 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'> { @@ -22,13 +28,13 @@ export class AirService extends BaseDataService<'cw_air_data'> { devEui: string, month: string, year: string, - jwtPayload: any, - ): Promise { + 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( @@ -57,17 +63,20 @@ 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(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, @@ -75,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, @@ -84,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'); @@ -93,28 +102,13 @@ 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 + 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'); @@ -124,7 +118,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') @@ -143,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( @@ -162,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/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/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..6366706 100644 --- a/src/v1/auth/auth.controller.ts +++ b/src/v1/auth/auth.controller.ts @@ -1,6 +1,16 @@ -import { Body, Controller, Get, Patch, Post, Req, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Headers, + Patch, + Post, + 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, @@ -24,7 +34,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) @@ -41,8 +51,8 @@ export class AuthController { message: 'Unauthorized', }, }) - async protected(@Req() req) { - return req.user; + protected(@CurrentUser() user: AuthenticatedUser) { + return user; } @Get('user-profile') @@ -60,8 +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') @@ -92,13 +102,18 @@ 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') @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,20 +127,27 @@ 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({ 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') @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 }, @@ -138,8 +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') @@ -161,8 +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.spec.ts b/src/v1/auth/auth.service.spec.ts index df552af..a85b5f7 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(); @@ -39,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 () => { @@ -52,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 () => { @@ -68,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..8de4210 100644 --- a/src/v1/auth/auth.service.ts +++ b/src/v1/auth/auth.service.ts @@ -7,8 +7,10 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import type { PostgrestError } from '@supabase/supabase-js'; import { SupabaseService } from '../../supabase/supabase.service'; -import { getAccessToken, getUserId } from '../../supabase/supabase-token.helper'; +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', @@ -83,15 +91,14 @@ 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); - const { data, error } = await client + async getUserProfile(user: AuthenticatedUser) { + const client = this.supabaseService.getClient(); + const userId = user.sub; + 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'); @@ -102,12 +109,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) { @@ -131,17 +136,22 @@ export class AuthService { } // Defensive pre-check: username must be unique across all profiles. - if (typeof normalized.username === 'string' && normalized.username.length > 0) { - const { data: existing, error: lookupError } = await client + if ( + typeof normalized.username === 'string' && + normalized.username.length > 0 + ) { + 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('Failed to verify username availability'); + throw new InternalServerErrorException( + 'Failed to verify username availability', + ); } if (existing) { @@ -149,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. @@ -174,12 +184,22 @@ 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(); - if (RESTRICTED_EMAIL_CHANGE_DOMAINS.some((domain) => currentEmail.endsWith(domain))) { + const currentEmail = user.email ?? ''; + 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 +235,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,21 +250,21 @@ 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.', }; } /** 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 + 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'); } @@ -249,11 +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 }) + .upsert( + { user_id: userId }, + { onConflict: 'user_id', ignoreDuplicates: false }, + ) .select('*') - .single(); + .single()) as QueryResult; if (insertError || !inserted) { throw new InternalServerErrorException('Failed to create preferences'); } @@ -263,18 +289,17 @@ 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) { 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; } } @@ -282,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'); @@ -297,4 +322,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/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..236451d --- /dev/null +++ b/src/v1/auth/current-user.decorator.ts @@ -0,0 +1,12 @@ +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/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/auth/strategies/supabase.strategy.ts b/src/v1/auth/strategies/supabase.strategy.ts index 8a3197b..8a3abd9 100644 --- a/src/v1/auth/strategies/supabase.strategy.ts +++ b/src/v1/auth/strategies/supabase.strategy.ts @@ -1,23 +1,42 @@ -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) { - 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, }); } - 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) }; } } 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..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 @@ -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,30 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot "/v1/auth": { "get": {}, }, + "/v1/auth/email": { + "patch": { + "parameters": [ + { + "in": "header", + "name": "authorization", + "required": true, + "schema": { + "type": "string", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateEmailDto", + }, + }, + }, + "required": true, + }, + }, + }, "/v1/auth/login": { "post": { "requestBody": { @@ -397,6 +571,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": { @@ -419,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", + }, }, ], }, @@ -461,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", + }, }, ], }, @@ -584,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", + }, + }, ], }, }, @@ -625,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", @@ -634,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", + }, + }, ], }, }, @@ -720,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", + }, }, ], }, @@ -897,20 +1142,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/base-data.service.ts b/src/v1/common/base-data.service.ts index 7b70085..3198389 100644 --- a/src/v1/common/base-data.service.ts +++ b/src/v1/common/base-data.service.ts @@ -7,8 +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 @@ -33,7 +33,7 @@ export abstract class BaseDataService { devEui: string, startDate: Date, endDate: Date, - jwtPayload: any, + user: AuthenticatedUser, timezone?: string, ): Promise[]> { const normalizedDevEui = devEui?.trim(); @@ -45,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() @@ -62,11 +62,11 @@ export abstract class BaseDataService { ); } - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - 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, ), @@ -75,10 +75,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') @@ -95,7 +95,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/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/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/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/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/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/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 8563dae..d22b968 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'; @@ -55,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 = ''; @@ -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 }, @@ -509,15 +504,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [ - MOCK_USER, - AUTH_HEADER, - 5, - 1000, - 'Field Group', - 'North Node', - 'Greenhouse', - ], + args: [MOCK_USER, 5, 1000, 'Field Group', 'North Node', 'Greenhouse'], method: 'findAll', service: 'devices', }, @@ -529,7 +516,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER], + args: [MOCK_USER], method: 'findAllStatus', service: 'devices', }, @@ -541,7 +528,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER], + args: [MOCK_USER], method: 'findAllDeviceGroups', service: 'devices', }, @@ -557,7 +544,6 @@ describe('V1 Route Input Contracts', () => { MOCK_USER, 7, 11, - AUTH_HEADER, 'Irrigation', 'West Node', 'Field 7', @@ -574,7 +560,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, 42, AUTH_HEADER], + args: [MOCK_USER, 42], method: 'findAllDevicesInLocation', service: 'devices', }, @@ -586,7 +572,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', }, @@ -598,7 +584,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', }, @@ -610,7 +596,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', }, @@ -630,7 +616,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, @@ -647,7 +632,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', }, @@ -659,7 +644,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', }, @@ -671,14 +656,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [ - MOCK_USER, - 'DEV-001', - 'Renamed Device', - 'Zone A', - 54, - AUTH_HEADER, - ], + args: [MOCK_USER, 'DEV-001', 'Renamed Device', 'Zone A', 54], method: 'updateDevice', service: 'devices', }, @@ -695,7 +673,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', }, @@ -710,7 +688,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER, undefined], + args: [MOCK_USER, undefined], method: 'findAll', service: 'locations', }, @@ -722,7 +700,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [MOCK_USER, AUTH_HEADER], + args: [MOCK_USER], method: 'findAllLocationGroups', service: 'locations', }, @@ -734,7 +712,7 @@ describe('V1 Route Input Contracts', () => { { auth: true, expectedCall: { - args: [15, MOCK_USER, AUTH_HEADER], + args: [15, MOCK_USER], method: 'findOne', service: 'locations', }, @@ -746,7 +724,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', }, @@ -771,7 +749,6 @@ describe('V1 Route Input Contracts', () => { 3, true, MOCK_USER, - AUTH_HEADER, ], method: 'createLocationPermission', service: 'locations', @@ -789,7 +766,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', }, @@ -809,7 +786,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', @@ -827,7 +803,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', }, @@ -836,17 +812,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: { @@ -1083,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.controller.ts b/src/v1/dashboard/dashboard.controller.ts index 9b5d75f..3254cd5 100644 --- a/src/v1/dashboard/dashboard.controller.ts +++ b/src/v1/dashboard/dashboard.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query, Req, Res, UseGuards } from '@nestjs/common'; +import { Controller, Get, Param, Query, Res, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, @@ -10,17 +10,20 @@ 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', + 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 +31,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( + @CurrentUser() user: AuthenticatedUser, + @Query() q: Record, + ) { const query: DashboardQuery = { skip: q.skip ? Number(q.skip) : undefined, take: q.take ? Number(q.take) : undefined, @@ -37,13 +43,13 @@ 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(user, 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 +57,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( + @CurrentUser() user: AuthenticatedUser, + @Query() q: Record, + ) { const query: DashboardQuery = { skip: q.skip ? Number(q.skip) : undefined, take: q.take ? Number(q.take) : undefined, @@ -60,21 +69,21 @@ 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(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)', + 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, devEui, req.headers.authorization); + const row = await this.dashboardService.getLatest(user, devEui); 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..85f2746 100644 --- a/src/v1/dashboard/dashboard.service.ts +++ b/src/v1/dashboard/dashboard.service.ts @@ -5,14 +5,11 @@ import { Logger, NotFoundException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; 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 type { TableRow } from '../types/supabase'; import { DashboardLocationGroup, DashboardLocationPage, @@ -21,6 +18,41 @@ import { DashboardRow, isDashboardDataTable, } 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 { @@ -29,14 +61,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); @@ -57,17 +87,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,10 +125,12 @@ 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 ?? []; + const devices = (data ?? []) as DashboardDeviceRecord[]; const rows = await Promise.all( devices.map((d) => this.buildRow(client, d)), @@ -111,14 +145,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); @@ -138,9 +170,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 +191,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. @@ -173,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, @@ -210,15 +244,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 +266,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,11 +282,17 @@ 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 = ( - 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. @@ -259,26 +302,28 @@ 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 }; } 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) { @@ -294,7 +339,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( @@ -306,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)) { @@ -317,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( @@ -332,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] @@ -418,7 +473,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']; } @@ -442,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'); } @@ -460,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/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.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 1cbed71..539de05 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'; @@ -27,18 +27,20 @@ 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'; +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) { } + constructor(private readonly devicesService: DevicesService) {} @Get() - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Current all of the user's authenticated devices returned when run successfully.", @@ -68,96 +70,162 @@ 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 }) - findAll(@Req() req) { - const parsedSkip = parseInt(req.query.skip, 10); - const parsedTake = parseInt(req.query.take, 10); + @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( + @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, skip, take, searchGroup, searchName, searchLocation); + return this.devicesService.findAll( + user, + skip, + take, + 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) { - return this.devicesService.findAllDeviceGroups(req.user, req.headers.authorization); + findAllDeviceGroups(@CurrentUser() user: AuthenticatedUser) { + return this.devicesService.findAllDeviceGroups(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) { - return this.devicesService.findAllDeviceTypes(req.user, req.headers.authorization); + findAllDeviceTypes() { + return this.devicesService.findAllDeviceTypes(); } @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: ` 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; - return this.devicesService.findAllLatestData(req.user, skip, take, req.headers.authorization, searchDeviceGroup, searchName, searchLocation, locationGroup); + 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( + user, + skip, + take, + searchDeviceGroup || undefined, + searchName || undefined, + searchLocation || undefined, + locationGroup || undefined, + ); } @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); + allDevicesInLocation( + @CurrentUser() user: AuthenticatedUser, + @Param('location_id') locationId: number, + ) { + return this.devicesService.findAllDevicesInLocation(user, locationId); } @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Current user's device returned successfully.", type: DeviceDto, @@ -204,34 +272,48 @@ 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, devEui, req.headers.authorization); + return this.devicesService.findOne(user, devEui); } @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: ` 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', @@ -256,18 +338,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', @@ -276,49 +364,67 @@ 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 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); + const skip = parseInt(rawSkip ?? '', 10) || 0; + const take = parseInt(rawTake ?? '', 10) || 144; + const start = + rawStart || new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); + const end = rawEnd || new Date().toISOString(); + return this.devicesService.findDataWithinRange( + user, + devEui, + start, + end, + skip, + take, + ); } @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, devEui, req.headers.authorization); + return this.devicesService.findLatestData(user, devEui); } @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, devEui, req.headers.authorization, true); + return this.devicesService.findLatestData(user, devEui, true); } @Post(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Create a new device for the authenticated user', @@ -328,48 +434,51 @@ export class DevicesController { Please contact support if you would like this feature to be prioritized. `, }) - create( - @Req() req, - @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.', ); } @Post(':dev_eui/replace') - @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. Please contact support if you would like this feature to be prioritized. `, }) - replace( - @Req() req, + async replace( + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: ReplaceDeviceDto, ) { 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 = await this.devicesService.replaceDevice( + user, + normalizedDevEui, + replacementDevice, + ); 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; } @Patch(':dev_eui/permission-level') - @UseGuards(JwtAuthGuard) @ApiParam({ name: 'dev_eui', description: 'Device dev_eui' }) @ApiOperation({ summary: 'Update the permission level for a device', @@ -380,7 +489,7 @@ export class DevicesController { `, }) updatePermissionLevel( - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: UpdateDevicePermissionDto, ) { @@ -390,7 +499,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'); @@ -399,16 +510,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', @@ -417,7 +526,7 @@ export class DevicesController { `, }) updateDevice( - @Req() req, + @CurrentUser() user: AuthenticatedUser, @Param('dev_eui') devEui: string, @Body() body: UpdateDeviceNameGroupLocalDto, ) { @@ -430,6 +539,12 @@ 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( + user, + devEui, + body.name, + body.group ?? null, + body.location_id, + ); } } diff --git a/src/v1/devices/devices.service.spec.ts b/src/v1/devices/devices.service.spec.ts index e9990d2..1036d74 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 { @@ -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 }), }; } @@ -238,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 }), }; } @@ -254,7 +266,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 +301,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 +348,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 +378,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 +423,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..7df19dc 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -6,13 +6,9 @@ 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 { - 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 +17,60 @@ import { PermissionLevel, READ_EXCLUSIVE_CEILING, } 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; @@ -33,33 +83,32 @@ 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, - 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); + ): Promise> { + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; - 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 +121,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}%`); @@ -90,26 +141,25 @@ 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( - 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); + ): Promise { + 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'); @@ -130,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'); @@ -146,13 +196,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') @@ -162,7 +210,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'); @@ -200,13 +250,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') @@ -225,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) { @@ -241,13 +290,8 @@ export class DevicesService { return groupCounts; } - public async findAllDeviceTypes( - jwtPayload: any, - authHeader: string, - ): Promise<{ type: string | null; count: number }[]> { - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const userId = getUserId(jwtPayload); + public async findAllDeviceTypes(): Promise { + const client = this.supabaseService.getClient(); const { data: types, error } = await client .from('cw_device_type') @@ -261,24 +305,21 @@ export class DevicesService { throw new NotFoundException('No device types found'); } - return types; + return types as DeviceTypeRow[]; } public async findData( - jwtPayload: any, + user: AuthenticatedUser, devEui: string, skip: number = 0, take: number = 144, - authHeader: string, - ): Promise> { + ): 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'); @@ -301,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( @@ -321,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( @@ -362,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( @@ -397,20 +439,18 @@ 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(), end: Date | string = new Date().toISOString(), 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); + ): Promise> { + 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'); @@ -418,17 +458,19 @@ 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); - const { data: device, error: deviceError } = await deviceQuery.single(); + const { data: device, error: deviceError } = + (await deviceQuery.single()) as SingleResult; if (deviceError) { - console.error(deviceError); + this.logger.error( + `Failed to fetch device ${normalizedDevEui}`, + deviceError.message, + ); throw new InternalServerErrorException('Failed to fetch device'); } @@ -436,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'); @@ -453,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'); @@ -482,27 +520,22 @@ 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); + ): Promise>> { + 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 ? 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)'; @@ -525,7 +558,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( @@ -558,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; @@ -571,10 +607,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; @@ -582,7 +614,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 +623,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 @@ -652,14 +684,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); + ): Promise { + const client = this.supabaseService.getClient(); + const userId = user.sub; + const isGlobalUser = user.isStaff; let query = client .from('cw_devices') @@ -668,7 +698,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'); @@ -678,19 +710,17 @@ export class DevicesService { throw new NotFoundException('No devices found for this location'); } - return devices; + return devices as DeviceRecord[]; } 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'); @@ -703,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'); @@ -713,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'); @@ -727,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'); @@ -756,7 +787,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], @@ -768,15 +800,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'); @@ -786,16 +816,21 @@ 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, + user, + )) as LocationRow | null; 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 - const { data: createdDeviceData, error: createDeviceError } = await client + const { data: createdDeviceData, error: createDeviceError } = (await client .from('cw_devices') .insert({ dev_eui: normalizedDevEui, @@ -806,7 +841,7 @@ export class DevicesService { user_id: userId, }) .select('*') - .single(); + .single()) as SingleResult; if (createDeviceError) { throw new InternalServerErrorException('Failed to create device'); @@ -819,9 +854,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 +868,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 +886,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', + ); } } @@ -856,16 +896,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'); @@ -883,7 +921,8 @@ export class DevicesService { PermissionLevel.ADMIN, ); - const { data: device, error: deviceError } = await existingDeviceQuery.single(); + const { data: device, error: deviceError } = + (await existingDeviceQuery.single()) as SingleResult; if (deviceError) { throw new InternalServerErrorException('Failed to fetch device'); @@ -906,7 +945,8 @@ export class DevicesService { PermissionLevel.ADMIN, ); - const { data: newDeviceData, error: newDeviceError } = await newDeviceQuery.single(); + const { data: newDeviceData, error: newDeviceError } = + (await newDeviceQuery.single()) as SingleResult; if (newDeviceError) { throw new InternalServerErrorException('Failed to fetch new device'); @@ -917,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, @@ -928,7 +968,7 @@ export class DevicesService { }) .eq('dev_eui', normalizedDevEui) .select('*') - .single(); + .single()) as SingleResult; if (updateDeviceError) { throw new InternalServerErrorException('Failed to update device'); @@ -942,16 +982,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'); @@ -977,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( @@ -1000,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( @@ -1017,17 +1055,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'); @@ -1056,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( @@ -1064,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); @@ -1097,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( @@ -1113,7 +1150,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 }; @@ -1122,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'); @@ -1153,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, @@ -1198,7 +1237,11 @@ export class DevicesService { } } - private applyDeviceReadScope(query: any, userId: string, isGlobalUser: boolean) { + private applyDeviceReadScope>( + query: Q, + userId: string, + isGlobalUser: boolean, + ): Q { if (isGlobalUser) { return query; } @@ -1209,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/UpdateDeviceNameGroupLocal.dto.ts b/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts deleted file mode 100644 index f02dae1..0000000 --- a/src/v1/devices/dto/UpdateDeviceNameGroupLocal.dto.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { Type } from 'class-transformer'; -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; - - @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; - - @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 deleted file mode 100644 index a7c94be..0000000 --- a/src/v1/devices/dto/UpdateDevicePermission.dto.ts +++ /dev/null @@ -1,23 +0,0 @@ -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'; - -export class UpdateDevicePermissionDto { - @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; - - @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..4719cd1 100644 --- a/src/v1/devices/dto/replace-device.dto.ts +++ b/src/v1/devices/dto/replace-device.dto.ts @@ -1,9 +1,12 @@ -import { ApiProperty, PartialType } from '@nestjs/swagger'; +import { ApiProperty } 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/devices/dto/update-device-name-group-local.dto.ts b/src/v1/devices/dto/update-device-name-group-local.dto.ts new file mode 100644 index 0000000..da85f41 --- /dev/null +++ b/src/v1/devices/dto/update-device-name-group-local.dto.ts @@ -0,0 +1,44 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +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; + + @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; + + @ApiPropertyOptional({ + example: 'eui-device-name', + type: String, + required: false, + }) + @IsOptional() + @IsString() + @MaxLength(255) + tti_name?: string | null; +} diff --git a/src/v1/devices/dto/update-device-permission.dto.ts b/src/v1/devices/dto/update-device-permission.dto.ts new file mode 100644 index 0000000..966f001 --- /dev/null +++ b/src/v1/devices/dto/update-device-permission.dto.ts @@ -0,0 +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'; + +export class UpdateDevicePermissionDto { + @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; + + @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/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.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 6809617..8ba3019 100644 --- a/src/v1/gateway/gateway.controller.ts +++ b/src/v1/gateway/gateway.controller.ts @@ -1,18 +1,11 @@ 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, @@ -25,20 +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) {} - // @Post() - // create(@Body() createGatewayDto: CreateGatewayDto) { - // return this.gatewayService.create(createGatewayDto); - // } - @Get() - @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Get gateways for the authenticated user', description: @@ -53,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: @@ -81,21 +70,14 @@ 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); } - - // @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.spec.ts b/src/v1/gateway/gateway.service.spec.ts index 4d2bff7..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' }), + 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' })).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/gateway/gateway.service.ts b/src/v1/gateway/gateway.service.ts index 91b740c..f4f69b2 100644 --- a/src/v1/gateway/gateway.service.ts +++ b/src/v1/gateway/gateway.service.ts @@ -4,24 +4,34 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; -import { CreateGatewayDto } from './dto/create-gateway.dto'; -import { UpdateGatewayDto } from './dto/update-gateway.dto'; +import type { PostgrestError } from '@supabase/supabase-js'; 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'; + +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) {} - create(createGatewayDto: CreateGatewayDto) { - void createGatewayDto; - return 'This action adds a new gateway'; - } - - 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') @@ -37,30 +47,37 @@ export class GatewayService { throw new InternalServerErrorException('Failed to fetch gateways'); } - const ownedGatewayIds = new Set( - ownedGateways?.map((og) => og.gateway_id) ?? [], - ); - - 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)), + const ownedRows = (ownedGateways ?? []) as GatewayRecord[]; + const publicRows = (publicGateways ?? []) as GatewayRow[]; + + const ownedGatewayIds = new Set(ownedRows.map((og) => og.gateway_id)); + + const allGateways: GatewayListItem[] = [ + ...ownedRows.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, + })), + ...publicRows.filter((pg) => !ownedGatewayIds.has(pg.gateway_id)), ]; - - return allGateways ?? []; + return allGateways; } async findOne( gatewayIdentifier: string, - jwtPayload: any, - ): Promise> { + user: AuthenticatedUser, + ): Promise { const normalizedGatewayIdentifier = gatewayIdentifier?.trim(); if (!normalizedGatewayIdentifier) { throw new BadRequestException('gateway_id is required'); } const client = this.supabaseService.getClient(); - const userId = getUserId(jwtPayload); + const userId = user.sub; const query = client .from('cw_gateways') @@ -72,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'); @@ -82,22 +100,11 @@ 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'); } 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/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.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/locations/locations.controller.ts b/src/v1/locations/locations.controller.ts index 9333eb0..c154910 100644 --- a/src/v1/locations/locations.controller.ts +++ b/src/v1/locations/locations.controller.ts @@ -8,14 +8,19 @@ import { Delete, Query, UseGuards, - Req, BadRequestException, } from '@nestjs/common'; 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'; @@ -24,73 +29,87 @@ 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) { } + 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); + create( + @Body() createLocationDto: CreateLocationDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.locationsService.create(createLocationDto, user); } - - @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 ?? ''; - 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.", + 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.", + 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) { - const authHeader = req.headers?.authorization ?? ''; - return this.locationsService.update(+id, updateLocationDto, req.user, authHeader); + update( + @Param('id') id: string, + @Body() updateLocationDto: UpdateLocationDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.locationsService.update(+id, updateLocationDto, user); } @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, @@ -101,12 +120,14 @@ 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(); - 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 +144,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( @@ -135,47 +158,89 @@ 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', 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) { - const authHeader = req.headers?.authorization ?? ''; + async updateLocationPermission( + @Param('id') id: string, + @Body() updateLocationOwnerDto: UpdateLocationOwnerDto, + @Query('applyToAllDevices') applyToAllDevices: string = 'false', + @CurrentUser() user: AuthenticatedUser, + ) { const applyToAllDevicesFlag = applyToAllDevices === 'true'; - return this.locationsService.updateLocationPermission(+id, updateLocationOwnerDto, applyToAllDevicesFlag, req.user, authHeader); + return this.locationsService.updateLocationPermission( + +id, + updateLocationOwnerDto, + applyToAllDevicesFlag, + user, + ); } @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) { - const authHeader = req.headers?.authorization ?? ''; + async updateUserPermissionLevel( + @Param('id') id: string, + @Body() updateLocationUserPermissionLevelDto: unknown, + @Query('applyToAllDevices') applyToAllDevices: string = 'false', + @CurrentUser() user: AuthenticatedUser, + ) { const applyToAllDevicesFlag = applyToAllDevices === 'true'; - return this.locationsService.updateUserPermissionLevel(+id, updateLocationUserPermissionLevelDto, applyToAllDevicesFlag, req.user, authHeader); + return this.locationsService.updateUserPermissionLevel( + +id, + updateLocationUserPermissionLevelDto, + applyToAllDevicesFlag, + user, + ); } @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, + @CurrentUser() user: AuthenticatedUser, + ) { 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, + user, + ); } } diff --git a/src/v1/locations/locations.service.spec.ts b/src/v1/locations/locations.service.spec.ts index 7b69a29..e218205 100644 --- a/src/v1/locations/locations.service.spec.ts +++ b/src/v1/locations/locations.service.spec.ts @@ -1,13 +1,33 @@ -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'; 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), @@ -17,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), @@ -26,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, ); @@ -60,7 +80,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', email: null, isStaff: false }), + ).rejects.toBeInstanceOf(NotFoundException); expect(locationQuery.maybeSingle).toHaveBeenCalledTimes(1); }); @@ -85,7 +107,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({ @@ -123,7 +145,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', isStaff: false }, + 'Bearer token-1', + ), ).resolves.toEqual({ location_id: 84, name: 'Customer Location', @@ -132,8 +158,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, @@ -160,14 +192,21 @@ describe('LocationsService', () => { is_active: true, }, true, - { sub: 'admin-1' }, + { sub: 'admin-1', email: null, isStaff: false }, 'Bearer token-1', ), ).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..6d82d86 100644 --- a/src/v1/locations/locations.service.ts +++ b/src/v1/locations/locations.service.ts @@ -1,16 +1,17 @@ -import { ForbiddenException, Injectable, InternalServerErrorException, NotFoundException, NotImplementedException, UnauthorizedException } from '@nestjs/common'; +import { + ForbiddenException, + Injectable, + InternalServerErrorException, + NotFoundException, + 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 { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { UpdateLocationOwnerDto } from './dto/update-location-owner.dto'; import { MANAGE_CEILING, @@ -18,25 +19,47 @@ 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( private readonly supabaseService: SupabaseService, private readonly paymentsService: PaymentsService, - ) { } + ) {} - async create(createLocationDto: CreateLocationDto, jwtPayload: any, authHeader: string) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); + async create(createLocationDto: CreateLocationDto, user: AuthenticatedUser) { + 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) && - !(await this.paymentsService.hasActiveBaseSubscription(jwtPayload, authHeader)) + !user.isStaff && + !(await this.paymentsService.hasActiveBaseSubscription(user)) ) { throw new ForbiddenException( 'An active base subscription is required to create a location.', @@ -45,11 +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.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()) as QueryResult; if (locationError) { throw new InternalServerErrorException('Failed to create location'); @@ -66,11 +92,13 @@ export class LocationsService { description: null, }; - const { data: ownerData, error: ownerError } = await client.from('cw_location_owners').insert({ - ...locationOwnerObject - }) - .select('*') - .single(); + const { error: ownerError } = await client + .from('cw_location_owners') + .insert({ + ...locationOwnerObject, + }) + .select('*') + .single(); if (ownerError) { throw new InternalServerErrorException('Failed to create location owner'); @@ -79,15 +107,12 @@ 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(` + let query = client.from('cw_locations').select(` *, owner_match:cw_location_owners(), cw_location_owners(*) @@ -105,26 +130,26 @@ export class LocationsService { throw new InternalServerErrorException('Failed to fetch locations'); } - return data; + return (data ?? []) as LocationRecord[]; } - 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') - .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); - 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'); @@ -143,29 +168,41 @@ export class LocationsService { }; } - async update(id: number, updateLocationDto: UpdateLocationDto, jwtPayload: any, authHeader: string) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async update( + id: number, + updateLocationDto: UpdateLocationDto, + user: AuthenticatedUser, + ) { + 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 .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()) as QueryResult; + 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,26 +216,27 @@ export class LocationsService { updateQuery = updateQuery.eq('owner_id', userId); } - const { data, error } = await updateQuery + const { data, error } = (await updateQuery .select('*') - .single(); + .single()) as QueryResult; 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 { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async findAllLocationGroups(user: AuthenticatedUser): Promise { + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; let query = client .from('cw_locations') @@ -219,34 +257,51 @@ 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 rows = (data ?? []) as Pick[]; + const uniqueGroupArray = Array.from( + new Set(rows.map((item) => item.group)), + ).filter((group) => group !== null); return uniqueGroupArray; } - 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); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async createLocationPermission( + id: number, + createLocationOwnerDto: CreateLocationOwnerDto, + permissionLevel: number, + applyPermissionToAllDevices: boolean, + user: AuthenticatedUser, + ) { + 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 .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()) as QueryResult; + 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 +313,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,35 +360,49 @@ 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) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async updateLocationPermission( + id: number, + updateLocationOwnerDto: UpdateLocationOwnerDto, + applyPermissionToAllDevices: boolean, + user: AuthenticatedUser, + ) { + 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 .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()) as QueryResult; + 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 +420,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,37 +452,53 @@ 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) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); - - const email = updateLocationOwnerDto.email; - const permission_level = updateLocationOwnerDto.permission_level; - const location_id = updateLocationOwnerDto.location_id; + async updateUserPermissionLevel( + id: number, + updateLocationOwnerDto: unknown, + applyPermissionToAllDevices: boolean, + user: AuthenticatedUser, + ) { + const userId = user.sub; + const client = this.supabaseService.getClient(); + const isGlobalUser = user.isStaff; + + 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 .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()) as QueryResult; + 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,66 +507,84 @@ 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) { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const client = this.supabaseService.getClient(accessToken); - const isGlobalUser = isCropwatchStaff(jwtPayload); + async removeLocationPermission( + location_id: number, + permissionId: number, + user: AuthenticatedUser, + ) { + 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 .from('cw_locations') - .select(` + .select( + ` *, owner_match:cw_location_owners(), cw_location_owners(*) - `) + `, + ) .eq('location_id', location_id); permissionQuery = this.applyLocationManageScope( permissionQuery, userId, isGlobalUser, ); - const { data: requestingUser, error } = await permissionQuery.maybeSingle(); + const { data: requestingUser, error } = + (await permissionQuery.maybeSingle()) as QueryResult; 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'); + .maybeSingle()) as QueryResult; + 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: Q, + userId: string, + isGlobalUser: boolean, + ): Q { 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: Q, + userId: string, + isGlobalUser: boolean, + ): Q { 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..03e8758 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. @@ -39,11 +40,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() @@ -62,14 +67,24 @@ 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, ) { - const authHeader = request.headers.authorization ?? ''; const result = await this.devicesService.findAll( request.user, - authHeader, skip ?? 0, take ?? 100, group, @@ -98,23 +113,20 @@ export class DeviceMcpTools { }, }) async getDevice( - { dev_eui }, + { dev_eui }: { dev_eui: string }, _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); } /** 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..5a159d5 100644 --- a/src/v1/payments/payments.controller.ts +++ b/src/v1/payments/payments.controller.ts @@ -12,14 +12,17 @@ import { Req, UseGuards, } from '@nestjs/common'; +import type { RawBodyRequest } from '@nestjs/common'; +import type { Request } from 'express'; import { ApiBearerAuth, - ApiOkResponse, ApiOperation, ApiParam, 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,58 +49,65 @@ export class PaymentsController { @ApiOperation({ summary: 'Get the full billing overview (base sub, device seats, licenses)', }) - getState(@Req() req) { - return this.paymentsService.getState(req.user, req.headers?.authorization ?? ''); + getState(@CurrentUser() user: AuthenticatedUser) { + return this.paymentsService.getState(user); } @Get('licenses') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: "List the user's device licenses" }) - getLicenses(@Req() req) { - return this.paymentsService.getLicenses(req.user, req.headers?.authorization ?? ''); + getLicenses(@CurrentUser() user: AuthenticatedUser) { + return this.paymentsService.getLicenses(user); } @Post('subscriptions/base/checkout') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Create a hosted checkout for the base subscription' }) - createBaseCheckout(@Body() dto: CreateBaseCheckoutDto, @Req() req) { + @ApiOperation({ + summary: 'Create a hosted checkout for the base subscription', + }) + createBaseCheckout( + @Body() dto: CreateBaseCheckoutDto, + @CurrentUser() user: AuthenticatedUser, + ) { return this.paymentsService.createBaseCheckout( - req.user, - req.headers?.authorization ?? '', + user, dto.discountId ?? null, ); } @Post('subscriptions/device/checkout') @UseGuards(JwtAuthGuard) - @ApiOperation({ summary: 'Create a hosted checkout for device licenses (seats)' }) - createDeviceCheckout(@Body() dto: CreateDeviceCheckoutDto, @Req() req) { - return this.paymentsService.createDeviceCheckout( - req.user, - req.headers?.authorization ?? '', - dto.quantity, - ); + @ApiOperation({ + summary: 'Create a hosted checkout for device licenses (seats)', + }) + 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, @Req() req) { - return this.paymentsService.changeDeviceSeats( - req.user, - req.headers?.authorization ?? '', - dto.seats, - ); + changeDeviceSeats( + @Body() dto: ChangeSeatsDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.changeDeviceSeats(user, dto.seats); } @Post('licenses/:id/assign') @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, + @CurrentUser() user: AuthenticatedUser, + ) { return this.paymentsService.assignLicense( - req.user, - req.headers?.authorization ?? '', + user, this.parseId(id), dto.devEui, ); @@ -107,66 +117,72 @@ 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) { - return this.paymentsService.moveLicense( - req.user, - req.headers?.authorization ?? '', - this.parseId(id), - dto.devEui, - ); + moveLicense( + @Param('id') id: string, + @Body() dto: MoveLicenseDto, + @CurrentUser() user: AuthenticatedUser, + ) { + 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, @Req() req) { - return this.paymentsService.unassignLicense( - req.user, - req.headers?.authorization ?? '', - this.parseId(id), - ); + unassignLicense( + @Param('id') id: string, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.paymentsService.unassignLicense(user, this.parseId(id)); } @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)' }) - cancelLicense(@Param('id') id: string, @Req() req) { - return this.paymentsService.cancelLicense( - req.user, - req.headers?.authorization ?? '', - this.parseId(id), - ); + @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)); } @Post('portal') @UseGuards(JwtAuthGuard) @ApiOperation({ summary: 'Open the Polar customer billing portal' }) - openPortal(@Req() req) { - return this.paymentsService.openPortal(req.user, req.headers?.authorization ?? ''); + openPortal(@CurrentUser() user: AuthenticatedUser) { + return this.paymentsService.openPortal(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, ); } @Post('webhook') @HttpCode(HttpStatus.ACCEPTED) - @ApiOperation({ summary: 'Receive Polar webhook events (signature-verified)' }) - handleWebhook(@Req() req) { + @ApiOperation({ + summary: 'Receive Polar webhook events (signature-verified)', + }) + handleWebhook(@Req() req: RawBodyRequest) { 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..c4b0eca 100644 --- a/src/v1/payments/payments.service.ts +++ b/src/v1/payments/payments.service.ts @@ -8,13 +8,8 @@ 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 { - getAccessToken, - getUserId, - isCropwatchStaff, -} from '../../supabase/supabase-token.helper'; import { MANAGE_CEILING } from '../common/permission-levels'; import type { TableInsert, TableRow } from '../types/supabase'; import { @@ -27,8 +22,17 @@ import { BillingProductsResponse, SubscriptionStateResponse, } from './payments.types'; +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']; @@ -51,23 +55,25 @@ 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, }; } - async getState( - jwtPayload: any, - authHeader: string, - ): Promise { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + async getState(user: AuthenticatedUser): Promise { + const userId = user.sub; + const client = this.supabaseService.getClient(); 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,9 +113,9 @@ export class PaymentsService { }; } - async getLicenses(jwtPayload: any, authHeader: string): Promise { - const userId = getUserId(jwtPayload); - const client = this.supabaseService.getClient(getAccessToken(authHeader)); + async getLicenses(user: AuthenticatedUser): Promise { + const userId = user.sub; + const client = this.supabaseService.getClient(); return this.fetchLicenses(client, userId); } @@ -119,24 +125,28 @@ 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 { - const userId = getUserId(jwtPayload); + async hasActiveBaseSubscription(user: AuthenticatedUser): Promise { + const userId = user.sub; 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( - `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(getAccessToken(authHeader)); - const { data } = await client + const client = this.supabaseService.getClient(); + 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) + !!data?.base_status && + ACTIVE_SUBSCRIPTION_STATUSES.includes(data.base_status) ); } } @@ -146,16 +156,18 @@ 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); - 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.'); } @@ -163,23 +175,25 @@ 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); - 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,9 +201,12 @@ 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), + 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 }, @@ -198,12 +215,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( @@ -217,7 +233,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) @@ -234,17 +252,15 @@ export class PaymentsService { return { seats }; } - async openPortal( - jwtPayload: any, - authHeader: string, - ): Promise<{ portalUrl: string }> { - const userId = getUserId(jwtPayload); - getAccessToken(authHeader); // validate bearer token shape + async openPortal(user: AuthenticatedUser): Promise<{ portalUrl: string }> { + const userId = user.sub; try { 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.', ); @@ -252,20 +268,25 @@ 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(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 @@ -290,14 +311,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) { @@ -313,14 +333,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); @@ -330,18 +349,21 @@ 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); 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) { @@ -357,12 +379,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') { @@ -510,7 +531,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, @@ -541,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'); } @@ -579,7 +605,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 +621,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', + ); } } } @@ -607,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'); } @@ -619,13 +649,18 @@ 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 }) + .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'); + throw new InternalServerErrorException( + 'Failed to create billing customer', + ); } return inserted; } @@ -649,7 +684,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 +727,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}: ${String(error)}`, + ); return []; } } @@ -726,8 +765,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; } @@ -736,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'); } @@ -832,7 +871,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 +917,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/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-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.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 41a6685..619434a 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); @@ -50,19 +45,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 +75,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)) { @@ -137,33 +143,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.controller.spec.ts b/src/v1/relay/relay.controller.spec.ts index 610af50..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: {} }, @@ -47,48 +47,38 @@ 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); + void 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( + void controller.pulseRelay( 'A8404194635A05FB', { 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 3c2ac5f..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,21 +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') @@ -116,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') @@ -143,24 +127,15 @@ 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, @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.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..b999c8c 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, ); @@ -78,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' }, ), @@ -92,20 +92,17 @@ 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); 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); @@ -114,20 +111,17 @@ 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); 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); @@ -136,17 +130,14 @@ 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( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', + { sub: 'user-1', email: 'user@example.com', isStaff: false }, 'A8404194635A05FB', ), ).rejects.toMatchObject({ @@ -163,8 +154,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 @@ -175,18 +164,20 @@ 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(() => + Promise.resolve( + new Response(JSON.stringify({}), { + headers: { + 'content-type': 'application/json', + }, + status: 200, + }), + ), + ) as typeof fetch; 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' }, ), @@ -218,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, @@ -229,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', @@ -237,18 +228,19 @@ 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( 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 }, ), @@ -280,15 +272,16 @@ 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 }, ), ).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, @@ -396,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 5b00dbf..45fb2e7 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, @@ -10,14 +9,14 @@ import { UnauthorizedException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import type { PostgrestError } from '@supabase/supabase-js'; 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, 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 { @@ -26,7 +25,6 @@ import { } from './relay-command-profile'; import { RelayCommandLockService } from './relay-command-lock.service'; import { - doesRelayRowConfirmTarget, parseRelayConfirmation, readRelayRowTimestamp, } from './relay-confirmation'; @@ -40,10 +38,8 @@ import { mapTtiClientError, resolveTtiApplicationId, } from './tti-client'; -import { - isValidTtiDeviceId, - normalizeTtiDeviceId, -} from './tti-device-id'; +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'>; @@ -52,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; @@ -64,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() : ''; } @@ -144,24 +137,22 @@ 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', ); } } - 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, - normalizedDevEui, - ); + const context = await this.loadRelayDeviceContext(user, normalizedDevEui); if (!canRead(context.permissionLevel)) { throw new NotFoundException('Device not found'); } @@ -175,8 +166,7 @@ export class RelayService { } async updateRelay( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, devEui: string, updateRelayDto: UpdateRelayDto, ) { @@ -186,11 +176,7 @@ export class RelayService { } const { relay, targetState } = updateRelayDto; - const context = await this.loadRelayDeviceContext( - jwtPayload, - authHeader, - normalizedDevEui, - ); + const context = await this.loadRelayDeviceContext(user, normalizedDevEui); if (!canManage(context.permissionLevel)) { throw new ForbiddenException( 'You do not have permission to control this relay', @@ -236,13 +222,6 @@ export class RelayService { downlinks: [buildRelayDownlink(relay, targetState, correlationIds)], }); - // const confirmedRow = await this.waitForRelayConfirmation( - // normalizedDevEui, - // relay, - // targetState, - // requestedAt, - // ); - return { confirmed: true, dev_eui: normalizedDevEui, @@ -252,10 +231,6 @@ export class RelayService { targetState, }; } catch (error) { - if (error instanceof GatewayTimeoutException) { - throw error; - } - if ( error instanceof BadRequestException || error instanceof ForbiddenException || @@ -273,8 +248,7 @@ export class RelayService { } async pulseRelay( - jwtPayload: any, - authHeader: string, + user: AuthenticatedUser, devEui: string, pulseRelayDto: PulseRelayDto, ) { @@ -284,11 +258,7 @@ export class RelayService { } const { durationSeconds, relay } = pulseRelayDto; - const context = await this.loadRelayDeviceContext( - jwtPayload, - authHeader, - normalizedDevEui, - ); + const context = await this.loadRelayDeviceContext(user, normalizedDevEui); if (!canManage(context.permissionLevel)) { throw new ForbiddenException( 'You do not have permission to control this relay', @@ -352,10 +322,6 @@ export class RelayService { targetState: 'on', }; } catch (error) { - if (error instanceof GatewayTimeoutException) { - throw error; - } - if ( error instanceof BadRequestException || error instanceof ConflictException || @@ -378,10 +344,7 @@ export class RelayService { authorizationHeader?: string, downlinkApiKeyHeader?: string, ) { - this.assertWebhookAuthorization( - authorizationHeader, - downlinkApiKeyHeader, - ); + this.assertWebhookAuthorization(authorizationHeader, downlinkApiKeyHeader); const confirmation = parseRelayConfirmation(payload); if (!confirmation) { @@ -425,20 +388,18 @@ 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 + 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( @@ -452,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'); @@ -500,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( @@ -516,7 +477,7 @@ export class RelayService { ); } - for (const owner of (data ?? []) as LocationOwnerRow[]) { + for (const owner of data ?? []) { permissionLevels.push(readPermissionLevel(owner.permission_level)); } } @@ -528,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( @@ -545,63 +506,7 @@ export class RelayService { throw new InternalServerErrorException('Failed to fetch relay data'); } - 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; + return data ?? null; } private async persistRelayConfirmation( @@ -618,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( @@ -636,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/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/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 87% rename from src/v1/reports-new/dto/report-form-context.dto.ts rename to src/v1/reports/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/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-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 87% rename from src/v1/reports-new/dto/report-template.dto.ts rename to src/v1/reports/dto/report-template.dto.ts index a27369f..d039833 100644 --- a/src/v1/reports-new/dto/report-template.dto.ts +++ b/src/v1/reports/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/dto/save-report-template.dto.ts similarity index 92% rename from src/v1/reports-new/dto/save-report-template.dto.ts rename to src/v1/reports/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/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/reports.controller.ts similarity index 54% rename from src/v1/reports-new/reports-new.controller.ts rename to src/v1/reports/reports.controller.ts index 3279f0d..4950a4d 100644 --- a/src/v1/reports-new/reports-new.controller.ts +++ b/src/v1/reports/reports.controller.ts @@ -8,7 +8,6 @@ import { Patch, Post, Query, - Req, UseGuards, } from '@nestjs/common'; import { @@ -24,15 +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' }) -export class ReportsNewController { - constructor(private readonly reportsNewService: ReportsNewService) {} - - @UseGuards(JwtAuthGuard) +@Controller({ path: 'reports', version: '1' }) +@UseGuards(JwtAuthGuard) +export class ReportsController { + constructor(private readonly reportsService: ReportsService) {} @ApiOkResponse({ description: 'Lists every report template visible to the current user.', type: ReportTemplateDto, @@ -44,12 +44,12 @@ 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.reportsService.findAll(user, search); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists every communication method a report template recipient can use.', @@ -57,32 +57,30 @@ export class ReportsNewController { isArray: true, }) @Get('communication-methods') - findAllCommunicationMethods(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.reportsNewService.findAllCommunicationMethods(authHeader); + findAllCommunicationMethods() { + return this.reportsService.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.', + '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({ 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, }) @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.reportsService.getFormContext(user, id); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Returns a signed URL to download a generated report PDF for a device the user can view.', @@ -95,18 +93,10 @@ 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, - ); + return this.reportsService.getDownloadUrl(devEui, reportName, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: "Lists generated report PDFs across the template's assigned devices, newest first.", @@ -114,37 +104,38 @@ 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.reportsService.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.reportsService.findOne(id, user); } - - @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, }) @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.reportsService.create(body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Replaces a report template with the provided configuration.', type: ReportTemplateDto, @@ -155,13 +146,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.reportsService.update(id, body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Deletes a report template the user manages.', schema: { @@ -170,8 +158,10 @@ 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.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 72% rename from src/v1/reports-new/reports-new.service.ts rename to src/v1/reports/reports.service.ts index 0c975a3..7617b5e 100644 --- a/src/v1/reports-new/reports-new.service.ts +++ b/src/v1/reports/reports.service.ts @@ -6,14 +6,18 @@ import { NotFoundException, UnauthorizedException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; 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 { + 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'; @@ -26,10 +30,13 @@ 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 }; -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; }; @@ -39,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; @@ -102,7 +99,7 @@ interface NormalizedSaveRequest { } @Injectable() -export class ReportsNewService { +export class ReportsService { constructor( private readonly supabaseService: SupabaseService, private readonly devicesService: DevicesService, @@ -110,29 +107,35 @@ 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 listManagedDevices( + this.supabaseService.getClient(), + 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('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[]; @@ -141,11 +144,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({ @@ -165,20 +168,22 @@ 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 listManagedDevices( + this.supabaseService.getClient(), + 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') @@ -189,7 +194,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 +208,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'); @@ -213,10 +222,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({ @@ -238,19 +247,21 @@ 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 listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); assertDevicesCanBeManaged(devices, normalized.devEuis); - const client = this.supabaseService.getClient(accessToken); - const { data: templateData, error: templateError } = await client + const client = this.supabaseService.getClient(); + const { data: templateData, error: templateError } = (await client .from('cw_report_templates') .insert({ name: normalized.name, @@ -263,39 +274,51 @@ 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('Failed to create report template'); + throw new InternalServerErrorException( + 'Failed to create report template', + ); } try { - await this.replaceTemplateChildren( - accessToken, - templateData.id, - normalized, - ); + await this.replaceTemplateChildren(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 listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const allDevEuis = uniqueValues([ ...existing.assignments.map((assignment) => assignment.devEui), @@ -303,7 +326,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({ @@ -316,74 +339,73 @@ 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); + await this.replaceTemplateChildren(id, normalized); - return this.findOne(id, jwtPayload, authHeader); + return this.findOne(id, user); } - async remove( - id: number, - jwtPayload: any, - authHeader: string, - ): Promise<{ id: number }> { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); - - const existing = await this.findOne(id, jwtPayload, authHeader); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + async remove(id: number, user: AuthenticatedUser): Promise<{ id: number }> { + const userId = user.sub; + const isStaff = user.isStaff; + + const existing = await this.findOne(id, user); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + 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() .eq('id', id); if (error) { - throw new InternalServerErrorException('Failed to delete report template'); + throw new InternalServerErrorException( + 'Failed to delete report template', + ); } return { id }; } 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), ]); return { - devices: (devicesPage.data ?? []) as ReportFormContextDto['devices'], - locations: (locations ?? []) as ReportFormContextDto['locations'], + devices: devicesPage.data ?? [], + locations: locations ?? [], communicationMethods, template, }; } - 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) @@ -404,14 +426,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), @@ -434,21 +454,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, }), ); }), @@ -460,12 +484,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(); @@ -488,7 +510,11 @@ export class ReportsNewService { ? normalizedName : `${normalizedName}.pdf`; - const devices = await this.listManagedDevices(userId, accessToken, 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( @@ -497,8 +523,7 @@ export class ReportsNewService { } const storageClient = - this.supabaseService.getAdminClient() ?? - this.supabaseService.getClient(accessToken); + this.supabaseService.getAdminClient() ?? this.supabaseService.getClient(); const { data, error } = await storageClient.storage .from(STORAGE_BUCKET) .createSignedUrl(`${normalizedDevEui}/${resolvedName}`, 60, { @@ -514,64 +539,13 @@ export class ReportsNewService { return { url: data.signedUrl }; } - private async listManagedDevices( - userId: string, - accessToken: string, - isStaff: boolean, - ): Promise { - const client = this.supabaseService.getClient(accessToken); - 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( - accessToken: string, 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', @@ -586,13 +560,12 @@ export class ReportsNewService { } private async loadScheduleByTemplateIds( - accessToken: string, 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', @@ -603,38 +576,36 @@ export class ReportsNewService { throw new InternalServerErrorException('Failed to load report schedule'); } - return (data ?? []) as ScheduleRow[]; + return data ?? []; } private async loadRecipientsByTemplateIds( - accessToken: string, 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', - ) + .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( - accessToken: string, 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', @@ -642,20 +613,21 @@ 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( - accessToken: string, 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', @@ -668,17 +640,16 @@ export class ReportsNewService { ); } - return (data ?? []) as DataProcessingScheduleRow[]; + return data ?? []; } private async replaceTemplateChildren( - accessToken: string, 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, @@ -689,7 +660,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 +678,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 +695,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', + ); } } @@ -769,11 +746,8 @@ export class ReportsNewService { } } - private async deleteTemplateChildren( - accessToken: string, - templateId: number, - ): Promise { - const client = this.supabaseService.getClient(accessToken); + private async deleteTemplateChildren(templateId: number): Promise { + const client = this.supabaseService.getClient(); const [assignments, schedule, recipients, alertPoints, dpSchedules] = await Promise.all([ client @@ -799,13 +773,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( @@ -819,14 +799,11 @@ export class ReportsNewService { } } - private async deleteTemplateBestEffort( - accessToken: string, - templateId: number, - ): Promise { + private async deleteTemplateBestEffort(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); @@ -858,8 +835,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 +852,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 +890,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) @@ -1110,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(); @@ -1129,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 87% rename from src/v1/rules-new/dto/rule-template-assignment.dto.ts rename to src/v1/rules/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/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/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 52% rename from src/v1/rules-new/rules-new.controller.ts rename to src/v1/rules/rules.controller.ts index a6b4441..44fe6d5 100644 --- a/src/v1/rules-new/rules-new.controller.ts +++ b/src/v1/rules/rules.controller.ts @@ -8,7 +8,6 @@ import { Patch, Post, Query, - Req, UseGuards, } from '@nestjs/common'; import { @@ -24,17 +23,18 @@ 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' }) -export class RulesNewController { - constructor(private readonly rulesNewService: RulesNewService) {} - - @UseGuards(JwtAuthGuard) +@Controller({ path: 'rules', version: '1' }) +@UseGuards(JwtAuthGuard) +export class RulesController { + constructor(private readonly rulesService: RulesService) {} @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, }) @@ -44,47 +44,43 @@ 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.rulesService.findAll(user, search); } - - @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, }) @Get('action-types') - findAllActionTypes(@Req() req) { - const authHeader = req.headers?.authorization ?? ''; - return this.rulesNewService.findAllActionTypes(authHeader); + findAllActionTypes() { + return this.rulesService.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.', + '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({ 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, + @CurrentUser() user: AuthenticatedUser, @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; - return this.rulesNewService.getFormContext(req.user, authHeader, id); + return this.rulesService.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.', @@ -92,12 +88,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.rulesService.findAllTriggered(user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Counts of currently-triggered rule templates and of all visible rule templates.', @@ -111,12 +104,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.rulesService.findTriggeredCount(user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Lists the trigger/reset history for a rule template, newest first.', @@ -124,37 +114,38 @@ 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.rulesService.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.rulesService.findOne(id, user); } - - @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, }) @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.rulesService.create(body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Replaces a rule template with the provided configuration.', type: RuleTemplateDto, @@ -165,13 +156,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.rulesService.update(id, body, user); } - - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Deletes a rule template the user manages.', schema: { @@ -180,8 +168,10 @@ 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.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 70% rename from src/v1/rules-new/rules-new.service.spec.ts rename to src/v1/rules/rules.service.spec.ts index b4764f4..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 }; @@ -31,32 +31,47 @@ 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; } -describe('RulesNewService', () => { - let service: RulesNewService; +describe('RulesService', () => { + let service: RulesService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - RulesNewService, + RulesService, { provide: SupabaseService, useValue: { @@ -75,7 +90,7 @@ describe('RulesNewService', () => { ], }).compile(); - service = module.get(RulesNewService); + service = module.get(RulesService); }); it('should be defined', () => { @@ -90,20 +105,21 @@ describe('RulesNewService', () => { from: jest.fn(() => devicesQuery), }; - const serviceWithClient = new RulesNewService( + const serviceWithClient = new RulesService( { 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( - serviceWithClient.findAll( - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', - ), + serviceWithClient.findAll({ + sub: 'user-1', + email: 'user@example.com', + isStaff: false, + }), ).resolves.toEqual([]); expect(client.from).toHaveBeenCalledWith('cw_devices'); @@ -117,9 +133,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, @@ -130,13 +144,13 @@ describe('RulesNewService', () => { from: jest.fn(() => devicesQuery), }; - const serviceWithClient = new RulesNewService( + const serviceWithClient = new RulesService( { 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( @@ -159,8 +173,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); }); @@ -202,28 +215,31 @@ describe('RulesNewService', () => { .mockImplementationOnce(() => templateQuery) .mockImplementationOnce(() => assignmentsQuery); - const serviceWithClient = new RulesNewService( + const serviceWithClient = new RulesService( { 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( - serviceWithClient.findOne( - 1, - { sub: 'user-1', email: 'user@example.com' }, - 'Bearer token-1', - ), + serviceWithClient.findOne(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, assignments: Array<{ devEui: string; isTriggered: boolean | null }>) => ({ + const buildRule = ( + id: number, + assignments: Array<{ devEui: string; isTriggered: boolean | null }>, + ) => ({ id, name: `Rule ${id}`, description: null, @@ -256,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 RulesService( + {} as unknown as SupabaseService, + {} as unknown as DevicesService, + {} as unknown as LocationsService, + ); jest.spyOn(service, 'findAll').mockResolvedValue([ buildRule(1, [ { devEui: 'AA', isTriggered: true }, @@ -266,7 +286,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); @@ -275,15 +295,23 @@ 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 }]), - ]); + const service = new RulesService( + {} as unknown as SupabaseService, + {} as unknown as DevicesService, + {} as unknown as LocationsService, + ); + 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'), - ).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/rules.service.ts similarity index 70% rename from src/v1/rules-new/rules-new.service.ts rename to src/v1/rules/rules.service.ts index 66e97af..ef3eeef 100644 --- a/src/v1/rules-new/rules-new.service.ts +++ b/src/v1/rules/rules.service.ts @@ -5,14 +5,18 @@ import { InternalServerErrorException, NotFoundException, } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; 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 { + 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'; @@ -24,6 +28,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'>; @@ -35,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; @@ -57,7 +52,7 @@ interface NormalizedSaveRequest { } @Injectable() -export class RulesNewService { +export class RulesService { constructor( private readonly supabaseService: SupabaseService, private readonly devicesService: DevicesService, @@ -65,22 +60,26 @@ 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 listManagedDevices( + this.supabaseService.getClient(), + 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('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), @@ -95,11 +94,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), ), @@ -124,11 +122,8 @@ export class RulesNewService { * triggered, narrowed to those triggered assignments. Visibility scoping is * inherited from findAll (device read scope). */ - async findAllTriggered( - jwtPayload: any, - authHeader: string, - ): Promise { - const rules = await this.findAll(jwtPayload, authHeader); + async findAllTriggered(user: AuthenticatedUser): Promise { + const rules = await this.findAll(user); return rules .map((rule) => ({ ...rule, @@ -140,12 +135,13 @@ 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), + rule.assignments.some( + (assignment) => assignment.state?.isTriggered === true, + ), ).length; return { @@ -156,22 +152,21 @@ export class RulesNewService { }; } - async findOne( - id: number, - jwtPayload: any, - authHeader: string, - ): Promise { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); + async findOne(id: number, user: AuthenticatedUser): Promise { + const userId = user.sub; + const isStaff = user.isStaff; - const devices = await this.listManagedDevices(userId, accessToken, 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'); } - const client = this.supabaseService.getClient(accessToken); + const client = this.supabaseService.getClient(); const [templateResult, assignmentsResult] = await Promise.all([ client .from('cw_rule_templates') @@ -204,17 +199,16 @@ 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), ), ]); const [rule] = buildRuleTemplates({ - templates: [templateResult.data as TemplateRow], + templates: [templateResult.data], assignments, criteria, actions, @@ -231,25 +225,27 @@ 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 listManagedDevices( + this.supabaseService.getClient(), + 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', @@ -283,19 +279,21 @@ 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 listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); assertDevicesCanBeManaged(devices, normalized.devEuis); - const client = this.supabaseService.getClient(accessToken); - const { data: templateData, error: templateError } = await client + const client = this.supabaseService.getClient(); + const { data: templateData, error: templateError } = (await client .from('cw_rule_templates') .insert({ name: normalized.name, @@ -304,39 +302,48 @@ 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'); } try { - await this.replaceTemplateChildren( - accessToken, - templateData.id, - normalized, - ); + await this.replaceTemplateChildren(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 listManagedDevices( + this.supabaseService.getClient(), + userId, + isStaff, + ); const allDevEuis = uniqueValues([ ...existing.assignments.map((assignment) => assignment.devEui), @@ -344,7 +351,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({ @@ -359,32 +366,31 @@ 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, - ): Promise<{ id: number }> { - const userId = getUserId(jwtPayload); - const accessToken = getAccessToken(authHeader); - const isStaff = isCropwatchStaff(jwtPayload); - - const existing = await this.findOne(id, jwtPayload, authHeader); - const devices = await this.listManagedDevices(userId, accessToken, isStaff); + async remove(id: number, user: AuthenticatedUser): Promise<{ id: number }> { + const userId = user.sub; + const isStaff = user.isStaff; + + const existing = await this.findOne(id, user); + const devices = await listManagedDevices( + this.supabaseService.getClient(), + 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() @@ -397,64 +403,13 @@ export class RulesNewService { return { id }; } - private async listManagedDevices( - userId: string, - accessToken: string, - isStaff: boolean, - ): Promise { - const client = this.supabaseService.getClient(accessToken); - 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( - accessToken: string, 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); @@ -463,17 +418,16 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule templates'); } - return (data ?? []) as TemplateRow[]; + return data ?? []; } private async loadCriteriaByTemplateIds( - accessToken: string, 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', @@ -484,17 +438,16 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule criteria'); } - return (data ?? []) as CriterionRow[]; + return data ?? []; } private async loadActionsByTemplateIds( - accessToken: string, 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)', @@ -505,13 +458,12 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule actions'); } - return (data ?? []) as ActionRow[]; + 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 }); @@ -528,29 +480,27 @@ 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), ]); return { - devices: (devicesPage.data ?? []) as RuleFormContextDto['devices'], - locations: (locations ?? []) as RuleFormContextDto['locations'], + devices: devicesPage.data ?? [], + locations: locations ?? [], actionTypes, template, }; } private async loadStates( - accessToken: string, templateIds: number[], devEuis: string[], ): Promise { @@ -558,7 +508,7 @@ export class RulesNewService { 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', @@ -570,17 +520,16 @@ export class RulesNewService { throw new InternalServerErrorException('Failed to load rule state'); } - return (data ?? []) as StateRow[]; + return data ?? []; } private async replaceTemplateChildren( - accessToken: string, 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, @@ -607,9 +556,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'); @@ -619,11 +566,8 @@ export class RulesNewService { } } - private async deleteTemplateChildren( - accessToken: string, - templateId: number, - ): Promise { - const client = this.supabaseService.getClient(accessToken); + private async deleteTemplateChildren(templateId: number): Promise { + const client = this.supabaseService.getClient(); const [assignmentsResult, criteriaResult, actionsResult] = await Promise.all([ client @@ -653,12 +597,9 @@ export class RulesNewService { } } - private async deleteTemplateState( - accessToken: string, - templateId: number, - ): Promise { + private async deleteTemplateState(templateId: number): Promise { const { error } = await this.supabaseService - .getClient(accessToken) + .getClient() .from('cw_rule_state') .delete() .eq('template_id', templateId); @@ -668,14 +609,11 @@ export class RulesNewService { } } - private async deleteTemplateBestEffort( - accessToken: string, - templateId: number, - ): Promise { + private async deleteTemplateBestEffort(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); @@ -766,13 +704,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 +724,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; @@ -890,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)]; -} 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..9d9db11 100644 --- a/src/v1/soil/soil.controller.ts +++ b/src/v1/soil/soil.controller.ts @@ -2,20 +2,14 @@ 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 { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -27,20 +21,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) {} - // @Post() - // create(@Body() createSoilDto: CreateSoilDto) { - // return this.soilService.create(createSoilDto); - // } - @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Soil data returned successfully.', type: SoilDataDto, @@ -100,7 +91,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, @@ -109,27 +100,8 @@ 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, - req.user, - timezone, - ); + return this.soilService.findOne(devEui, startDate, endDate, user, timezone); } } 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/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/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 e24b5fd..17f302f 100644 --- a/src/v1/traffic/traffic.controller.ts +++ b/src/v1/traffic/traffic.controller.ts @@ -2,22 +2,16 @@ import { BadRequestException, Controller, Get, - Post, - Body, - Patch, Param, - Delete, Query, UseGuards, - Req, } from '@nestjs/common'; 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 { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -29,15 +23,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, @@ -97,7 +93,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, @@ -106,32 +102,18 @@ 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, startDate, endDate, - req.user, + user, timezone, ); } @Get(':dev_eui/monthly') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Monthly traffic report returned successfully.', type: TrafficMonthlyReportDto, @@ -150,8 +132,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, @@ -162,7 +154,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'); @@ -171,7 +163,7 @@ export class TrafficController { devEui, query.year, query.month, - req.user, + user, query.timezone, ); } 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 8aaf4f4..a331214 100644 --- a/src/v1/traffic/traffic.service.ts +++ b/src/v1/traffic/traffic.service.ts @@ -1,10 +1,21 @@ import { Injectable, InternalServerErrorException } from '@nestjs/common'; +import type { PostgrestError } from '@supabase/supabase-js'; 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 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'> { @@ -15,32 +26,16 @@ 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, 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); @@ -48,17 +43,24 @@ 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('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 }); + .order('traffic_hour', { ascending: true })) as { + data: TrafficHourRow[] | null; + error: PostgrestError | null; + }; 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 @@ -76,12 +78,16 @@ 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) { 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 +98,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); 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..7f04b72 100644 --- a/src/v1/water/water.controller.ts +++ b/src/v1/water/water.controller.ts @@ -2,20 +2,14 @@ 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 { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { ApiBadRequestResponse, ApiBearerAuth, @@ -27,20 +21,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) {} - // @Post() - // create(@Body() createWaterDto: CreateWaterDto) { - // return this.waterService.create(createWaterDto); - // } - @Get(':dev_eui') - @UseGuards(JwtAuthGuard) @ApiOkResponse({ description: 'Water data returned successfully.', type: WaterDataDto, @@ -100,7 +91,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, @@ -109,26 +100,13 @@ 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, startDate, endDate, - req.user, + user, timezone, ); } 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(); - }); -}); 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 } }