Tattoo SaaS · AI tattoo simulation
InkVision

Problem
Tattoo clients decide in the dark: they only see how the art looks on skin during the session, needle already in hand.
Solution
AI-powered tattoo simulation on the client's own photo, in-app chat with the artist to approve the design, scheduling and a multi-tenant marketplace connecting clients to studios and artists.
SaaS under active development.
How it was built
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).
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.
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.
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.
AI simulation as a pipeline
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.
Pluggable AI provider
packages/ai swaps between Fal.ai, Replicate, OpenAI, Gemini or Stable Diffusion with just an env var. No use case imports a concrete provider.
3-layer multi-tenancy
A Prisma extension enforcing studioId, Postgres RLS as defense-in-depth, and an automated cross-tenant isolation test suite.
Bugs only visible in real production
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.
Stack
- Next.js
- TypeScript
- Prisma
- PostgreSQL
- Turborepo
- Stripe