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' };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user