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
17 changes: 17 additions & 0 deletions src/privacy/dataMap.ts
Original file line number Diff line number Diff line change
@@ -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',
}
56 changes: 52 additions & 4 deletions src/push/pushProvider.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,49 @@
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<string, unknown>
data?: Record<string, unknown> // 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<PushSendResult>
}

/**
* 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<PushSendResult> {
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}`
Expand All @@ -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<PushSendResult> {
// 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}`,
Expand All @@ -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),
})
Expand All @@ -73,18 +98,26 @@ 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<PushSendResult> {
// 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'
)
}

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}`,
Expand All @@ -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),
})
Expand All @@ -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<string, PushProvider> = new Map()

constructor() {
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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<PushSendResult> {
const provider = this.getProvider(platform)
return provider.send(message)
}
}

// Shared singleton used across the app
export const pushRegistry = new PushRegistry()
30 changes: 30 additions & 0 deletions src/services/alertRuleLifecycle.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -10,17 +22,27 @@ async function changeAlertRuleLifecycle(
): Promise<boolean> {
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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
Loading