- 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
253 lines
7.9 KiB
TypeScript
253 lines
7.9 KiB
TypeScript
'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' };
|
|
}
|
|
}
|