Merge pull request 'Feat/messaging ui foundation' (#7) from feat/messaging-ui-foundation into dev
Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@insignia/iios-messaging-ui",
|
"name": "@insignia/iios-messaging-ui",
|
||||||
"version": "0.1.0",
|
"version": "0.1.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.js",
|
"module": "dist/index.js",
|
||||||
|
|||||||
@@ -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 };
|
return { contentRef: `mock/${this.seq++}`, mimeType: file.type || 'application/octet-stream', sizeBytes: file.size, filename: file.name };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async downloadAttachment(attachment: MailAttachment): Promise<string> {
|
||||||
|
// 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<MailPerson[]> {
|
async directory(): Promise<MailPerson[]> {
|
||||||
return [...PEOPLE];
|
return [...PEOPLE];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export function Composer({
|
|||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [staged, setStaged] = useState<Attachment | null>(null);
|
const [staged, setStaged] = useState<Attachment | null>(null);
|
||||||
const [uploading, setUploading] = useState(false);
|
const [uploading, setUploading] = useState(false);
|
||||||
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const query = trailingMentionQuery(draft);
|
const query = trailingMentionQuery(draft);
|
||||||
@@ -59,12 +60,14 @@ export function Composer({
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setUploading(true);
|
setUploading(true);
|
||||||
|
setUploadingName(file.name);
|
||||||
try {
|
try {
|
||||||
setStaged(await upload(file));
|
setStaged(await upload(file));
|
||||||
} catch {
|
} catch {
|
||||||
/* host surfaces upload errors */
|
/* host surfaces upload errors */
|
||||||
} finally {
|
} finally {
|
||||||
setUploading(false);
|
setUploading(false);
|
||||||
|
setUploadingName(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,7 +113,11 @@ export function Composer({
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
) : null}
|
) : null}
|
||||||
{staged ? (
|
{uploading && uploadingName ? (
|
||||||
|
<div className="miu-staged is-uploading">
|
||||||
|
<span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…
|
||||||
|
</div>
|
||||||
|
) : staged ? (
|
||||||
<div className="miu-staged">
|
<div className="miu-staged">
|
||||||
📎 {staged.name}
|
📎 {staged.name}
|
||||||
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
<button type="button" className="miu-staged-x" onClick={() => setStaged(null)} aria-label="Remove attachment">
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export interface InboxAdapter {
|
|||||||
/** Upload a file to storage, returning a reference to send with a reply/compose. */
|
/** Upload a file to storage, returning a reference to send with a reply/compose. */
|
||||||
uploadAttachment?(file: File): Promise<MailAttachment>;
|
uploadAttachment?(file: File): Promise<MailAttachment>;
|
||||||
|
|
||||||
|
/** Resolve a short-lived URL to view/download an attachment. Absent => attachment chips are
|
||||||
|
* shown but not clickable. */
|
||||||
|
downloadAttachment?(attachment: MailAttachment): Promise<string>;
|
||||||
|
|
||||||
// ── Compose (optional) — absent hides the "New message" affordance ──
|
// ── Compose (optional) — absent hides the "New message" affordance ──
|
||||||
/** People you can compose an in-app message to. */
|
/** People you can compose an in-app message to. */
|
||||||
directory?(): Promise<MailPerson[]>;
|
directory?(): Promise<MailPerson[]>;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export interface MailThreadState {
|
|||||||
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
|
reply: (content: string, attachment?: MailAttachment) => Promise<void>;
|
||||||
canAttach: boolean;
|
canAttach: boolean;
|
||||||
upload: (file: File) => Promise<MailAttachment>;
|
upload: (file: File) => Promise<MailAttachment>;
|
||||||
|
canDownload: boolean;
|
||||||
|
download: (attachment: MailAttachment) => Promise<string>;
|
||||||
refetch: () => void;
|
refetch: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,8 +115,25 @@ export function useMailThread(threadId: string | null): MailThreadState {
|
|||||||
},
|
},
|
||||||
[adapter],
|
[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 {
|
export interface ComposeState {
|
||||||
|
|||||||
@@ -117,19 +117,30 @@ function Detail({ item, onTransition }: { item: InboxItem; onTransition: (state:
|
|||||||
|
|
||||||
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
|
/** Read a mail thread (HTML in a sandboxed iframe) + reply. */
|
||||||
export function MailReader({ threadId, subject }: { threadId: string; subject: string }) {
|
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 [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [pending, setPending] = useState<MailAttachment | null>(null);
|
const [pending, setPending] = useState<MailAttachment | null>(null);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const [attachErr, setAttachErr] = useState<string | null>(null);
|
const [attachErr, setAttachErr] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
async function openAttachment(att: MailAttachment): Promise<void> {
|
||||||
|
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<HTMLInputElement>): Promise<void> {
|
async function pick(e: React.ChangeEvent<HTMLInputElement>): Promise<void> {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setAttaching(true);
|
setAttaching(true);
|
||||||
|
setUploadingName(file.name);
|
||||||
setAttachErr(null);
|
setAttachErr(null);
|
||||||
try {
|
try {
|
||||||
setPending(await upload(file));
|
setPending(await upload(file));
|
||||||
@@ -137,6 +148,7 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
|||||||
setAttachErr(err instanceof Error ? err.message : String(err));
|
setAttachErr(err instanceof Error ? err.message : String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setAttaching(false);
|
setAttaching(false);
|
||||||
|
setUploadingName(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,14 +188,24 @@ export function MailReader({ threadId, subject }: { threadId: string; subject: s
|
|||||||
<div className="miu-mail-text">{m.text}</div>
|
<div className="miu-mail-text">{m.text}</div>
|
||||||
) : null}
|
) : null}
|
||||||
{m.attachment ? (
|
{m.attachment ? (
|
||||||
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
|
canDownload ? (
|
||||||
|
<button type="button" className="miu-attach-chip miu-attach-dl" onClick={() => void openAttachment(m.attachment!)} title="Download">
|
||||||
|
📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''} ↓
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="miu-attach-chip">📎 {m.attachment.filename ?? 'attachment'}{m.attachment.sizeBytes ? ` · ${fmtBytes(m.attachment.sizeBytes)}` : ''}</span>
|
||||||
|
)
|
||||||
) : null}
|
) : null}
|
||||||
</article>
|
</article>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<form className="miu-composer" onSubmit={submit}>
|
<form className="miu-composer" onSubmit={submit}>
|
||||||
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
{attachErr ? <div className="miu-empty miu-error">{attachErr}</div> : null}
|
||||||
{pending ? (
|
{attaching && uploadingName ? (
|
||||||
|
<div className="miu-attach-pending">
|
||||||
|
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
||||||
|
</div>
|
||||||
|
) : pending ? (
|
||||||
<div className="miu-attach-pending">
|
<div className="miu-attach-pending">
|
||||||
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
|
<span className="miu-attach-chip">📎 {pending.filename ?? 'attachment'}{pending.sizeBytes ? ` · ${fmtBytes(pending.sizeBytes)}` : ''}</span>
|
||||||
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment">✕</button>
|
<button type="button" className="miu-attach-x" onClick={() => setPending(null)} aria-label="Remove attachment">✕</button>
|
||||||
@@ -217,6 +239,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
const [err, setErr] = useState<string | null>(null);
|
const [err, setErr] = useState<string | null>(null);
|
||||||
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
const [attachments, setAttachments] = useState<MailAttachment[]>([]);
|
||||||
const [attaching, setAttaching] = useState(false);
|
const [attaching, setAttaching] = useState(false);
|
||||||
|
const [uploadingName, setUploadingName] = useState<string | null>(null);
|
||||||
const fileRef = useRef<HTMLInputElement>(null);
|
const fileRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
const filtered = compose.directory.filter((p) => p.name.toLowerCase().includes(q.trim().toLowerCase()));
|
||||||
@@ -227,6 +250,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
if (!file || attachments.length >= 10) return;
|
if (!file || attachments.length >= 10) return;
|
||||||
setAttaching(true);
|
setAttaching(true);
|
||||||
|
setUploadingName(file.name);
|
||||||
setErr(null);
|
setErr(null);
|
||||||
try {
|
try {
|
||||||
const ref = await compose.upload(file);
|
const ref = await compose.upload(file);
|
||||||
@@ -235,6 +259,7 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
setErr(e2 instanceof Error ? e2.message : String(e2));
|
setErr(e2 instanceof Error ? e2.message : String(e2));
|
||||||
} finally {
|
} finally {
|
||||||
setAttaching(false);
|
setAttaching(false);
|
||||||
|
setUploadingName(null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,9 +333,14 @@ function ComposeModal({ onClose, onSent }: { onClose: () => void; onSent: () =>
|
|||||||
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
<button type="button" className="miu-attach-x" onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))} aria-label="Remove attachment">✕</button>
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
|
{attaching && uploadingName ? (
|
||||||
|
<span className="miu-attach-pending">
|
||||||
|
<span className="miu-attach-chip is-uploading"><span className="miu-spinner" aria-hidden="true" /> {uploadingName} · uploading…</span>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
<input ref={fileRef} type="file" hidden onChange={pick} aria-label="Attach file" />
|
||||||
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
|
<button type="button" className="miu-tab" onClick={() => fileRef.current?.click()} disabled={attaching || attachments.length >= 10}>
|
||||||
{attaching ? 'Uploading…' : '📎 Attach'}
|
📎 Attach
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -353,13 +353,40 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.miu-mail-msg .miu-attach-chip {
|
.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 {
|
.miu-attach-pending {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
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 {
|
.miu-attach-x {
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
@@ -763,7 +790,7 @@
|
|||||||
.miu-detail {
|
.miu-detail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.miu-detail-actions {
|
.miu-detail-actions {
|
||||||
@@ -790,7 +817,7 @@
|
|||||||
.miu-mail {
|
.miu-mail {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.miu-mail-head {
|
.miu-mail-head {
|
||||||
|
|||||||
+20
@@ -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");
|
||||||
@@ -543,6 +543,22 @@ model IiosMessagePart {
|
|||||||
@@unique([interactionId, partIndex])
|
@@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.
|
/// Transactional outbox — written in the same tx as the business row.
|
||||||
model IiosOutboxEvent {
|
model IiosOutboxEvent {
|
||||||
eventId String @id @default(cuid())
|
eventId String @id @default(cuid())
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ const url = process.env.DATABASE_URL ?? 'postgresql://iios:iios@localhost:5434/i
|
|||||||
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
const prisma = new PrismaClient({ datasources: { db: { url } } });
|
||||||
const asService = prisma as unknown as PrismaService;
|
const asService = prisma as unknown as PrismaService;
|
||||||
const ports = { ...makeFakePorts(), opa: new DevOpaPort() } as IiosPlatformPorts;
|
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 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
|
// point storage at an isolated temp dir for the test run
|
||||||
process.env.MEDIA_DIR = process.env.MEDIA_DIR ?? '/tmp/iios-media-test';
|
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();
|
await expect(svc().presignDownload(alice, 'some-other-scope/abc', 'image/png')).rejects.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('MediaService orphan sweep', () => {
|
||||||
|
async function upload(s: MediaService): Promise<string> {
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
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 jwt from 'jsonwebtoken';
|
||||||
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
import type { IiosPlatformPorts } from '@insignia/iios-contracts';
|
||||||
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
import { PLATFORM_PORTS } from '../platform/platform-ports';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { decideOrThrow } from '../platform/fail-closed';
|
import { decideOrThrow } from '../platform/fail-closed';
|
||||||
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
import { ActorResolver, type MessagePrincipal } from '../identity/actor.resolver';
|
||||||
import { STORAGE_PORT, type StoragePort } from './storage.port';
|
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 UploadToken { op: 'put'; objectKey: string; mime: string; maxBytes: number }
|
||||||
interface DownloadToken { op: 'get'; objectKey: string; mime: string }
|
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.
|
* kernel only ever stores the object key (contentRef) on a MessagePart.
|
||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MediaService {
|
export class MediaService implements OnModuleInit, OnModuleDestroy {
|
||||||
private readonly secret = process.env.MEDIA_SECRET?.trim() || 'dev-media-secret';
|
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(/\/$/, '');
|
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<typeof setInterval>;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
|
||||||
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
@Inject(PLATFORM_PORTS) private readonly ports: IiosPlatformPorts,
|
||||||
private readonly actors: ActorResolver,
|
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. */
|
/** Authorize an upload and return a short-lived signed PUT url + the object key. */
|
||||||
async presignUpload(
|
async presignUpload(
|
||||||
principal: MessagePrincipal,
|
principal: MessagePrincipal,
|
||||||
@@ -44,9 +66,58 @@ export class MediaService {
|
|||||||
const t = this.verify<UploadToken>(token, 'put');
|
const t = this.verify<UploadToken>(token, 'put');
|
||||||
if (data.length > t.maxBytes) throw new PayloadTooLargeException('upload exceeds the presigned size');
|
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);
|
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 };
|
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<number> {
|
||||||
|
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). */
|
/** 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 }> {
|
async presignDownload(principal: MessagePrincipal, contentRef: string, mime = 'application/octet-stream'): Promise<{ url: string }> {
|
||||||
const scope = await this.actors.resolveScope(principal);
|
const scope = await this.actors.resolveScope(principal);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ export async function resetDb(prisma: PrismaClient): Promise<void> {
|
|||||||
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
"IiosCalendarSyncCursor","IiosCalendarEvent","IiosCalendarProviderAccount","IiosAvailabilityWindow",
|
||||||
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
"IiosAiEvidenceLink","IiosAiClaim","IiosAiToolCall","IiosAiArtifact","IiosAiModelRun","IiosAiJob","IiosEmbeddingRef",
|
||||||
"IiosRouteDecision","IiosRouteBinding","IiosModerationFlag",
|
"IiosRouteDecision","IiosRouteBinding","IiosModerationFlag",
|
||||||
"IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket",
|
"IiosInboundRawEvent","IiosDeliveryAttempt","IiosOutboundCommand","IiosRateLimitBucket","IiosMediaObject",
|
||||||
"IiosTicketStateHistory","IiosTicketThreadLink","IiosCallbackRequest","IiosTicket",
|
"IiosTicketStateHistory","IiosTicketThreadLink","IiosCallbackRequest","IiosTicket",
|
||||||
"IiosSupportTeamMember","IiosSupportQueue",
|
"IiosSupportTeamMember","IiosSupportQueue",
|
||||||
"IiosInboxItemStateHistory","IiosInboxItem","IiosUnreadCounter","IiosMessageReceipt",
|
"IiosInboxItemStateHistory","IiosInboxItem","IiosUnreadCounter","IiosMessageReceipt",
|
||||||
|
|||||||
Reference in New Issue
Block a user