Files
Dramlog-Prod/src/services/buddy-link.ts
robin 75461d7c30 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
2025-12-25 22:11:50 +01:00

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' };
}
}