MILWEB®
MILWEB®
MW/006INKVISIONSTUDIO

BEFORETHE NEEDLE.

InkVision — home screen
STUDIO
MILWEB
TYPE
DIGITAL PRODUCT
STATUS
IN DEVELOPMENT

02 / THE IDEA

Tattoo SaaS · AI tattoo simulation

Tattoo clients decide in the dark: they only see how the art looks on skin during the session, needle already in hand.

03 / EXPERIENCE
  1. 01 / 01

    Skin segmentation, perspective and curvature estimation, artwork warp and a low-intensity img2img pass to harmonize shadow and texture. It isn't a single AI call.

    InkVision — 01
  2. 02 / 02

    packages/ai swaps between Fal.ai, Replicate, OpenAI, Gemini or Stable Diffusion with just an env var. No use case imports a concrete provider.

    InkVision — 02
  3. 03 / 03

    A Prisma extension enforcing studioId, Postgres RLS as defense-in-depth, and an automated cross-tenant isolation test suite.

    InkVision — 03
  4. 04 / 04

    A tstzrange() migration only broke on a real migrate deploy (dev uses db push); caught during the first Vercel+Neon deploy, before it could reach the VPS.

    InkVision — 04
Tattoo SaaS · AI tattoo simulation

04 / UNDER THE HOOD

  • 01pnpm + Turborepo monorepo with explicit Clean Architecture: apps/web (Next.js 15, App Router, React 19) covers SSR/ISR for public pages and …+

    pnpm + Turborepo monorepo with explicit Clean Architecture: apps/web (Next.js 15, App Router, React 19) covers SSR/ISR for public pages and Server Actions for mutations, apps/realtime isolates Socket.IO in its own process (long-lived WebSockets don't play well with Next's serverless model) and apps/worker runs async jobs through BullMQ. Business rules never live in routes: they sit in packages/core (pure use cases, no I/O, zod-validated DTOs) which only knows interfaces (ports) implemented by packages/infra (Prisma repositories). That decision would let the HTTP edge be swapped for another framework without touching the domain. Auth uses Better Auth instead of Auth.js because its organization plugin resolves multi-tenant membership natively (platform ADMIN role plus per-studio OWNER/MANAGER/ARTIST; clients are users with no membership).

  • 02Tattoo simulation is the centerpiece feature and was designed as a pipeline, not a single AI call: packages/ai exposes ports (TattooSimulati…+

    Tattoo simulation is the centerpiece feature and was designed as a pipeline, not a single AI call: packages/ai exposes ports (TattooSimulationProvider, ImageGenerationProvider, SkinSegmentationProvider) and a registry that swaps providers (Fal.ai, Replicate, OpenAI, Gemini, Stable Diffusion) with just an env var, with no use case importing a concrete provider. The flow is to segment skin and body part, estimate perspective and curvature, warp the artwork to the scale and position the client chose in the editor (drag, resize, rotate), then finish with a low-intensity img2img pass that blends shadow, texture and lighting while preserving the original line work. Since generation takes 10 to 60 seconds, the API only enqueues a BullMQ job; the worker calls the provider and the result reaches the client over WebSocket (simulation:done event), with no HTTP request left hanging on the AI. Every call writes an AiUsageLog (provider, operation, cost) that feeds both the admin dashboard and per-plan credit limits.

  • 03Multi-tenant isolation is a single database with studioId, protected in three layers instead of a loose filter bolted onto each query. A Pri…+

    Multi-tenant isolation is a single database with studioId, protected in three layers instead of a loose filter bolted onto each query. A Prisma Client extension requires studioId on every tenant-scoped model query: it throws in dev and blocks in prod. Postgres RLS (SET app.current_studio_id) holds even if the application layer fails. And a dedicated test suite attempts cross-tenant access against every route. That redundancy caught a real bug. getActor() was reading the StudioMember table, which is RLS-protected, without opening the tenant or admin context. The policy never matched, the query came back empty even with real memberships, and studio owners ended up seeing a dashboard identical to a regular client's. In local dev the bug never showed, because the Postgres role used there bypasses RLS by default. It only turned up in a visual audit, comparing real screens. The fix was withAdmin(), safe because the final filter is still the caller's own userId. The lesson is blunt: without testing against a production role, with no RLS bypass, this class of bug stays invisible indefinitely.

  • 04The first real deploy (Vercel + Neon, a test environment ahead of the production VPS) surfaced three bugs no earlier environment exercised. …+

    The first real deploy (Vercel + Neon, a test environment ahead of the production VPS) surfaced three bugs no earlier environment exercised. The anti-overbooking constraint migration used tstzrange() on an index. That function depends on the session timezone (it's STABLE, not IMMUTABLE) and breaks on a real migrate deploy. The fix was switching to tsrange(), which matches the column's actual type. Local dev uses prisma db push, which ignores raw SQL, and CI never ran migrations, so the bug stayed invisible until the first real migrate deploy. It would have blocked the VPS deploy too. The Vercel serverless bundle also 'lost' Prisma's query engine inside a pnpm monorepo, fixed with the official workaround plugin, active only when VERCEL=1 so it doesn't touch the VPS's Docker build. And Better Auth's rate limiting used an in-memory limiter, which simply doesn't work across multiple serverless instances. Swapped for database-backed storage: a RateLimit table, deliberately outside RLS, following the same pattern as User and Session, which also aren't multi-tenant.

BUILT WITHNext.js · TypeScript · Prisma · PostgreSQL · Turborepo · StripeCODE

06 / RESULT

AI tattoo simulation · multi-tenant

IN DEVELOPMENT

[ I WANT SOMETHING LIKE THIS ]

NEXT EXPERIENCE14 / LOGISTICS DEMO

EVERY MILEUNDER CONTROL.

Logistics Demo

ALL WORK

ALL WORK