MotirBuilding in public
MOTIR · moooon
onMotir
You’re viewing a public project. Anyone can view it — no account needed. Sign in to submit, upvote, or comment on requests.View-only — you can’t edit work items
MOTIR-92

2.3.7 Description file upload — Vercel Blob + paste/drop (images inline, other files as links)

Done
Description

Estimate: 16m · Depends on: 2.3.5, 2.3.9

Plugs a real file-upload pipeline into the description editor so paste/drop actually persists. GitHub-comment model (finding #52, decision-authority rung 1 — Motir is a dev-oriented Markdown tool): paste/drop any allowed file; an image embeds inline (![alt](url), it renders in the Markdown body), any other file inserts as a link ([filename](url)) — because Markdown only renders images inline, a non-image can only be a link, never a broken embed. This is NOT a general "attachments panel" (a managed, work_item-linked list with download/delete) — that stays a first-class Epic 5 feature and REUSES this same upload service/route/table. v1 storage = Vercel Blob via the @vercel/blob SDK and the standard handleUpload client-direct-upload pattern (the file goes browser → Blob directly; the server-side endpoint signs the upload URL and records the metadata).

Prerequisite (manual/human): Subtask 2.3.9 — the Vercel Blob store + BLOB_READ_WRITE_TOKEN must be provisioned for live uploads (Yue, dashboard). 2.3.7's CODE ships first behind a placeholder token (build green, the Blob SDK mocked in tests); the feature goes live once 2.3.9 is done. This prerequisite is now an explicit planned subtask per notes.html mistake #30 (plan ALL subtask types, not just code).

Why Vercel Blob, not S3. Decision-authority ladder rung 1 (the mirror product context): we're already on Vercel + Neon (MOTIR.md lines 549–574); adding an S3 bucket means a new IAM principal, a new env-var matrix, and a separate per-PR preview-cleanup story. Blob is in the same Vercel-managed lifecycle as the rest of the stack — provisioned via the Marketplace, env vars auto-wired, per-preview-deploy cleanup handled by the same lifecycle hook 1.1.10 set up. Justified deviation: if/when an enterprise customer needs S3 for compliance, the storage adapter behind lib/blob/uploader.ts is the single swap point.

Endpoint. POST /api/upload/issue-attachment using the @vercel/blob/client handleUpload helper. Returns the public Blob URL + the resolved MIME on success (the client needs the MIME to choose ![] vs []). Gate: session-required; workspaceId resolved from the session (NOT from the client payload); rate-limited per user (~10 uploads / minute v1, a simple in-memory counter is fine pre-Epic-8); max file size 10 MB. Allowed MIME = a general allowlist (NOT image-only): images (image/png|jpeg|gif|webp|svg+xml), application/pdf, plain text / logs (text/plain, text/csv, text/markdown), application/zip, and common office docs — the allowlist lives in lib/blob/allowlist.ts as a single exported set so Epic 5's attachments panel reuses the exact policy. Reject everything else (executables/unknown). Rejections return typed errors: FileTooLargeError 413, UnsupportedFileTypeError 415, RateLimitError 429.

Service + repository. Per CLAUDE.md 4-layer: the route calls attachmentsService.uploadAttachment(file, ctx) — a GENERAL method, not image-specific (the rename from the original card's uploadIssueImage is the point of finding #52: one upload primitive serves the inline-image case AND Epic 5's attachments). The service handles the gates and calls the Blob SDK + writes an attachment row recording the uploader, workspaceId, blob URL, MIME, size, and original filename. Schema addition: new attachment table — id / workspaceId / uploaderUserId / blobUrl / mimeType / sizeBytes / originalFilename / createdAt, with RLS scoped to workspace (mirrors the workflow_status pattern from 2.2.1 — pure workspace gate, no system-admin hatch, no nullable workspaceId, see finding #44). NOT linked to a specific work_item v1 (the markdown image just references the URL — the row is a billing/audit trail). Linking attachments to work_items is Epic 5 (file attachments as a first-class issue feature).

Editor wiring. 2.3.5's MarkdownEditor upload hook generalizes from onImageUploadonFileUpload (and the drop/paste handler stops filtering to images so any allowed file is accepted); the wrapper gains a default implementation when consumers don't pass one — the edit form (2.3.6) and the create modal (2.3.3) both pick it up. The handler uploads, then splices by MIME: image → ![alt](url) (inline), other → [filename](url) (link), at the cursor position. Upload-in-flight surfaces a progress placeholder (![Uploading…](pending) / [Uploading…](pending)) that's replaced on resolve; failure surfaces the typed error message via toast AND leaves the placeholder reverted. Knock-on: rename touches 2.3.5's shipped onImageUpload prop — a small refactor (2.3.3 / 2.3.6 currently rely on the default, don't pass it), update 2.3.5's component test.

Acceptance criteria

  • New migration add_attachment_and_rls creates the attachment table with the documented columns + forced RLS (pure workspace gate per finding #44).
  • @vercel/blob added to package.json; BLOB_READ_WRITE_TOKEN documented in .env.example + the CI workflow (placeholder value sufficient for build).
  • Endpoint POST /api/upload/issue-attachment: thin route, session-required, calls attachmentsService.uploadAttachment; returns blob URL + MIME; typed errors → 413/415/429 mapping.
  • attachmentsService.uploadAttachment (GENERAL, not image-only — finding #52) + attachmentRepository follow the 4-layer; repo write requires tx; service owns the rate-limit + size + MIME gates; the MIME allowlist is the single shared lib/blob/allowlist.ts set (reused by Epic 5).
  • Vitest: happy path writes the row + returns {url, mime}; oversize → 413, disallowed MIME → 415 (assert a non-image allowed type e.g. application/pdf SUCCEEDS, and an executable FAILS), rate-limit fires after threshold; cross-workspace uploader is impossible (workspaceId comes from session).
  • Editor integration test — BOTH branches: paste an image → resolves to ![filename](…) (inline); drop a non-image (e.g. a .pdf) → resolves to [filename](…) (link); each shows the progress placeholder; the failure path reverts + surfaces the typed message.
  • Rendering snapshot in MarkdownView (2.3.5's render path): the image renders as an <img>, the file link as an <a>.
  • 2.3.5's MarkdownEditor prop rename onImageUploadonFileUpload applied; its component test updated; 2.3.3 / 2.3.6 unaffected (they use the default).
  • Existing per-PR cleanup workflow (cleanup-preview-deployments.yml from 1.1.10) is updated to also delete the preview's Blob store; documented in the PR body if Vercel's auto-lifecycle doesn't cover it (verify against the Vercel Blob docs at execution time).
  • STRICT shell-a11y sweep extended to a story page that opens the editor — paste/drop affordances are AA-contrast + keyboard-reachable.

Context refs

  • components/ui/MarkdownEditor.tsx (2.3.5) — the upload hook to fulfill + generalize (onImageUploadonFileUpload); drop/paste handler currently image-filtered
  • Finding #44 (2.2.1's RLS-policy precedent) — pure workspace gate, no system-admin hatch
  • prisma/migrations/.../workflow_status (2.2.1) — the migration shape to mirror for the new table
  • @vercel/blob client-direct-upload docs — verify the exact handleUpload shape at execution time (the API is stable but evolves)
  • 1.1.10's cleanup-preview-deployments.yml — the per-PR cleanup workflow to extend
  • motir-core/CLAUDE.md — 4-layer + entity-naming + tx-required-on-writes