From 8878ee8c54d91d5afda9feca4ffcc417acc6a5aa Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 23 Jul 2026 00:53:08 +0530 Subject: [PATCH 1/3] feat(messaging-ui): mail attachment download + scrollable reader (0.1.1) - Attachment chips in the mail reader are now clickable: new InboxAdapter downloadAttachment() resolves a short-lived URL, opened in a new tab. - Mail reader scrolls again: convert the .miu-detail/.miu-mail height:100% chain to flex fill so a long thread scrolls within the pane instead of being clipped. MockInboxAdapter implements download. Bump to 0.1.1. Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/package.json | 2 +- .../src/adapters/mock-inbox.ts | 5 +++++ .../iios-messaging-ui/src/inbox/adapter.ts | 4 ++++ packages/iios-messaging-ui/src/inbox/hooks.ts | 21 ++++++++++++++++++- .../iios-messaging-ui/src/inbox/inbox.tsx | 19 +++++++++++++++-- packages/iios-messaging-ui/src/styles.css | 14 ++++++++++--- 6 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 6a9d110..69ac4b7 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-messaging-ui", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts index c2603e4..dfa03a5 100644 --- a/packages/iios-messaging-ui/src/adapters/mock-inbox.ts +++ b/packages/iios-messaging-ui/src/adapters/mock-inbox.ts @@ -88,6 +88,11 @@ export class MockInboxAdapter implements InboxAdapter { return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name }; } + async downloadAttachment(attachment: MailAttachment): Promise { + // Demo: no real bytes — hand back a data URL so the click resolves without a network call. + return `data:${attachment.mimeType};base64,`; + } + async directory(): Promise { return [...PEOPLE]; } diff --git a/packages/iios-messaging-ui/src/inbox/adapter.ts b/packages/iios-messaging-ui/src/inbox/adapter.ts index dec547e..df26cb3 100644 --- a/packages/iios-messaging-ui/src/inbox/adapter.ts +++ b/packages/iios-messaging-ui/src/inbox/adapter.ts @@ -22,6 +22,10 @@ export interface InboxAdapter { /** Upload a file to storage, returning a reference to send with a reply/compose. */ uploadAttachment?(file: File): Promise; + /** Resolve a short-lived URL to view/download an attachment. Absent => attachment chips are + * shown but not clickable. */ + downloadAttachment?(attachment: MailAttachment): Promise; + // ── Compose (optional) — absent hides the "New message" affordance ── /** People you can compose an in-app message to. */ directory?(): Promise; diff --git a/packages/iios-messaging-ui/src/inbox/hooks.ts b/packages/iios-messaging-ui/src/inbox/hooks.ts index b9ab6a2..323b68a 100644 --- a/packages/iios-messaging-ui/src/inbox/hooks.ts +++ b/packages/iios-messaging-ui/src/inbox/hooks.ts @@ -60,6 +60,8 @@ export interface MailThreadState { reply: (content: string, attachment?: MailAttachment) => Promise; canAttach: boolean; upload: (file: File) => Promise; + canDownload: boolean; + download: (attachment: MailAttachment) => Promise; refetch: () => void; } @@ -113,8 +115,25 @@ export function useMailThread(threadId: string | null): MailThreadState { }, [adapter], ); + const download = useCallback( + async (attachment: MailAttachment) => { + if (!adapter.downloadAttachment) throw new Error('attachment download is not supported by this adapter'); + return adapter.downloadAttachment(attachment); + }, + [adapter], + ); - return { messages, loading, error, reply, canAttach: typeof adapter.uploadAttachment === 'function', upload, refetch }; + return { + messages, + loading, + error, + reply, + canAttach: typeof adapter.uploadAttachment === 'function', + upload, + canDownload: typeof adapter.downloadAttachment === 'function', + download, + refetch, + }; } export interface ComposeState { diff --git a/packages/iios-messaging-ui/src/inbox/inbox.tsx b/packages/iios-messaging-ui/src/inbox/inbox.tsx index 4cf235a..3587b6c 100644 --- a/packages/iios-messaging-ui/src/inbox/inbox.tsx +++ b/packages/iios-messaging-ui/src/inbox/inbox.tsx @@ -117,7 +117,7 @@ function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state: /** Read a mail thread (HTML in a sandboxed iframe) + reply. */ export function MailReader({ threadId, subject }: { threadId: string; subject: string }) { - const { messages, loading, error, reply, canAttach, upload } = useMailThread(threadId); + const { messages, loading, error, reply, canAttach, upload, canDownload, download } = useMailThread(threadId); const [draft, setDraft] = useState(''); const [sending, setSending] = useState(false); const [pending, setPending] = useState(null); @@ -125,6 +125,15 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s const [attachErr, setAttachErr] = useState(null); const fileRef = useRef(null); + async function openAttachment(att: MailAttachment): Promise { + try { + const url = await download(att); + window.open(url, '_blank', 'noopener,noreferrer'); + } catch (err) { + setAttachErr(err instanceof Error ? err.message : String(err)); + } + } + async function pick(e: React.ChangeEvent): Promise { const file = e.target.files?.[0]; e.target.value = ''; @@ -176,7 +185,13 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
{m.text}
) : null} {m.attachment ? ( - 📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''} + canDownload ? ( + + ) : ( + 📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''} + ) ) : null} ))} diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index c7b01c2..68f0d5f 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -353,7 +353,15 @@ white-space: nowrap; } .miu-mail-msg .miu-attach-chip { - margin-top: 6px; + margin: 8px 12px 12px; +} +button.miu-attach-dl { + cursor: pointer; + color: var(--miu-text); +} +button.miu-attach-dl:hover { + border-color: var(--miu-accent); + color: var(--miu-accent); } .miu-attach-pending { display: inline-flex; @@ -763,7 +771,7 @@ .miu-detail { display: flex; flex-direction: column; - height: 100%; + flex: 1; min-height: 0; } .miu-detail-actions { @@ -790,7 +798,7 @@ .miu-mail { display: flex; flex-direction: column; - height: 100%; + flex: 1; min-height: 0; } .miu-mail-head { From 22eaf0f654bb234717f094aceca2aa534a50f107 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 23 Jul 2026 01:04:52 +0530 Subject: [PATCH 2/3] feat(messaging-ui): Gmail-style uploading chip while an attachment uploads (0.1.2) Picking a file now shows an immediate chip with the filename + a spinner while the bytes upload (mail compose, mail reply, and the messenger composer), instead of only appearing once upload finishes. Respects prefers-reduced-motion. Co-Authored-By: Claude Opus 4.8 --- packages/iios-messaging-ui/package.json | 2 +- .../src/components/composer.tsx | 9 ++++++++- .../iios-messaging-ui/src/inbox/inbox.tsx | 19 +++++++++++++++++-- packages/iios-messaging-ui/src/styles.css | 19 +++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/packages/iios-messaging-ui/package.json b/packages/iios-messaging-ui/package.json index 69ac4b7..a36ef95 100644 --- a/packages/iios-messaging-ui/package.json +++ b/packages/iios-messaging-ui/package.json @@ -1,6 +1,6 @@ { "name": "@insignia/iios-messaging-ui", - "version": "0.1.1", + "version": "0.1.2", "type": "module", "main": "dist/index.js", "module": "dist/index.js", diff --git a/packages/iios-messaging-ui/src/components/composer.tsx b/packages/iios-messaging-ui/src/components/composer.tsx index 56c50ec..0437cfa 100644 --- a/packages/iios-messaging-ui/src/components/composer.tsx +++ b/packages/iios-messaging-ui/src/components/composer.tsx @@ -38,6 +38,7 @@ export function Composer({ const [sending, setSending] = useState(false); const [staged, setStaged] = useState(null); const [uploading, setUploading] = useState(false); + const [uploadingName, setUploadingName] = useState(null); const fileRef = useRef(null); const query = trailingMentionQuery(draft); @@ -59,12 +60,14 @@ export function Composer({ e.target.value = ''; if (!file) return; setUploading(true); + setUploadingName(file.name); try { setStaged(await upload(file)); } catch { /* host surfaces upload errors */ } finally { setUploading(false); + setUploadingName(null); } } @@ -110,7 +113,11 @@ export function Composer({ ))} ) : null} - {staged ? ( + {uploading && uploadingName ? ( +
+
+ ) : staged ? (
📎 {staged.name}
{attachErr ?
{attachErr}
: null} - {pending ? ( + {attaching && uploadingName ? ( +
+ +
+ ) : pending ? (
📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''} @@ -232,6 +239,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => const [err, setErr] = useState(null); const [attachments, setAttachments] = useState([]); const [attaching, setAttaching] = useState(false); + const [uploadingName, setUploadingName] = useState(null); const fileRef = useRef(null); const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase())); @@ -242,6 +250,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => e.target.value = ''; if (!file || attachments.length >= 10) return; setAttaching(true); + setUploadingName(file.name); setErr(null); try { const ref = await compose.upload(file); @@ -250,6 +259,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => setErr(e2 instanceof Error ? e2.message : String(e2)); } finally { setAttaching(false); + setUploadingName(null); } } @@ -323,9 +333,14 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () => ))} + {attaching && uploadingName ? ( + + + + ) : null}
diff --git a/packages/iios-messaging-ui/src/styles.css b/packages/iios-messaging-ui/src/styles.css index 68f0d5f..34e0776 100644 --- a/packages/iios-messaging-ui/src/styles.css +++ b/packages/iios-messaging-ui/src/styles.css @@ -368,6 +368,25 @@ button.miu-attach-dl:hover { align-items: center; gap: 6px; } +.miu-attach-chip.is-uploading, +.miu-staged.is-uploading { + color: var(--miu-muted); +} +.miu-spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid var(--miu-border); + border-top-color: var(--miu-accent); + border-radius: 50%; + animation: miu-spin 0.7s linear infinite; +} +@keyframes miu-spin { + to { transform: rotate(360deg); } +} +@media (prefers-reduced-motion: reduce) { + .miu-spinner { animation-duration: 2s; } +} .miu-attach-x { border: none; background: none; From 0ee63c139f487e177db8a5113f8948b740524c32 Mon Sep 17 00:00:00 2001 From: maaz519 Date: Thu, 23 Jul 2026 01:19:11 +0530 Subject: [PATCH 3/3] =?UTF-8?q?feat(media):=20orphan=20cleanup=20=E2=80=94?= =?UTF-8?q?=20track=20objects=20+=20reap=20unreferenced=20attachments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attachments upload direct-to-storage on attach, so an abandoned attach (removed, cancelled, tab closed) would linger forever. Add IiosMediaObject: a row is recorded when bytes land (MediaService.put); a scheduled sweepOrphans() reaps objects past a grace window (IIOS_MEDIA_ORPHAN_GRACE_MS, default 24h) that no IiosMessagePart references (derived live — no flag to drift). Storage delete via the StoragePort; best-effort + idempotent. Gated on IIOS_MEDIA_GC_INTERVAL_MS (off by default). Migration 20260722193958_media_object_tracking. 10 media tests green. Co-Authored-By: Claude Opus 4.8 --- .../migration.sql | 20 +++++ packages/iios-service/prisma/schema.prisma | 16 ++++ .../src/media/media.service.spec.ts | 52 ++++++++++++- .../iios-service/src/media/media.service.ts | 75 ++++++++++++++++++- .../iios-service/src/test-utils/reset-db.ts | 2 +- 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 packages/iios-service/prisma/migrations/20260722193958_media_object_tracking/migration.sql diff --git a/packages/iios-service/prisma/migrations/20260722193958_media_object_tracking/migration.sql b/packages/iios-service/prisma/migrations/20260722193958_media_object_tracking/migration.sql new file mode 100644 index 0000000..6cf15ec --- /dev/null +++ b/packages/iios-service/prisma/migrations/20260722193958_media_object_tracking/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable +ALTER TABLE "IiosClientRegistry" ALTER COLUMN "allowedAppIds" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "IiosMediaObject" ( + "id" TEXT NOT NULL, + "scopeId" TEXT NOT NULL, + "objectKey" TEXT NOT NULL, + "mime" TEXT NOT NULL, + "sizeBytes" BIGINT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "IiosMediaObject_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "IiosMediaObject_objectKey_key" ON "IiosMediaObject"("objectKey"); + +-- CreateIndex +CREATE INDEX "IiosMediaObject_createdAt_idx" ON "IiosMediaObject"("createdAt"); diff --git a/packages/iios-service/prisma/schema.prisma b/packages/iios-service/prisma/schema.prisma index 799aeeb..c59a711 100644 --- a/packages/iios-service/prisma/schema.prisma +++ b/packages/iios-service/prisma/schema.prisma @@ -543,6 +543,22 @@ model IiosMessagePart { @@unique([interactionId, partIndex]) } +/// A stored media object (bytes behind the StoragePort). A row is recorded when bytes actually +/// land (MediaService.put), so an orphan sweep can reap objects that no message part references — +/// e.g. a file attached in a composer but never sent. "Referenced" is derived at sweep time from +/// IiosMessagePart.contentRef (no stored flag to drift), after a grace period so in-progress +/// composes are never reaped. +model IiosMediaObject { + id String @id @default(cuid()) + scopeId String + objectKey String @unique + mime String + sizeBytes BigInt + createdAt DateTime @default(now()) + + @@index([createdAt]) +} + /// Transactional outbox — written in the same tx as the business row. model IiosOutboxEvent { eventId String @id @default(cuid()) diff --git a/packages/iios-service/src/media/media.service.spec.ts b/packages/iios-service/src/media/media.service.spec.ts index 8778671..89721e1 100644 --- a/packages/iios-service/src/media/media.service.spec.ts +++ b/packages/iios-service/src/media/media.service.spec.ts @@ -13,9 +13,11 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i const prisma = new PrismaClient({ datasources: { db: { url } } }); const asService = prisma as unknown as PrismaService; const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts; -const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService)); +const svc = () => new MediaService(new LocalDiskStorage(), ports, new ActorResolver(asService), asService); const alice: MessagePrincipal = { userId: 'alice', orgId: 'org_demo', appId: 'portal-demo', displayName: 'Alice' }; +const DAY_MS = 24 * 60 * 60 * 1000; + // point storage at an isolated temp dir for the test run process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test'; @@ -70,3 +72,51 @@ describe('MediaService (presigned local storage)', () => { await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow(); }); }); + +describe('MediaService orphan sweep', () => { + async function upload(s: MediaService): Promise { + const bytes = Buffer.from('orphan-candidate'); + const { objectKey, uploadUrl } = await s.presignUpload(alice, { mime: 'image/png', sizeBytes: bytes.length }); + await s.put(tokenFrom(uploadUrl), bytes); + return objectKey; + } + + it('put() records a tracking row for the stored object', async () => { + const key = await upload(svc()); + const row = await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } }); + expect(row).not.toBeNull(); + expect(row!.mime).toBe('image/png'); + }); + + it('reaps an object that is past the grace window and unreferenced', async () => { + const s = svc(); + const key = await upload(s); + // Simulate the grace window having elapsed by sweeping "in the future". + const deleted = await s.sweepOrphans(Date.now() + 2 * DAY_MS); + expect(deleted).toBe(1); + expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).toBeNull(); + }); + + it('keeps an object still within the grace window', async () => { + const s = svc(); + const key = await upload(s); + expect(await s.sweepOrphans(Date.now())).toBe(0); + expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull(); + }); + + it('keeps an object a message part references, even past grace', async () => { + const s = svc(); + const key = await upload(s); + const scope = await new ActorResolver(asService).resolveScope(alice); + await prisma.iiosInteraction.create({ + data: { + scopeId: scope.id, + kind: 'MESSAGE', + idempotencyKey: 'ref-1', + parts: { create: [{ partIndex: 0, kind: 'MEDIA_REF', contentRef: key }] }, + }, + }); + expect(await s.sweepOrphans(Date.now() + 2 * DAY_MS)).toBe(0); + expect(await prisma.iiosMediaObject.findUnique({ where: { objectKey: key } })).not.toBeNull(); + }); +}); diff --git a/packages/iios-service/src/media/media.service.ts b/packages/iios-service/src/media/media.service.ts index ad7f8be..eb2cf11 100644 --- a/packages/iios-service/src/media/media.service.ts +++ b/packages/iios-service/src/media/media.service.ts @@ -1,12 +1,15 @@ import { randomUUID } from 'node:crypto'; -import { BadRequestException, ForbiddenException, Inject, Injectable, PayloadTooLargeException } from '@nestjs/common'; +import { BadRequestException, ForbiddenException, Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit, PayloadTooLargeException } from '@nestjs/common'; import jwt from 'jsonwebtoken'; import type { IiosPlatformPorts } from '@insignia/iios-contracts'; import { PLATFORM_PORTS } from '../platform/platform-ports'; +import { PrismaService } from '../prisma/prisma.service'; import { decideOrThrow } from '../platform/fail-closed'; import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver'; import { STORAGE_PORT, type StoragePort } from './storage.port'; +const DAY_MS = 24 * 60 * 60 * 1000; + interface UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number } interface DownloadToken { op: 'get'; objectKey: string; mime: string } @@ -17,16 +20,35 @@ interface DownloadToken { op: 'get'; objectKey: string; mime: string } * kernel only ever stores the object key (contentRef) on a MessagePart. */ @Injectable() -export class MediaService { +export class MediaService implements OnModuleInit, OnModuleDestroy { private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret'; private readonly publicUrl = (process.env.PUBLIC_URL?.trim() || `http://localhost:${process.env.PORT ?? 3200}`).replace(/\/$/, ''); + /** Objects unreferenced by any message part AND older than this are reaped by the sweep. The grace + * window must exceed the longest realistic compose time so an attached-but-unsent file survives. */ + private readonly orphanGraceMs = Number(process.env.IIOS_MEDIA_ORPHAN_GRACE_MS ?? DAY_MS); + private readonly logger = new Logger(MediaService.name); + private gcTimer?: ReturnType; constructor( @Inject(STORAGE_PORT) private readonly storage: StoragePort, @Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts, private readonly actors: ActorResolver, + private readonly prisma: PrismaService, ) {} + onModuleInit(): void { + const ms = Number(process.env.IIOS_MEDIA_GC_INTERVAL_MS ?? 0); + if (ms > 0) { + this.gcTimer = setInterval(() => { + void this.sweepOrphans().catch((err) => this.logger.warn(`media orphan sweep failed: ${(err as Error).message}`)); + }, ms); + } + } + + onModuleDestroy(): void { + if (this.gcTimer) clearInterval(this.gcTimer); + } + /** Authorize an upload and return a short-lived signed PUT url + the object key. */ async presignUpload( principal: MessagePrincipal, @@ -44,9 +66,58 @@ export class MediaService { const t = this.verify(token, 'put'); if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size'); const { sizeBytes, checksumSha256 } = await this.storage.put(t.objectKey, data, t.mime); + // Track the object so the orphan sweep can reap it if it's never referenced by a message part. + // Best-effort: tracking must never fail an otherwise-successful upload. scopeId is the key prefix. + const scopeId = t.objectKey.split('/')[0] ?? 'unknown'; + await this.prisma.iiosMediaObject + .upsert({ + where: { objectKey: t.objectKey }, + create: { scopeId, objectKey: t.objectKey, mime: t.mime, sizeBytes: BigInt(sizeBytes) }, + update: { sizeBytes: BigInt(sizeBytes) }, + }) + .catch((err) => this.logger.warn(`media tracking upsert failed for ${t.objectKey}: ${(err as Error).message}`)); return { objectKey: t.objectKey, sizeBytes, checksumSha256 }; } + /** + * Reap orphaned media: objects older than the grace window that NO message part references. A + * file attached in a composer uploads immediately (direct-to-storage), so an abandoned attach + * (removed, cancelled, tab closed) would otherwise linger forever — there is no draft/commit step. + * "Referenced" is derived live from IiosMessagePart.contentRef, so nothing can drift. Returns the + * number of objects deleted. Safe to call repeatedly (idempotent); storage delete is best-effort. + */ + async sweepOrphans(now: number = Date.now(), batch = 1000): Promise { + const cutoff = new Date(now - this.orphanGraceMs); + const candidates = await this.prisma.iiosMediaObject.findMany({ + where: { createdAt: { lt: cutoff } }, + select: { id: true, objectKey: true }, + take: batch, + }); + if (candidates.length === 0) return 0; + + const keys = candidates.map((c) => c.objectKey); + const referenced = new Set( + ( + await this.prisma.iiosMessagePart.findMany({ + where: { contentRef: { in: keys } }, + select: { contentRef: true }, + }) + ) + .map((p) => p.contentRef) + .filter((ref): ref is string => ref !== null), + ); + + const orphans = candidates.filter((c) => !referenced.has(c.objectKey)); + let deleted = 0; + for (const o of orphans) { + await this.storage.remove(o.objectKey).catch((err) => this.logger.warn(`storage remove failed for ${o.objectKey}: ${(err as Error).message}`)); + await this.prisma.iiosMediaObject.delete({ where: { id: o.id } }).catch(() => undefined); + deleted += 1; + } + if (deleted > 0) this.logger.log(`media orphan sweep reaped ${deleted} object(s)`); + return deleted; + } + /** Authorize a download and return a short-lived signed GET url (tenant-fenced). */ async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> { const scope = await this.actors.resolveScope(principal); diff --git a/packages/iios-service/src/test-utils/reset-db.ts b/packages/iios-service/src/test-utils/reset-db.ts index 7a01cc0..91a09f9 100644 --- a/packages/iios-service/src/test-utils/reset-db.ts +++ b/packages/iios-service/src/test-utils/reset-db.ts @@ -13,7 +13,7 @@ export async function resetDb(prisma: PrismaClient): Promise { "IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow", "IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef", "IiosRouteDecision","IiosRouteBinding","IiosModerationFlag", - "IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket", + "IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket","IiosMediaObject", "IiosTicketStateHistory","IiosTicketThreadLink","IiosCallbackRequest","IiosTicket", "IiosSupportTeamMember","IiosSupportQueue", "IiosInboxItemStateHistory","IiosInboxItem","IiosUnreadCounter","IiosMessageReceipt",