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:
252
src/services/buddy-link.ts
Normal file
252
src/services/buddy-link.ts
Normal 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
390
src/services/bulk-scan.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user