diff --git a/src/v1/air/air.controller.spec.ts b/src/v1/air/air.controller.spec.ts index 86b2ab2..d46077b 100644 --- a/src/v1/air/air.controller.spec.ts +++ b/src/v1/air/air.controller.spec.ts @@ -3,11 +3,13 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AirController } from './air.controller'; import { AirService } from './air.service'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; +import { UpdateAirAnnotationDto } from './dto/update-air-annotation.dto'; describe('AirController', () => { let controller: AirController; const mockAirService = { createNote: jest.fn(), + updateNote: jest.fn(), }; beforeEach(async () => { @@ -78,4 +80,45 @@ describe('AirController', () => { expect(result).toMatchObject(payload); }); }); + + describe('updateNote', () => { + it('passes the note id, body, and authenticated user to the service', async () => { + const dto = { + include_in_report: false, + note: 'corrected reading', + title: 'Amended review', + } as UpdateAirAnnotationDto; + const user = { + sub: 'user-123', + email: 'user@example.com', + isStaff: false, + }; + const expected = { ...dto, dev_eui: 'ABC123', id: 7 }; + + mockAirService.updateNote.mockResolvedValue(expected); + + await expect(controller.updateNote(7, dto, user)).resolves.toEqual( + expected, + ); + expect(mockAirService.updateNote).toHaveBeenCalledWith(7, dto, user); + }); + + it('accepts a partial payload under the global validation pipe settings', async () => { + const pipe = new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true, + }); + const payload = { note: 'corrected reading' }; + + const result: unknown = await pipe.transform(payload, { + type: 'body', + metatype: UpdateAirAnnotationDto, + data: '', + }); + + expect(result).toBeInstanceOf(UpdateAirAnnotationDto); + expect(result).toMatchObject(payload); + }); + }); }); diff --git a/src/v1/air/air.controller.ts b/src/v1/air/air.controller.ts index 49cbf9f..a83edc5 100644 --- a/src/v1/air/air.controller.ts +++ b/src/v1/air/air.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Controller, Get, + Patch, Post, Body, Param, @@ -12,6 +13,7 @@ import { import { AirService } from './air.service'; import { AirDataDto } from './dto/air-data.dto'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; +import { UpdateAirAnnotationDto } from './dto/update-air-annotation.dto'; import { JwtAuthGuard } from '../auth/guards/jwt.auth.guard'; import { parseTimeseriesRange } from '../common/timeseries-range.helper'; import { @@ -53,6 +55,15 @@ export class AirController { return this.airService.findAllNotes(devEui, month, year, user); } + @Patch('notes/:note_id') + async updateNote( + @Param('note_id') noteId: number, + @Body() updateAirNoteDto: UpdateAirAnnotationDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.airService.updateNote(noteId, updateAirNoteDto, user); + } + @Delete('notes/:note_id') async deleteNote( @Param('note_id') noteId: number, diff --git a/src/v1/air/air.service.spec.ts b/src/v1/air/air.service.spec.ts index 772f819..6690922 100644 --- a/src/v1/air/air.service.spec.ts +++ b/src/v1/air/air.service.spec.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { AirService } from './air.service'; import { CreateAirAnnotationDto } from './dto/create-air-annotation.dto'; +import { UpdateAirAnnotationDto } from './dto/update-air-annotation.dto'; import { SupabaseService } from '../../supabase/supabase.service'; import { TimezoneFormatterService } from '../common/timezone-formatter.service'; @@ -225,4 +226,124 @@ describe('AirService', () => { expect(insertBuilder.insert).not.toHaveBeenCalled(); }); }); + + describe('updateNote', () => { + const existingNote = { + created_at: '2026-03-13T14:30:01.232544+00:00', + created_by: 'owner@example.com', + dev_eui: '2CF7F1C073800102', + id: 7, + include_in_report: true, + note: 'original note', + title: 'Original title', + }; + + function createUpdateBuilder(fetchResponse: { + data: typeof existingNote | null; + error: { message: string } | null; + }) { + const single = jest.fn(); + const updateSelect = jest.fn().mockReturnValue({ single }); + const updateEq = jest.fn().mockReturnValue({ select: updateSelect }); + const update = jest.fn().mockReturnValue({ eq: updateEq }); + const maybeSingle = jest.fn().mockResolvedValue(fetchResponse); + const fetchEq = jest.fn().mockReturnValue({ maybeSingle }); + const select = jest.fn().mockReturnValue({ eq: fetchEq }); + + return { fetchEq, maybeSingle, select, single, update, updateEq }; + } + + it('updates only whitelisted fields, ignoring dev_eui/created_at in the body', async () => { + const user = { email: 'user@example.com', isStaff: false, sub: 'user-1' }; + const builder = createUpdateBuilder({ data: existingNote, error: null }); + const updatedNote = { + ...existingNote, + include_in_report: false, + note: 'corrected note', + title: 'New title', + }; + builder.single.mockResolvedValue({ data: updatedNote, error: null }); + client.from.mockReturnValue(builder); + + const dto = { + created_at: '2020-01-01T00:00:00Z', + dev_eui: 'FFFFFFFFFFFFFFFF', + include_in_report: false, + note: 'corrected note', + title: 'New title', + } as UpdateAirAnnotationDto; + + await expect(service.updateNote(7, dto, user)).resolves.toEqual( + updatedNote, + ); + + // Access is asserted against the STORED dev_eui, not the body's. + expect( + (service as unknown as { assertDeviceAccess: jest.Mock }) + .assertDeviceAccess, + ).toHaveBeenCalledWith('2CF7F1C073800102', user); + // The update payload must never contain dev_eui or created_at. + expect(builder.update).toHaveBeenCalledWith({ + include_in_report: false, + note: 'corrected note', + title: 'New title', + }); + expect(builder.updateEq).toHaveBeenCalledWith('id', 7); + }); + + it('rejects when the note does not exist', async () => { + const builder = createUpdateBuilder({ data: null, error: null }); + client.from.mockReturnValue(builder); + + await expect( + service.updateNote( + 999, + { title: 'x' }, + { + sub: 'user-1', + }, + ), + ).rejects.toThrow(new BadRequestException('Air annotation not found')); + expect(builder.update).not.toHaveBeenCalled(); + }); + + it('rejects an update with no editable fields', async () => { + const builder = createUpdateBuilder({ data: existingNote, error: null }); + client.from.mockReturnValue(builder); + + await expect( + service.updateNote( + 7, + { dev_eui: 'FFFFFFFFFFFFFFFF' }, + { sub: 'user-1' }, + ), + ).rejects.toThrow( + new BadRequestException( + 'At least one of title, note, or include_in_report is required', + ), + ); + expect(builder.update).not.toHaveBeenCalled(); + }); + + it('propagates access denial before updating', async () => { + const builder = createUpdateBuilder({ data: existingNote, error: null }); + client.from.mockReturnValue(builder); + ( + service as unknown as { assertDeviceAccess: jest.Mock } + ).assertDeviceAccess.mockRejectedValue( + new BadRequestException('Device not found'), + ); + + await expect( + service.updateNote( + 7, + { title: 'x' }, + { + sub: 'intruder', + }, + ), + ).rejects.toThrow('Device not found'); + expect(builder.update).not.toHaveBeenCalled(); + }); + }); }); diff --git a/src/v1/air/air.service.ts b/src/v1/air/air.service.ts index 50c1af7..a223994 100644 --- a/src/v1/air/air.service.ts +++ b/src/v1/air/air.service.ts @@ -8,6 +8,7 @@ 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 { UpdateAirAnnotationDto } from './dto/update-air-annotation.dto'; import type { TableRow } from '../types/supabase'; import type { AuthenticatedUser } from '../auth/authenticated-user'; @@ -102,6 +103,64 @@ export class AirService extends BaseDataService<'cw_air_data'> { return data; } + async updateNote( + noteId: number, + updateAirNoteDto: UpdateAirAnnotationDto, + user: AuthenticatedUser, + ) { + const client = this.supabaseService.getClient(); + const { data: existingNote, error: fetchError } = (await client + .from('cw_air_annotations') + .select('*') + .eq('id', noteId) + .maybeSingle()) as QueryResult; + + if (fetchError) { + throw new InternalServerErrorException('Failed to fetch air annotation'); + } + + if (!existingNote) { + throw new BadRequestException('Air annotation not found'); + } + + await this.assertDeviceAccess(existingNote.dev_eui, user); + + // Whitelist: UpdateAirAnnotationDto (PartialType of the create DTO) also + // admits dev_eui/created_at — never let an update re-point a note at a + // different device or reading. + const updates: Partial< + Pick + > = {}; + if (updateAirNoteDto.title !== undefined) { + updates.title = updateAirNoteDto.title; + } + if (updateAirNoteDto.note !== undefined) { + updates.note = updateAirNoteDto.note; + } + if (updateAirNoteDto.include_in_report !== undefined) { + updates.include_in_report = updateAirNoteDto.include_in_report; + } + + if (Object.keys(updates).length === 0) { + throw new BadRequestException( + 'At least one of title, note, or include_in_report is required', + ); + } + + const { data, error: updateError } = (await client + .from('cw_air_annotations') + .update(updates) + .eq('id', noteId) + .select('*') + .single()) as QueryResult; + + if (updateError) { + throw new BadRequestException('Failed to update air annotation'); + } + + return data; + } + async deleteNote(noteId: number, user: AuthenticatedUser) { const client = this.supabaseService.getClient(); const { data: existingNote, error: fetchError } = (await client 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 479aabb..bbebb07 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 @@ -228,6 +228,28 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot ], "type": "object", }, + "UpdateAirAnnotationDto": { + "properties": { + "created_at": { + "format": "date-time", + "type": "string", + }, + "dev_eui": { + "type": "string", + }, + "include_in_report": { + "type": "boolean", + }, + "note": { + "nullable": true, + "type": "string", + }, + "title": { + "type": "string", + }, + }, + "type": "object", + }, "UpdateDeviceNameGroupLocalDto": { "properties": { "group": { @@ -579,6 +601,28 @@ exports[`V1 Route Input Contracts matches the full v1 request contract snapshot }, ], }, + "patch": { + "parameters": [ + { + "in": "path", + "name": "note_id", + "required": true, + "schema": { + "type": "number", + }, + }, + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAirAnnotationDto", + }, + }, + }, + "required": true, + }, + }, }, "/v1/air/{dev_eui}": { "get": { diff --git a/src/v1/devices/devices.service.ts b/src/v1/devices/devices.service.ts index f024ad4..6095f3a 100644 --- a/src/v1/devices/devices.service.ts +++ b/src/v1/devices/devices.service.ts @@ -498,9 +498,16 @@ export class DevicesService { const startDate = new Date(start).toISOString(); const endDate = new Date(end).toISOString(); + // Air rows carry their annotations/alerts, same as findData: the report + // note-editing page reads notes from this endpoint's rows. + const withAnnotations = + deviceType.data_table_v2 === 'cw_air_data' + ? '*, cw_air_annotations(*), cw_air_alerts(*)' + : '*'; + const { data: latestData, error: dataError } = (await client .from(deviceType.data_table_v2) - .select('*') + .select(withAnnotations) .eq('dev_eui', normalizedDevEui) .gte('created_at', startDate) .lte('created_at', endDate) diff --git a/src/v1/reports/dto/report-regeneration-item.dto.ts b/src/v1/reports/dto/report-regeneration-item.dto.ts new file mode 100644 index 0000000..eacb121 --- /dev/null +++ b/src/v1/reports/dto/report-regeneration-item.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty } from '@nestjs/swagger'; + +/** + * A row in `cw_report_regeneration_queue`. Returned when a regeneration is + * requested; the CW-Reports cron consumes pending rows and flips their status. + */ +export class ReportRegenerationItemDto { + @ApiProperty() + id: number; + + @ApiProperty() + templateId: number; + + @ApiProperty() + devEui: string; + + @ApiProperty({ format: 'date-time' }) + periodStart: string; + + @ApiProperty({ format: 'date-time' }) + periodEnd: string; + + @ApiProperty({ enum: ['pending', 'processing', 'completed', 'failed'] }) + status: string; + + @ApiProperty({ format: 'date-time' }) + requestedAt: string; + + @ApiProperty({ + description: 'Total note edits accumulated onto this queue row.', + }) + editCount: number; +} diff --git a/src/v1/reports/dto/request-report-regeneration.dto.ts b/src/v1/reports/dto/request-report-regeneration.dto.ts new file mode 100644 index 0000000..6989e3a --- /dev/null +++ b/src/v1/reports/dto/request-report-regeneration.dto.ts @@ -0,0 +1,59 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { + IsInt, + IsISO8601, + IsNotEmpty, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +/** + * Request body for queueing a regeneration of an already-generated report PDF + * after the user edited its data-point notes. The period is echoed back from + * the original storage object name (`YYYY_MM_DD-YYYY_MM_DD.pdf`) — reports + * have no id of their own, so (template, device, period) identifies one. + */ +export class RequestReportRegenerationDto { + @ApiProperty({ description: 'Device the original report belongs to.' }) + @IsString() + @IsNotEmpty() + devEui: string; + + @ApiProperty({ format: 'date-time', description: 'Report period start.' }) + @IsISO8601() + periodStart: string; + + @ApiProperty({ format: 'date-time', description: 'Report period end.' }) + @IsISO8601() + periodEnd: string; + + @ApiProperty({ + description: + 'Storage object name of the original PDF inside Reports//.', + }) + @IsString() + @IsNotEmpty() + sourceObjectName: string; + + @ApiProperty({ + required: false, + description: + "IANA timezone of the report window. Defaults to 'Asia/Tokyo'.", + }) + @IsOptional() + @IsString() + timezone?: string; + + @ApiProperty({ + required: false, + description: + 'Number of note edits in this save. Accumulates onto the pending row so the UI can show how many edits a queued regeneration covers. Defaults to 1.', + }) + @IsOptional() + @IsInt() + @Min(1) + @Max(10000) + editCount?: number; +} diff --git a/src/v1/reports/reports.controller.ts b/src/v1/reports/reports.controller.ts index 4950a4d..9370eac 100644 --- a/src/v1/reports/reports.controller.ts +++ b/src/v1/reports/reports.controller.ts @@ -22,6 +22,8 @@ import { CommunicationMethodDto } from './dto/communication-method.dto'; 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 { ReportRegenerationItemDto } from './dto/report-regeneration-item.dto'; +import { RequestReportRegenerationDto } from './dto/request-report-regeneration.dto'; import { SaveReportTemplateDto } from './dto/save-report-template.dto'; import { ReportsService } from './reports.service'; import { CurrentUser } from '../auth/current-user.decorator'; @@ -110,6 +112,34 @@ export class ReportsController { ) { return this.reportsService.getHistory(id, user); } + @ApiOkResponse({ + description: + 'Lists queued (pending/processing) regenerations for the template, newest first. Used by the history dialog to badge reports slated for regeneration.', + type: ReportRegenerationItemDto, + isArray: true, + }) + @Get(':id/regenerations') + findRegenerations( + @Param('id', ParseIntPipe) id: number, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.reportsService.getRegenerations(id, user); + } + @ApiOkResponse({ + description: + 'Queues regeneration of a generated report PDF after note edits. The CW-Reports cron picks the row up on its next scheduled run; the regenerated PDF is stored next to the original and never emailed.', + type: ReportRegenerationItemDto, + isArray: false, + }) + @ApiBody({ type: RequestReportRegenerationDto }) + @Post(':id/regenerate') + requestRegeneration( + @Param('id', ParseIntPipe) id: number, + @Body() body: RequestReportRegenerationDto, + @CurrentUser() user: AuthenticatedUser, + ) { + return this.reportsService.requestRegeneration(id, body, user); + } @ApiOkResponse({ description: 'Returns a single report template the user can view.', type: ReportTemplateDto, diff --git a/src/v1/reports/reports.service.spec.ts b/src/v1/reports/reports.service.spec.ts new file mode 100644 index 0000000..caa47eb --- /dev/null +++ b/src/v1/reports/reports.service.spec.ts @@ -0,0 +1,281 @@ +import { BadRequestException, ForbiddenException } from '@nestjs/common'; +import { ReportsService } from './reports.service'; +import { RequestReportRegenerationDto } from './dto/request-report-regeneration.dto'; +import { SupabaseService } from '../../supabase/supabase.service'; +import { DevicesService } from '../devices/devices.service'; +import { LocationsService } from '../locations/locations.service'; +import * as managedDevicesHelper from '../common/managed-devices.helper'; + +jest.mock('../common/managed-devices.helper', () => ({ + listManagedDevices: jest.fn(), +})); + +const DEV_EUI = '2CF7F1C073800102'; +const USER = { email: 'user@example.com', isStaff: false, sub: 'user-1' }; + +function recentPeriod(): { periodEnd: string; periodStart: string } { + const end = new Date(); + end.setDate(end.getDate() - 7); + const start = new Date(end); + start.setDate(start.getDate() - 6); + return { periodEnd: end.toISOString(), periodStart: start.toISOString() }; +} + +function baseDto(): RequestReportRegenerationDto { + return { + devEui: DEV_EUI, + sourceObjectName: '2026_07_12-2026_07_18.pdf', + ...recentPeriod(), + }; +} + +function createQueueTableMock() { + const selectMaybeSingle = jest.fn(); + const insertSingle = jest.fn(); + const updateMaybeSingle = jest.fn(); + + return { + insert: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ single: insertSingle }), + }), + insertSingle, + select: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnThis(), + maybeSingle: selectMaybeSingle, + }), + selectMaybeSingle, + update: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + eq: jest.fn().mockReturnValue({ + select: jest.fn().mockReturnValue({ maybeSingle: updateMaybeSingle }), + }), + }), + }), + updateMaybeSingle, + }; +} + +describe('ReportsService.requestRegeneration', () => { + let service: ReportsService; + let queueTable: ReturnType; + const listManagedDevices = + managedDevicesHelper.listManagedDevices as jest.Mock; + + beforeEach(() => { + queueTable = createQueueTableMock(); + const client = { + from: jest.fn((table: string) => { + if (table === 'cw_report_regeneration_queue') return queueTable; + throw new Error(`Unexpected table ${table}`); + }), + }; + const supabaseService = { + getAdminClient: jest.fn(() => null), + getClient: jest.fn(() => client), + } as unknown as SupabaseService; + + service = new ReportsService( + supabaseService, + {} as DevicesService, + {} as LocationsService, + ); + + // findOne is exercised by its own integration paths; here it gates the + // template and supplies assignments. + jest.spyOn(service, 'findOne').mockResolvedValue({ + assignments: [{ devEui: DEV_EUI }], + id: 42, + } as never); + + listManagedDevices.mockResolvedValue([ + { canManage: true, canView: true, devEui: DEV_EUI }, + ]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('inserts a pending queue row for a valid request', async () => { + queueTable.selectMaybeSingle.mockResolvedValue({ data: null, error: null }); + const dto = baseDto(); + const row = { + dev_eui: DEV_EUI, + id: 1, + period_end: dto.periodEnd, + period_start: dto.periodStart, + requested_at: '2026-07-25T12:00:00Z', + requested_by: USER.email, + source_object_name: dto.sourceObjectName, + status: 'pending', + template_id: 42, + timezone: 'Asia/Tokyo', + }; + queueTable.insertSingle.mockResolvedValue({ data: row, error: null }); + + await expect(service.requestRegeneration(42, dto, USER)).resolves.toEqual({ + devEui: DEV_EUI, + editCount: 1, + id: 1, + periodEnd: dto.periodEnd, + periodStart: dto.periodStart, + requestedAt: '2026-07-25T12:00:00Z', + status: 'pending', + templateId: 42, + }); + expect(queueTable.insert).toHaveBeenCalledWith( + expect.objectContaining({ + dev_eui: DEV_EUI, + requested_by: USER.email, + source_object_name: dto.sourceObjectName, + template_id: 42, + timezone: 'Asia/Tokyo', + }), + ); + }); + + it('re-touches an existing pending row instead of inserting a duplicate', async () => { + const dto = baseDto(); + const existing = { + dev_eui: DEV_EUI, + id: 9, + period_end: dto.periodEnd, + period_start: dto.periodStart, + requested_at: '2026-07-25T11:00:00Z', + requested_by: 'earlier@example.com', + source_object_name: dto.sourceObjectName, + status: 'pending', + template_id: 42, + timezone: 'Asia/Tokyo', + }; + queueTable.selectMaybeSingle.mockResolvedValue({ + data: existing, + error: null, + }); + queueTable.updateMaybeSingle.mockResolvedValue({ + data: { ...existing, requested_by: USER.email }, + error: null, + }); + + const result = await service.requestRegeneration(42, dto, USER); + expect(result.id).toBe(9); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + + it('accumulates editCount onto the pending row across repeated saves', async () => { + const dto = { ...baseDto(), editCount: 3 }; + const existing = { + dev_eui: DEV_EUI, + edit_count: 2, + id: 9, + period_end: dto.periodEnd, + period_start: dto.periodStart, + requested_at: '2026-07-25T11:00:00Z', + requested_by: 'earlier@example.com', + source_object_name: dto.sourceObjectName, + status: 'pending', + template_id: 42, + timezone: 'Asia/Tokyo', + }; + queueTable.selectMaybeSingle.mockResolvedValue({ + data: existing, + error: null, + }); + queueTable.updateMaybeSingle.mockResolvedValue({ + data: { ...existing, edit_count: 5 }, + error: null, + }); + + const result = await service.requestRegeneration(42, dto, USER); + expect(result.editCount).toBe(5); + expect(queueTable.update).toHaveBeenCalledWith( + expect.objectContaining({ edit_count: 5 }), + ); + }); + + it('rejects a device that is not assigned to the template', async () => { + await expect( + service.requestRegeneration( + 42, + { ...baseDto(), devEui: 'FFFFFFFFFFFFFFFF' }, + USER, + ), + ).rejects.toThrow( + new BadRequestException('Device is not assigned to this report template'), + ); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + + it('rejects when the user cannot manage the device', async () => { + listManagedDevices.mockResolvedValue([ + { canManage: false, canView: true, devEui: DEV_EUI }, + ]); + + await expect( + service.requestRegeneration(42, baseDto(), USER), + ).rejects.toThrow(ForbiddenException); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + + it('rejects periods past the 23-month retention cutoff', async () => { + const dto = baseDto(); + const oldEnd = new Date(); + oldEnd.setMonth(oldEnd.getMonth() - 24); + const oldStart = new Date(oldEnd); + oldStart.setDate(oldStart.getDate() - 6); + dto.periodStart = oldStart.toISOString(); + dto.periodEnd = oldEnd.toISOString(); + + await expect(service.requestRegeneration(42, dto, USER)).rejects.toThrow( + /can no longer be regenerated/, + ); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + + it('rejects a sourceObjectName with path traversal', async () => { + await expect( + service.requestRegeneration( + 42, + { ...baseDto(), sourceObjectName: '../other-device/report.pdf' }, + USER, + ), + ).rejects.toThrow(new BadRequestException('Invalid sourceObjectName')); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + + it('rejects an inverted period', async () => { + const dto = baseDto(); + [dto.periodStart, dto.periodEnd] = [dto.periodEnd, dto.periodStart]; + + await expect(service.requestRegeneration(42, dto, USER)).rejects.toThrow( + new BadRequestException('periodEnd must be after periodStart'), + ); + expect(queueTable.insert).not.toHaveBeenCalled(); + }); + + it('returns the winning row when the insert loses the unique-index race', async () => { + const dto = baseDto(); + const winner = { + dev_eui: DEV_EUI, + id: 3, + period_end: dto.periodEnd, + period_start: dto.periodStart, + requested_at: '2026-07-25T12:00:01Z', + requested_by: 'other@example.com', + source_object_name: dto.sourceObjectName, + status: 'pending', + template_id: 42, + timezone: 'Asia/Tokyo', + }; + queueTable.selectMaybeSingle + .mockResolvedValueOnce({ data: null, error: null }) // pre-insert check + .mockResolvedValueOnce({ data: winner, error: null }); // post-23505 re-select + queueTable.insertSingle.mockResolvedValue({ + data: null, + error: { code: '23505', message: 'duplicate key value' }, + }); + + const result = await service.requestRegeneration(42, dto, USER); + expect(result.id).toBe(3); + }); +}); diff --git a/src/v1/reports/reports.service.ts b/src/v1/reports/reports.service.ts index 7617b5e..ea5c101 100644 --- a/src/v1/reports/reports.service.ts +++ b/src/v1/reports/reports.service.ts @@ -26,6 +26,8 @@ import { ReportTemplateAlertPointDto } from './dto/report-template-alert-point.d import { ReportTemplateAssignmentDto } from './dto/report-template-assignment.dto'; import { ReportTemplateDataProcessingScheduleDto } from './dto/report-template-data-processing-schedule.dto'; import { ReportTemplateHistoryItemDto } from './dto/report-template-history-item.dto'; +import { ReportRegenerationItemDto } from './dto/report-regeneration-item.dto'; +import { RequestReportRegenerationDto } from './dto/request-report-regeneration.dto'; import { ReportTemplateRecipientDto } from './dto/report-template-recipient.dto'; import { ReportTemplateScheduleDto } from './dto/report-template-schedule.dto'; import { ReportTemplateDto } from './dto/report-template.dto'; @@ -49,6 +51,36 @@ type CommunicationMethodRow = TableRow<'communication_methods'>; const STORAGE_BUCKET = 'Reports'; +// Storage object names/dev_euis are interpolated into service-role storage +// paths (which bypass RLS) and echoed to the report generator — reject path +// separators / traversal wherever either is accepted from a client. +const UNSAFE_PATH_SEGMENT = /[\\/]|\.\./; + +// Sensor data is retained for 24 months and a queued regeneration can wait up +// to a month for the next scheduled cron run — so note edits (and therefore +// regeneration requests) are only allowed while the report period ends within +// the last 23 months. The frontend enforces the same cutoff in the history +// dialog and the edit page; CW-Reports re-checks defensively when consuming. +const REPORT_EDIT_RETENTION_MONTHS = 23; + +// Guard against nonsense period ranges: the longest real report window is one +// month; anything beyond ~2 months is a malformed request. +const MAX_REGENERATION_PERIOD_DAYS = 62; + +interface RegenerationQueueRow { + id: number; + template_id: number; + dev_eui: string; + period_start: string; + period_end: string; + timezone: string; + source_object_name: string; + status: string; + requested_by: string; + requested_at: string; + edit_count: number; +} + interface NormalizedScheduleRow { endOfDay: boolean; endOfWeek: boolean; @@ -496,10 +528,7 @@ export class ReportsService { } // Both values are interpolated into the storage object path // (`${devEui}/${reportName}`) and signed with the service-role client, which - // bypasses storage RLS. Reject path separators / traversal so a crafted - // reportName (e.g. `..//report`) can't reach another tenant's - // folder. - const UNSAFE_PATH_SEGMENT = /[\\/]|\.\./; + // bypasses storage RLS — see UNSAFE_PATH_SEGMENT above. if ( UNSAFE_PATH_SEGMENT.test(normalizedDevEui) || UNSAFE_PATH_SEGMENT.test(normalizedName) @@ -539,6 +568,206 @@ export class ReportsService { return { url: data.signedUrl }; } + async requestRegeneration( + id: number, + dto: RequestReportRegenerationDto, + user: AuthenticatedUser, + ): Promise { + // 404-gates the template exactly like getHistory: a template the user + // cannot view does not exist as far as they are concerned. + const template = await this.findOne(id, user); + + const normalizedDevEui = dto.devEui?.trim(); + if (!normalizedDevEui || UNSAFE_PATH_SEGMENT.test(normalizedDevEui)) { + throw new BadRequestException('Invalid devEui'); + } + const isAssigned = template.assignments.some( + (assignment) => assignment.devEui === normalizedDevEui, + ); + if (!isAssigned) { + throw new BadRequestException( + 'Device is not assigned to this report template', + ); + } + + // Regenerating a customer-facing PDF is a manage action (same tier as + // template create/update/remove), not a view action. + const devices = await listManagedDevices( + this.supabaseService.getClient(), + user.sub, + user.isStaff, + ); + assertDevicesCanBeManaged(devices, [normalizedDevEui]); + + const periodStart = new Date(dto.periodStart); + const periodEnd = new Date(dto.periodEnd); + if ( + Number.isNaN(periodStart.getTime()) || + Number.isNaN(periodEnd.getTime()) || + periodEnd <= periodStart + ) { + throw new BadRequestException('periodEnd must be after periodStart'); + } + const periodDays = + (periodEnd.getTime() - periodStart.getTime()) / (24 * 60 * 60 * 1000); + if (periodDays > MAX_REGENERATION_PERIOD_DAYS) { + throw new BadRequestException( + `Report period cannot exceed ${MAX_REGENERATION_PERIOD_DAYS} days`, + ); + } + const retentionCutoff = new Date(); + retentionCutoff.setMonth( + retentionCutoff.getMonth() - REPORT_EDIT_RETENTION_MONTHS, + ); + if (periodEnd < retentionCutoff) { + throw new BadRequestException( + `Reports older than ${REPORT_EDIT_RETENTION_MONTHS} months can no longer be regenerated (sensor data is retained for 24 months)`, + ); + } + + const normalizedObjectName = dto.sourceObjectName?.trim(); + if ( + !normalizedObjectName || + UNSAFE_PATH_SEGMENT.test(normalizedObjectName) + ) { + throw new BadRequestException('Invalid sourceObjectName'); + } + + const timezone = dto.timezone?.trim() || 'Asia/Tokyo'; + const requestedBy = user.email?.trim() || user.sub; + const editCount = dto.editCount ?? 1; + const client = this.supabaseService.getClient(); + const matchKeys = { + dev_eui: normalizedDevEui, + period_end: periodEnd.toISOString(), + period_start: periodStart.toISOString(), + template_id: id, + }; + + // Dedupe: at most one pending row per (template, device, period) — enforced + // by a partial unique index. PostgREST upserts can't target a partial + // index, so select-then-insert and treat the 23505 race as success. + const existing = await this.findPendingRegeneration(client, matchKeys); + if (existing) { + const { data: touched, error: touchError } = (await client + .from('cw_report_regeneration_queue') + .update({ + requested_at: new Date().toISOString(), + requested_by: requestedBy, + edit_count: (existing.edit_count ?? 1) + editCount, + }) + .eq('id', existing.id) + .eq('status', 'pending') + .select('*') + .maybeSingle()) as { + data: RegenerationQueueRow | null; + error: PostgrestError | null; + }; + if (touchError) { + throw new InternalServerErrorException( + 'Failed to update regeneration request', + ); + } + // Row may have been claimed between select and update — fall through to + // insert a fresh pending row in that case. + if (touched) { + return toRegenerationItemDto(touched); + } + } + + const { data: inserted, error: insertError } = (await client + .from('cw_report_regeneration_queue') + .insert({ + ...matchKeys, + requested_by: requestedBy, + source_object_name: normalizedObjectName, + timezone, + edit_count: editCount, + }) + .select('*') + .single()) as { + data: RegenerationQueueRow | null; + error: PostgrestError | null; + }; + + if (insertError) { + if (insertError.code === '23505') { + // Concurrent request won the insert race; the pending row it created + // covers this request too. + const winner = await this.findPendingRegeneration(client, matchKeys); + if (winner) { + return toRegenerationItemDto(winner); + } + } + throw new InternalServerErrorException( + 'Failed to queue report regeneration', + ); + } + if (!inserted) { + throw new InternalServerErrorException( + 'Failed to queue report regeneration', + ); + } + + return toRegenerationItemDto(inserted); + } + + async getRegenerations( + id: number, + user: AuthenticatedUser, + ): Promise { + // Same 404 gate as getHistory: an invisible template has no queue either. + await this.findOne(id, user); + + const { data, error } = (await this.supabaseService + .getClient() + .from('cw_report_regeneration_queue') + .select('*') + .eq('template_id', id) + .in('status', ['pending', 'processing']) + .order('requested_at', { ascending: false })) as { + data: RegenerationQueueRow[] | null; + error: PostgrestError | null; + }; + + if (error) { + throw new InternalServerErrorException( + 'Failed to load regeneration queue', + ); + } + + return (data ?? []).map(toRegenerationItemDto); + } + + private async findPendingRegeneration( + client: ReturnType, + matchKeys: { + dev_eui: string; + period_end: string; + period_start: string; + template_id: number; + }, + ): Promise { + const { data, error } = (await client + .from('cw_report_regeneration_queue') + .select('*') + .eq('template_id', matchKeys.template_id) + .eq('dev_eui', matchKeys.dev_eui) + .eq('period_start', matchKeys.period_start) + .eq('period_end', matchKeys.period_end) + .eq('status', 'pending') + .maybeSingle()) as { + data: RegenerationQueueRow | null; + error: PostgrestError | null; + }; + if (error) { + throw new InternalServerErrorException( + 'Failed to check for existing regeneration request', + ); + } + return data; + } + private async loadTemplatesByIds( templateIds: number[], ): Promise { @@ -1088,6 +1317,21 @@ function normalizeSaveRequest( }; } +function toRegenerationItemDto( + row: RegenerationQueueRow, +): ReportRegenerationItemDto { + return { + id: row.id, + templateId: row.template_id, + devEui: row.dev_eui, + periodStart: row.period_start, + periodEnd: row.period_end, + status: row.status, + requestedAt: row.requested_at, + editCount: row.edit_count ?? 1, + }; +} + function assertDevicesCanBeManaged( devices: ManagedDevice[], devEuis: string[], diff --git a/supabase/updates/016_report_regeneration_queue.sql b/supabase/updates/016_report_regeneration_queue.sql new file mode 100644 index 0000000..98186bd --- /dev/null +++ b/supabase/updates/016_report_regeneration_queue.sql @@ -0,0 +1,87 @@ +-- ============================================================================= +-- 016_report_regeneration_queue.sql +-- Queue that lets users request regeneration of an already-generated report +-- PDF after editing its data-point notes (cw_air_annotations). +-- +-- Producer: the Nest API (POST /v1/reports/:id/regenerate) inserts a row after +-- a user saves note edits on the report-data edit page. +-- Consumer: CW-Reports (cw-report-sender) polls this table during its normal +-- scheduled cron runs, regenerates the PDF for the stored period, uploads it to +-- the `Reports` storage bucket as `_updated_.pdf` next to the +-- untouched original, and marks the row completed/failed. Regenerated reports +-- are stored only — never emailed. +-- +-- Design notes: +-- * A report has no UUID anywhere in the system — its identity is +-- (template_id, dev_eui) plus the period encoded in the storage object name +-- (`YYYY_MM_DD-YYYY_MM_DD.pdf`). The row therefore carries all of it. +-- * status is text + CHECK (the DB has no enums; matches device_licenses.status +-- per 010/014 convention). +-- * The partial unique index dedupes pending work: repeated saves on the same +-- (template, device, period) re-touch the one pending row instead of stacking +-- jobs. It deliberately does NOT block a new pending row while another is +-- processing — an in-flight regeneration may miss the newest note edits, and +-- the next cron run must be able to pick those up. +-- * Claiming is a conditional UPDATE (status 'pending' -> 'processing' filtered +-- on id AND status) so the daily and weekly crons cannot double-generate. +-- * Editability is capped API-side at periods ending within the last 23 months +-- (24-month data retention minus up to a month of cron lag); the consumer +-- re-checks defensively for rows that age out while queued. +-- +-- RLS is enabled with no anon/authenticated policies, matching the posture of +-- 002_enable_rls_all_public.sql: the API and CW-Reports both use service-role +-- clients and authorization is enforced in Nest. +-- +-- Idempotent: CREATE ... IF NOT EXISTS. +-- Regenerate database.types.ts (api + CropWatch) after running. +-- ============================================================================= + +BEGIN; + +CREATE TABLE IF NOT EXISTS public.cw_report_regeneration_queue ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + template_id bigint NOT NULL + REFERENCES public.cw_report_templates (id) ON DELETE CASCADE, + dev_eui text NOT NULL + REFERENCES public.cw_devices (dev_eui) ON DELETE CASCADE, + period_start timestamptz NOT NULL, + period_end timestamptz NOT NULL, + timezone text NOT NULL DEFAULT 'Asia/Tokyo', -- IANA zone the report window is computed in + source_object_name text NOT NULL, -- original storage object, e.g. 2026_07_12-2026_07_18.pdf + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'processing', 'completed', 'failed')), + requested_by text NOT NULL, -- user email (mirrors cw_air_annotations.created_by) + requested_at timestamptz NOT NULL DEFAULT now(), + claimed_at timestamptz, + completed_at timestamptz, + attempts integer NOT NULL DEFAULT 0, + last_error text, + output_object_name text, -- set on completion: _updated_.pdf + created_at timestamptz NOT NULL DEFAULT now(), + CHECK (period_end > period_start) +); + +-- At most ONE pending row per (template, device, period): repeated saves +-- re-touch the existing pending row instead of stacking duplicate jobs. +CREATE UNIQUE INDEX IF NOT EXISTS idx_cw_report_regeneration_queue_pending_dedupe + ON public.cw_report_regeneration_queue (template_id, dev_eui, period_start, period_end) + WHERE status = 'pending'; + +-- The consumer polls `WHERE status = 'pending' ORDER BY requested_at`. +CREATE INDEX IF NOT EXISTS idx_cw_report_regeneration_queue_status + ON public.cw_report_regeneration_queue (status, requested_at); + +ALTER TABLE public.cw_report_regeneration_queue ENABLE ROW LEVEL SECURITY; + +-- Additive (safe to re-run on an already-created table): running tally of note +-- edits covered by this queue row. Each save re-touching the pending row adds +-- its op count; the history dialog surfaces it next to the pending badge. +ALTER TABLE public.cw_report_regeneration_queue + ADD COLUMN IF NOT EXISTS edit_count integer NOT NULL DEFAULT 1; + +COMMENT ON COLUMN public.cw_report_regeneration_queue.source_object_name IS + 'Storage object name of the original PDF inside the Reports// folder; the period is also encoded here as YYYY_MM_DD-YYYY_MM_DD.'; +COMMENT ON COLUMN public.cw_report_regeneration_queue.output_object_name IS + 'Storage object name of the regenerated PDF (_updated_.pdf); NULL until completed.'; + +COMMIT; diff --git a/supabase/updates/README.md b/supabase/updates/README.md index 934dbed..aad1966 100644 --- a/supabase/updates/README.md +++ b/supabase/updates/README.md @@ -25,6 +25,7 @@ Full background: [`docs/security-review.md`](../../docs/security-review.md), | `010_polar_device_licenses.sql` | Creates `billing_customers` + `device_licenses` for the Polar subscription/licensing feature | Before deploying the Polar API release; regenerate `database.types.ts` after | | `014_profile_preferences.sql` | Creates `profile_preferences` (1-to-1 with `profiles`) and an `auth.users.email` → `profiles.email` sync trigger for the account preferences + verified email-change feature | Before deploying the profile/preferences API release; regenerate `database.types.ts` after | | `015_stripe_billing.sql` | Polar → Stripe billing migration: renames `polar_customer_id`/`polar_subscription_id` to `stripe_customer_id`/`stripe_subscription_id` and clears Polar-era cached rows (zero production customers at migration time) | Before deploying the Stripe API release; regenerate `database.types.ts` after | +| `016_report_regeneration_queue.sql` | Creates `cw_report_regeneration_queue` — queue for regenerating report PDFs after note edits (produced by the API, consumed by CW-Reports during its cron runs) | Before deploying the report-notes-edit API release; regenerate `database.types.ts` (api + CropWatch) after | ## Deploy/run interleaving (critical)