feat: Buddy System & Bulk Scanner

- Add Buddy linking via QR code/handshake (buddy_invites table)
- Add Bulk Scanner for rapid-fire bottle scanning in sessions
- Add processing_status to bottles for background AI analysis
- Fix offline OCR with proper tessdata caching in Service Worker
- Fix Supabase GoTrueClient singleton warning
- Add collection refresh after offline sync completes

New components:
- BuddyHandshake.tsx - QR code display and code entry
- BulkScanSheet.tsx - Camera UI with capture queue
- BottleSkeletonCard.tsx - Pending bottle display
- useBulkScanner.ts - Queue management hook
- buddy-link.ts - Server actions for buddy linking
- bulk-scan.ts - Server actions for batch processing
This commit is contained in:
2025-12-25 22:11:50 +01:00
parent afe9197776
commit 75461d7c30
22 changed files with 2050 additions and 146 deletions

252
src/services/buddy-link.ts Normal file
View File

@@ -0,0 +1,252 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { customAlphabet } from 'nanoid';
import { revalidatePath } from 'next/cache';
// Generate 6-char uppercase alphanumeric codes (no confusing chars like 0/O, 1/I/L)
const generateCode = customAlphabet('ABCDEFGHJKMNPQRSTUVWXYZ23456789', 6);
const CODE_EXPIRY_MINUTES = 15;
/**
* Generate a buddy invite code for the current user.
* Deletes any existing expired codes first.
*/
export async function generateBuddyCode(): Promise<{ success: boolean; code?: string; error?: string }> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
// Delete expired codes for this user
await supabase
.from('buddy_invites')
.delete()
.eq('creator_id', user.id)
.lt('expires_at', new Date().toISOString());
// Check if user already has an active code
const { data: existingCode } = await supabase
.from('buddy_invites')
.select('code, expires_at')
.eq('creator_id', user.id)
.gt('expires_at', new Date().toISOString())
.single();
if (existingCode) {
// Return existing code
return { success: true, code: existingCode.code };
}
// Generate new code
const code = generateCode();
const expiresAt = new Date(Date.now() + CODE_EXPIRY_MINUTES * 60 * 1000);
const { error } = await supabase
.from('buddy_invites')
.insert({
creator_id: user.id,
code,
expires_at: expiresAt.toISOString(),
});
if (error) {
console.error('Error creating buddy code:', error);
return { success: false, error: 'Code konnte nicht erstellt werden' };
}
return { success: true, code };
} catch (error) {
console.error('generateBuddyCode error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}
/**
* Redeem a buddy invite code and create the buddy relationship.
*/
export async function redeemBuddyCode(code: string): Promise<{ success: boolean; buddyName?: string; error?: string }> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const normalizedCode = code.toUpperCase().replace(/[^A-Z0-9]/g, '');
if (normalizedCode.length !== 6) {
return { success: false, error: 'Ungültiger Code-Format' };
}
// Find the invite
const { data: invite, error: findError } = await supabase
.from('buddy_invites')
.select('id, creator_id, expires_at')
.eq('code', normalizedCode)
.single();
if (findError || !invite) {
return { success: false, error: 'Code nicht gefunden' };
}
// Check if expired
if (new Date(invite.expires_at) < new Date()) {
return { success: false, error: 'Code ist abgelaufen' };
}
// Cannot buddy yourself
if (invite.creator_id === user.id) {
return { success: false, error: 'Du kannst dich nicht selbst hinzufügen' };
}
// Check if relationship already exists
const { data: existingBuddy } = await supabase
.from('buddies')
.select('id')
.eq('user_id', user.id)
.eq('buddy_profile_id', invite.creator_id)
.single();
if (existingBuddy) {
return { success: false, error: 'Ihr seid bereits verbunden' };
}
// Get creator's profile for the buddy name
const { data: creatorProfile } = await supabase
.from('profiles')
.select('username')
.eq('id', invite.creator_id)
.single();
const buddyName = creatorProfile?.username || 'Buddy';
// Create buddy relationship (both directions)
// 1. Redeemer adds Creator as buddy
const { error: buddy1Error } = await supabase
.from('buddies')
.insert({
user_id: user.id,
name: buddyName,
buddy_profile_id: invite.creator_id,
});
if (buddy1Error) {
console.error('Error creating buddy (redeemer->creator):', buddy1Error);
return { success: false, error: 'Verbindung konnte nicht erstellt werden' };
}
// 2. Creator adds Redeemer as buddy
const { data: redeemerProfile } = await supabase
.from('profiles')
.select('username')
.eq('id', user.id)
.single();
const redeemerName = redeemerProfile?.username || 'Buddy';
// Use service role or direct insert (creator will see it via RLS)
const { error: buddy2Error } = await supabase
.from('buddies')
.insert({
user_id: invite.creator_id,
name: redeemerName,
buddy_profile_id: user.id,
});
if (buddy2Error) {
console.error('Error creating buddy (creator->redeemer):', buddy2Error);
// Don't fail - at least one direction worked
}
// Delete the used invite
await supabase
.from('buddy_invites')
.delete()
.eq('id', invite.id);
// Revalidate paths
revalidatePath('/');
return { success: true, buddyName };
} catch (error) {
console.error('redeemBuddyCode error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}
/**
* Get all buddies that are linked to real accounts (have buddy_profile_id).
*/
export async function getLinkedBuddies(): Promise<{
success: boolean;
buddies?: Array<{ id: string; name: string; buddy_profile_id: string; username?: string }>;
error?: string;
}> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const { data, error } = await supabase
.from('buddies')
.select(`
id,
name,
buddy_profile_id,
profiles:buddy_profile_id (username)
`)
.eq('user_id', user.id)
.not('buddy_profile_id', 'is', null)
.order('name');
if (error) {
console.error('Error fetching linked buddies:', error);
return { success: false, error: 'Fehler beim Laden der Buddies' };
}
const buddies = (data || []).map(b => ({
id: b.id,
name: b.name,
buddy_profile_id: b.buddy_profile_id!,
username: (b.profiles as any)?.username,
}));
return { success: true, buddies };
} catch (error) {
console.error('getLinkedBuddies error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}
/**
* Revoke/cancel an active buddy invite code.
*/
export async function revokeBuddyCode(): Promise<{ success: boolean; error?: string }> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
await supabase
.from('buddy_invites')
.delete()
.eq('creator_id', user.id);
return { success: true };
} catch (error) {
console.error('revokeBuddyCode error:', error);
return { success: false, error: 'Unerwarteter Fehler' };
}
}

390
src/services/bulk-scan.ts Normal file
View File

@@ -0,0 +1,390 @@
'use server';
import { createClient } from '@/lib/supabase/server';
import { revalidatePath } from 'next/cache';
export interface BulkScanResult {
success: boolean;
bottleIds?: string[];
tastingIds?: string[];
error?: string;
}
/**
* Process multiple bottle images in bulk for a tasting session.
* Creates skeleton bottles immediately and triggers background AI analysis.
*
* @param sessionId - The tasting session to link bottles to
* @param imageDataUrls - Array of base64 image data URLs
*/
export async function processBulkScan(
sessionId: string,
imageDataUrls: string[]
): Promise<BulkScanResult> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
// Verify session exists and belongs to user
const { data: session, error: sessionError } = await supabase
.from('tasting_sessions')
.select('id')
.eq('id', sessionId)
.eq('user_id', user.id)
.single();
if (sessionError || !session) {
return { success: false, error: 'Session nicht gefunden' };
}
const bottleIds: string[] = [];
const tastingIds: string[] = [];
// Process each image
for (let i = 0; i < imageDataUrls.length; i++) {
const imageDataUrl = imageDataUrls[i];
// 1. Upload image to Supabase Storage
const imageUrl = await uploadImage(supabase, user.id, imageDataUrl);
// 2. Create skeleton bottle with pending status
const { data: bottle, error: bottleError } = await supabase
.from('bottles')
.insert({
user_id: user.id,
name: `Wird analysiert... (#${i + 1})`,
processing_status: 'pending',
image_url: imageUrl,
status: 'sealed',
})
.select('id')
.single();
if (bottleError || !bottle) {
console.error('Error creating bottle:', bottleError);
continue;
}
bottleIds.push(bottle.id);
// 3. Create tasting to link bottle to session
const { data: tasting, error: tastingError } = await supabase
.from('tastings')
.insert({
bottle_id: bottle.id,
user_id: user.id,
session_id: sessionId,
// No rating yet - placeholder tasting
})
.select('id')
.single();
if (tastingError) {
console.error('Error creating tasting:', tastingError);
} else if (tasting) {
tastingIds.push(tasting.id);
}
}
// 4. Trigger background analysis for all bottles (fire & forget)
// This won't block the response
triggerBackgroundAnalysis(bottleIds, user.id).catch(err => {
console.error('Background analysis error:', err);
});
revalidatePath(`/sessions/${sessionId}`);
revalidatePath('/');
return {
success: true,
bottleIds,
tastingIds,
};
} catch (error) {
console.error('processBulkScan error:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unbekannter Fehler',
};
}
}
/**
* Upload a base64 image to Supabase Storage
*/
async function uploadImage(
supabase: Awaited<ReturnType<typeof createClient>>,
userId: string,
dataUrl: string
): Promise<string | null> {
try {
// Convert base64 to blob
const base64Data = dataUrl.split(',')[1];
const mimeType = dataUrl.match(/data:(.*?);/)?.[1] || 'image/webp';
const byteCharacters = atob(base64Data);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: mimeType });
// Generate unique filename
const ext = mimeType.split('/')[1] || 'webp';
const filename = `${userId}/${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
// Upload to storage
const { error: uploadError } = await supabase.storage
.from('bottles')
.upload(filename, blob, {
contentType: mimeType,
upsert: false,
});
if (uploadError) {
console.error('Upload error:', uploadError);
return null;
}
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('bottles')
.getPublicUrl(filename);
return publicUrl;
} catch (error) {
console.error('Image upload failed:', error);
return null;
}
}
/**
* Trigger background AI analysis for bottles.
* This runs asynchronously and updates bottles with results.
*/
async function triggerBackgroundAnalysis(bottleIds: string[], userId: string): Promise<void> {
const supabase = await createClient();
for (const bottleId of bottleIds) {
try {
// Update status to analyzing
await supabase
.from('bottles')
.update({ processing_status: 'analyzing' })
.eq('id', bottleId);
// Get bottle image
const { data: bottle } = await supabase
.from('bottles')
.select('image_url')
.eq('id', bottleId)
.single();
if (!bottle?.image_url) {
await markBottleError(supabase, bottleId, 'Kein Bild gefunden');
continue;
}
// Call Gemini analysis
const analysisResult = await analyzeBottleImage(bottle.image_url);
if (analysisResult.success && analysisResult.data) {
// Update bottle with AI results
await supabase
.from('bottles')
.update({
name: analysisResult.data.name || 'Unbekannter Whisky',
distillery: analysisResult.data.distillery,
category: analysisResult.data.category,
abv: analysisResult.data.abv,
age: analysisResult.data.age,
is_whisky: analysisResult.data.is_whisky ?? true,
confidence: analysisResult.data.confidence ?? 80,
processing_status: 'complete',
updated_at: new Date().toISOString(),
})
.eq('id', bottleId);
} else {
await markBottleError(supabase, bottleId, analysisResult.error || 'Analyse fehlgeschlagen');
}
} catch (error) {
console.error(`Analysis failed for bottle ${bottleId}:`, error);
await markBottleError(supabase, bottleId, 'Analysefehler');
}
}
}
async function markBottleError(
supabase: Awaited<ReturnType<typeof createClient>>,
bottleId: string,
error: string
): Promise<void> {
await supabase
.from('bottles')
.update({
processing_status: 'error',
name: `Fehler: ${error.slice(0, 50)}`,
updated_at: new Date().toISOString(),
})
.eq('id', bottleId);
}
/**
* Call Gemini to analyze bottle image
* Uses existing Gemini integration
*/
async function analyzeBottleImage(imageUrl: string): Promise<{
success: boolean;
data?: {
name: string;
distillery?: string;
category?: string;
abv?: number;
age?: number;
is_whisky?: boolean;
confidence?: number;
};
error?: string;
}> {
try {
// Fetch image and convert to base64
const response = await fetch(imageUrl);
if (!response.ok) {
return { success: false, error: 'Bild konnte nicht geladen werden' };
}
const blob = await response.blob();
const buffer = await blob.arrayBuffer();
const base64 = Buffer.from(buffer).toString('base64');
const mimeType = blob.type || 'image/webp';
// Call Gemini
const apiKey = process.env.GOOGLE_GENERATIVE_AI_API_KEY;
if (!apiKey) {
return { success: false, error: 'API Key nicht konfiguriert' };
}
const geminiResponse = await fetch(
`https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key=${apiKey}`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
contents: [{
parts: [
{
text: `Analyze this whisky bottle image. Extract:
- name: Full product name
- distillery: Distillery name
- category: e.g. "Single Malt", "Bourbon", "Blend"
- abv: Alcohol percentage as number (e.g. 46.0)
- age: Age statement as number (e.g. 12), null if NAS
- is_whisky: boolean, false if not a whisky
- confidence: 0-100 how confident you are
Respond ONLY with valid JSON, no markdown.`
},
{
inline_data: {
mime_type: mimeType,
data: base64
}
}
]
}],
generationConfig: {
temperature: 0.1,
maxOutputTokens: 500,
}
})
}
);
if (!geminiResponse.ok) {
return { success: false, error: 'Gemini API Fehler' };
}
const geminiData = await geminiResponse.json();
const textContent = geminiData.candidates?.[0]?.content?.parts?.[0]?.text;
if (!textContent) {
return { success: false, error: 'Keine Antwort von Gemini' };
}
// Parse JSON response
const jsonMatch = textContent.match(/\{[\s\S]*\}/);
if (!jsonMatch) {
return { success: false, error: 'Ungültige Gemini-Antwort' };
}
const parsed = JSON.parse(jsonMatch[0]);
return { success: true, data: parsed };
} catch (error) {
console.error('Gemini analysis error:', error);
return { success: false, error: 'Analysefehler' };
}
}
/**
* Get bottles for a session with their processing status
*/
export async function getSessionBottles(sessionId: string): Promise<{
success: boolean;
bottles?: Array<{
id: string;
name: string;
distillery?: string;
abv?: number;
age?: number;
image_url?: string;
processing_status: string;
tasting_id: string;
}>;
error?: string;
}> {
const supabase = await createClient();
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return { success: false, error: 'Nicht autorisiert' };
}
const { data, error } = await supabase
.from('tastings')
.select(`
id,
bottles (
id,
name,
distillery,
abv,
age,
image_url,
processing_status
)
`)
.eq('session_id', sessionId)
.eq('user_id', user.id);
if (error) {
return { success: false, error: error.message };
}
const bottles = (data || [])
.filter(t => t.bottles)
.map(t => ({
...(t.bottles as any),
tasting_id: t.id,
}));
return { success: true, bottles };
} catch (error) {
return { success: false, error: 'Fehler beim Laden' };
}
}