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
3 changes: 3 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ const config: Config = {
'src/backend/services/notification-service.ts',
'src/backend/services/ar-preview-service.ts',
'src/backend/services/audit-log-service.ts',
'src/backend/services/price-estimator-service.ts',
'src/backend/services/repost-service.ts',
'src/backend/services/collection-service.ts',
'!src/**/*.d.ts',
'!src/**/index.ts',
],
Expand Down
10 changes: 8 additions & 2 deletions src/app/api/collections/[id]/items/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,10 @@ export const POST = withErrorHandler(async (req: Request, ctx: unknown) => {
return withRequestId(req, NextResponse.json({ success: true, item }, { status: 201 }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed';
return withRequestId(req, NextResponse.json({ success: false, error: message }, { status: 404 }));
return withRequestId(
req,
NextResponse.json({ success: false, error: message }, { status: 404 }),
);
}
}, 'POST /api/collections/[id]/items');

Expand All @@ -66,6 +69,9 @@ export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => {
await removeCollectionItem(itemId, session.user.id);
return withRequestId(req, NextResponse.json({ success: true }));
} catch {
return withRequestId(req, NextResponse.json({ success: false, error: 'Item not found' }, { status: 404 }));
return withRequestId(
req,
NextResponse.json({ success: false, error: 'Item not found' }, { status: 404 }),
);
}
}, 'DELETE /api/collections/[id]/items');
5 changes: 1 addition & 4 deletions src/app/api/collections/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,7 @@ 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 {
createCollection,
getCollectionsForOwner,
} from '@/backend/services/collection-service';
import { createCollection, getCollectionsForOwner } from '@/backend/services/collection-service';
import { createCollectionSchema, paginationSchema } from '@/lib/validations';
import { getPaginationParams } from '@/utils/pagination';

Expand Down
31 changes: 18 additions & 13 deletions src/app/api/messages/[conversationId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,14 @@ 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 {
getMessages,
getConversationById,
markConversationRead,
} from '@/backend/services/message-service';
import { emitConversationRead } from '@/backend/realtime/socket-server';
import { getPaginationParams } from '@/utils/pagination';
import { paginationSchema } from '@/lib/validations';

Expand All @@ -15,9 +18,10 @@ interface Params {
}

// get messages for conversation
export async function GET(req: Request, { params }: Params) {
export const GET = withErrorHandler(async (req: Request, ctx: unknown) => {
const { params } = ctx as Params;
const { session, error } = await authGuard();
if (error) return error;
if (error) return withRequestId(req, error);

const { conversationId } = await params;
const { searchParams } = new URL(req.url);
Expand All @@ -30,10 +34,9 @@ export async function GET(req: Request, { params }: Params) {
const limit = parsed.success ? parsed.data.limit : 50;

try {
// verify membership before fetching
const conversation = await getConversationById(conversationId, session.user.id);
if (!conversation) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
return withRequestId(req, NextResponse.json({ error: 'Forbidden' }, { status: 403 }));
}

const result = await getMessages(
Expand All @@ -42,24 +45,26 @@ export async function GET(req: Request, { params }: Params) {
getPaginationParams(page, limit),
);

return NextResponse.json({ conversation, ...result });
return withRequestId(req, NextResponse.json({ conversation, ...result }));
} catch (err) {
const msg = err instanceof Error ? err.message : 'Failed';
return NextResponse.json({ error: msg }, { status: 403 });
return withRequestId(req, NextResponse.json({ error: msg }, { status: 403 }));
}
}
}, 'GET /api/messages/[conversationId]');

// mark conversation as read
export async function PATCH(_req: Request, { params }: Params) {
// mark conversation as read + broadcast receipt
export const PATCH = withErrorHandler(async (req: Request, ctx: unknown) => {
const { params } = ctx as Params;
const { session, error } = await authGuard();
if (error) return error;
if (error) return withRequestId(req, error);

const { conversationId } = await params;

try {
await markConversationRead(conversationId, session.user.id);
return NextResponse.json({ success: true });
emitConversationRead(conversationId, session.user.id);
return withRequestId(req, NextResponse.json({ success: true }));
} catch {
return NextResponse.json({ error: 'Failed' }, { status: 400 });
return withRequestId(req, NextResponse.json({ error: 'Failed' }, { status: 400 }));
}
}
}, 'PATCH /api/messages/[conversationId]');
10 changes: 8 additions & 2 deletions src/app/api/posts/[id]/repost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,10 @@ export const POST = withErrorHandler(async (req: Request, ctx: unknown) => {
return withRequestId(req, NextResponse.json({ success: true, repost }, { status: 201 }));
} catch (err) {
const message = err instanceof Error ? err.message : 'Repost failed';
return withRequestId(req, NextResponse.json({ success: false, error: message }, { status: 400 }));
return withRequestId(
req,
NextResponse.json({ success: false, error: message }, { status: 400 }),
);
}
}, 'POST /api/posts/[id]/repost');

Expand All @@ -49,6 +52,9 @@ export const DELETE = withErrorHandler(async (req: Request, ctx: unknown) => {
await deleteRepost(session.user.id, id);
return withRequestId(req, NextResponse.json({ success: true }));
} catch {
return withRequestId(req, NextResponse.json({ success: false, error: 'Repost not found' }, { status: 404 }));
return withRequestId(
req,
NextResponse.json({ success: false, error: 'Repost not found' }, { status: 404 }),
);
}
}, 'DELETE /api/posts/[id]/repost');
25 changes: 25 additions & 0 deletions src/app/api/users/online/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
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 { listOnlineUsers, isUserOnline } from '@/backend/realtime/socket-server';

// presence — list every online user, or check a specific id with ?ids=a,b,c
export const GET = withErrorHandler(async (req: Request) => {
const { error } = await authGuard();
if (error) return withRequestId(req, error);

const { searchParams } = new URL(req.url);
const ids = searchParams.get('ids');

if (ids) {
const requested = ids.split(',').slice(0, 100);
const status: Record<string, boolean> = {};
for (const id of requested) status[id] = isUserOnline(id);
return withRequestId(req, NextResponse.json({ success: true, status }));
}

return withRequestId(req, NextResponse.json({ success: true, online: listOnlineUsers() }));
}, 'GET /api/users/online');
4 changes: 1 addition & 3 deletions src/app/app/shops/[city]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,9 +73,7 @@ export default async function CityShopsPage({ params, searchParams }: PageProps)
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<div className="flex items-baseline gap-3">
<h1 className="text-h2 text-foreground">
Tattoo shops in {titleCase(cityName)}
</h1>
<h1 className="text-h2 text-foreground">Tattoo shops in {titleCase(cityName)}</h1>
<GlassBadge variant="primary">{meta.total} shops</GlassBadge>
</div>

Expand Down
36 changes: 30 additions & 6 deletions src/backend/realtime/socket-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ let io: Server | null = null;
// event types
export interface ServerToClientEvents {
'message:new': (data: MessageEvent) => void;
'message:read': (data: { conversationId: string }) => void;
'message:read': (data: { conversationId: string; byUserId: string }) => void;
'message:typing': (data: { conversationId: string; userId: string }) => void;
'message:typing-stop': (data: { conversationId: string; userId: string }) => void;
'notification:new': (data: NotificationEvent) => void;
'user:online': (data: { userId: string }) => void;
'user:offline': (data: { userId: string }) => void;
Expand All @@ -18,6 +20,7 @@ export interface ClientToServerEvents {
'conversation:join': (conversationId: string) => void;
'conversation:leave': (conversationId: string) => void;
'message:typing': (conversationId: string) => void;
'message:typing-stop': (conversationId: string) => void;
}

export interface MessageEvent {
Expand Down Expand Up @@ -80,13 +83,20 @@ export function initSocketServer(httpServer: HttpServer): Server {
socket.leave(`conversation:${conversationId}`);
});

// typing indicator
// typing indicators (start + stop)
socket.on('message:typing', (conversationId: string) => {
socket.to(`conversation:${conversationId}`).emit('message:typing' as never, {
conversationId,
userId,
});
socket
.to(`conversation:${conversationId}`)
.emit('message:typing' as never, { conversationId, userId });
});
socket.on('message:typing-stop', (conversationId: string) => {
socket
.to(`conversation:${conversationId}`)
.emit('message:typing-stop' as never, { conversationId, userId });
});

// presence: tell others this user is online + replay at connect
socket.broadcast.emit('user:online' as never, { userId });

// cleanup on disconnect
socket.on('disconnect', () => {
Expand All @@ -95,6 +105,7 @@ export function initSocketServer(httpServer: HttpServer): Server {
sockets.delete(socket.id);
if (sockets.size === 0) {
userSockets.delete(userId);
socket.broadcast.emit('user:offline' as never, { userId });
}
}
});
Expand All @@ -118,7 +129,20 @@ export function emitNotification(userId: string, event: NotificationEvent) {
io?.to(`user:${userId}`).emit('notification:new', event);
}

// broadcast read receipt to the other participant(s)
export function emitConversationRead(conversationId: string, byUserId: string) {
io?.to(`conversation:${conversationId}`).emit('message:read', {
conversationId,
byUserId,
} as never);
}

// check if user is online
export function isUserOnline(userId: string): boolean {
return userSockets.has(userId);
}

// list currently-online user ids (presence)
export function listOnlineUsers(): string[] {
return [...userSockets.keys()];
}
7 changes: 4 additions & 3 deletions src/backend/services/aftercare-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,10 @@ export async function askAftercare(input: AftercareInput): Promise<AftercareRepl
}

const openai = getOpenAI();
const userPrompt = input.daysSinceTattoo !== undefined
? `Day ${input.daysSinceTattoo} of healing. ${input.question}`
: input.question;
const userPrompt =
input.daysSinceTattoo !== undefined
? `Day ${input.daysSinceTattoo} of healing. ${input.question}`
: input.question;

const completion = await openai.chat.completions.create({
model: process.env.AFTERCARE_MODEL ?? 'gpt-4o-mini',
Expand Down
58 changes: 58 additions & 0 deletions src/backend/services/ai-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,61 @@ export async function getUserGenerations(userId: string, pagination: PaginationP
export async function getGenerationById(id: string) {
return prisma.aIGeneration.findUnique({ where: { id } });
}

// coverup variant — same dispatch path, different prompt
interface CoverupGenInput {
userId: string;
existingDescription: string;
desiredSubject?: string;
desiredStyle?: string;
placement?: string;
}

export async function generateCoverupDesign(input: CoverupGenInput) {
if (!isFeatureEnabled('COVERUP_ENABLED')) {
throw new Error('Coverup finder is disabled');
}
const allowed = await checkDailyLimit(input.userId);
if (!allowed) throw new Error('Daily generation limit reached');

const { buildCoverupPrompt } = await import('@/utils/ai-prompt-builder');
const prompt = buildCoverupPrompt({
existingDescription: input.existingDescription,
desiredSubject: input.desiredSubject,
desiredStyle: input.desiredStyle,
placement: input.placement,
});

const generation = await prisma.aIGeneration.create({
data: {
userId: input.userId,
prompt,
style: input.desiredStyle,
placement: input.placement,
status: 'PENDING',
},
});

try {
const openai = getOpenAI();
const response = await openai.images.generate({
model: process.env.AI_IMAGE_MODEL ?? 'dall-e-3',
prompt,
n: 1,
size: '1024x1024',
quality: 'standard',
});
const imageUrl = response.data?.[0]?.url;
if (!imageUrl) throw new Error('No image generated');
return prisma.aIGeneration.update({
where: { id: generation.id },
data: { imageUrl, status: 'COMPLETED' },
});
} catch (err) {
await prisma.aIGeneration.update({
where: { id: generation.id },
data: { status: 'FAILED' },
});
throw err;
}
}
4 changes: 1 addition & 3 deletions src/backend/services/price-estimator-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,9 +88,7 @@ export function estimatePrice(input: EstimateInput): EstimateBreakdown {
}
const baseHours = SIZE_HOURS[input.size];
const colorMultiplier = COLOR_MULTIPLIER[input.colorType ?? 'BLACK_AND_GREY'];
const placementMultiplier = input.placement
? (PLACEMENT_MULTIPLIER[input.placement] ?? 1)
: 1;
const placementMultiplier = input.placement ? (PLACEMENT_MULTIPLIER[input.placement] ?? 1) : 1;
const complexityMultiplier = COMPLEXITY_MULTIPLIER[input.complexity ?? 'moderate'];
const styleMultiplier = styleFactor(input.styles);

Expand Down
6 changes: 1 addition & 5 deletions src/components/features/coverup/coverup-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,11 +79,7 @@ export function CoverupForm() {
{result?.imageUrl && (
<GlassCard padding="md">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={result.imageUrl}
alt="Generated coverup design"
className="w-full rounded-xl"
/>
<img src={result.imageUrl} alt="Generated coverup design" className="w-full rounded-xl" />
</GlassCard>
)}
</form>
Expand Down
5 changes: 3 additions & 2 deletions src/utils/mentions.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
// extract @mentions from caption / comment content.
// rules: 3-30 chars, [a-zA-Z0-9_-], must follow whitespace or string start.
// rules: 3-30 chars, [a-zA-Z0-9_-], must follow whitespace or string start
// and end at a non-username char (so 31-char strings don't truncate-match).

const MENTION_RE = /(^|\s)@([a-zA-Z0-9_-]{3,30})/g;
const MENTION_RE = /(^|\s)@([a-zA-Z0-9_-]{3,30})(?![a-zA-Z0-9_-])/g;

export function extractMentions(text: string): string[] {
if (!text) return [];
Expand Down
Loading
Loading