diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6a7114c..7cbdf4a 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -125,6 +125,7 @@ model Artist { reviews Review[] availability ArtistAvailability[] shopArtists ShopArtist[] + styleDna ArtistStyleDna? @@index([userId]) @@index([slug]) @@ -133,6 +134,24 @@ model Artist { @@index([verified]) } +// per-artist style fingerprint, recomputed weekly from portfolio tattoos +model ArtistStyleDna { + id String @id @default(cuid()) + artistId String @unique + // style weights as { TRADITIONAL: 0.6, JAPANESE: 0.25, ... } summing to ~1 + weights Json + // optional aggregate embedding vector for similarity search. + // stored as float[] until pgvector migration lands. + embedding Float[] + sampleSize Int @default(0) + computedAt DateTime @default(now()) + + artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade) + + @@index([artistId]) + @@index([computedAt]) +} + model Shop { id String @id @default(cuid()) name String @@ -677,3 +696,76 @@ model CollectionItem { @@index([tattooId]) @@index([postId]) } + +// tattoo conventions / events +model Event { + id String @id @default(cuid()) + name String + slug String @unique + description String? @db.Text + venue String? + city String? + country String? + latitude Float? + longitude Float? + startsAt DateTime + endsAt DateTime + websiteUrl String? + coverImage String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + attendees EventAttendance[] + + @@index([slug]) + @@index([startsAt]) + @@index([city]) +} + +model EventAttendance { + id String @id @default(cuid()) + eventId String + userId String + status EventAttendanceStatus @default(GOING) + createdAt DateTime @default(now()) + + event Event @relation(fields: [eventId], references: [id], onDelete: Cascade) + + @@unique([eventId, userId]) + @@index([eventId]) + @@index([userId]) +} + +enum EventAttendanceStatus { + GOING + INTERESTED + NOT_GOING +} + +// web push subscriptions (vapid) +model PushSubscription { + id String @id @default(cuid()) + userId String + endpoint String @unique + p256dh String + authKey String + userAgent String? + createdAt DateTime @default(now()) + + @@index([userId]) +} + +// cached og/oembed link previews (rich previews on posts) +model LinkPreview { + id String @id @default(cuid()) + url String @unique + canonicalUrl String? + title String? + description String? + image String? + siteName String? + fetchedAt DateTime @default(now()) + expiresAt DateTime + + @@index([expiresAt]) +} diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..5866f36 --- /dev/null +++ b/public/sw.js @@ -0,0 +1,48 @@ +// AETCH service worker for web push notifications. +// keep this small + dependency-free so first-load cost is minimal. + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + let payload = { title: 'AETCH', body: 'New activity', url: '/app/notifications' }; + try { + if (event.data) { + const text = event.data.text(); + if (text) payload = { ...payload, ...JSON.parse(text) }; + } + } catch { + // empty payload (tickle) — fall through to default copy + } + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + data: { url: payload.url }, + badge: '/icon.png', + icon: '/icon.png', + }), + ); +}); + +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const url = (event.notification.data && event.notification.data.url) || '/app/notifications'; + event.waitUntil( + self.clients + .matchAll({ type: 'window', includeUncontrolled: true }) + .then((clientList) => { + for (const client of clientList) { + if ('focus' in client) { + client.navigate(url); + return client.focus(); + } + } + return self.clients.openWindow(url); + }), + ); +}); diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts index 7b4f5e7..c179ac9 100644 --- a/src/app/api/admin/users/route.ts +++ b/src/app/api/admin/users/route.ts @@ -8,6 +8,7 @@ import { prisma } from '@/lib/prisma'; import { paginationSchema } from '@/lib/validations'; import { getPaginationParams, buildPaginationMeta } from '@/utils/pagination'; import { recordAuditEvent, clientContext } from '@/backend/services/audit-log-service'; +import { rotateUserSessions } from '@/lib/session-rotate'; // list users (admin only) export const GET = withErrorHandler(async (req: Request) => { @@ -69,12 +70,13 @@ export const PATCH = withErrorHandler(async (req: Request) => { ); } - // remove all roles to disable + // remove all roles to disable + rotate sessions so the user is forced to re-auth if (disabled) { await prisma.user.update({ where: { id: userId }, data: { roles: [] }, }); + await rotateUserSessions(userId); } await recordAuditEvent({ diff --git a/src/app/api/artists/[artistId]/similar/route.ts b/src/app/api/artists/[artistId]/similar/route.ts new file mode 100644 index 0000000..ca7ae53 --- /dev/null +++ b/src/app/api/artists/[artistId]/similar/route.ts @@ -0,0 +1,23 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { findSimilarArtists } from '@/backend/services/style-dna-service'; + +interface Ctx { + params: Promise<{ artistId: string }>; +} + +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + if (!isFeatureEnabled('STYLE_DNA_ENABLED')) { + return withRequestId(req, NextResponse.json({ success: true, similar: [] })); + } + const { params } = ctx as Ctx; + const { artistId } = await params; + const url = new URL(req.url); + const limit = Math.min(20, Math.max(1, Number(url.searchParams.get('limit') ?? 8))); + const similar = await findSimilarArtists(artistId, limit); + return withRequestId(req, NextResponse.json({ success: true, similar })); +}, 'GET /api/artists/[artistId]/similar'); diff --git a/src/app/api/artists/[artistId]/style-dna/route.ts b/src/app/api/artists/[artistId]/style-dna/route.ts new file mode 100644 index 0000000..b73d8ce --- /dev/null +++ b/src/app/api/artists/[artistId]/style-dna/route.ts @@ -0,0 +1,71 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { + getStyleDna, + computeArtistStyleDna, + persistStyleDna, +} from '@/backend/services/style-dna-service'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { prisma } from '@/lib/prisma'; + +interface Ctx { + params: Promise<{ artistId: string }>; +} + +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + if (!isFeatureEnabled('STYLE_DNA_ENABLED')) { + return withRequestId(req, NextResponse.json({ success: true, dna: null })); + } + const { params } = ctx as Ctx; + const { artistId } = await params; + const dna = await getStyleDna(artistId); + return withRequestId(req, NextResponse.json({ success: true, dna })); +}, 'GET /api/artists/[artistId]/style-dna'); + +// owner-triggered recompute +export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { + if (!isFeatureEnabled('STYLE_DNA_ENABLED')) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Style DNA disabled' }, { status: 403 }), + ); + } + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { params } = ctx as Ctx; + const { artistId } = await params; + const artist = await prisma.artist.findUnique({ + where: { id: artistId }, + select: { userId: true }, + }); + if (!artist) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Artist not found' }, { status: 404 }), + ); + } + if (artist.userId !== session.user.id && !session.user.roles.includes('ADMIN')) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Forbidden' }, { status: 403 }), + ); + } + + const result = await computeArtistStyleDna(artistId); + if (result.sampleSize === 0) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: 'Need at least one tattoo with a style' }, + { status: 422 }, + ), + ); + } + await persistStyleDna(result); + return withRequestId(req, NextResponse.json({ success: true, dna: result })); +}, 'POST /api/artists/[artistId]/style-dna'); diff --git a/src/app/api/cron/cleanup/route.ts b/src/app/api/cron/cleanup/route.ts new file mode 100644 index 0000000..9724064 --- /dev/null +++ b/src/app/api/cron/cleanup/route.ts @@ -0,0 +1,60 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { assertCronAuth } from '@/lib/cron'; +import { prisma } from '@/lib/prisma'; +import { logger } from '@/lib/logger'; + +const NOTIFICATION_TTL_DAYS = 60; +const PENDING_BOOKING_TTL_DAYS = 14; +const AI_GENERATION_TTL_DAYS = 30; + +// vercel cron + manual trigger; runs at 03:00 utc daily +export const GET = withErrorHandler(async (req: Request) => { + const unauth = assertCronAuth(req); + if (unauth) return withRequestId(req, unauth); + + const now = Date.now(); + const notifCutoff = new Date(now - NOTIFICATION_TTL_DAYS * 24 * 60 * 60 * 1000); + const bookingCutoff = new Date(now - PENDING_BOOKING_TTL_DAYS * 24 * 60 * 60 * 1000); + const aiCutoff = new Date(now - AI_GENERATION_TTL_DAYS * 24 * 60 * 60 * 1000); + + const [staleNotifications, expiredBookings, staleAi] = await Promise.all([ + prisma.notification.deleteMany({ + where: { read: true, createdAt: { lt: notifCutoff } }, + }), + prisma.booking.updateMany({ + where: { + status: 'PENDING', + date: { lt: bookingCutoff }, + }, + data: { status: 'CANCELLED' }, + }), + prisma.aIGeneration.deleteMany({ + where: { status: 'FAILED', createdAt: { lt: aiCutoff } }, + }), + ]); + + logger.info( + { + staleNotifications: staleNotifications.count, + expiredBookings: expiredBookings.count, + staleAi: staleAi.count, + }, + 'cron cleanup completed', + ); + + return withRequestId( + req, + NextResponse.json({ + success: true, + cleaned: { + notifications: staleNotifications.count, + bookings: expiredBookings.count, + aiGenerations: staleAi.count, + }, + }), + ); +}, 'GET /api/cron/cleanup'); diff --git a/src/app/api/cron/style-dna/route.ts b/src/app/api/cron/style-dna/route.ts new file mode 100644 index 0000000..7052e79 --- /dev/null +++ b/src/app/api/cron/style-dna/route.ts @@ -0,0 +1,16 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { assertCronAuth } from '@/lib/cron'; +import { recomputeAllStyleDna } from '@/backend/services/style-dna-service'; + +// vercel cron: weekly style-dna recompute (sundays 04:00 utc) +export const GET = withErrorHandler(async (req: Request) => { + const unauth = assertCronAuth(req); + if (unauth) return withRequestId(req, unauth); + + const result = await recomputeAllStyleDna(); + return withRequestId(req, NextResponse.json({ success: true, ...result })); +}, 'GET /api/cron/style-dna'); diff --git a/src/app/api/events/[slug]/ics/route.ts b/src/app/api/events/[slug]/ics/route.ts new file mode 100644 index 0000000..a6013cd --- /dev/null +++ b/src/app/api/events/[slug]/ics/route.ts @@ -0,0 +1,33 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { getEventBySlug, eventToIcs } from '@/backend/services/event-service'; + +interface Ctx { + params: Promise<{ slug: string }>; +} + +export const GET = withErrorHandler(async (req: Request, ctx: unknown) => { + const { params } = ctx as Ctx; + const { slug } = await params; + const event = await getEventBySlug(slug); + if (!event) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }), + ); + } + const ics = eventToIcs(event); + return withRequestId( + req, + new NextResponse(ics, { + status: 200, + headers: { + 'content-type': 'text/calendar; charset=utf-8', + 'content-disposition': `attachment; filename="${event.slug}.ics"`, + }, + }), + ); +}, 'GET /api/events/[slug]/ics'); diff --git a/src/app/api/events/[slug]/rsvp/route.ts b/src/app/api/events/[slug]/rsvp/route.ts new file mode 100644 index 0000000..fe7ddb3 --- /dev/null +++ b/src/app/api/events/[slug]/rsvp/route.ts @@ -0,0 +1,57 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { eventRsvpSchema } from '@/lib/validations'; +import { rsvpToEvent, cancelRsvp, getEventBySlug } from '@/backend/services/event-service'; + +interface Ctx { + params: Promise<{ slug: string }>; +} + +export const POST = withErrorHandler(async (req: Request, ctx: unknown) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { params } = ctx as Ctx; + const { slug } = await params; + const event = await getEventBySlug(slug); + if (!event) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }), + ); + } + const body = await req.json(); + const parsed = eventRsvpSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid status' }, + { status: 400 }, + ), + ); + } + const rsvp = await rsvpToEvent(event.id, session.user.id, parsed.data.status); + return withRequestId(req, NextResponse.json({ success: true, rsvp })); +}, 'POST /api/events/[slug]/rsvp'); + +export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { params } = ctx as Ctx; + const { slug } = await params; + const event = await getEventBySlug(slug); + if (!event) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Event not found' }, { status: 404 }), + ); + } + await cancelRsvp(event.id, session.user.id); + return withRequestId(req, NextResponse.json({ success: true })); +}, 'DELETE /api/events/[slug]/rsvp'); diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts new file mode 100644 index 0000000..e6fe401 --- /dev/null +++ b/src/app/api/events/route.ts @@ -0,0 +1,47 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { requireRole } from '@/backend/middleware/role-guard'; +import { createEventSchema, paginationSchema } from '@/lib/validations'; +import { createEvent, listUpcomingEvents } from '@/backend/services/event-service'; +import { getPaginationParams } from '@/utils/pagination'; + +export const GET = withErrorHandler(async (req: Request) => { + const { searchParams } = new URL(req.url); + const parsed = paginationSchema.safeParse({ + page: searchParams.get('page'), + limit: searchParams.get('limit'), + }); + const pagination = getPaginationParams( + parsed.success ? parsed.data.page : 1, + parsed.success ? parsed.data.limit : 20, + ); + const data = await listUpcomingEvents(pagination); + return withRequestId(req, NextResponse.json({ success: true, ...data })); +}, 'GET /api/events'); + +// admin-only event creation for now — public-facing convention listings later +export const POST = withErrorHandler(async (req: Request) => { + const { error } = await requireRole('ADMIN'); + if (error) return withRequestId(req, error); + + const body = await req.json(); + const parsed = createEventSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + const event = await createEvent({ + ...parsed.data, + startsAt: new Date(parsed.data.startsAt), + endsAt: new Date(parsed.data.endsAt), + }); + return withRequestId(req, NextResponse.json({ success: true, event }, { status: 201 })); +}, 'POST /api/events'); diff --git a/src/app/api/link-preview/route.ts b/src/app/api/link-preview/route.ts new file mode 100644 index 0000000..96610cd --- /dev/null +++ b/src/app/api/link-preview/route.ts @@ -0,0 +1,34 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { rateLimit } from '@/backend/middleware/rate-limit'; +import { linkPreviewSchema } from '@/lib/validations'; +import { getLinkPreview } from '@/backend/services/link-preview-service'; + +export const GET = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const rl = await rateLimit(session.user.id, 'api'); + if (!rl.success) return withRequestId(req, rl.error); + + const url = new URL(req.url).searchParams.get('url'); + const parsed = linkPreviewSchema.safeParse({ url }); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid url' }, + { status: 400 }, + ), + ); + } + const preview = await getLinkPreview(parsed.data.url); + if (!preview) { + return withRequestId(req, NextResponse.json({ success: true, preview: null })); + } + return withRequestId(req, NextResponse.json({ success: true, preview })); +}, 'GET /api/link-preview'); diff --git a/src/app/api/longevity/route.ts b/src/app/api/longevity/route.ts new file mode 100644 index 0000000..9ee3a9a --- /dev/null +++ b/src/app/api/longevity/route.ts @@ -0,0 +1,44 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { longevitySchema } from '@/lib/validations'; +import { simulateAgingTimeline } from '@/backend/services/longevity-service'; + +export const POST = withErrorHandler(async (req: Request) => { + if (!isFeatureEnabled('LONGEVITY_ENABLED')) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Longevity disabled' }, { status: 403 }), + ); + } + const { error } = await authGuard(); + if (error) return withRequestId(req, error); + + const body = await req.json(); + const parsed = longevitySchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + const timeline = simulateAgingTimeline({ + lineThickness: parsed.data.lineThickness, + colorPalette: parsed.data.colorPalette, + placement: parsed.data.placement, + style: parsed.data.style, + }); + + return withRequestId( + req, + NextResponse.json({ success: true, imageUrl: parsed.data.imageUrl, timeline }), + ); +}, 'POST /api/longevity'); diff --git a/src/app/api/push/subscribe/route.ts b/src/app/api/push/subscribe/route.ts new file mode 100644 index 0000000..0643ce4 --- /dev/null +++ b/src/app/api/push/subscribe/route.ts @@ -0,0 +1,51 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { pushSubscribeSchema } from '@/lib/validations'; +import { prisma } from '@/lib/prisma'; + +export const POST = withErrorHandler(async (req: Request) => { + if (!isFeatureEnabled('WEB_PUSH_ENABLED')) { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'Web push disabled' }, { status: 403 }), + ); + } + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const body = await req.json(); + const parsed = pushSubscribeSchema.safeParse(body); + if (!parsed.success) { + return withRequestId( + req, + NextResponse.json( + { success: false, error: parsed.error.issues[0]?.message ?? 'Invalid input' }, + { status: 400 }, + ), + ); + } + + await prisma.pushSubscription.upsert({ + where: { endpoint: parsed.data.endpoint }, + create: { + userId: session.user.id, + endpoint: parsed.data.endpoint, + p256dh: parsed.data.keys.p256dh, + authKey: parsed.data.keys.auth, + userAgent: parsed.data.userAgent, + }, + update: { + userId: session.user.id, + p256dh: parsed.data.keys.p256dh, + authKey: parsed.data.keys.auth, + userAgent: parsed.data.userAgent, + }, + }); + + return withRequestId(req, NextResponse.json({ success: true })); +}, 'POST /api/push/subscribe'); diff --git a/src/app/api/push/unsubscribe/route.ts b/src/app/api/push/unsubscribe/route.ts new file mode 100644 index 0000000..2c508e0 --- /dev/null +++ b/src/app/api/push/unsubscribe/route.ts @@ -0,0 +1,24 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { prisma } from '@/lib/prisma'; + +export const POST = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const { endpoint } = await req.json(); + if (typeof endpoint !== 'string') { + return withRequestId( + req, + NextResponse.json({ success: false, error: 'endpoint required' }, { status: 400 }), + ); + } + await prisma.pushSubscription.deleteMany({ + where: { endpoint, userId: session.user.id }, + }); + return withRequestId(req, NextResponse.json({ success: true })); +}, 'POST /api/push/unsubscribe'); diff --git a/src/app/api/shops/map/route.ts b/src/app/api/shops/map/route.ts new file mode 100644 index 0000000..1e590ad --- /dev/null +++ b/src/app/api/shops/map/route.ts @@ -0,0 +1,23 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { listShopsForMap } from '@/backend/services/shop-service'; + +export const GET = withErrorHandler(async (req: Request) => { + const shops = await listShopsForMap(); + // re-shape into geojson-ish features so map clients can render directly + const features = shops.map((s) => ({ + id: s.id, + slug: s.slug, + name: s.name, + city: s.city, + country: s.country, + image: s.image, + verified: s.verified, + lng: s.longitude, + lat: s.latitude, + })); + return withRequestId(req, NextResponse.json({ success: true, shops: features })); +}, 'GET /api/shops/map'); diff --git a/src/app/api/socket/token/route.ts b/src/app/api/socket/token/route.ts new file mode 100644 index 0000000..83a2f8a --- /dev/null +++ b/src/app/api/socket/token/route.ts @@ -0,0 +1,20 @@ +export const runtime = 'nodejs'; + +import { NextResponse } from 'next/server'; +import { authGuard } from '@/backend/middleware/auth-guard'; +import { withErrorHandler } from '@/lib/api-error'; +import { withRequestId } from '@/backend/middleware/request-log'; +import { mintSocketToken } from '@/lib/socket-jwt'; + +// mint a short-lived bearer for the socket handshake. +// client calls this on connect + on reconnect. +export const GET = withErrorHandler(async (req: Request) => { + const { session, error } = await authGuard(); + if (error) return withRequestId(req, error); + + const token = mintSocketToken(session.user.id); + return withRequestId( + req, + NextResponse.json({ success: true, token, expiresIn: 60 }), + ); +}, 'GET /api/socket/token'); diff --git a/src/app/app/ar-preview/page.tsx b/src/app/app/ar-preview/page.tsx index 8624754..c061d63 100644 --- a/src/app/app/ar-preview/page.tsx +++ b/src/app/app/ar-preview/page.tsx @@ -18,6 +18,7 @@ import { PreviewExportButton } from '@/components/features/ar/preview-export-but import { History, ScanEye } from 'lucide-react'; import Link from 'next/link'; import { BODY_PLACEMENTS, PLACEMENT_LABELS } from '@/lib/validations'; +import { PriceEstimateWidget } from '@/components/features/pricing/price-estimate-widget'; const DEFAULT_TRANSFORM: PreviewTransform = { positionX: 50, @@ -158,6 +159,13 @@ export default function ARPreviewPage() { {canPreview && ( )} + + {placement && ( + 1.5 ? 'LARGE' : transform.scale > 0.8 ? 'MEDIUM' : 'SMALL'} + placement={placement} + /> + )} diff --git a/src/app/app/artist/[artistSlug]/page.tsx b/src/app/app/artist/[artistSlug]/page.tsx index 413b2d6..83842f8 100644 --- a/src/app/app/artist/[artistSlug]/page.tsx +++ b/src/app/app/artist/[artistSlug]/page.tsx @@ -1,6 +1,8 @@ import { notFound } from 'next/navigation'; import { getArtistBySlug } from '@/backend/services/artist-service'; import { getArtistTattoos } from '@/backend/services/tattoo-service'; +import { getStyleDna } from '@/backend/services/style-dna-service'; +import { isFeatureEnabled } from '@/lib/feature-flags'; import { getPaginationParams } from '@/utils/pagination'; import { GlassCard } from '@/components/ui/glass-card'; import { GlassAvatar } from '@/components/ui/glass-avatar'; @@ -9,6 +11,7 @@ import { GlassButton } from '@/components/ui/glass-button'; import { PageContainer } from '@/components/layouts/page-container'; import { TattooGrid } from '@/components/features/gallery/tattoo-grid'; import { Pagination } from '@/components/ui/pagination'; +import { StyleDnaChart } from '@/components/features/artists/style-dna-chart'; import Link from 'next/link'; interface Props { @@ -34,7 +37,10 @@ export default async function ArtistProfilePage({ params, searchParams }: Props) const page = Number(resolvedSearchParams.page ?? '1'); const pagination = getPaginationParams(page, 12); - const portfolio = await getArtistTattoos(artist.id, pagination); + const [portfolio, dna] = await Promise.all([ + getArtistTattoos(artist.id, pagination), + isFeatureEnabled('STYLE_DNA_ENABLED') ? getStyleDna(artist.id) : Promise.resolve(null), + ]); return ( @@ -83,6 +89,16 @@ export default async function ArtistProfilePage({ params, searchParams }: Props) + {/* Style DNA */} + {dna && ( +
+ } + sampleSize={dna.sampleSize} + /> +
+ )} + {/* Specialties */} {artist.specialties.length > 0 && ( diff --git a/src/app/app/events/[slug]/page.tsx b/src/app/app/events/[slug]/page.tsx new file mode 100644 index 0000000..ce717d9 --- /dev/null +++ b/src/app/app/events/[slug]/page.tsx @@ -0,0 +1,82 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassButton } from '@/components/ui/glass-button'; +import { GlassBadge } from '@/components/ui/glass-badge'; +import { getEventBySlug } from '@/backend/services/event-service'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { auth } from '@/lib/auth'; +import { EventRsvpControls } from '@/components/features/events/event-rsvp-controls'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateMetadata({ params }: Props) { + const { slug } = await params; + const event = await getEventBySlug(slug); + if (!event) return { title: 'Event not found' }; + return { + title: `${event.name} — AETCH`, + description: event.description ?? `${event.name} on AETCH`, + }; +} + +export default async function EventDetailPage({ params }: Props) { + if (!isFeatureEnabled('EVENTS_ENABLED')) notFound(); + + const { slug } = await params; + const event = await getEventBySlug(slug); + if (!event) notFound(); + + const session = await auth(); + const myRsvp = session?.user + ? event.attendees.find((a) => a.userId === session.user.id)?.status ?? null + : null; + + return ( +
+ +
+
+

{event.name}

+

+ {new Date(event.startsAt).toLocaleString()} →{' '} + {new Date(event.endsAt).toLocaleString()} +

+ {(event.venue || event.city) && ( +

+ {[event.venue, event.city, event.country].filter(Boolean).join(', ')} +

+ )} +
+ {event._count.attendees} going +
+ + {event.description && ( +

+ {event.description} +

+ )} + +
+ {session?.user ? ( + + ) : ( + + Sign in to RSVP + + )} + + Add to calendar (.ics) + + {event.websiteUrl && ( + + Official site → + + )} +
+
+
+ ); +} diff --git a/src/app/app/events/page.tsx b/src/app/app/events/page.tsx new file mode 100644 index 0000000..a704a4c --- /dev/null +++ b/src/app/app/events/page.tsx @@ -0,0 +1,67 @@ +import Link from 'next/link'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassBadge } from '@/components/ui/glass-badge'; +import { listUpcomingEvents } from '@/backend/services/event-service'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { getPaginationParams } from '@/utils/pagination'; + +export const metadata = { + title: 'Tattoo Events & Conventions — AETCH', + description: 'Upcoming tattoo conventions, expos and meet-ups around the world.', +}; + +export default async function EventsPage() { + if (!isFeatureEnabled('EVENTS_ENABLED')) { + return ( +
+ +

Events

+

Coming soon. Set FF_EVENTS=true to enable.

+
+
+ ); + } + + const { events } = await listUpcomingEvents(getPaginationParams(1, 24)); + + return ( +
+
+

Tattoo Conventions

+

Upcoming events around the world.

+
+ + {events.length === 0 ? ( + +

No upcoming events yet.

+
+ ) : ( +
+ {events.map((event) => ( + + +
+

{event.name}

+ + {event._count.attendees} going + +
+

+ {new Date(event.startsAt).toLocaleDateString(undefined, { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + {event.city && ` · ${event.city}`} +

+ {event.description && ( +

{event.description}

+ )} +
+ + ))} +
+ )} +
+ ); +} diff --git a/src/app/app/learn/[slug]/page.tsx b/src/app/app/learn/[slug]/page.tsx new file mode 100644 index 0000000..efa32df --- /dev/null +++ b/src/app/app/learn/[slug]/page.tsx @@ -0,0 +1,48 @@ +import { notFound } from 'next/navigation'; +import Link from 'next/link'; +import { GlassCard } from '@/components/ui/glass-card'; +import { getLearnEntry, renderMarkdown, listLearnEntries } from '@/backend/services/learn-service'; +import { isFeatureEnabled } from '@/lib/feature-flags'; + +interface Props { + params: Promise<{ slug: string }>; +} + +export async function generateStaticParams() { + const entries = await listLearnEntries(); + return entries.map((e) => ({ slug: e.slug })); +} + +export async function generateMetadata({ params }: Props) { + const { slug } = await params; + const entry = await getLearnEntry(slug); + if (!entry) return { title: 'Not found' }; + return { + title: `${entry.title} — AETCH Learn`, + description: entry.description, + }; +} + +export default async function LearnEntryPage({ params }: Props) { + if (!isFeatureEnabled('LEARN_ENABLED')) notFound(); + + const { slug } = await params; + const entry = await getLearnEntry(slug); + if (!entry) notFound(); + + const html = renderMarkdown(entry.body); + + return ( +
+ + ← Back to Learn + + +
+ +
+ ); +} diff --git a/src/app/app/learn/page.tsx b/src/app/app/learn/page.tsx new file mode 100644 index 0000000..5accf50 --- /dev/null +++ b/src/app/app/learn/page.tsx @@ -0,0 +1,48 @@ +import Link from 'next/link'; +import { GlassCard } from '@/components/ui/glass-card'; +import { listLearnEntries } from '@/backend/services/learn-service'; +import { isFeatureEnabled } from '@/lib/feature-flags'; + +export const metadata = { + title: 'Tattoo Learn — AETCH', + description: 'Tattoo education hub: prep, aftercare, styles, and more.', +}; + +export default async function LearnPage() { + if (!isFeatureEnabled('LEARN_ENABLED')) { + return ( +
+ +

Learn

+

Coming soon. Set FF_LEARN=true to enable.

+
+
+ ); + } + + const entries = await listLearnEntries(); + + return ( +
+
+

Tattoo Learn

+

+ Short, opinionated guides curated by AETCH artists. +

+
+ +
+ {entries.map((entry) => ( + + +

{entry.title}

+ {entry.description && ( +

{entry.description}

+ )} +
+ + ))} +
+
+ ); +} diff --git a/src/app/app/longevity/page.tsx b/src/app/app/longevity/page.tsx new file mode 100644 index 0000000..a3e88c5 --- /dev/null +++ b/src/app/app/longevity/page.tsx @@ -0,0 +1,40 @@ +import { redirect } from 'next/navigation'; +import { auth } from '@/lib/auth'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { GlassCard } from '@/components/ui/glass-card'; +import { LongevitySimulator } from '@/components/features/longevity/longevity-simulator'; + +export const dynamic = 'force-dynamic'; + +export const metadata = { + title: 'Longevity Simulator — AETCH', + description: 'See how a tattoo will age over 1, 5, and 10 years.', +}; + +export default async function LongevityPage() { + const session = await auth(); + if (!session?.user) redirect('/login?callbackUrl=/app/longevity'); + + if (!isFeatureEnabled('LONGEVITY_ENABLED')) { + return ( +
+ +

Longevity Simulator

+

+ This feature is coming soon. Set FF_LONGEVITY=true to enable it. +

+
+
+ ); + } + + return ( +
+
+

Longevity Simulator

+

Preview how a tattoo will age over time.

+
+ +
+ ); +} diff --git a/src/app/app/map/page.tsx b/src/app/app/map/page.tsx new file mode 100644 index 0000000..340c513 --- /dev/null +++ b/src/app/app/map/page.tsx @@ -0,0 +1,35 @@ +import { GlassCard } from '@/components/ui/glass-card'; +import { ShopsMap } from '@/components/features/map/shops-map'; +import { isFeatureEnabled } from '@/lib/feature-flags'; + +export const metadata = { + title: 'Tattoo Shops Map — AETCH', + description: 'Discover tattoo shops near you on the global AETCH map.', +}; + +export default function MapPage() { + if (!isFeatureEnabled('MAP_ENABLED')) { + return ( +
+ +

Shops Map

+

+ This feature is coming soon. Set FF_MAP=true to enable it. +

+
+
+ ); + } + + const styleUrl = process.env.NEXT_PUBLIC_MAP_STYLE_URL; + + return ( +
+
+

Tattoo Shops

+

Browse studios on the global map.

+
+ +
+ ); +} diff --git a/src/backend/realtime/socket-server.ts b/src/backend/realtime/socket-server.ts index 64ef277..ea18044 100644 --- a/src/backend/realtime/socket-server.ts +++ b/src/backend/realtime/socket-server.ts @@ -1,5 +1,6 @@ import { Server as HttpServer } from 'http'; import { Server, Socket } from 'socket.io'; +import { verifySocketToken } from '@/lib/socket-jwt'; export const runtime = 'nodejs'; @@ -44,6 +45,25 @@ export interface NotificationEvent { // user id to socket mapping const userSockets = new Map>(); +// prefer short-lived jwt; fall back to legacy `userId` only when SOCKET_JWT_SECRET +// is unset (dev-only convenience). production should always require a token. +function resolveUserId(socket: Socket): string | null { + const token = (socket.handshake.auth.token ?? socket.handshake.query.token) as + | string + | undefined; + if (token) { + try { + const payload = verifySocketToken(token); + return payload?.sub ?? null; + } catch { + return null; + } + } + if (process.env.SOCKET_JWT_SECRET) return null; + const userId = socket.handshake.auth.userId as string | undefined; + return userId ?? null; +} + // initialize socket server export function initSocketServer(httpServer: HttpServer): Server { if (io) return io; @@ -58,7 +78,7 @@ export function initSocketServer(httpServer: HttpServer): Server { }); io.on('connection', (socket: Socket) => { - const userId = socket.handshake.auth.userId as string; + const userId = resolveUserId(socket); if (!userId) { socket.disconnect(); diff --git a/src/backend/services/event-service.ts b/src/backend/services/event-service.ts new file mode 100644 index 0000000..ef6a250 --- /dev/null +++ b/src/backend/services/event-service.ts @@ -0,0 +1,106 @@ +import { prisma } from '@/lib/prisma'; +import { type PaginationParams, buildPaginationMeta } from '@/utils/pagination'; +import type { EventAttendanceStatus } from '@prisma/client'; + +interface CreateEventInput { + name: string; + slug: string; + description?: string; + venue?: string; + city?: string; + country?: string; + latitude?: number; + longitude?: number; + startsAt: Date; + endsAt: Date; + websiteUrl?: string; + coverImage?: string; +} + +export async function createEvent(data: CreateEventInput) { + return prisma.event.create({ data }); +} + +export async function listUpcomingEvents(pagination: PaginationParams) { + const where = { endsAt: { gte: new Date() } } as const; + const [events, total] = await Promise.all([ + prisma.event.findMany({ + where, + orderBy: { startsAt: 'asc' }, + skip: pagination.skip, + take: pagination.limit, + include: { + _count: { select: { attendees: true } }, + }, + }), + prisma.event.count({ where }), + ]); + return { events, pagination: buildPaginationMeta(total, pagination) }; +} + +export async function getEventBySlug(slug: string) { + return prisma.event.findUnique({ + where: { slug }, + include: { + attendees: { + take: 50, + orderBy: { createdAt: 'desc' }, + }, + _count: { select: { attendees: true } }, + }, + }); +} + +export async function rsvpToEvent(eventId: string, userId: string, status: EventAttendanceStatus) { + return prisma.eventAttendance.upsert({ + where: { eventId_userId: { eventId, userId } }, + create: { eventId, userId, status }, + update: { status }, + }); +} + +export async function cancelRsvp(eventId: string, userId: string) { + return prisma.eventAttendance.deleteMany({ + where: { eventId, userId }, + }); +} + +// build an RFC 5545 ICS calendar entry +export function eventToIcs(event: { + id: string; + name: string; + description?: string | null; + venue?: string | null; + city?: string | null; + startsAt: Date; + endsAt: Date; + websiteUrl?: string | null; +}): string { + const fmt = (d: Date) => + d.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, '').slice(0, 15) + 'Z'; + const escape = (s?: string | null) => + s + ?.replace(/\\/g, '\\\\') + .replace(/\n/g, '\\n') + .replace(/,/g, '\\,') + .replace(/;/g, '\\;') ?? ''; + return [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//AETCH//Tattoo Events//EN', + 'CALSCALE:GREGORIAN', + 'BEGIN:VEVENT', + `UID:${event.id}@aetch.app`, + `DTSTAMP:${fmt(new Date())}`, + `DTSTART:${fmt(event.startsAt)}`, + `DTEND:${fmt(event.endsAt)}`, + `SUMMARY:${escape(event.name)}`, + event.description ? `DESCRIPTION:${escape(event.description)}` : '', + event.venue || event.city ? `LOCATION:${escape([event.venue, event.city].filter(Boolean).join(', '))}` : '', + event.websiteUrl ? `URL:${escape(event.websiteUrl)}` : '', + 'END:VEVENT', + 'END:VCALENDAR', + ] + .filter(Boolean) + .join('\r\n'); +} diff --git a/src/backend/services/learn-service.ts b/src/backend/services/learn-service.ts new file mode 100644 index 0000000..6b00bb9 --- /dev/null +++ b/src/backend/services/learn-service.ts @@ -0,0 +1,91 @@ +import { promises as fs } from 'fs'; +import path from 'path'; + +export interface LearnEntry { + slug: string; + title: string; + description: string; + order: number; + body: string; +} + +const CONTENT_DIR = path.join(process.cwd(), 'src', 'content', 'learn'); + +export async function listLearnEntries(): Promise { + const files = await fs.readdir(CONTENT_DIR).catch(() => [] as string[]); + const entries: LearnEntry[] = []; + for (const file of files) { + if (!file.endsWith('.md')) continue; + const raw = await fs.readFile(path.join(CONTENT_DIR, file), 'utf8'); + entries.push(parseEntry(raw, file.replace(/\.md$/, ''))); + } + return entries.sort((a, b) => a.order - b.order); +} + +export async function getLearnEntry(slug: string): Promise { + const safe = slug.replace(/[^a-z0-9-]/gi, ''); + if (!safe) return null; + const filePath = path.join(CONTENT_DIR, `${safe}.md`); + const raw = await fs.readFile(filePath, 'utf8').catch(() => null); + if (!raw) return null; + return parseEntry(raw, safe); +} + +function parseEntry(raw: string, fallbackSlug: string): LearnEntry { + const fmMatch = raw.match(/^---\n([\s\S]*?)\n---\n?/); + const meta: Record = {}; + let body = raw; + if (fmMatch) { + body = raw.slice(fmMatch[0].length); + for (const line of fmMatch[1].split('\n')) { + const idx = line.indexOf(':'); + if (idx === -1) continue; + const key = line.slice(0, idx).trim(); + const value = line.slice(idx + 1).trim(); + meta[key] = value; + } + } + return { + slug: meta.slug ?? fallbackSlug, + title: meta.title ?? fallbackSlug, + description: meta.description ?? '', + order: Number(meta.order ?? 99), + body, + }; +} + +// minimal markdown → html. handles headings, lists, paragraphs, bold, code, and inline links. +export function renderMarkdown(md: string): string { + let html = md; + html = html + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); + + html = html.replace(/^# (.*)$/gm, '

$1

'); + html = html.replace(/^## (.*)$/gm, '

$1

'); + html = html.replace(/^### (.*)$/gm, '

$1

'); + + html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1'); + html = html.replace(/`([^`]+)`/g, '$1'); + html = html.replace(/\*\*([^*]+)\*\*/g, '$1'); + + html = html.replace(/((?:^- .+\n?)+)/gm, (block) => { + const items = block + .trim() + .split('\n') + .map((line) => `
  • ${line.replace(/^- /, '')}
  • `) + .join(''); + return `
      ${items}
    `; + }); + + html = html + .split(/\n{2,}/) + .map((para) => + para.startsWith('${para}

    `, + ) + .join('\n'); + + return html; +} diff --git a/src/backend/services/link-preview-service.ts b/src/backend/services/link-preview-service.ts new file mode 100644 index 0000000..02db5fe --- /dev/null +++ b/src/backend/services/link-preview-service.ts @@ -0,0 +1,152 @@ +import { prisma } from '@/lib/prisma'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { safeFetch } from '@/lib/ssrf'; +import { logger } from '@/lib/logger'; + +const CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const FETCH_BYTE_CAP = 200_000; + +interface PreviewData { + url: string; + canonicalUrl?: string; + title?: string; + description?: string; + image?: string; + siteName?: string; +} + +export async function getLinkPreview(rawUrl: string): Promise { + if (!isFeatureEnabled('LINK_PREVIEWS_ENABLED')) return null; + + const url = normalizeUrl(rawUrl); + if (!url) return null; + + const cached = await prisma.linkPreview.findUnique({ where: { url } }); + if (cached && cached.expiresAt > new Date()) { + return toPreviewData(cached); + } + + let preview: PreviewData; + try { + preview = await fetchPreview(url); + } catch (err) { + logger.warn({ err, url }, 'link preview fetch failed'); + return null; + } + + await prisma.linkPreview.upsert({ + where: { url }, + create: { + url, + canonicalUrl: preview.canonicalUrl, + title: preview.title, + description: preview.description, + image: preview.image, + siteName: preview.siteName, + expiresAt: new Date(Date.now() + CACHE_TTL_MS), + }, + update: { + canonicalUrl: preview.canonicalUrl, + title: preview.title, + description: preview.description, + image: preview.image, + siteName: preview.siteName, + fetchedAt: new Date(), + expiresAt: new Date(Date.now() + CACHE_TTL_MS), + }, + }); + + return preview; +} + +function normalizeUrl(rawUrl: string): string | null { + try { + const u = new URL(rawUrl); + u.hash = ''; + return u.toString(); + } catch { + return null; + } +} + +async function fetchPreview(url: string): Promise { + const res = await safeFetch(url, { + maxBytes: FETCH_BYTE_CAP, + headers: { + accept: 'text/html,application/xhtml+xml', + 'user-agent': 'AetchBot/1.0 (link preview)', + }, + }); + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.includes('html')) { + return { url }; + } + const text = (await res.text()).slice(0, FETCH_BYTE_CAP); + return { url, ...parseMetadata(text, url) }; +} + +function parseMetadata(html: string, baseUrl: string): Omit { + const get = (re: RegExp) => { + const m = html.match(re); + return m ? decodeHtml(m[1].trim()) : undefined; + }; + + const ogTitle = get(/]+property=["']og:title["'][^>]+content=["']([^"']+)["']/i); + const ogDesc = get(/]+property=["']og:description["'][^>]+content=["']([^"']+)["']/i); + const ogImage = get(/]+property=["']og:image["'][^>]+content=["']([^"']+)["']/i); + const ogSite = get(/]+property=["']og:site_name["'][^>]+content=["']([^"']+)["']/i); + const twImage = get(/]+name=["']twitter:image["'][^>]+content=["']([^"']+)["']/i); + const desc = get(/]+name=["']description["'][^>]+content=["']([^"']+)["']/i); + const title = get(/]*>([^<]+)<\/title>/i); + const canonical = get(/]+rel=["']canonical["'][^>]+href=["']([^"']+)["']/i); + + const image = ogImage ?? twImage; + const absoluteImage = image ? toAbsolute(image, baseUrl) : undefined; + const absoluteCanonical = canonical ? toAbsolute(canonical, baseUrl) : undefined; + + return { + canonicalUrl: absoluteCanonical, + title: ogTitle ?? title, + description: ogDesc ?? desc, + image: absoluteImage, + siteName: ogSite, + }; +} + +function decodeHtml(s: string): string { + return s + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); +} + +function toAbsolute(href: string, base: string): string { + try { + return new URL(href, base).toString(); + } catch { + return href; + } +} + +interface DbPreview { + url: string; + canonicalUrl: string | null; + title: string | null; + description: string | null; + image: string | null; + siteName: string | null; +} + +function toPreviewData(row: DbPreview): PreviewData { + return { + url: row.url, + canonicalUrl: row.canonicalUrl ?? undefined, + title: row.title ?? undefined, + description: row.description ?? undefined, + image: row.image ?? undefined, + siteName: row.siteName ?? undefined, + }; +} diff --git a/src/backend/services/longevity-service.ts b/src/backend/services/longevity-service.ts new file mode 100644 index 0000000..9920e21 --- /dev/null +++ b/src/backend/services/longevity-service.ts @@ -0,0 +1,117 @@ +// v0 longevity simulator — derives aged-tattoo CSS filter parameters from +// inputs (placement, style, line thickness, color palette). v1 will swap to +// stable diffusion img2img with an aged-tattoo lora. + +export type AgeYears = 1 | 5 | 10; +export type LineThickness = 'fine' | 'medium' | 'bold'; +export type ColorPalette = 'COLOR' | 'BLACK_AND_GREY' | 'MIXED'; + +interface SimulateInput { + ageYears: AgeYears; + lineThickness?: LineThickness; + colorPalette?: ColorPalette; + placement?: string; + style?: string; +} + +export interface AgedFilter { + ageYears: AgeYears; + blurPx: number; + brightness: number; + contrast: number; + saturate: number; + sepia: number; + // svg displacement seed for irregular fade + noiseSeed: number; + notes: string[]; +} + +// placements with high friction or sun exposure age tattoos faster +const PLACEMENT_AGING_BIAS: Record = { + hand: 1.4, + finger: 1.6, + foot: 1.4, + ankle: 1.2, + neck: 1.15, + ribs: 1.05, + chest: 1.0, + shoulder: 0.95, + back: 0.9, + thigh: 0.92, + forearm: 0.95, +}; + +// styles with delicate work degrade faster +const STYLE_AGING_BIAS: Record = { + FINE_LINE: 1.3, + MINIMALIST: 1.2, + WATERCOLOR: 1.4, + REALISM: 1.1, + TRADITIONAL: 0.85, + BLACKWORK: 0.85, + TRIBAL: 0.85, + JAPANESE: 0.9, +}; + +const LINE_THICKNESS_BIAS: Record = { + fine: 1.25, + medium: 1.0, + bold: 0.8, +}; + +// color tattoos lose vibrancy faster than black-and-grey +const COLOR_BIAS: Record = { + COLOR: 1.2, + MIXED: 1.05, + BLACK_AND_GREY: 0.9, +}; + +export function simulateAging(input: SimulateInput): AgedFilter { + const placementBias = input.placement ? (PLACEMENT_AGING_BIAS[input.placement] ?? 1) : 1; + const styleBias = input.style ? (STYLE_AGING_BIAS[input.style] ?? 1) : 1; + const thicknessBias = LINE_THICKNESS_BIAS[input.lineThickness ?? 'medium']; + const colorBias = COLOR_BIAS[input.colorPalette ?? 'BLACK_AND_GREY']; + + const composite = placementBias * styleBias * thicknessBias * colorBias; + + // base aging curve — log-like fade rather than linear + const ageFactor = Math.log10(1 + input.ageYears) * composite; + + const blurPx = round2(0.3 + ageFactor * 0.7); + const brightness = round2(1 + ageFactor * 0.05); + const contrast = round2(Math.max(0.6, 1 - ageFactor * 0.18)); + const saturate = round2(Math.max(0.4, 1 - ageFactor * 0.32)); + const sepia = round2(Math.min(0.4, ageFactor * 0.12)); + + const notes = buildNotes(input, composite); + return { + ageYears: input.ageYears, + blurPx, + brightness, + contrast, + saturate, + sepia, + noiseSeed: Math.floor(input.ageYears * 137 + composite * 1000), + notes, + }; +} + +function buildNotes(input: SimulateInput, composite: number): string[] { + const notes: string[] = []; + if (input.lineThickness === 'fine') notes.push('Fine lines tend to soften within 2-3 years.'); + if (input.colorPalette === 'COLOR') + notes.push('Color saturation fades faster than black-and-grey.'); + if (input.placement && (PLACEMENT_AGING_BIAS[input.placement] ?? 1) > 1.1) + notes.push('High-friction placement accelerates fading.'); + if (composite > 1.3) notes.push('Aftercare and sunscreen will matter a lot here.'); + return notes; +} + +export function simulateAgingTimeline(input: Omit): AgedFilter[] { + const ages: AgeYears[] = [1, 5, 10]; + return ages.map((ageYears) => simulateAging({ ...input, ageYears })); +} + +function round2(n: number): number { + return Math.round(n * 100) / 100; +} diff --git a/src/backend/services/notification-service.ts b/src/backend/services/notification-service.ts index 3feab2a..bd04429 100644 --- a/src/backend/services/notification-service.ts +++ b/src/backend/services/notification-service.ts @@ -1,5 +1,8 @@ import { prisma } from '@/lib/prisma'; import type { NotificationType } from '@prisma/client'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { sendPushTickle, vapidConfigured } from '@/lib/web-push'; +import { logger } from '@/lib/logger'; export async function createNotification(data: { userId: string; @@ -9,7 +12,28 @@ export async function createNotification(data: { bookingId?: string; link?: string; }) { - return prisma.notification.create({ data }); + const notification = await prisma.notification.create({ data }); + if (isFeatureEnabled('WEB_PUSH_ENABLED') && vapidConfigured()) { + pushFanout(data.userId).catch((err) => + logger.warn({ err, userId: data.userId }, 'web push fanout failed'), + ); + } + return notification; +} + +async function pushFanout(userId: string): Promise { + const subs = await prisma.pushSubscription.findMany({ where: { userId } }); + for (const sub of subs) { + const ok = await sendPushTickle({ + endpoint: sub.endpoint, + p256dh: sub.p256dh, + authKey: sub.authKey, + }); + if (!ok) { + // dead subscription — purge so we stop pushing to it + await prisma.pushSubscription.deleteMany({ where: { endpoint: sub.endpoint } }); + } + } } export async function getUserNotifications(userId: string, limit = 20) { diff --git a/src/backend/services/shop-service.ts b/src/backend/services/shop-service.ts index c6d30ac..8e36bad 100644 --- a/src/backend/services/shop-service.ts +++ b/src/backend/services/shop-service.ts @@ -49,6 +49,28 @@ export async function getShopByOwnerId(ownerId: string) { }); } +// shops with lat/lng for the global map. caps at 5000 to stay light on the wire. +export async function listShopsForMap() { + return prisma.shop.findMany({ + where: { + latitude: { not: null }, + longitude: { not: null }, + }, + select: { + id: true, + slug: true, + name: true, + city: true, + country: true, + latitude: true, + longitude: true, + image: true, + verified: true, + }, + take: 5000, + }); +} + // list distinct cities — feeds /app/shops/[city] sitemap export async function listShopCities(limit = 200) { const rows = await prisma.shop.findMany({ diff --git a/src/backend/services/style-dna-service.ts b/src/backend/services/style-dna-service.ts new file mode 100644 index 0000000..9f28573 --- /dev/null +++ b/src/backend/services/style-dna-service.ts @@ -0,0 +1,158 @@ +import { prisma } from '@/lib/prisma'; +import { isFeatureEnabled } from '@/lib/feature-flags'; +import { logger } from '@/lib/logger'; +import type { TattooStyle } from '@prisma/client'; + +interface DnaWeights { + [style: string]: number; +} + +interface DnaResult { + artistId: string; + weights: DnaWeights; + sampleSize: number; + embedding: number[]; +} + +const STYLE_AXES: TattooStyle[] = [ + 'TRADITIONAL', + 'NEO_TRADITIONAL', + 'JAPANESE', + 'BLACKWORK', + 'FINE_LINE', + 'MINIMALIST', + 'REALISM', + 'TRIBAL', + 'BIOMECHANICAL', + 'ABSTRACT', + 'WATERCOLOR', + 'GEOMETRIC', + 'DOTWORK', + 'CHICANO', + 'OTHER', +]; + +// aggregate style weights from an artist's portfolio. +// each tattoo contributes to its declared styles + likes-weighted importance. +export async function computeArtistStyleDna(artistId: string): Promise { + const tattoos = await prisma.tattoo.findMany({ + where: { artistId }, + select: { styles: true, likesCount: true }, + }); + + const totals: Record = {}; + let totalWeight = 0; + + for (const t of tattoos) { + const importance = 1 + Math.log10(1 + (t.likesCount ?? 0)); + for (const style of t.styles) { + totals[style] = (totals[style] ?? 0) + importance; + totalWeight += importance; + } + } + + const weights: DnaWeights = {}; + if (totalWeight > 0) { + for (const [style, sum] of Object.entries(totals)) { + weights[style] = round3(sum / totalWeight); + } + } + + // 15-dim embedding vector aligned to STYLE_AXES — placeholder for clip image embeddings + const embedding = STYLE_AXES.map((style) => weights[style] ?? 0); + + return { + artistId, + weights, + sampleSize: tattoos.length, + embedding, + }; +} + +export async function persistStyleDna(result: DnaResult) { + await prisma.artistStyleDna.upsert({ + where: { artistId: result.artistId }, + create: { + artistId: result.artistId, + weights: result.weights, + embedding: result.embedding, + sampleSize: result.sampleSize, + }, + update: { + weights: result.weights, + embedding: result.embedding, + sampleSize: result.sampleSize, + computedAt: new Date(), + }, + }); +} + +export async function getStyleDna(artistId: string) { + return prisma.artistStyleDna.findUnique({ where: { artistId } }); +} + +// recompute every artist's style DNA — called from /api/cron/style-dna weekly +export async function recomputeAllStyleDna(): Promise<{ processed: number }> { + if (!isFeatureEnabled('STYLE_DNA_ENABLED')) { + logger.info('style-dna recompute skipped — flag off'); + return { processed: 0 }; + } + const artists = await prisma.artist.findMany({ select: { id: true } }); + let processed = 0; + for (const a of artists) { + try { + const result = await computeArtistStyleDna(a.id); + if (result.sampleSize > 0) { + await persistStyleDna(result); + processed += 1; + } + } catch (err) { + logger.warn({ err, artistId: a.id }, 'style-dna compute failed'); + } + } + return { processed }; +} + +// rank artists by cosine similarity to a target dna vector +export async function findSimilarArtists(artistId: string, limit = 8) { + const target = await getStyleDna(artistId); + if (!target || target.embedding.length === 0) return []; + const all = await prisma.artistStyleDna.findMany({ + where: { artistId: { not: artistId } }, + include: { + artist: { + select: { id: true, slug: true, displayName: true, user: { select: { image: true } } }, + }, + }, + }); + type Row = (typeof all)[number]; + const scored = all + .map((row: Row) => ({ + artist: row.artist, + score: cosineSimilarity(target.embedding, row.embedding), + })) + .filter((row: { score: number }) => row.score > 0) + .sort((a: { score: number }, b: { score: number }) => b.score - a.score) + .slice(0, limit); + return scored; +} + +function cosineSimilarity(a: number[], b: number[]): number { + if (a.length !== b.length) return 0; + let dot = 0; + let na = 0; + let nb = 0; + for (let i = 0; i < a.length; i++) { + dot += a[i] * b[i]; + na += a[i] * a[i]; + nb += b[i] * b[i]; + } + if (na === 0 || nb === 0) return 0; + return dot / Math.sqrt(na * nb); +} + +function round3(n: number): number { + return Math.round(n * 1000) / 1000; +} + +export const STYLE_DNA_AXES = STYLE_AXES; diff --git a/src/backend/services/user-service.ts b/src/backend/services/user-service.ts index 1d7a600..ce1ab9c 100644 --- a/src/backend/services/user-service.ts +++ b/src/backend/services/user-service.ts @@ -1,7 +1,8 @@ import { prisma } from '@/lib/prisma'; import type { UserRole } from '@prisma/client'; +import { rotateUserSessions } from '@/lib/session-rotate'; -// add role to user +// add role to user — rotates sessions so new role takes effect immediately export async function addUserRole(userId: string, role: UserRole) { const user = await prisma.user.findUnique({ where: { id: userId }, @@ -9,15 +10,20 @@ export async function addUserRole(userId: string, role: UserRole) { }); if (!user) throw new Error('User not found'); - const roles = user.roles.includes(role) ? user.roles : [...user.roles, role]; + if (user.roles.includes(role)) { + return prisma.user.update({ where: { id: userId }, data: {} }); + } - return prisma.user.update({ + const roles = [...user.roles, role]; + const updated = await prisma.user.update({ where: { id: userId }, data: { roles }, }); + await rotateUserSessions(userId); + return updated; } -// remove role from user +// remove role from user — rotates sessions so revoked role drops immediately export async function removeUserRole(userId: string, role: UserRole) { const user = await prisma.user.findUnique({ where: { id: userId }, @@ -25,10 +31,16 @@ export async function removeUserRole(userId: string, role: UserRole) { }); if (!user) throw new Error('User not found'); - return prisma.user.update({ + if (!user.roles.includes(role)) { + return prisma.user.update({ where: { id: userId }, data: {} }); + } + + const updated = await prisma.user.update({ where: { id: userId }, data: { roles: user.roles.filter((r) => r !== role) }, }); + await rotateUserSessions(userId); + return updated; } export async function completeUserOnboarding( diff --git a/src/components/features/ai/ai-prompt-form.tsx b/src/components/features/ai/ai-prompt-form.tsx index 08ea092..fd2d25d 100644 --- a/src/components/features/ai/ai-prompt-form.tsx +++ b/src/components/features/ai/ai-prompt-form.tsx @@ -10,6 +10,14 @@ import { AIPlacementSelector } from './ai-placement-selector'; import { COLOR_TYPES, AI_COMPLEXITIES } from '@/lib/validations'; import { cn } from '@/utils/cn'; import { Sparkles } from 'lucide-react'; +import { PriceEstimateWidget } from '@/components/features/pricing/price-estimate-widget'; + +const COMPLEXITY_TO_SIZE: Record = { + simple: 'SMALL', + moderate: 'MEDIUM', + detailed: 'LARGE', + complex: 'EXTRA_LARGE', +}; const COLOR_LABELS: Record = { COLOR: 'Full Color', @@ -152,6 +160,14 @@ export function AIPromptForm({ onGenerate }: AIPromptFormProps) {
    + + Generate Tattoo Design diff --git a/src/components/features/artists/style-dna-chart.tsx b/src/components/features/artists/style-dna-chart.tsx new file mode 100644 index 0000000..3ccb8eb --- /dev/null +++ b/src/components/features/artists/style-dna-chart.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useMemo } from 'react'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassBadge } from '@/components/ui/glass-badge'; +import { STYLE_LABELS } from '@/lib/validations'; + +interface StyleDnaChartProps { + weights: Record; + sampleSize: number; +} + +// horizontal bars + top-3 badge summary. SVG kept simple for accessibility. +export function StyleDnaChart({ weights, sampleSize }: StyleDnaChartProps) { + const sorted = useMemo( + () => + Object.entries(weights) + .filter(([, v]) => v > 0) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8), + [weights], + ); + + if (sorted.length === 0) { + return null; + } + + const top = sorted.slice(0, 3); + + return ( + +
    +

    Style DNA

    + {sampleSize} tattoos analyzed +
    + +
    + {top.map(([style, weight]) => ( + + {STYLE_LABELS[style] ?? style} · {Math.round(weight * 100)}% + + ))} +
    + +
    + {sorted.map(([style, weight]) => { + const pct = Math.round(weight * 100); + return ( +
    +
    + {STYLE_LABELS[style] ?? style} + {pct}% +
    +
    +
    +
    +
    + ); + })} +
    + + ); +} diff --git a/src/components/features/booking/booking-form.tsx b/src/components/features/booking/booking-form.tsx index 0507744..99b2ad9 100644 --- a/src/components/features/booking/booking-form.tsx +++ b/src/components/features/booking/booking-form.tsx @@ -9,6 +9,7 @@ import { GlassTextarea } from '@/components/ui/glass-textarea'; import { FormError } from '@/components/forms/form-error'; import { cn } from '@/utils/cn'; import { BODY_PLACEMENTS, PLACEMENT_LABELS } from '@/lib/validations'; +import { PriceEstimateWidget } from '@/components/features/pricing/price-estimate-widget'; const SIZES = [ { value: 'SMALL', label: 'Small (< 3")' }, @@ -143,6 +144,12 @@ export function BookingForm({ artistId, artistSlug }: BookingFormProps) {
    + + (initialStatus); + const [busy, setBusy] = useState(false); + + const choose = async (value: RsvpStatus) => { + setBusy(true); + try { + const res = await fetch(`/api/events/${slug}/rsvp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ status: value }), + }); + if (res.ok) setStatus(value); + } finally { + setBusy(false); + } + }; + + const cancel = async () => { + setBusy(true); + try { + const res = await fetch(`/api/events/${slug}/rsvp`, { method: 'DELETE' }); + if (res.ok) setStatus(undefined); + } finally { + setBusy(false); + } + }; + + return ( +
    + {OPTIONS.map((opt) => ( + + ))} + {status && ( + + Clear + + )} +
    + ); +} diff --git a/src/components/features/longevity/longevity-simulator.tsx b/src/components/features/longevity/longevity-simulator.tsx new file mode 100644 index 0000000..7beb1b4 --- /dev/null +++ b/src/components/features/longevity/longevity-simulator.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { GlassButton } from '@/components/ui/glass-button'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassInput } from '@/components/ui/glass-input'; +import { cn } from '@/utils/cn'; +import { TATTOO_STYLES, STYLE_LABELS, BODY_PLACEMENTS, PLACEMENT_LABELS } from '@/lib/validations'; + +type AgeYears = 1 | 5 | 10; +const AGES: AgeYears[] = [1, 5, 10]; + +interface AgedFilter { + ageYears: AgeYears; + blurPx: number; + brightness: number; + contrast: number; + saturate: number; + sepia: number; + notes: string[]; +} + +interface Result { + imageUrl: string; + timeline: AgedFilter[]; +} + +const LINE_OPTIONS = ['fine', 'medium', 'bold'] as const; +const COLOR_OPTIONS = ['BLACK_AND_GREY', 'MIXED', 'COLOR'] as const; + +export function LongevitySimulator() { + const [imageUrl, setImageUrl] = useState(''); + const [lineThickness, setLineThickness] = useState<(typeof LINE_OPTIONS)[number]>('medium'); + const [colorPalette, setColorPalette] = + useState<(typeof COLOR_OPTIONS)[number]>('BLACK_AND_GREY'); + const [placement, setPlacement] = useState(''); + const [style, setStyle] = useState(''); + const [age, setAge] = useState(1); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState(null); + + const current = useMemo(() => result?.timeline.find((t) => t.ageYears === age), [result, age]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + setResult(null); + setLoading(true); + try { + const res = await fetch('/api/longevity', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + imageUrl, + lineThickness, + colorPalette, + placement: placement || undefined, + style: style || undefined, + }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data?.error ?? 'Simulation failed'); + setResult({ imageUrl: data.imageUrl, timeline: data.timeline }); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed'); + } finally { + setLoading(false); + } + }; + + return ( +
    +
    + setImageUrl(e.target.value)} + placeholder="https://..." + required + /> + + +

    Line thickness

    +
    + {LINE_OPTIONS.map((l) => ( + + ))} +
    +
    + + +

    Color palette

    +
    + {COLOR_OPTIONS.map((c) => ( + + ))} +
    +
    + + +

    Placement

    + +
    + + +

    Style

    + +
    + + + Simulate aging + + + + {error && ( + + {error} + + )} + + {result && current && ( + +
    + {AGES.map((y) => ( + + ))} +
    + +
    + {/* eslint-disable-next-line @next/next/no-img-element */} + {`Tattoo +
    + + {current.notes.length > 0 && ( +
      + {current.notes.map((note) => ( +
    • {note}
    • + ))} +
    + )} +

    + v0 simulation — visual approximation only. Real aging depends on individual skin, sun + exposure, and aftercare. +

    +
    + )} +
    + ); +} diff --git a/src/components/features/map/shops-map.tsx b/src/components/features/map/shops-map.tsx new file mode 100644 index 0000000..7dc1594 --- /dev/null +++ b/src/components/features/map/shops-map.tsx @@ -0,0 +1,165 @@ +'use client'; + +import { useEffect, useMemo, useState } from 'react'; +import Link from 'next/link'; +import Script from 'next/script'; +import { GlassCard } from '@/components/ui/glass-card'; +import { GlassBadge } from '@/components/ui/glass-badge'; +import { GlassInput } from '@/components/ui/glass-input'; + +interface ShopFeature { + id: string; + slug: string; + name: string; + city?: string | null; + country?: string | null; + image?: string | null; + verified: boolean; + lng: number; + lat: number; +} + +interface ShopsMapProps { + styleUrl?: string; +} + +interface MaplibreMarker { + setLngLat: (l: [number, number]) => MaplibreMarker; + setPopup: (p: unknown) => MaplibreMarker; + addTo: (m: unknown) => MaplibreMarker; +} + +interface MaplibreLib { + Map: new (opts: unknown) => unknown; + Marker: new () => MaplibreMarker; + Popup: new (opts?: unknown) => { setHTML: (s: string) => unknown }; + LngLatBounds: new () => { + extend: (l: [number, number]) => unknown; + isEmpty: () => boolean; + }; +} + +declare global { + interface Window { + maplibregl?: MaplibreLib; + } +} + +const FALLBACK_STYLE = 'https://demotiles.maplibre.org/style.json'; + +export function ShopsMap({ styleUrl }: ShopsMapProps) { + const [shops, setShops] = useState([]); + const [query, setQuery] = useState(''); + const [active, setActive] = useState(null); + const [scriptReady, setScriptReady] = useState(false); + + useEffect(() => { + fetch('/api/shops/map') + .then((r) => r.json()) + .then((data) => setShops(data.shops ?? [])) + .catch(() => setShops([])); + }, []); + + // initialize maplibre once both shop data + script have loaded + useEffect(() => { + if (!scriptReady || !shops.length) return; + const ml = window.maplibregl; + if (!ml) return; + const map = new ml.Map({ + container: 'shops-map', + style: styleUrl || FALLBACK_STYLE, + center: [shops[0].lng, shops[0].lat], + zoom: 1.5, + cooperativeGestures: true, + }); + const bounds = new ml.LngLatBounds(); + for (const s of shops) { + const popup = new ml.Popup({ offset: 24 }).setHTML( + `${escapeHtml(s.name)}` + + (s.city ? `
    ${escapeHtml(s.city)}
    ` : ''), + ); + new ml.Marker().setLngLat([s.lng, s.lat]).setPopup(popup).addTo(map); + bounds.extend([s.lng, s.lat]); + } + if (!bounds.isEmpty()) { + // @ts-expect-error map is the maplibre instance, fitBounds exists at runtime + map.fitBounds(bounds, { padding: 40, maxZoom: 6, duration: 0 }); + } + }, [scriptReady, shops, styleUrl]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + if (!q) return shops; + return shops.filter( + (s) => + s.name.toLowerCase().includes(q) || + s.city?.toLowerCase().includes(q) || + s.country?.toLowerCase().includes(q), + ); + }, [query, shops]); + + return ( +
    +