Skip to content

Commit a20790b

Browse files
feat: add location and errors endpoints (formatted)
1 parent f36b19d commit a20790b

File tree

9 files changed

+243
-0
lines changed

9 files changed

+243
-0
lines changed

src/app.module.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import { EventsModule } from './events/events.module';
1515
import { OpenTelemetryModule } from 'nestjs-otel';
1616
import { LoggerModule } from './logger/logger.module';
1717
import { RedisModule } from './common/redis/redis.module';
18+
import { NotificationsModule } from './notifications/notifications.module';
19+
import { LocationModule } from './location/location.module';
20+
import { ErrorsModule } from './errors/errors.module';
1821

1922
//import { ConfigModule } from '@nestjs/config';
2023
//import { AppResolver } from './app.resolver';
@@ -74,6 +77,9 @@ const OpenTelemetryModuleConfig = OpenTelemetryModule.forRoot({
7477
//RedisModule,
7578

7679
WssModule,
80+
NotificationsModule,
81+
LocationModule,
82+
ErrorsModule,
7783
],
7884
providers: [],
7985
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsBoolean, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator';
3+
import { Type } from 'class-transformer';
4+
5+
class ErrorOptionsDto {
6+
@ApiProperty({ required: false }) @IsOptional() @IsBoolean() isFatal?: boolean;
7+
@ApiProperty({ required: false, description: 'React Error Boundary info object' })
8+
@IsOptional()
9+
@IsObject()
10+
errorInfo?: any;
11+
}
12+
13+
export class CreateErrorReportDto {
14+
@ApiProperty({ description: 'Error message', example: 'TypeError: Cannot read properties of undefined' })
15+
@IsString()
16+
message!: string;
17+
18+
@ApiProperty({ required: false, description: 'Stack trace' })
19+
@IsOptional()
20+
@IsString()
21+
stack?: string;
22+
23+
@ApiProperty({ required: false, description: 'Client platform (web, android, ios, etc.)' })
24+
@IsOptional()
25+
@IsString()
26+
platform?: string;
27+
28+
@ApiProperty({ required: false, type: () => ErrorOptionsDto })
29+
@IsOptional()
30+
@ValidateNested()
31+
@Type(() => ErrorOptionsDto)
32+
options?: ErrorOptionsDto;
33+
}

src/errors/errors.controller.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
2+
import { ApiTags } from '@nestjs/swagger';
3+
import { Api } from '../common/decorators/api.decorator';
4+
import { CreateErrorReportDto } from './dto/create-error-report.dto';
5+
import { ErrorsService } from './errors.service';
6+
7+
@ApiTags('errors')
8+
@Controller('errors')
9+
export class ErrorsController {
10+
constructor(private readonly errorsService: ErrorsService) {}
11+
12+
@Post()
13+
@HttpCode(HttpStatus.CREATED)
14+
@Api({
15+
summary: 'Log a client error',
16+
description: 'Stores an error report mirroring the frontend /api/errors endpoint.',
17+
bodyType: CreateErrorReportDto,
18+
responses: [
19+
{ status: 201, description: 'Error logged successfully.' },
20+
{ status: 400, description: 'Invalid error report payload.' },
21+
],
22+
envelope: true,
23+
})
24+
async create(@Body() dto: CreateErrorReportDto) {
25+
const report = await this.errorsService.create(dto);
26+
return { message: 'Error logged successfully.', reportId: report.id };
27+
}
28+
}

src/errors/errors.module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { ErrorsController } from './errors.controller';
3+
import { ErrorsService } from './errors.service';
4+
5+
@Module({
6+
controllers: [ErrorsController],
7+
providers: [ErrorsService],
8+
})
9+
export class ErrorsModule {}

src/errors/errors.service.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { Injectable } from '@nestjs/common';
2+
import { PrismaService } from 'nestjs-prisma';
3+
import { CreateErrorReportDto } from './dto/create-error-report.dto';
4+
5+
@Injectable()
6+
export class ErrorsService {
7+
constructor(private readonly prisma: PrismaService) {}
8+
9+
async create(dto: CreateErrorReportDto) {
10+
const { message, stack, platform, options } = dto;
11+
const { isFatal, errorInfo } = options || {};
12+
13+
const report = await this.prisma.errorReport.create({
14+
data: {
15+
message,
16+
stack,
17+
platform,
18+
isFatal,
19+
errorInfo: errorInfo ?? null,
20+
payload: dto as any, // store full original payload
21+
},
22+
});
23+
return report;
24+
}
25+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { ApiProperty } from '@nestjs/swagger';
2+
import { IsBoolean, IsNumber, IsOptional, IsString } from 'class-validator';
3+
4+
/**
5+
* DTO for creating a Location record based on the frontend /api/location endpoint.
6+
* The frontend sends flat fields (not nested coords/address objects) plus a `subscriptionId`
7+
* which corresponds to a push subscription token (stored inside `Subscription.keys` JSON).
8+
*/
9+
export class CreateLocationDto {
10+
@ApiProperty({ description: 'Subscription token (maps to Subscription.keys.token)', example: 'fcm-token-123' })
11+
@IsString()
12+
subscriptionId!: string;
13+
14+
// Coordinates -------------------------------------------------------------
15+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() accuracy?: number;
16+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() altitude?: number;
17+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() altitudeAccuracy?: number;
18+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() heading?: number;
19+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() latitude?: number;
20+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() longitude?: number;
21+
@ApiProperty({ required: false }) @IsOptional() @IsNumber() speed?: number;
22+
@ApiProperty({ required: false }) @IsOptional() @IsBoolean() mocked?: boolean;
23+
@ApiProperty({ required: false, description: 'Original client timestamp (ms since epoch)' })
24+
@IsOptional()
25+
@IsNumber()
26+
timestamp?: number;
27+
28+
// Address -----------------------------------------------------------------
29+
@ApiProperty({ required: false }) @IsOptional() @IsString() city?: string;
30+
@ApiProperty({ required: false }) @IsOptional() @IsString() country?: string;
31+
@ApiProperty({ required: false }) @IsOptional() @IsString() district?: string;
32+
@ApiProperty({ required: false }) @IsOptional() @IsString() formattedAddress?: string;
33+
@ApiProperty({ required: false }) @IsOptional() @IsString() isoCountryCode?: string;
34+
@ApiProperty({ required: false }) @IsOptional() @IsString() name?: string;
35+
@ApiProperty({ required: false }) @IsOptional() @IsString() postalCode?: string;
36+
@ApiProperty({ required: false }) @IsOptional() @IsString() region?: string;
37+
@ApiProperty({ required: false }) @IsOptional() @IsString() street?: string;
38+
@ApiProperty({ required: false }) @IsOptional() @IsString() streetNumber?: string;
39+
@ApiProperty({ required: false }) @IsOptional() @IsString() subregion?: string;
40+
@ApiProperty({ required: false }) @IsOptional() @IsString() timezone?: string;
41+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { Body, Controller, Headers, HttpCode, HttpStatus, Ip, Post } from '@nestjs/common';
2+
import { ApiTags } from '@nestjs/swagger';
3+
import { Api } from '../common/decorators/api.decorator';
4+
import { CreateLocationDto } from './dto/create-location.dto';
5+
import { LocationService } from './location.service';
6+
7+
@ApiTags('location')
8+
@Controller('location')
9+
export class LocationController {
10+
constructor(private readonly locationService: LocationService) {}
11+
12+
@Post()
13+
@HttpCode(HttpStatus.CREATED)
14+
@Api({
15+
summary: 'Create a location data point',
16+
description:
17+
'Stores a new location record linked to a push subscription identified by the provided subscription token.',
18+
bodyType: CreateLocationDto,
19+
responses: [
20+
{ status: 201, description: 'Location added successfully.' },
21+
{ status: 404, description: 'Subscription not found.' },
22+
{ status: 400, description: 'Invalid location data.' },
23+
],
24+
envelope: true,
25+
})
26+
async create(@Body() dto: CreateLocationDto, @Ip() ip: string, @Headers('x-forwarded-for') forwardedFor?: string) {
27+
// Determine client IP similar to frontend implementation
28+
let clientIp = 'Unknown';
29+
if (forwardedFor) {
30+
clientIp = forwardedFor.split(',')[0].trim();
31+
} else if (ip) {
32+
clientIp = ip;
33+
}
34+
35+
const location = await this.locationService.createFromSubscriptionToken(dto, clientIp);
36+
return { message: 'Location added successfully.', location };
37+
}
38+
}

src/location/location.module.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import { Module } from '@nestjs/common';
2+
import { LocationController } from './location.controller';
3+
import { LocationService } from './location.service';
4+
5+
@Module({
6+
controllers: [LocationController],
7+
providers: [LocationService],
8+
})
9+
export class LocationModule {}

src/location/location.service.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Injectable, NotFoundException } from '@nestjs/common';
2+
import { PrismaService } from 'nestjs-prisma';
3+
import { CreateLocationDto } from './dto/create-location.dto';
4+
5+
@Injectable()
6+
export class LocationService {
7+
constructor(private readonly prisma: PrismaService) {}
8+
9+
/**
10+
* Creates a Location entry by resolving the provided subscription token to a Subscription row.
11+
* Mirrors the logic in the Next.js /api/location route (token stored in Subscription.keys JSON).
12+
*/
13+
async createFromSubscriptionToken(dto: CreateLocationDto, ipAddress: string) {
14+
// Find subscription whose JSON `keys` contains the provided token.
15+
// Prisma JSON partial match: we need to fetch candidates then filter if driver lacks contains helper in generated types.
16+
// We'll search for any subscription where keys is not null then filter in memory.
17+
const candidates = await this.prisma.subscription.findMany({ where: { keys: { not: null } } });
18+
const subscription = candidates.find((s: any) => s.keys && s.keys.token === dto.subscriptionId);
19+
20+
if (!subscription) {
21+
throw new NotFoundException('Subscription not found.');
22+
}
23+
24+
const newLocation = await this.prisma.location.create({
25+
data: {
26+
subscriptionId: subscription.id,
27+
ipAddress: ipAddress || 'Unknown',
28+
accuracy: dto.accuracy,
29+
altitude: dto.altitude,
30+
altitudeAccuracy: dto.altitudeAccuracy,
31+
heading: dto.heading,
32+
latitude: dto.latitude,
33+
longitude: dto.longitude,
34+
speed: dto.speed,
35+
mocked: dto.mocked ?? false,
36+
timestamp: dto.timestamp ? BigInt(dto.timestamp) : undefined,
37+
city: dto.city,
38+
country: dto.country,
39+
district: dto.district,
40+
formattedAddress: dto.formattedAddress,
41+
isoCountryCode: dto.isoCountryCode,
42+
name: dto.name,
43+
postalCode: dto.postalCode,
44+
region: dto.region,
45+
street: dto.street,
46+
streetNumber: dto.streetNumber,
47+
subregion: dto.subregion,
48+
timezone: dto.timezone,
49+
},
50+
});
51+
52+
return newLocation;
53+
}
54+
}

0 commit comments

Comments
 (0)