feat: implement tenant OAuth flow and guest achievements

This commit is contained in:
2025-09-25 08:32:37 +02:00
parent ef6203c603
commit b22d91ed32
84 changed files with 5984 additions and 1399 deletions

View File

@@ -0,0 +1,212 @@
import { getDeviceId } from '../lib/device';
export interface AchievementBadge {
id: string;
title: string;
description: string;
earned: boolean;
progress: number;
target: number;
}
export interface LeaderboardEntry {
guest: string;
photos: number;
likes: number;
}
export interface TopPhotoHighlight {
photoId: number;
guest: string;
likes: number;
task?: string | null;
createdAt: string;
thumbnail: string | null;
}
export interface TrendingEmotionHighlight {
emotionId: number;
name: string;
count: number;
}
export interface TimelinePoint {
date: string;
photos: number;
guests: number;
}
export interface FeedEntry {
photoId: number;
guest: string;
task?: string | null;
likes: number;
createdAt: string;
thumbnail: string | null;
}
export interface AchievementsPayload {
summary: {
totalPhotos: number;
uniqueGuests: number;
tasksSolved: number;
likesTotal: number;
};
personal: {
guestName: string;
photos: number;
tasks: number;
likes: number;
badges: AchievementBadge[];
} | null;
leaderboards: {
uploads: LeaderboardEntry[];
likes: LeaderboardEntry[];
};
highlights: {
topPhoto: TopPhotoHighlight | null;
trendingEmotion: TrendingEmotionHighlight | null;
timeline: TimelinePoint[];
};
feed: FeedEntry[];
}
function toNumber(value: unknown, fallback = 0): number {
if (typeof value === 'number' && Number.isFinite(value)) {
return value;
}
if (typeof value === 'string' && value !== '') {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}
return fallback;
}
function safeString(value: unknown): string {
return typeof value === 'string' ? value : '';
}
export async function fetchAchievements(
slug: string,
guestName?: string,
signal?: AbortSignal
): Promise<AchievementsPayload> {
const params = new URLSearchParams();
if (guestName && guestName.trim().length > 0) {
params.set('guest_name', guestName.trim());
}
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/achievements?${params.toString()}`, {
method: 'GET',
headers: {
'X-Device-Id': getDeviceId(),
'Cache-Control': 'no-store',
},
signal,
});
if (!response.ok) {
const message = await response.text();
throw new Error(message || 'Achievements request failed');
}
const json = await response.json();
const summary = json.summary ?? {};
const personalRaw = json.personal ?? null;
const leaderboards = json.leaderboards ?? {};
const highlights = json.highlights ?? {};
const feedRaw = Array.isArray(json.feed) ? json.feed : [];
const personal = personalRaw
? {
guestName: safeString(personalRaw.guest_name),
photos: toNumber(personalRaw.photos),
tasks: toNumber(personalRaw.tasks),
likes: toNumber(personalRaw.likes),
badges: Array.isArray(personalRaw.badges)
? personalRaw.badges.map((badge: any): AchievementBadge => ({
id: safeString(badge.id),
title: safeString(badge.title),
description: safeString(badge.description),
earned: Boolean(badge.earned),
progress: toNumber(badge.progress),
target: toNumber(badge.target, 1),
}))
: [],
}
: null;
const uploadsBoard = Array.isArray(leaderboards.uploads)
? leaderboards.uploads.map((row: any): LeaderboardEntry => ({
guest: safeString(row.guest),
photos: toNumber(row.photos),
likes: toNumber(row.likes),
}))
: [];
const likesBoard = Array.isArray(leaderboards.likes)
? leaderboards.likes.map((row: any): LeaderboardEntry => ({
guest: safeString(row.guest),
photos: toNumber(row.photos),
likes: toNumber(row.likes),
}))
: [];
const topPhotoRaw = highlights.top_photo ?? null;
const topPhoto = topPhotoRaw
? {
photoId: toNumber(topPhotoRaw.photo_id),
guest: safeString(topPhotoRaw.guest),
likes: toNumber(topPhotoRaw.likes),
task: topPhotoRaw.task ?? null,
createdAt: safeString(topPhotoRaw.created_at),
thumbnail: topPhotoRaw.thumbnail ? safeString(topPhotoRaw.thumbnail) : null,
}
: null;
const trendingRaw = highlights.trending_emotion ?? null;
const trendingEmotion = trendingRaw
? {
emotionId: toNumber(trendingRaw.emotion_id),
name: safeString(trendingRaw.name),
count: toNumber(trendingRaw.count),
}
: null;
const timeline = Array.isArray(highlights.timeline)
? highlights.timeline.map((row: any): TimelinePoint => ({
date: safeString(row.date),
photos: toNumber(row.photos),
guests: toNumber(row.guests),
}))
: [];
const feed = feedRaw.map((row: any): FeedEntry => ({
photoId: toNumber(row.photo_id),
guest: safeString(row.guest),
task: row.task ?? null,
likes: toNumber(row.likes),
createdAt: safeString(row.created_at),
thumbnail: row.thumbnail ? safeString(row.thumbnail) : null,
}));
return {
summary: {
totalPhotos: toNumber(summary.total_photos),
uniqueGuests: toNumber(summary.unique_guests),
tasksSolved: toNumber(summary.tasks_solved),
likesTotal: toNumber(summary.likes_total),
},
personal,
leaderboards: {
uploads: uploadsBoard,
likes: likesBoard,
},
highlights: {
topPhoto,
trendingEmotion,
timeline,
},
feed,
};
}