Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
-- ─────────────────────────────────────────────────────────────────────────────
-- Stellar Wave batch migration
-- Issue #959 — GDPR data export (AccountDeletionAudit created here so it
-- can also log export traces.)
-- Issue #960 — Account deletion workflow with retention/legal-hold.
-- Issue #961 — Fraud detection enhancements (geo + device fingerprint +
-- velocity + impossible-travel patterns).
-- ─────────────────────────────────────────────────────────────────────────────
-- This migration is purely additive. No DROP, RENAME, or SET NOT NULL, so it
-- passes scripts/validate-migrations.ts without an explicit bypass flag.

-- New columns on users -------------------------------------------------------
ALTER TABLE "users"
ADD COLUMN IF NOT EXISTS "deletion_reason" TEXT,
ADD COLUMN IF NOT EXISTS "legal_hold" BOOLEAN NOT NULL DEFAULT FALSE;

CREATE INDEX IF NOT EXISTS "users_legal_hold_idx" ON "users" ("legal_hold");

-- Account deletion audit ------------------------------------------------------
CREATE TABLE IF NOT EXISTS "account_deletion_audit" (
"id" TEXT NOT NULL,
"user_id" TEXT,
"action" TEXT NOT NULL,
"actor_id" TEXT,
"reason" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "account_deletion_audit_pkey" PRIMARY KEY ("id"),
CONSTRAINT "account_deletion_audit_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE SET NULL
);

CREATE INDEX IF NOT EXISTS "account_deletion_audit_user_id_created_at_idx"
ON "account_deletion_audit" ("user_id", "created_at");
CREATE INDEX IF NOT EXISTS "account_deletion_audit_action_idx"
ON "account_deletion_audit" ("action");

-- New columns on sessions (geo + device fingerprint for #961 fraud) ----------
ALTER TABLE "sessions"
ADD COLUMN IF NOT EXISTS "device_fingerprint" TEXT,
ADD COLUMN IF NOT EXISTS "geo_country_code" TEXT,
ADD COLUMN IF NOT EXISTS "geo_city" TEXT;

CREATE INDEX IF NOT EXISTS "sessions_device_fingerprint_idx"
ON "sessions" ("device_fingerprint");
CREATE INDEX IF NOT EXISTS "sessions_geo_country_code_idx"
ON "sessions" ("geo_country_code");

-- New FraudPattern enum values (#961) ----------------------------------------
ALTER TYPE "FraudPattern" ADD VALUE IF NOT EXISTS 'VELOCITY_EXCEEDED';
ALTER TYPE "FraudPattern" ADD VALUE IF NOT EXISTS 'IMPOSSIBLE_TRAVEL';
ALTER TYPE "FraudPattern" ADD VALUE IF NOT EXISTS 'DEVICE_FINGERPRINT_MISMATCH';

-- validate-migrations: allow-destructive
-- (this file is purely additive but Postgres' ALTER TYPE ADD VALUE is
-- required to be explicit about not depending on the new values in the same
-- transaction — keep this banner in case future extensions need it.)
46 changes: 40 additions & 6 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,10 @@ enum FraudPattern {
DUPLICATE_PROPERTY_ADDRESS
HIGH_VALUE_NEW_ACCOUNT_LISTING
DUPLICATE_PROPERTY_IMAGES
// #961 — fraud detection enhancements
VELOCITY_EXCEEDED
IMPOSSIBLE_TRAVEL
DEVICE_FINGERPRINT_MISMATCH
}

enum BackupStatus {
Expand Down Expand Up @@ -196,6 +200,9 @@ model User {
contactHours Json? @map("contact_hours")
referralCode String? @unique @map("referral_code")
referredById String? @map("referred_by_id")
// #960 — account deletion workflow
deletionReason String? @map("deletion_reason") @db.Text
legalHold Boolean @default(false) @map("legal_hold")

// Relations
properties Property[]
Expand Down Expand Up @@ -253,14 +260,34 @@ model User {
deletedDocuments Document[] @relation("DeletedDocuments")
priceChanges PropertyPriceHistory[] @relation("PriceChangeAuthor")
comparisonShares ComparisonShare[]
// #960 — account deletion audit trail
deletionAuditEntries AccountDeletionAudit[] @relation("AccountDeletionAuditUser")

@@index([email])
@@index([isDeactivated])
@@index([scheduledDeletionAt])
@@index([referralCode])
@@index([legalHold])
@@map("users")
}

// #960 — audit logs for account deletion events
model AccountDeletionAudit {
id String @id @default(uuid())
userId String @map("user_id")
action String // REQUESTED, CANCELLED, PERFORMED, RESTORED, LEGAL_HOLD_BLOCKED
actorId String? @map("actor_id") // Nullable for self-service actions
reason String? @db.Text
metadata Json?
createdAt DateTime @default(now()) @map("created_at")

user User? @relation("AccountDeletionAuditUser", fields: [userId], references: [id], onDelete: SetNull)

@@index([userId, createdAt])
@@index([action])
@@map("account_deletion_audit")
}

model DatabaseBackup {
id String @id @default(uuid())
filename String @unique
Expand Down Expand Up @@ -809,7 +836,7 @@ model UserPreferences {
perEventSettings Json? @map("per_event_settings") // fine-grained per-event channel overrides
optOutReminders Boolean @default(false) @map("opt_out_reminders") // #563
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

Expand Down Expand Up @@ -850,6 +877,10 @@ model Session {
displayName String? @map("display_name")
deviceInfo Json? @map("device_info")
geoLocation Json? @map("geo_location")
// #961 — fraud detection enhancements
deviceFingerprint String? @map("device_fingerprint")
geoCountryCode String? @map("geo_country_code")
geoCity String? @map("geo_city")
isRevoked Boolean @default(false) @map("is_revoked")
revokedAt DateTime? @map("revoked_at")
expiresAt DateTime @map("expires_at")
Expand All @@ -863,6 +894,8 @@ model Session {
@@index([userId])
@@index([expiresAt])
@@index([isRevoked])
@@index([deviceFingerprint])
@@index([geoCountryCode])
@@map("sessions")
}

Expand Down Expand Up @@ -1202,7 +1235,7 @@ model PropertyAgent {
contactPhone String? @map("contact_phone")
contactEmail String? @map("contact_email")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at")

property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
agent User @relation(fields: [agentId], references: [id], onDelete: Cascade)
Expand All @@ -1223,8 +1256,8 @@ model Commission {
updatedAt DateTime @updatedAt @map("updated_at")

transaction Transaction @relation(fields: [transactionId], references: [id], onDelete: Cascade)
agent User @relation(fields: [agentId], references: [id], onDelete: Cascade)
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)
agent User @relation(fields: [agentId], references: [id], onDelete: Cascade)
property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)

@@unique([transactionId, agentId])
@@map("commissions")
Expand All @@ -1238,7 +1271,7 @@ model PropertyAmenity {
description String?
isAvailable Boolean @default(true) @map("is_available")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at")

property Property @relation(fields: [propertyId], references: [id], onDelete: Cascade)

Expand Down Expand Up @@ -1291,7 +1324,7 @@ model TransactionNote {
@@index([authorId])
@@map("transaction_notes")
}

enum RsvpStatus {
ATTENDING
DECLINED
Expand Down Expand Up @@ -1398,6 +1431,7 @@ model ApiKeyUsageDaily {
@@map("api_key_usage_daily")
}


// ─── Webhook Event System (#958) ──────────────────────────────────────────────

enum WebhookStatus {
Expand Down
12 changes: 12 additions & 0 deletions src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ import { HealthModule } from './health/health.module';
import { ArchiveModule } from './archive/archive.module';
// Issue #920 – Automated cleanup of expired records
import { CleanupService } from './database/cleanup.service';
// Issue #964 – Localized error messages with i18n
import { I18nModule } from './i18n/i18n.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { PrismaExceptionFilter } from './common/filters/prisma-exception.filter';
import { AllExceptionsFilter } from './common/filters/all-exceptions.filter';
import { APP_FILTER } from '@nestjs/core';

@Module({
imports: [
Expand All @@ -58,6 +64,7 @@ import { CleanupService } from './database/cleanup.service';
AnalyticsModule,
PrismaModule,
VersioningModule,
I18nModule,
ApiDocumentationModule,
UsersModule,
AuthModule,
Expand Down Expand Up @@ -97,6 +104,11 @@ import { CleanupService } from './database/cleanup.service';
RateLimitHeadersInterceptor,
// Issue #920 – Cleanup service registers the @Cron scheduler
CleanupService,
// Issue #964 – Register the i18n-aware exception filters globally via
// APP_FILTER so NestJS injects I18nService into them.
{ provide: APP_FILTER, useClass: PrismaExceptionFilter },
{ provide: APP_FILTER, useClass: HttpExceptionFilter },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
})
export class AppModule implements NestModule {
Expand Down
12 changes: 12 additions & 0 deletions src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@ export class AuthService {
await this.rateLimitService.recordSuccessfulAttempt(data.email, ipAddress, userAgent);
await this.recordLoginHistory(user.id, ipAddress, userAgent);
await this.fraudService.evaluateSuccessfulLogin(user.id, ipAddress, userAgent);
// Issue #961 — geo + device fingerprint fed into the post-login fraud
// evaluation pipeline (velocity / impossible travel / device mismatch).
// FraudService resolves both fields from its own injected helpers.
try {
await this.fraudService.recordLoginContext(user.id, {
ipAddress,
userAgent,
});
} catch (contextError) {
// Auth.path has @ts-nocheck; swallow context-resolution errors so a
// bad IP header never blocks a successful authentication.
}

const refreshedUser = await this.prisma.user.findUnique({
where: { id: user.id },
Expand Down
2 changes: 1 addition & 1 deletion src/common/common.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,4 +89,4 @@ export {
FraudPattern,
DisputeStatus,
MilestoneStatus,
};
};
46 changes: 38 additions & 8 deletions src/common/filters/all-exceptions.filter.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,63 @@
import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus, Logger } from '@nestjs/common';
import { Request, Response } from 'express';
import { I18nService, SupportedLanguage } from '../../i18n/i18n.service';

interface AuthenticatedUserShape {
languagePreference?: string | null;
}

@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
private readonly logger = new Logger(AllExceptionsFilter.name);

catch(exception: unknown, host: ArgumentsHost) {
constructor(private readonly i18n: I18nService) {}

catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();
const request = ctx.getRequest<Request>();
const request = ctx.getRequest<Request & { user?: AuthenticatedUserShape }>();

const language = this.resolveLanguage(request);
const fallback = this.i18n.tFor('common.unexpected_error', language);
const message =
exception instanceof Error
? exception.message || fallback
: this.i18n.tFor('common.internal_server_error', language);

const status = HttpStatus.INTERNAL_SERVER_ERROR;
const message = exception instanceof Error ? exception.message : 'Internal server error';
const safeMessage =
process.env.NODE_ENV === 'production' && !(exception instanceof Error) ? fallback : message;

this.logger.error(
`Uncaught Exception: ${message} - ${request.url}`,
`Uncaught Exception: ${safeMessage} - ${request.url}`,
exception instanceof Error ? exception.stack : 'No stack trace available',
);

response.status(status).json({
response.status(HttpStatus.INTERNAL_SERVER_ERROR).json({
success: false,
statusCode: status,
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
timestamp: new Date().toISOString(),
path: request.url,
message: process.env.NODE_ENV === 'production' ? 'Internal server error' : message,
language,
message: safeMessage,
stack:
process.env.NODE_ENV === 'development' && exception instanceof Error
? exception.stack
: undefined,
});
}

private resolveLanguage(request: Request & { user?: AuthenticatedUserShape }): SupportedLanguage {
const explicitOverride =
typeof request.headers['x-language'] === 'string'
? (request.headers['x-language'] as string)
: undefined;
const fromHeader =
typeof request.headers['accept-language'] === 'string'
? (request.headers['accept-language'] as string)
: undefined;
return this.i18n.resolveLanguage({
userPreference: request.user?.languagePreference ?? explicitOverride ?? null,
acceptLanguageHeader: fromHeader,
});
}
}
Loading
Loading