From 861c6834cdc00cd0a3eed135b62ba6db9f75b21f Mon Sep 17 00:00:00 2001 From: Chidimj Date: Mon, 29 Jun 2026 07:13:36 +0100 Subject: [PATCH 1/2] feat: push dispatch, file soft/hard delete, push hygiene, coalescing Closes #231, #236, #237, #239 #236: content-free web push to offline devices after send_message #237: prune 410/404 subscriptions, backoff on transient failures, lastUsedAt tracking #239: 2s coalesce window per device+conversation, 30s per-device rate limit #231: files table, soft-delete on retraction, background hard-delete S3 job --- .../drizzle/0009_push_file_hygiene.sql | 18 ++ apps/backend/package.json | 2 + .../backend/src/__tests__/fileCleanup.test.ts | 110 +++++++++ .../src/__tests__/messages.routes.test.ts | 4 + .../src/__tests__/pushNotification.test.ts | 215 ++++++++++++++++++ apps/backend/src/db/schema.ts | 24 ++ apps/backend/src/index.ts | 4 + apps/backend/src/routes/messages.ts | 6 + apps/backend/src/services/deviceRevocation.ts | 5 + apps/backend/src/services/fileCleanup.ts | 89 ++++++++ apps/backend/src/services/pushNotification.ts | 155 +++++++++++++ apps/backend/src/socket/messaging.ts | 30 ++- pnpm-lock.yaml | 150 +++++++++--- 13 files changed, 775 insertions(+), 37 deletions(-) create mode 100644 apps/backend/drizzle/0009_push_file_hygiene.sql create mode 100644 apps/backend/src/__tests__/fileCleanup.test.ts create mode 100644 apps/backend/src/__tests__/pushNotification.test.ts create mode 100644 apps/backend/src/services/fileCleanup.ts create mode 100644 apps/backend/src/services/pushNotification.ts diff --git a/apps/backend/drizzle/0009_push_file_hygiene.sql b/apps/backend/drizzle/0009_push_file_hygiene.sql new file mode 100644 index 0000000..fd46900 --- /dev/null +++ b/apps/backend/drizzle/0009_push_file_hygiene.sql @@ -0,0 +1,18 @@ +-- #231: files table for tracking S3 storage objects with soft/hard delete +CREATE TABLE IF NOT EXISTS "files" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "storage_key" text NOT NULL, + "deleted_at" timestamp, + "hard_deleted_at" timestamp, + "created_at" timestamp NOT NULL DEFAULT now() +);--> statement-breakpoint +ALTER TABLE "files" ADD CONSTRAINT "files_storage_key_unique" UNIQUE("storage_key");--> statement-breakpoint + +-- #231: link messages to their S3 file object +ALTER TABLE "messages" ADD COLUMN "file_id" uuid;--> statement-breakpoint +ALTER TABLE "messages" ADD CONSTRAINT "messages_file_id_files_id_fk" FOREIGN KEY ("file_id") REFERENCES "public"."files"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint + +-- #237: push subscription hygiene columns +ALTER TABLE "push_subscriptions" ADD COLUMN "last_used_at" timestamp;--> statement-breakpoint +ALTER TABLE "push_subscriptions" ADD COLUMN "disabled_at" timestamp;--> statement-breakpoint +CREATE INDEX IF NOT EXISTS "push_subscriptions_device_active_idx" ON "push_subscriptions" ("device_id") WHERE "disabled_at" IS NULL; diff --git a/apps/backend/package.json b/apps/backend/package.json index b7b4c69..2498653 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -40,6 +40,7 @@ "postgres": "^3.4.9", "redis": "^6.0.0", "socket.io": "^4.8.3", + "web-push": "^3.6.7", "zod": "^4.4.3" }, "devDependencies": { @@ -50,6 +51,7 @@ "@types/morgan": "^1.9.10", "@types/node": "^20.19.37", "@types/supertest": "^7.2.0", + "@types/web-push": "^3.6.4", "@vitest/coverage-v8": "^4.1.6", "drizzle-kit": "^0.31.10", "eslint": "^9.39.4", diff --git a/apps/backend/src/__tests__/fileCleanup.test.ts b/apps/backend/src/__tests__/fileCleanup.test.ts new file mode 100644 index 0000000..d03eed4 --- /dev/null +++ b/apps/backend/src/__tests__/fileCleanup.test.ts @@ -0,0 +1,110 @@ +/** + * Tests for file cleanup service (#231). + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// ── S3 mock (must use vi.hoisted so it's available in the factory) ──────────── +const mockS3Send = vi.hoisted(() => vi.fn()); + +vi.mock('@aws-sdk/client-s3', () => ({ + S3Client: class MockS3 { + send = mockS3Send; + }, + DeleteObjectCommand: class MockDeleteCmd { + constructor(public input: unknown) {} + }, +})); + +// ── DB mock ─────────────────────────────────────────────────────────────────── +const mockFindMany = vi.fn(); +const mockUpdate = vi.fn(); +const mockExecute = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { files: { findMany: mockFindMany } }, + update: mockUpdate, + execute: mockExecute, + }, +})); + +vi.mock('../db/schema.js', () => ({ + files: { id: 'id', deletedAt: 'deleted_at', hardDeletedAt: 'hard_deleted_at' }, +})); + +vi.mock('drizzle-orm', () => ({ + isNotNull: vi.fn((col: unknown) => ({ col, isNotNull: true })), + isNull: vi.fn((col: unknown) => ({ col, isNull: true })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals })), + { raw: vi.fn() }, + ), +})); + +vi.mock('../services/pushNotification.js', () => ({ + reenableExpiredBackoffs: vi.fn().mockResolvedValue(undefined), +})); + +const mockSetFn = vi.fn().mockReturnThis(); +const mockWhereFn = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + vi.clearAllMocks(); + mockS3Send.mockResolvedValue(undefined); + mockUpdate.mockReturnValue({ set: mockSetFn }); + mockSetFn.mockReturnValue({ where: mockWhereFn }); +}); + +const { softDeleteFile, runHardDeletePass } = await import('../services/fileCleanup.js'); + +describe('#231 – softDeleteFile', () => { + it('calls db.update with deletedAt set on the matching file', async () => { + await softDeleteFile('file-uuid-1'); + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSetFn).toHaveBeenCalledWith({ deletedAt: expect.any(Date) }); + }); +}); + +describe('#231 – runHardDeletePass', () => { + it('skips files that still have live message references', async () => { + mockFindMany.mockResolvedValue([{ id: 'file-1', storageKey: 'key-1' }]); + mockExecute.mockResolvedValueOnce([{ '?column?': 1 }]); // live ref exists + + await runHardDeletePass(); + + expect(mockS3Send).not.toHaveBeenCalled(); + expect(mockSetFn).not.toHaveBeenCalled(); + }); + + it('hard-deletes from S3 and marks hardDeletedAt when no live refs', async () => { + mockFindMany.mockResolvedValue([{ id: 'file-2', storageKey: 'key-2' }]); + mockExecute.mockResolvedValueOnce([]); // no live refs + + await runHardDeletePass(); + + expect(mockS3Send).toHaveBeenCalledTimes(1); + expect(mockSetFn).toHaveBeenCalledWith({ hardDeletedAt: expect.any(Date) }); + }); + + it('does not mark hardDeletedAt when S3 delete throws (safe retry)', async () => { + mockFindMany.mockResolvedValue([{ id: 'file-3', storageKey: 'key-3' }]); + mockExecute.mockResolvedValueOnce([]); + mockS3Send.mockRejectedValueOnce(new Error('NoSuchKey')); + + await runHardDeletePass(); + + expect(mockSetFn).not.toHaveBeenCalledWith({ hardDeletedAt: expect.any(Date) }); + }); + + it('processes multiple files in one pass', async () => { + mockFindMany.mockResolvedValue([ + { id: 'file-a', storageKey: 'key-a' }, + { id: 'file-b', storageKey: 'key-b' }, + ]); + mockExecute.mockResolvedValue([]); // no live refs for either + + await runHardDeletePass(); + + expect(mockS3Send).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/backend/src/__tests__/messages.routes.test.ts b/apps/backend/src/__tests__/messages.routes.test.ts index aa5361b..4ccae0f 100644 --- a/apps/backend/src/__tests__/messages.routes.test.ts +++ b/apps/backend/src/__tests__/messages.routes.test.ts @@ -59,6 +59,10 @@ vi.mock('drizzle-orm', () => ({ sql: vi.fn(), })); +vi.mock('../services/fileCleanup.js', () => ({ + softDeleteFile: vi.fn().mockResolvedValue(undefined), +})); + vi.mock('../middleware/auth.js', () => ({ requireAuth: (req: express.Request, _res: express.Response, next: express.NextFunction) => { (req as express.Request & { auth: { userId: string } }).auth = { userId: 'user-1' }; diff --git a/apps/backend/src/__tests__/pushNotification.test.ts b/apps/backend/src/__tests__/pushNotification.test.ts new file mode 100644 index 0000000..f6d18e7 --- /dev/null +++ b/apps/backend/src/__tests__/pushNotification.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for push notification service (#236, #237, #239). + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// ── DB mock ──────────────────────────────────────────────────────────────────── +const mockUpdate = vi.fn(); +const mockDelete = vi.fn(); +const mockExecute = vi.fn(); +const mockFindMany = vi.fn(); + +vi.mock('../db/index.js', () => ({ + db: { + query: { pushSubscriptions: { findMany: mockFindMany } }, + update: mockUpdate, + delete: mockDelete, + execute: mockExecute, + }, +})); + +vi.mock('../db/schema.js', () => ({ + pushSubscriptions: { id: 'id', deviceId: 'device_id', disabledAt: 'disabled_at' }, +})); + +vi.mock('drizzle-orm', () => ({ + and: vi.fn((...args: unknown[]) => args), + eq: vi.fn((col: unknown, val: unknown) => ({ col, val })), + isNull: vi.fn((col: unknown) => ({ col, isNull: true })), + sql: Object.assign( + vi.fn((strings: TemplateStringsArray, ...vals: unknown[]) => ({ strings, vals })), + { raw: vi.fn() }, + ), +})); + +// ── web-push mock ────────────────────────────────────────────────────────────── +const mockSendNotification = vi.fn(); +vi.mock('web-push', () => ({ + default: { + setVapidDetails: vi.fn(), + sendNotification: mockSendNotification, + }, +})); + +// ── deviceRevocation mock ────────────────────────────────────────────────────── +const mockIsDeviceConnected = vi.fn(); +vi.mock('../services/deviceRevocation.js', () => ({ + isDeviceConnected: mockIsDeviceConnected, +})); + +process.env['VAPID_PUBLIC_KEY'] = 'test-public-key'; +process.env['VAPID_PRIVATE_KEY'] = 'test-private-key'; + +const { dispatchOfflinePush } = await import('../services/pushNotification.js'); + +const mockSetFn = vi.fn().mockReturnThis(); +const mockWhereFn = vi.fn().mockResolvedValue(undefined); +const mockDeleteWhereFn = vi.fn().mockResolvedValue(undefined); + +beforeEach(() => { + vi.clearAllMocks(); + mockUpdate.mockReturnValue({ set: mockSetFn }); + mockSetFn.mockReturnValue({ where: mockWhereFn }); + mockDelete.mockReturnValue({ where: mockDeleteWhereFn }); + mockExecute.mockResolvedValue(undefined); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +// Use unique device IDs per test to avoid rate-limit state bleed between tests. +let testDeviceCounter = 0; +function uniqueDevice(): string { + return `dev-push-test-${++testDeviceCounter}`; +} + +describe('#236 – dispatchOfflinePush', () => { + it('skips devices that are connected', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(true); + + await dispatchOfflinePush('conv-1', 'msg-1', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + expect(mockFindMany).not.toHaveBeenCalled(); + }); + + it('queues push for offline devices and sends after coalesce window', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-1', endpoint: 'https://push.example.com/sub1', p256dh: 'p256', auth: 'auth' }, + ]); + mockSendNotification.mockResolvedValue(undefined); + + await dispatchOfflinePush('conv-2', 'msg-2', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + expect(mockFindMany).toHaveBeenCalled(); + expect(mockSendNotification).toHaveBeenCalledWith( + { endpoint: 'https://push.example.com/sub1', keys: { p256dh: 'p256', auth: 'auth' } }, + expect.stringContaining('"type":"new_message"'), + ); + }); + + it('payload is content-free: no ciphertext or sender data', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-2', endpoint: 'https://push.example.com/s2', p256dh: 'p', auth: 'a' }, + ]); + mockSendNotification.mockResolvedValue(undefined); + + await dispatchOfflinePush('conv-3', 'msg-3', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + const [, payloadStr] = mockSendNotification.mock.calls[0] as [unknown, string]; + const payload = JSON.parse(payloadStr) as Record; + expect(payload).toHaveProperty('type', 'new_message'); + expect(payload).toHaveProperty('conversationId', 'conv-3'); + expect(payload).toHaveProperty('messageId', 'msg-3'); + expect(payload).not.toHaveProperty('ciphertext'); + expect(payload).not.toHaveProperty('content'); + expect(payload).not.toHaveProperty('sender'); + }); +}); + +describe('#239 – coalescing', () => { + it('coalesces burst into single push with accurate count', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-c', endpoint: 'https://push.example.com/sc', p256dh: 'p', auth: 'a' }, + ]); + mockSendNotification.mockResolvedValue(undefined); + + const dev = uniqueDevice(); + await dispatchOfflinePush('conv-burst', 'msg-b1', [dev]); + await dispatchOfflinePush('conv-burst', 'msg-b2', [dev]); + await dispatchOfflinePush('conv-burst', 'msg-b3', [dev]); + await vi.runAllTimersAsync(); + + // Only one push must be sent + expect(mockSendNotification).toHaveBeenCalledTimes(1); + + const [, payloadStr] = mockSendNotification.mock.calls[0] as [unknown, string]; + const payload = JSON.parse(payloadStr) as Record; + expect(payload).toHaveProperty('count', 3); + expect(payload).toHaveProperty('messageId', 'msg-b3'); + }); +}); + +describe('#237 – push hygiene', () => { + it('prunes dead subscription on 410 Gone', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-dead', endpoint: 'https://gone.example.com', p256dh: 'p', auth: 'a' }, + ]); + const err = Object.assign(new Error('Gone'), { statusCode: 410 }); + mockSendNotification.mockRejectedValue(err); + + await dispatchOfflinePush('conv-4', 'msg-4', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + expect(mockDelete).toHaveBeenCalled(); + expect(mockUpdate).not.toHaveBeenCalled(); + }); + + it('prunes dead subscription on 404 Not Found', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-404', endpoint: 'https://notfound.example.com', p256dh: 'p', auth: 'a' }, + ]); + const err = Object.assign(new Error('Not Found'), { statusCode: 404 }); + mockSendNotification.mockRejectedValue(err); + + await dispatchOfflinePush('conv-5', 'msg-5', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + expect(mockDelete).toHaveBeenCalled(); + }); + + it('backs off on transient 500 error (sets disabledAt)', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-500', endpoint: 'https://push.example.com/s500', p256dh: 'p', auth: 'a' }, + ]); + const err = Object.assign(new Error('Server Error'), { statusCode: 500 }); + mockSendNotification.mockRejectedValue(err); + + await dispatchOfflinePush('conv-6', 'msg-6', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + expect(mockUpdate).toHaveBeenCalled(); + expect(mockSetFn).toHaveBeenCalledWith({ disabledAt: expect.any(Date) }); + expect(mockDelete).not.toHaveBeenCalled(); + }); + + it('updates lastUsedAt on successful delivery', async () => { + vi.useFakeTimers(); + mockIsDeviceConnected.mockReturnValue(false); + mockFindMany.mockResolvedValue([ + { id: 'sub-ok', endpoint: 'https://push.example.com/sok', p256dh: 'p', auth: 'a' }, + ]); + mockSendNotification.mockResolvedValue(undefined); + + await dispatchOfflinePush('conv-7', 'msg-7', [uniqueDevice()]); + await vi.runAllTimersAsync(); + + expect(mockSetFn).toHaveBeenCalledWith({ lastUsedAt: expect.any(Date) }); + }); +}); diff --git a/apps/backend/src/db/schema.ts b/apps/backend/src/db/schema.ts index 3305884..9b6c81a 100644 --- a/apps/backend/src/db/schema.ts +++ b/apps/backend/src/db/schema.ts @@ -52,6 +52,22 @@ export const contentTypeEnum = pgEnum('content_type', [ 'system', ]); +// ─── Files (#231) ───────────────────────────────────────────────────────────── +// +// Tracks S3 storage objects for file-type messages. Soft-deleted when all +// referencing messages are retracted; hard-deleted by the background cleanup job. + +export const files = pgTable('files', { + id: uuid('id').primaryKey().defaultRandom(), + storageKey: text('storage_key').notNull().unique(), + deletedAt: timestamp('deleted_at'), + hardDeletedAt: timestamp('hard_deleted_at'), + createdAt: timestamp('created_at').notNull().defaultNow(), +}); + +export type File = typeof files.$inferSelect; +export type NewFile = typeof files.$inferInsert; + export const conversationMembers = pgTable('conversation_members', { id: uuid('id').primaryKey().defaultRandom(), conversationId: uuid('conversation_id') @@ -82,6 +98,7 @@ export const messages = pgTable('messages', { contentType: text('content_type').notNull().default('text/plain'), sequenceNumber: serial('sequence_number'), ciphertext: text('ciphertext'), + fileId: uuid('file_id').references(() => files.id, { onDelete: 'set null' }), createdAt: timestamp('created_at').notNull().defaultNow(), deletedAt: timestamp('deleted_at'), }); @@ -270,6 +287,8 @@ export const pushSubscriptions = pgTable('push_subscriptions', { endpoint: text('endpoint').notNull().unique(), p256dh: text('p256dh').notNull(), auth: text('auth').notNull(), + lastUsedAt: timestamp('last_used_at'), + disabledAt: timestamp('disabled_at'), createdAt: timestamp('created_at').notNull().defaultNow(), }); @@ -315,9 +334,14 @@ export const messagesRelations = relations(messages, ({ one, many }) => ({ fields: [messages.senderDeviceId], references: [userDevices.id], }), + file: one(files, { fields: [messages.fileId], references: [files.id] }), envelopes: many(messageEnvelopes), })); +export const filesRelations = relations(files, ({ many }) => ({ + messages: many(messages), +})); + export const messageEnvelopesRelations = relations(messageEnvelopes, ({ one }) => ({ message: one(messages, { fields: [messageEnvelopes.messageId], references: [messages.id] }), recipientDevice: one(userDevices, { diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index 3335e09..1c02636 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -31,6 +31,7 @@ import { buildTreasuryRpcFetcher, runForever as runStellarListener, } from './services/stellarListener.js'; +import { startFileCleanupJob } from './services/fileCleanup.js'; import { loadEnv } from './config.js'; dotenv.config(); @@ -204,6 +205,9 @@ httpServer.listen(PORT, () => { // Redis is unreachable; on failure we fall back to the in-process adapter. void attachRedisAdapter(); +// #231 – start background file cleanup + push backoff re-enable job +startFileCleanupJob(); + // Subscribe to device_revoked:* channels so any gateway instance can // disconnect a revoked device's sockets within seconds, even when the // revocation was issued on a different node. diff --git a/apps/backend/src/routes/messages.ts b/apps/backend/src/routes/messages.ts index 32e1ba3..f912599 100644 --- a/apps/backend/src/routes/messages.ts +++ b/apps/backend/src/routes/messages.ts @@ -3,6 +3,7 @@ import type { IRouter } from 'express'; import { and, eq } from 'drizzle-orm'; import { db } from '../db/index.js'; import { conversationMembers, messages, messageEnvelopes } from '../db/schema.js'; +import { softDeleteFile } from '../services/fileCleanup.js'; import { requireAuth, type AuthRequest } from '../middleware/auth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { getSocketServer } from '../lib/socket.js'; @@ -41,6 +42,11 @@ messagesRouter.delete('/:id', async (req: AuthRequest, res) => { await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); + // #231 – soft-delete file record when message has a file attachment + if (message.fileId) { + await softDeleteFile(message.fileId); + } + getSocketServer()?.to(message.conversationId).emit('message_deleted', { messageId: message.id, conversationId: message.conversationId, diff --git a/apps/backend/src/services/deviceRevocation.ts b/apps/backend/src/services/deviceRevocation.ts index 20f1ef0..57d5b59 100644 --- a/apps/backend/src/services/deviceRevocation.ts +++ b/apps/backend/src/services/deviceRevocation.ts @@ -35,6 +35,11 @@ export function isDeviceRevoked(deviceId: string): boolean { return revokedMidSession.has(deviceId); } +export function isDeviceConnected(deviceId: string): boolean { + const sockets = deviceSockets.get(deviceId); + return sockets !== undefined && sockets.size > 0; +} + export function markDeviceRevoked(deviceId: string): void { revokedMidSession.add(deviceId); } diff --git a/apps/backend/src/services/fileCleanup.ts b/apps/backend/src/services/fileCleanup.ts new file mode 100644 index 0000000..3533860 --- /dev/null +++ b/apps/backend/src/services/fileCleanup.ts @@ -0,0 +1,89 @@ +/** + * Background file cleanup service. + * + * Implements #231 – soft-delete (files.deletedAt) is set immediately when a + * message is retracted. This job hard-deletes the S3 object once every + * referencing message is also soft-deleted (ref-counting across envelopes). + * + * The job is idempotent: it sets hardDeletedAt only after a successful S3 + * delete, so a crash between steps is safe to retry. + */ +import { S3Client, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { isNotNull, isNull, sql } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { files } from '../db/schema.js'; +import { reenableExpiredBackoffs } from './pushNotification.js'; + +const s3 = new S3Client({ region: process.env['AWS_REGION'] ?? 'us-east-1' }); +const BUCKET = process.env['AWS_BUCKET'] ?? 'clicked-files'; + +const CLEANUP_INTERVAL_MS = 5 * 60 * 1_000; // every 5 minutes + +/** + * Soft-delete a file record when its owning message is retracted. + * Call this when setting message.deletedAt. + */ +export async function softDeleteFile(fileId: string): Promise { + await db.update(files).set({ deletedAt: new Date() }).where(sql` + ${files.id} = ${fileId} + AND ${files.hardDeletedAt} IS NULL + AND NOT EXISTS ( + SELECT 1 FROM messages + WHERE file_id = ${fileId} + AND deleted_at IS NULL + ) + `); +} + +/** + * Hard-delete all S3 objects whose files rows are soft-deleted and have no + * remaining live message references. Idempotent and safe to retry. + */ +export async function runHardDeletePass(): Promise { + const candidates = await db.query.files.findMany({ + where: (f) => isNotNull(f.deletedAt) && isNull(f.hardDeletedAt), + columns: { id: true, storageKey: true }, + }); + + for (const file of candidates) { + // Re-check: skip if any non-deleted message still references this file + const liveRef = await db.execute(sql` + SELECT 1 FROM messages + WHERE file_id = ${file.id} + AND deleted_at IS NULL + LIMIT 1 + `); + + if ((liveRef as unknown[]).length > 0) continue; + + try { + await s3.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: file.storageKey })); + await db.update(files).set({ hardDeletedAt: new Date() }).where(sql`${files.id} = ${file.id}`); + console.log(`[file-cleanup] hard-deleted s3://${BUCKET}/${file.storageKey}`); + } catch (err) { + console.error(`[file-cleanup] failed to delete ${file.storageKey}:`, err); + } + } +} + +let cleanupTimer: ReturnType | null = null; + +export function startFileCleanupJob(): void { + if (cleanupTimer) return; + cleanupTimer = setInterval(async () => { + try { + await runHardDeletePass(); + await reenableExpiredBackoffs(); + } catch (err) { + console.error('[file-cleanup] job error:', err); + } + }, CLEANUP_INTERVAL_MS); + cleanupTimer.unref(); +} + +export function stopFileCleanupJob(): void { + if (cleanupTimer) { + clearInterval(cleanupTimer); + cleanupTimer = null; + } +} diff --git a/apps/backend/src/services/pushNotification.ts b/apps/backend/src/services/pushNotification.ts new file mode 100644 index 0000000..b49bc22 --- /dev/null +++ b/apps/backend/src/services/pushNotification.ts @@ -0,0 +1,155 @@ +/** + * Push notification delivery service. + * + * Implements: + * #236 – dispatch content-free Web Push when recipient device is offline + * #237 – prune dead subscriptions (410/404), back off on transient failures + * #239 – coalesce burst messages into a single push, rate-limit per device + */ +import webpush from 'web-push'; +import { and, eq, isNull } from 'drizzle-orm'; +import { db } from '../db/index.js'; +import { pushSubscriptions } from '../db/schema.js'; +import { isDeviceConnected } from './deviceRevocation.js'; + +const FILE_CONTENT_TYPES = new Set(['file', 'image', 'video', 'audio']); + +// ── VAPID initialisation ────────────────────────────────────────────────────── + +const VAPID_SUBJECT = process.env['VAPID_SUBJECT'] ?? 'mailto:admin@clicked.app'; +const VAPID_PUBLIC_KEY = process.env['VAPID_PUBLIC_KEY']; +const VAPID_PRIVATE_KEY = process.env['VAPID_PRIVATE_KEY']; + +let vapidReady = false; +if (VAPID_PUBLIC_KEY && VAPID_PRIVATE_KEY) { + webpush.setVapidDetails(VAPID_SUBJECT, VAPID_PUBLIC_KEY, VAPID_PRIVATE_KEY); + vapidReady = true; +} + +// ── #239 Coalescing state ───────────────────────────────────────────────────── + +const COALESCE_WINDOW_MS = 2_000; +const RATE_LIMIT_WINDOW_MS = 30_000; + +interface CoalesceEntry { + count: number; + latestMessageId: string; + timer: ReturnType; +} + +const pendingCoalesce = new Map(); +const lastPushSentAt = new Map(); + +// ── Public API ──────────────────────────────────────────────────────────────── + +/** + * #236 – After a message is persisted, dispatch push to every recipient device + * that currently has no active socket connection. + */ +export async function dispatchOfflinePush( + conversationId: string, + messageId: string, + recipientDeviceIds: string[], +): Promise { + if (!vapidReady || recipientDeviceIds.length === 0) return; + + for (const deviceId of recipientDeviceIds) { + if (!isDeviceConnected(deviceId)) { + queueCoalescedPush(deviceId, conversationId, messageId); + } + } +} + +export { FILE_CONTENT_TYPES }; + +// ── #239 Coalescing ─────────────────────────────────────────────────────────── + +function queueCoalescedPush(deviceId: string, conversationId: string, messageId: string): void { + const key = `${deviceId}:${conversationId}`; + const existing = pendingCoalesce.get(key); + + if (existing) { + existing.count += 1; + existing.latestMessageId = messageId; + return; + } + + const entry: CoalesceEntry = { + count: 1, + latestMessageId: messageId, + timer: setTimeout(async () => { + pendingCoalesce.delete(key); + await flushPush(deviceId, conversationId, entry.count, entry.latestMessageId); + }, COALESCE_WINDOW_MS), + }; + + pendingCoalesce.set(key, entry); +} + +async function flushPush( + deviceId: string, + conversationId: string, + count: number, + messageId: string, +): Promise { + // #239 – per-device rate limiting + const now = Date.now(); + const lastSent = lastPushSentAt.get(deviceId) ?? 0; + if (now - lastSent < RATE_LIMIT_WINDOW_MS) return; + lastPushSentAt.set(deviceId, now); + + const subs = await db.query.pushSubscriptions.findMany({ + where: and(eq(pushSubscriptions.deviceId, deviceId), isNull(pushSubscriptions.disabledAt)), + }); + + const payload = JSON.stringify({ type: 'new_message', conversationId, messageId, count }); + + await Promise.allSettled(subs.map((sub) => sendWebPush(sub, payload))); +} + +// ── #237 Core send with hygiene ─────────────────────────────────────────────── + +type SubRow = { id: string; endpoint: string; p256dh: string; auth: string }; + +async function sendWebPush(sub: SubRow, payload: string): Promise { + try { + await webpush.sendNotification( + { endpoint: sub.endpoint, keys: { p256dh: sub.p256dh, auth: sub.auth } }, + payload, + ); + + await db + .update(pushSubscriptions) + .set({ lastUsedAt: new Date() }) + .where(eq(pushSubscriptions.id, sub.id)); + + console.log(`[push] ok → ${sub.endpoint.slice(0, 50)}`); + } catch (err: unknown) { + const status = (err as { statusCode?: number }).statusCode; + + if (status === 410 || status === 404) { + // Dead subscription – prune immediately (#237) + console.log(`[push] prune ${status} → ${sub.endpoint.slice(0, 50)}`); + await db.delete(pushSubscriptions).where(eq(pushSubscriptions.id, sub.id)); + } else { + // Transient failure – back off by disabling for 5 min (#237) + const retryAfter = new Date(Date.now() + 5 * 60 * 1_000); + console.warn(`[push] backoff (${status ?? 'err'}) → ${sub.endpoint.slice(0, 50)}`); + await db + .update(pushSubscriptions) + .set({ disabledAt: retryAfter }) + .where(eq(pushSubscriptions.id, sub.id)); + } + } +} + +/** + * Re-enable subscriptions whose backoff window has expired. + * Called periodically by the cleanup job. + */ +export async function reenableExpiredBackoffs(): Promise { + const { sql } = await import('drizzle-orm'); + await db.execute( + sql`UPDATE push_subscriptions SET disabled_at = NULL WHERE disabled_at IS NOT NULL AND disabled_at <= NOW()`, + ); +} diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 47b1471..1fb53de 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -7,11 +7,13 @@ import { messages, messageEnvelopes, userDevices, + files, } from '../db/schema.js'; import type { AuthSocket } from '../middleware/socketAuth.js'; import { invalidateConversationCaches } from '../lib/conversationCache.js'; import { serializeMessage } from '../lib/messages.js'; import { redis } from '../lib/redis.js'; +import { dispatchOfflinePush, FILE_CONTENT_TYPES } from '../services/pushNotification.js'; const PAGE_SIZE = 30; @@ -91,6 +93,18 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void return; } + // #231 – create file tracking record for file-type messages + let fileId: string | undefined; + const resolvedContentType = contentType || 'text/plain'; + if (FILE_CONTENT_TYPES.has(resolvedContentType)) { + const [fileRow] = await db + .insert(files) + .values({ storageKey: messageId }) + .onConflictDoUpdate({ target: files.storageKey, set: { storageKey: messageId } }) + .returning({ id: files.id }); + fileId = fileRow?.id; + } + const [message] = await db .insert(messages) .values({ @@ -98,11 +112,14 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void conversationId, senderId: userId, senderDeviceId: deviceId, - contentType: contentType || 'text/plain', + contentType: resolvedContentType, ciphertext: ciphertext || null, + fileId: fileId ?? null, }) .returning(); + let recipientDeviceIds: string[] = []; + if (envelopes && envelopes.length > 0) { const deviceIds = envelopes.map((e) => e.recipientDeviceId); const devicesList = await db.query.userDevices.findMany({ @@ -123,6 +140,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void if (validEnvelopes.length > 0) { await db.insert(messageEnvelopes).values(validEnvelopes); } + + recipientDeviceIds = validEnvelopes.map((e) => e.recipientDeviceId); } // Emit acknowledgment to sender @@ -138,6 +157,9 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }); await invalidateConversationCaches(members.map((member) => member.userId)); + + // #236 – push to offline recipient devices (fire-and-forget) + void dispatchOfflinePush(conversationId, messageId, recipientDeviceIds); }, ); @@ -207,6 +229,12 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void .where(eq(messages.id, messageId)); await db.delete(messageEnvelopes).where(eq(messageEnvelopes.messageId, messageId)); + // #231 – soft-delete file record when message had a file attachment + if (message.fileId) { + const { softDeleteFile } = await import('../services/fileCleanup.js'); + await softDeleteFile(message.fileId); + } + io.to(message.conversationId).emit('message_deleted', { messageId }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1ac0a3..24eb0d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,10 +10,10 @@ importers: devDependencies: prettier: specifier: latest - version: 3.8.3 + version: 3.9.1 turbo: specifier: latest - version: 2.9.16 + version: 2.10.0 apps/backend: dependencies: @@ -62,6 +62,9 @@ importers: socket.io: specifier: ^4.8.3 version: 4.8.3 + web-push: + specifier: ^3.6.7 + version: 3.6.7 zod: specifier: ^4.4.3 version: 4.4.3 @@ -87,6 +90,9 @@ importers: '@types/supertest': specifier: ^7.2.0 version: 7.2.0 + '@types/web-push': + specifier: ^3.6.4 + version: 3.6.4 '@vitest/coverage-v8': specifier: ^4.1.6 version: 4.1.6(vitest@4.1.6) @@ -1337,7 +1343,7 @@ packages: '@stellar/stellar-base@11.1.0': resolution: {integrity: sha512-nMg7QSpFqCZFq3Je/lG12+DY18y01QHRNyCxvjM8i4myS9tPRMDq7zqGcd215BGbCJxenckiOW45YJjQjzdcMQ==} - deprecated: This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support. + deprecated: ⚠️ This version contains breaking changes, use v11.0.1 for compatibility or upgrade to v12. '@stellar/stellar-base@15.0.0': resolution: {integrity: sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==} @@ -1451,33 +1457,33 @@ packages: '@tsconfig/node16@1.0.4': resolution: {integrity: sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==} - '@turbo/darwin-64@2.9.16': - resolution: {integrity: sha512-jLjApWTSNd7JZ5JaLYfelW1ytnGQOvB7ivl+2RD1xQvJTbi8I9gBjzcga7tDZVPyaxpl10YTfJt3BrYXR18KDw==} + '@turbo/darwin-64@2.10.0': + resolution: {integrity: sha512-EwvHThXzpY0KGd1/NAmuewI5D+aVa3Rl/OlxE36yfjUKb/+ySrfJrSlEFt8aD1OXwnnaHnQnPKHFndor0Zxlsg==} cpu: [x64] os: [darwin] - '@turbo/darwin-arm64@2.9.16': - resolution: {integrity: sha512-YPgrn+5HIGzrx0O2a631SV4MBQUe4W/DafMFUuBVgaU32PW9/OTT0ehviF0QSxTXuRJlHvW2eUTemddF5/spmw==} + '@turbo/darwin-arm64@2.10.0': + resolution: {integrity: sha512-9d2fTyyG0lf5Wq1bwJA9qUaeecViMkLcdctWaMMmCkxZ/JqypmqOwK3W6vmejeKVgkr06gSoiX8bD+xN5Jpxcg==} cpu: [arm64] os: [darwin] - '@turbo/linux-64@2.9.16': - resolution: {integrity: sha512-vAEf1H6l26lTpl9FJ/peQo1NUB8RC0sbEJJz5mPcUhHA2bPDup2x3CZPgo/bH8S4cUcBLm4FN3UHd5iUO2RAew==} + '@turbo/linux-64@2.10.0': + resolution: {integrity: sha512-sZBtjMuufitanjzi6UssoUpJMnnPlLMcdcJj3m3ptNsSq31Xh7MnjhwA5nWvLDTfEFg8GPcbYFXMo8vSdKRfqQ==} cpu: [x64] os: [linux] - '@turbo/linux-arm64@2.9.16': - resolution: {integrity: sha512-xDBLR2PZg4BrQOchfG6svgpv5FCNJ2TOtT2psLdEJcdKo1BH+pnPs9Xj6pvUjgfkHbuvBOfeE4R6tvxMoQKDHQ==} + '@turbo/linux-arm64@2.10.0': + resolution: {integrity: sha512-vkq/Z8R+1DQ+kifWFa810IjRy2NNBVvha3cg9sWA3nFh6nnGrHSMnnJKrzH7c/No9kq4Jb55Ru44YKsCSBgrKg==} cpu: [arm64] os: [linux] - '@turbo/windows-64@2.9.16': - resolution: {integrity: sha512-NBAJnaUiGdgkSzQwUIdOvkCkcpTSu58G/sBGa0mvBtzfvFOOgrQwepKOOQ8cp6sWM6OcKDNFj2p1dsZA1OWjPg==} + '@turbo/windows-64@2.10.0': + resolution: {integrity: sha512-CRUEguLWxFQHptYZS7HjPhNhAFawfea07iR+xAQ5e4klgLrPCMdexBkXwSCwOxqTFknJ7RZFN3gOaADsw+Gttg==} cpu: [x64] os: [win32] - '@turbo/windows-arm64@2.9.16': - resolution: {integrity: sha512-Y7SJppD0Z8wjO3Ec0ZGd9KQ4Yv0BMnA8CIowj5Vp+OEVsosXDG2weK6/t1RRLfJmc2Ozrnd6y4DOgQys+mn3WQ==} + '@turbo/windows-arm64@2.10.0': + resolution: {integrity: sha512-dVHGaf9F8twzgibcBqKoADT/LLqf9++jDb+hq/LPWWaOmRpp4M+/pVOm7vy4z9D++xg8eaxWLT0+wQxFwhYu9A==} cpu: [arm64] os: [win32] @@ -1561,6 +1567,9 @@ packages: '@types/supertest@7.2.0': resolution: {integrity: sha512-uh2Lv57xvggst6lCqNdFAmDSvoMG7M/HDtX4iUCquxQ5EGPtaPM5PL5Hmi7LCvOG8db7YaCPNJEeoI8s/WzIQw==} + '@types/web-push@3.6.4': + resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==} + '@types/ws@8.18.1': resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} @@ -1837,6 +1846,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -1889,6 +1902,9 @@ packages: asap@2.0.6: resolution: {integrity: sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==} + asn1.js@5.4.1: + resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1970,6 +1986,9 @@ packages: bignumber.js@9.3.1: resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bn.js@4.12.4: + resolution: {integrity: sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==} + body-parser@2.2.2: resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} engines: {node: '>=18'} @@ -2731,6 +2750,14 @@ packages: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} + http_ece@1.2.0: + resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==} + engines: {node: '>=16'} + + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.7.2: resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} engines: {node: '>=0.10.0'} @@ -3138,6 +3165,9 @@ packages: engines: {node: '>=4.0.0'} hasBin: true + minimalistic-assert@1.0.1: + resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} + minimatch@10.2.4: resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==} engines: {node: 18 || 20 || >=22} @@ -3351,6 +3381,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.9.1: + resolution: {integrity: sha512-ppiDo2CSwexck1eyZUwJHg/N3nf1+6IRCv7W/VJ5vaLnVCmB7+3CdRfMwoCHBBX6xTrREDTksZ4OZl5SSf4zXA==} + engines: {node: '>=14'} + hasBin: true + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3735,8 +3770,8 @@ packages: engines: {node: '>=18.0.0'} hasBin: true - turbo@2.9.16: - resolution: {integrity: sha512-NqgRQy6j6dPYcdSdv0q1g9QsZg7SWg87RERM8otw/1AtKU2yTFVClOM7cbwKzOonZr/Ek1blTBucw64L9H0Bwg==} + turbo@2.10.0: + resolution: {integrity: sha512-o016H9PPtuH2deb3mh3Vci3Avfi9UYgM/RONQisY7HnloupP0IFSbFS3gFYJgFJP8nwBrByHWFQIDa8T2zIXPw==} hasBin: true tweetnacl@1.0.3: @@ -3906,6 +3941,11 @@ packages: jsdom: optional: true + web-push@3.6.7: + resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==} + engines: {node: '>= 16'} + hasBin: true + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -5118,22 +5158,22 @@ snapshots: '@tsconfig/node16@1.0.4': {} - '@turbo/darwin-64@2.9.16': + '@turbo/darwin-64@2.10.0': optional: true - '@turbo/darwin-arm64@2.9.16': + '@turbo/darwin-arm64@2.10.0': optional: true - '@turbo/linux-64@2.9.16': + '@turbo/linux-64@2.10.0': optional: true - '@turbo/linux-arm64@2.9.16': + '@turbo/linux-arm64@2.10.0': optional: true - '@turbo/windows-64@2.9.16': + '@turbo/windows-64@2.10.0': optional: true - '@turbo/windows-arm64@2.9.16': + '@turbo/windows-arm64@2.10.0': optional: true '@tybys/wasm-util@0.10.1': @@ -5234,6 +5274,10 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.10 + '@types/web-push@3.6.4': + dependencies: + '@types/node': 20.19.37 + '@types/ws@8.18.1': dependencies: '@types/node': 20.19.37 @@ -5554,6 +5598,8 @@ snapshots: acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -5640,6 +5686,13 @@ snapshots: asap@2.0.6: {} + asn1.js@5.4.1: + dependencies: + bn.js: 4.12.4 + inherits: 2.0.4 + minimalistic-assert: 1.0.1 + safer-buffer: 2.1.2 + assertion-error@2.0.1: {} ast-types-flow@0.0.8: {} @@ -5702,6 +5755,8 @@ snapshots: bignumber.js@9.3.1: {} + bn.js@4.12.4: {} + body-parser@2.2.2: dependencies: bytes: 3.1.2 @@ -5917,7 +5972,7 @@ snapshots: ecdsa-sig-formatter@1.0.11: dependencies: - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 ee-first@1.1.1: {} @@ -6162,7 +6217,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -6195,7 +6250,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -6210,7 +6265,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.57.1(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -6612,6 +6667,15 @@ snapshots: statuses: 2.0.2 toidentifier: 1.0.1 + http_ece@1.2.0: {} + + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.7.2: dependencies: safer-buffer: 2.1.2 @@ -6843,12 +6907,12 @@ snapshots: dependencies: buffer-equal-constant-time: 1.0.1 ecdsa-sig-formatter: 1.0.11 - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 jws@4.0.1: dependencies: jwa: 2.0.1 - safe-buffer: 5.1.2 + safe-buffer: 5.2.1 keyv@4.5.4: dependencies: @@ -6991,6 +7055,8 @@ snapshots: mime@2.6.0: {} + minimalistic-assert@1.0.1: {} + minimatch@10.2.4: dependencies: brace-expansion: 5.0.4 @@ -7197,6 +7263,8 @@ snapshots: prettier@3.8.3: {} + prettier@3.9.1: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7742,14 +7810,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - turbo@2.9.16: + turbo@2.10.0: optionalDependencies: - '@turbo/darwin-64': 2.9.16 - '@turbo/darwin-arm64': 2.9.16 - '@turbo/linux-64': 2.9.16 - '@turbo/linux-arm64': 2.9.16 - '@turbo/windows-64': 2.9.16 - '@turbo/windows-arm64': 2.9.16 + '@turbo/darwin-64': 2.10.0 + '@turbo/darwin-arm64': 2.10.0 + '@turbo/linux-64': 2.10.0 + '@turbo/linux-arm64': 2.10.0 + '@turbo/windows-64': 2.10.0 + '@turbo/windows-arm64': 2.10.0 tweetnacl@1.0.3: {} @@ -7915,6 +7983,16 @@ snapshots: transitivePeerDependencies: - msw + web-push@3.6.7: + dependencies: + asn1.js: 5.4.1 + http_ece: 1.2.0 + https-proxy-agent: 7.0.6 + jws: 4.0.1 + minimist: 1.2.8 + transitivePeerDependencies: + - supports-color + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 From 6b98c402c3f9d9266eb9cb6261bd5ca657d0fae4 Mon Sep 17 00:00:00 2001 From: Chidimj <165854539+Chidimj@users.noreply.github.com> Date: Mon, 29 Jun 2026 21:07:30 +0100 Subject: [PATCH 2/2] fix: move recipientDeviceIds to edit_message scope to resolve no-unused-vars --- apps/backend/src/socket/messaging.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/backend/src/socket/messaging.ts b/apps/backend/src/socket/messaging.ts index 199c412..da1c5dd 100644 --- a/apps/backend/src/socket/messaging.ts +++ b/apps/backend/src/socket/messaging.ts @@ -160,8 +160,6 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }) .returning(); - let recipientDeviceIds: string[] = []; - if (envelopes && envelopes.length > 0) { const deviceIds = envelopes.map((e) => e.recipientDeviceId); @@ -184,8 +182,6 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void if (validEnvelopes.length > 0) { await db.insert(messageEnvelopes).values(validEnvelopes); } - - recipientDeviceIds = validEnvelopes.map((e) => e.recipientDeviceId); } if (message) { @@ -275,6 +271,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void }) .returning(); + let recipientDeviceIds: string[] = []; + if (envelopes && envelopes.length > 0) { const deviceIds = envelopes.map((e) => e.recipientDeviceId); @@ -297,6 +295,8 @@ export function registerMessagingHandlers(io: Server, socket: AuthSocket): void if (validEnvelopes.length > 0) { await db.insert(messageEnvelopes).values(validEnvelopes); } + + recipientDeviceIds = validEnvelopes.map((e) => e.recipientDeviceId); } if (message) {