Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ model Artist {
reviews Review[]
availability ArtistAvailability[]
shopArtists ShopArtist[]
styleDna ArtistStyleDna?

@@index([userId])
@@index([slug])
Expand All @@ -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
Expand Down Expand Up @@ -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])
}
48 changes: 48 additions & 0 deletions public/sw.js
Original file line number Diff line number Diff line change
@@ -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);
}),
);
});
4 changes: 3 additions & 1 deletion src/app/api/admin/users/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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({
Expand Down
23 changes: 23 additions & 0 deletions src/app/api/artists/[artistId]/similar/route.ts
Original file line number Diff line number Diff line change
@@ -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');
71 changes: 71 additions & 0 deletions src/app/api/artists/[artistId]/style-dna/route.ts
Original file line number Diff line number Diff line change
@@ -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');
60 changes: 60 additions & 0 deletions src/app/api/cron/cleanup/route.ts
Original file line number Diff line number Diff line change
@@ -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');
16 changes: 16 additions & 0 deletions src/app/api/cron/style-dna/route.ts
Original file line number Diff line number Diff line change
@@ -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');
33 changes: 33 additions & 0 deletions src/app/api/events/[slug]/ics/route.ts
Original file line number Diff line number Diff line change
@@ -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');
Loading
Loading