diff --git a/src/privacy/dataMap.ts b/src/privacy/dataMap.ts index a05fd07..473232a 100644 --- a/src/privacy/dataMap.ts +++ b/src/privacy/dataMap.ts @@ -1,11 +1,28 @@ +/** + * Data-erasure policy per model, applied when a user asks for their data to + * be erased (e.g. a GDPR "right to be forgotten" request). + * + * - DELETE: remove the records outright. + * - ANONYMIZE: keep the records but strip or replace anything that identifies + * the user, so aggregates and history stay intact. + * - IMMUTABLE: never modified or erased, usually because the record is part + * of a tamper-evident or append-only log. + */ export const erasurePolicies = { + // Operational data with no value once the user is gone, so it is deleted. Session: 'DELETE', WebhookSubscription: 'DELETE', AlertRule: 'DELETE', + + // Financial and business records that likely have to be retained + // (accounting, tax, audit), so they are kept without personal identifiers. Transaction: 'ANONYMIZE', CostBasisLot: 'ANONYMIZE', FiatOrder: 'ANONYMIZE', ReferralConversion: 'ANONYMIZE', + + // Append-only or tamper-evident records. Changing them would break the + // integrity of the log, so they are never altered or erased. AuditBlock: 'IMMUTABLE', OutboxOp: 'IMMUTABLE', } diff --git a/src/push/pushProvider.ts b/src/push/pushProvider.ts index 7ea30b5..f34d2b4 100644 --- a/src/push/pushProvider.ts +++ b/src/push/pushProvider.ts @@ -1,19 +1,31 @@ import { logger } from '../utils/logger' +/** + * A single push notification addressed to one device. + */ export interface PushMessage { - deviceToken: string + deviceToken: string // Provider-specific token identifying the target device title: string body: string - data?: Record + data?: Record // Optional custom payload delivered with the notification } +/** + * Outcome of a send attempt. Providers report delivery problems here + * (success: false with an error message) rather than throwing, except for + * configuration errors, which are thrown. + */ export interface PushSendResult { messageId: string - provider: string + provider: string // Name of the provider that handled the message success: boolean - error?: string + error?: string // Present only when success is false } +/** + * Common interface every push backend implements, so callers don't need to + * know which service actually delivers the message. + */ export interface PushProvider { name: string send(message: PushMessage): Promise @@ -21,13 +33,17 @@ export interface PushProvider { /** * Mock Push Provider for local testing & development. + * Delivers nothing; it records each message so tests can assert on what + * would have been sent. */ export class MockPushProvider implements PushProvider { name = 'mock' + // Every message passed to send(), in order, for test assertions sentMessages: PushMessage[] = [] async send(message: PushMessage): Promise { this.sentMessages.push(message) + // Timestamp plus a short random suffix keeps IDs unique within a run const messageId = `push_mock_${Date.now()}_${Math.random().toString(36).slice(2, 8)}` logger.info( `[MockPushProvider] Sent push to ${message.deviceToken}: ${message.title}` @@ -38,16 +54,23 @@ export class MockPushProvider implements PushProvider { /** * FCM Push Provider for Android and Web Push. + * + * NOTE: This is currently a stub. It checks configuration and logs, but never + * calls FCM, so it always reports success. A real HTTP call to FCM needs to be + * added inside the try block. */ export class FcmPushProvider implements PushProvider { name = 'fcm' async send(message: PushMessage): Promise { + // A missing credential is a deployment problem, not a delivery failure, + // so it throws instead of returning success: false. if (!process.env.FCM_SERVER_KEY) { throw new Error('FCM provider not configured: FCM_SERVER_KEY missing') } try { + // Placeholder ID; no request is actually made to FCM yet const messageId = `push_fcm_${Date.now()}` logger.info( `[FcmPushProvider] Sent push via FCM to ${message.deviceToken}`, @@ -58,6 +81,8 @@ export class FcmPushProvider implements PushProvider { return { messageId, provider: this.name, success: true } } catch (err) { + // Delivery errors are returned as a failed result so one bad send + // doesn't crash the caller logger.error('[FcmPushProvider] Failed to send push via FCM', { error: err instanceof Error ? err.message : String(err), }) @@ -73,11 +98,18 @@ export class FcmPushProvider implements PushProvider { /** * APNs Push Provider for iOS. + * + * NOTE: Like FcmPushProvider, this is currently a stub. It validates + * configuration and logs but does not contact Apple, so it always reports + * success. It also only checks APNS_KEY_ID and APNS_TEAM_ID; a real + * implementation will need the signing key and bundle ID as well. */ export class ApnsPushProvider implements PushProvider { name = 'apns' async send(message: PushMessage): Promise { + // As with FCM, missing configuration throws rather than returning a + // failed result if (!process.env.APNS_KEY_ID || !process.env.APNS_TEAM_ID) { throw new Error( 'APNs provider not configured: APNS_KEY_ID or APNS_TEAM_ID missing' @@ -85,6 +117,7 @@ export class ApnsPushProvider implements PushProvider { } try { + // Placeholder ID; no request is actually made to APNs yet const messageId = `push_apns_${Date.now()}` logger.info( `[ApnsPushProvider] Sent push via APNs to ${message.deviceToken}`, @@ -95,6 +128,7 @@ export class ApnsPushProvider implements PushProvider { return { messageId, provider: this.name, success: true } } catch (err) { + // Delivery errors are returned as a failed result, not thrown logger.error('[ApnsPushProvider] Failed to send push via APNs', { error: err instanceof Error ? err.message : String(err), }) @@ -110,8 +144,11 @@ export class ApnsPushProvider implements PushProvider { /** * Push Provider Registry with Platform-based Dispatch. + * Maps a device platform (android, web, ios, mock) to the provider that + * should deliver to it. */ export class PushRegistry { + // Keyed by lowercase platform name private providers: Map = new Map() constructor() { @@ -120,6 +157,7 @@ export class PushRegistry { const apnsProvider = new ApnsPushProvider() this.providers.set('mock', mockProvider) + // Android and web both go through FCM, so they share one instance this.providers.set('android', fcmProvider) this.providers.set('web', fcmProvider) this.providers.set('ios', apnsProvider) @@ -129,6 +167,11 @@ export class PushRegistry { }) } + /** + * Find the provider for a platform (case-insensitive). Unknown platforms + * fall back to the mock provider with a warning, so nothing is really + * delivered to them. Make sure that is acceptable in production. + */ getProvider(platform: string): PushProvider { const provider = this.providers.get(platform.toLowerCase()) if (!provider) { @@ -140,10 +183,15 @@ export class PushRegistry { return provider } + /** + * Send a message using the provider that matches the device's platform. + * May throw if the chosen provider is not configured. + */ async send(platform: string, message: PushMessage): Promise { const provider = this.getProvider(platform) return provider.send(message) } } +// Shared singleton used across the app export const pushRegistry = new PushRegistry() diff --git a/src/services/alertRuleLifecycle.ts b/src/services/alertRuleLifecycle.ts index 85567ed..53cf525 100644 --- a/src/services/alertRuleLifecycle.ts +++ b/src/services/alertRuleLifecycle.ts @@ -1,7 +1,19 @@ import db from '../db' +// The database client type, so callers (e.g. tests) can inject their own +// client instead of the shared one type LifecycleDatabase = typeof db +/** + * Soft-delete or restore an alert rule and record the change in the audit log. + * Both steps run in one transaction, so a change is never saved without its + * audit entry (or the other way around). + * + * Soft delete means the row stays in the table with `deletedAt` set; restoring + * clears it. Returns true if a rule was changed, false if nothing matched + * (rule not found, not owned by this user, or already in the requested state), + * in which case no audit entry is written. + */ async function changeAlertRuleLifecycle( userId: string, alertRuleId: string, @@ -10,17 +22,27 @@ async function changeAlertRuleLifecycle( ): Promise { return database.$transaction(async (transaction) => { const now = new Date() + // `as any` is used because the transaction client's types don't expose + // these models here const result = await (transaction as any).alertRule.updateMany({ where: { id: alertRuleId, + // Scoping by userId stops a user from touching another user's rules userId, + // Only act on rules in the opposite state: DELETE targets rules that + // are not yet deleted, RESTORE targets rules that are. This makes both + // operations safe to repeat. deletedAt: action === 'DELETE' ? null : { not: null }, }, + // DELETE stamps the deletion time; RESTORE clears it data: { deletedAt: action === 'DELETE' ? now : null }, }) + // Nothing matched, so there is nothing to audit if (result.count === 0) return false + // Audit trail of who changed which record and how. The user is both the + // tenant and the actor, since they can only change their own rules. await (transaction as any).userDataAudit.create({ data: { tenantUserId: userId, @@ -34,6 +56,10 @@ async function changeAlertRuleLifecycle( }) } +/** + * Soft-delete a user's alert rule (it is hidden, not erased from the table). + * Returns false if the rule doesn't exist, isn't theirs, or is already deleted. + */ export function softDeleteAlertRule( userId: string, alertRuleId: string, @@ -42,6 +68,10 @@ export function softDeleteAlertRule( return changeAlertRuleLifecycle(userId, alertRuleId, 'DELETE', database) } +/** + * Restore a previously soft-deleted alert rule. + * Returns false if the rule doesn't exist, isn't theirs, or isn't deleted. + */ export function restoreAlertRule( userId: string, alertRuleId: string,