Feat: Refactor postgress

This commit is contained in:
Till-JS 2025-11-27 02:25:37 +01:00
parent 046a0e3fe7
commit 98efa6f6e8
134 changed files with 9459 additions and 1904 deletions

View file

@ -1,10 +1,6 @@
# Mana Core Auth Configuration
EXPO_PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Configuration (for database only, not auth)
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
# Chat Backend API
# The backend handles AI API calls securely - no API keys needed in the mobile app
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002

View file

@ -1,4 +1,4 @@
import { supabase } from '../../utils/supabase';
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3001';
// Definiere den Typ für ein Modell
export type Model = {
@ -10,7 +10,7 @@ export type Model = {
updated_at?: string;
};
// Fallback-Modelle, falls keine aus der Datenbank geladen werden können
// Fallback-Modelle, falls keine aus dem Backend geladen werden können
const FALLBACK_MODELS: Model[] = [
{
id: '550e8400-e29b-41d4-a716-446655440000',
@ -56,28 +56,25 @@ const FALLBACK_MODELS: Model[] = [
// GET-Handler für Modelle
export async function GET(request: Request) {
try {
// Versuche, Modelle aus der Supabase-Datenbank zu laden
// Versuche, Modelle vom Backend zu laden
let models: Model[] = FALLBACK_MODELS;
// Wenn Supabase konfiguriert ist, versuche die Modelle von dort zu laden
try {
if (supabase) {
const { data, error } = await supabase
.from('models')
.select('*');
// Entfernt: .order('created_at', { ascending: false })
if (error) {
console.error('Fehler beim Laden der Modelle aus Supabase:', error);
} else if (data && data.length > 0) {
const response = await fetch(`${BACKEND_URL}/api/chat/models`);
if (response.ok) {
const data = await response.json();
if (data && data.length > 0) {
models = data as Model[];
}
} else {
console.error('Fehler beim Laden der Modelle vom Backend:', response.status);
}
} catch (e) {
console.error('Fehler bei der Supabase-Verbindung:', e);
console.error('Fehler bei der Backend-Verbindung:', e);
// Fallback zu den vordefinierten Modellen
}
return Response.json(models);
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
@ -90,59 +87,12 @@ export async function GET(request: Request) {
}
}
// POST-Handler zum Erstellen eines neuen Modells
// POST-Handler zum Erstellen eines neuen Modells (nicht unterstützt ohne Backend-Endpoint)
export async function POST(request: Request) {
try {
const body = await request.json();
// Validiere die Eingabedaten
if (!body.name || !body.description) {
return new Response(JSON.stringify({ error: 'Name und Beschreibung sind erforderlich' }), {
status: 400,
headers: {
'Content-Type': 'application/json',
},
});
}
// Erstelle ein neues Modell in der Datenbank
if (supabase) {
const { data, error } = await supabase
.from('models')
.insert([{
name: body.name,
description: body.description,
parameters: body.parameters || {},
}])
.select();
if (error) {
console.error('Fehler beim Erstellen des Modells:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Erstellen des Modells' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
return Response.json(data[0]);
} else {
// Wenn Supabase nicht verfügbar ist, gib einen Fehler zurück
return new Response(JSON.stringify({ error: 'Datenbank nicht verfügbar' }), {
status: 503,
headers: {
'Content-Type': 'application/json',
},
});
}
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
return new Response(JSON.stringify({ error: 'Interner Serverfehler' }), {
status: 500,
headers: {
'Content-Type': 'application/json',
},
});
}
return new Response(JSON.stringify({ error: 'Modell-Erstellung wird über das Backend nicht unterstützt' }), {
status: 501,
headers: {
'Content-Type': 'application/json',
},
});
}

View file

@ -1,4 +1,5 @@
import { supabase } from '../../utils/supabase';
// TODO: Implement usage statistics via Backend API
// The Backend needs endpoints for user usage statistics
// Typ für die Token-Nutzung pro Modell
export type ModelUsage = {
@ -28,57 +29,31 @@ export type ConversationUsage = {
};
// Handler für GET /api/usage
// TODO: Backend-Endpoints für Usage-Statistiken implementieren
export async function GET(request: Request) {
try {
const url = new URL(request.url);
const userId = url.searchParams.get('userId');
const period = url.searchParams.get('period') || 'month';
if (!userId) {
return new Response(JSON.stringify({ error: 'User ID ist erforderlich' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Lade die Tokennutzung nach Modell
const { data: modelUsage, error: modelError } = await supabase
.rpc('get_user_model_usage', { user_id: userId });
if (modelError) {
console.error('Fehler beim Laden der Modellnutzung:', modelError);
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Modellnutzung' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Lade die Tokennutzung nach Zeitraum
const { data: periodUsage, error: periodError } = await supabase
.rpc('get_user_usage_by_period', {
user_id: userId,
period: period
});
if (periodError) {
console.error('Fehler beim Laden der Zeitraumnutzung:', periodError);
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Zeitraumnutzung' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Berechne Gesamtkosten und Token
const totalCost = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0);
const totalTokens = (modelUsage as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0);
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
// Gebe leere Daten zurück
console.log('Usage-Statistiken: Backend-Endpoints noch nicht implementiert');
return Response.json({
modelUsage,
periodUsage,
modelUsage: [],
periodUsage: [],
summary: {
totalCost,
totalTokens
}
totalCost: 0,
totalTokens: 0
},
message: 'Usage-Statistiken sind derzeit nicht verfügbar'
});
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
@ -90,42 +65,31 @@ export async function GET(request: Request) {
}
// Handler für GET /api/usage/conversation
// TODO: Backend-Endpoints für Conversation-Usage implementieren
export async function GET_conversation(request: Request) {
try {
const url = new URL(request.url);
const conversationId = url.searchParams.get('conversationId');
if (!conversationId) {
return new Response(JSON.stringify({ error: 'Conversation ID ist erforderlich' }), {
status: 400,
headers: { 'Content-Type': 'application/json' }
});
}
// Lade die Tokennutzung für die Konversation
const { data: conversationUsage, error } = await supabase
.rpc('get_conversation_usage', { conversation_id: conversationId });
if (error) {
console.error('Fehler beim Laden der Konversationsnutzung:', error);
return new Response(JSON.stringify({ error: 'Fehler beim Laden der Konversationsnutzung' }), {
status: 500,
headers: { 'Content-Type': 'application/json' }
});
}
// Berechne Gesamtkosten und Token für diese Konversation
const usage = conversationUsage as ConversationUsage[];
const totalCost = usage.reduce((sum, item) => sum + item.estimated_cost, 0);
const totalTokens = usage.reduce((sum, item) => sum + item.total_tokens, 0);
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
// Gebe leere Daten zurück
console.log('Conversation-Usage: Backend-Endpoints noch nicht implementiert');
return Response.json({
conversationUsage,
conversationUsage: [],
summary: {
totalCost,
totalTokens,
messageCount: usage.length
}
totalCost: 0,
totalTokens: 0,
messageCount: 0
},
message: 'Usage-Statistiken sind derzeit nicht verfügbar'
});
} catch (error) {
console.error('Fehler beim Verarbeiten der Anfrage:', error);
@ -134,4 +98,4 @@ export async function GET_conversation(request: Request) {
headers: { 'Content-Type': 'application/json' }
});
}
}
}

View file

@ -15,13 +15,13 @@ import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import CustomDrawer from '../components/CustomDrawer';
import {
getArchivedConversations,
getMessages,
deleteConversation,
import {
getArchivedConversations,
getMessages,
deleteConversation,
unarchiveConversation
} from '../services/conversation';
import { supabase } from '../utils/supabase';
import { modelApi } from '../services/api';
// Typendefinitionen für Konversationen
type ConversationItem = {
@ -69,18 +69,14 @@ export default function ArchiveScreen() {
try {
// Lade die Nachrichten der Konversation
const messages = await getMessages(conv.id);
// Lade das Modell aus der Datenbank
const { data: modelData } = await supabase
.from('models')
.select('name')
.eq('id', conv.model_id)
.single();
// Lade das Modell über die Backend API
const modelData = await modelApi.getModel(conv.model_id);
// Finde die letzte Nachricht (die nicht vom System ist)
const lastMessage = messages
.filter(msg => msg.sender !== 'system')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastMessage) {
conversationItems.push({
id: conv.id,
@ -88,7 +84,7 @@ export default function ArchiveScreen() {
title: conv.title || 'Unbenannte Konversation',
lastMessage: lastMessage.message_text,
timestamp: new Date(conv.updated_at),
mode: conv.conversation_mode === 'free' ? 'frei' :
mode: conv.conversation_mode === 'free' ? 'frei' :
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
});
}

View file

@ -4,7 +4,6 @@ import { useTheme } from '@react-navigation/native';
import { useRouter, Link } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../../context/AuthProvider';
import { supabase } from '../../utils/supabase';
import { useAppTheme } from '../../theme/ThemeProvider';
export default function LoginScreen() {
@ -24,24 +23,13 @@ export default function LoginScreen() {
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse und dein Passwort ein.');
return;
}
try {
setLoading(true);
const { error } = await signIn(email, password);
if (error) {
console.log('Anmeldung mit Passwort fehlgeschlagen, versuche direkte Anmeldung...');
// Wenn die normale Anmeldung fehlschlägt, versuche eine direkte Anmeldung
const { error: directError } = await supabase.auth.signInWithPassword({
email,
password,
});
if (directError) {
Alert.alert('Anmeldung fehlgeschlagen', directError.message);
} else {
router.replace('/');
}
Alert.alert('Anmeldung fehlgeschlagen', error.message || 'Unbekannter Fehler');
} else {
// Erfolgreich angemeldet, navigiere zur Hauptseite
router.replace('/');
@ -54,37 +42,12 @@ export default function LoginScreen() {
}
};
// Magic Link ist derzeit nicht verfügbar (mana-core-auth unterstützt dies nicht)
const handleMagicLink = async () => {
if (!email) {
Alert.alert('Fehler', 'Bitte gib deine E-Mail-Adresse ein.');
return;
}
try {
setLoading(true);
const { error } = await supabase.auth.signInWithOtp({
email,
options: {
emailRedirectTo: 'exp://localhost:8081/',
},
});
if (error) {
Alert.alert('Fehler', error.message);
} else {
setIsMagicLinkSent(true);
Alert.alert(
'Magic Link gesendet',
'Wir haben dir einen Magic Link an deine E-Mail-Adresse gesendet. Bitte öffne den Link, um dich anzumelden.'
);
}
} catch (error) {
console.error('Fehler beim Senden des Magic Links:', error);
Alert.alert('Fehler', 'Beim Senden des Magic Links ist ein Fehler aufgetreten. Bitte versuche es später erneut.');
} finally {
setLoading(false);
}
Alert.alert(
'Nicht verfügbar',
'Magic Link Anmeldung ist derzeit nicht verfügbar. Bitte nutze E-Mail und Passwort.'
);
};
return (

View file

@ -13,8 +13,9 @@ import DocumentVersions from '../../components/DocumentVersions';
// Import der Konversations- und OpenAI-Services
import { createConversation, addMessage, getMessages, sendMessageAndGetResponse, Message as DbMessage } from '../../services/conversation';
import { supabase } from '../../utils/supabase';
import { conversationApi } from '../../services/api';
import { Document, createDocument, createDocumentVersion, getLatestDocument, getAllDocumentVersions, hasDocument, deleteDocumentVersion } from '../../services/document';
import { useAuth } from '../../context/AuthProvider';
// Typdefinition für die Nachrichten in der UI
type UIMessage = {
@ -38,10 +39,11 @@ function convertDbToUiMessages(dbMessages: DbMessage[]): UIMessage[] {
export default function ConversationScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
// Hole Parameter aus URL und Query-String
const params = useLocalSearchParams();
const { id } = params;
// Drawer (Seitenmenü) Status
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
@ -79,20 +81,15 @@ export default function ConversationScreen() {
const [isDocumentLoading, setIsDocumentLoading] = useState<boolean>(false);
const [isVersionsModalVisible, setIsVersionsModalVisible] = useState<boolean>(false);
// Überprüfe den aktuellen Benutzer
// Setze userId vom AuthProvider
useEffect(() => {
const checkUser = async () => {
const { data } = await supabase.auth.getUser();
if (data?.user) {
setUserId(data.user.id);
} else {
// In einer echten App würden wir hier zur Login-Seite weiterleiten
// Für jetzt verwenden wir eine Test-ID
setUserId('test-user-id');
}
};
checkUser();
}, []);
if (user?.id) {
setUserId(user.id);
} else {
// Fallback für Test-Zwecke
setUserId('test-user-id');
}
}, [user]);
// Lade das Modell
useEffect(() => {
@ -117,32 +114,28 @@ export default function ConversationScreen() {
}
}
// Wenn kein URL-Modell gefunden wurde oder keins angegeben war,
// Wenn kein URL-Modell gefunden wurde oder keins angegeben war,
// hole die Konversation, um die Model-ID zu bekommen
if (!isNewConversation && conversationId) {
console.log("Hole Modell-ID aus Konversation:", conversationId);
const { data: conversationData, error: conversationError } = await supabase
.from('conversations')
.select('model_id, title')
.eq('id', conversationId)
.single();
if (conversationData && conversationData.model_id) {
console.log("✓ Model-ID aus der Konversation geladen:", conversationData.model_id);
const conversationData = await conversationApi.getConversation(conversationId);
if (conversationData && conversationData.modelId) {
console.log("✓ Model-ID aus der Konversation geladen:", conversationData.modelId);
// Setze das modelId, wenn wir es aus der Konversation bekommen haben
const fetchedModelId = conversationData.model_id;
const fetchedModelId = conversationData.modelId;
// Setze den Titel aus der Konversation
if (conversationData.title) {
console.log("✓ Titel aus der Konversation geladen:", conversationData.title);
setConversationTitle(conversationData.title);
}
// Hole jetzt das Modell mit der ID
const response = await fetch(`/api/models`);
const models = await response.json();
const model = models.find((m: any) => m.id === fetchedModelId);
if (model) {
console.log("✓ Model-Daten aus Konversation geladen:", model.name);
setModelName(model.name);
@ -150,8 +143,8 @@ export default function ConversationScreen() {
} else {
console.warn("Modell mit ID aus Konversation nicht gefunden:", fetchedModelId);
}
} else if (conversationError) {
console.error('Fehler beim Laden der Konversation:', conversationError);
} else {
console.error('Fehler beim Laden der Konversation oder keine Model-ID gefunden');
}
}
} catch (error) {
@ -174,13 +167,9 @@ export default function ConversationScreen() {
// Prüfe, ob es eine bestehende Konversation mit Dokumentmodus ist
if (conversationId) {
const { data: convData, error: convError } = await supabase
.from('conversations')
.select('document_mode')
.eq('id', conversationId)
.single();
if (convData && convData.document_mode) {
const convData = await conversationApi.getConversation(conversationId);
if (convData && convData.documentMode) {
setIsDocumentMode(true);
await loadDocumentData(conversationId);
}
@ -203,45 +192,21 @@ export default function ConversationScreen() {
try {
console.log(`[loadDocumentData] Lade Dokumentdaten für Konversation ${convId}`);
setIsDocumentLoading(true);
// Längere Verzögerung zur Sicherstellung, dass Datenbanktransaktionen Zeit haben
await new Promise(resolve => setTimeout(resolve, 1000));
// Direkter Supabase-Aufruf mit Cache-Umgehung
console.log('Lade alle Dokumentversionen direkt...');
// Generiere einen zufälligen String, um Caching zu verhindern
const timestamp = new Date().getTime();
const randomString = Math.random().toString(36).substring(2, 8);
const noCache = `${timestamp}-${randomString}`;
const { data: freshVersions, error } = await supabase
.from('documents')
.select(`*,noCacheKey:conversation_id(id)`)
.eq('conversation_id', convId)
.order('version', { ascending: false })
.filter('noCacheKey.id', 'not.is', null)
.limit(100);
if (error) {
console.error('Fehler beim direkten Laden der Dokumentversionen:', error);
setDocumentVersions([]);
return;
}
// Entferne noCacheKey-Feld
const cleanVersions = freshVersions.map(v => {
const { noCacheKey, ...rest } = v;
return rest;
});
console.log(`${cleanVersions.length} Dokumentversionen direkt geladen`);
setDocumentVersions(cleanVersions);
// Kurze Verzögerung zur Sicherstellung, dass DB-Transaktionen abgeschlossen sind
await new Promise(resolve => setTimeout(resolve, 500));
// Lade alle Dokumentversionen über den Service
console.log('Lade alle Dokumentversionen über Backend API...');
const versions = await getAllDocumentVersions(convId);
console.log(`${versions.length} Dokumentversionen geladen`);
setDocumentVersions(versions);
// Wenn Versionen existieren, nehme die neueste
if (cleanVersions.length > 0) {
console.log('Setze neuestes Dokument aus Liste', cleanVersions[0]);
setCurrentDocument(cleanVersions[0]);
if (versions.length > 0) {
console.log('Setze neuestes Dokument aus Liste', versions[0]);
setCurrentDocument(versions[0]);
} else {
// Wenn keine Versionen existieren, setze alles zurück
console.log('Keine Dokumentversionen vorhanden, setze null');
@ -322,53 +287,26 @@ export default function ConversationScreen() {
console.error('Keine aktuelle Konversations-ID verfügbar');
return;
}
try {
console.log(`[handleDeleteVersion] Versuche Dokumentversion ${document.version} (ID: ${document.id}) zu löschen`);
setIsDocumentLoading(true);
// Debug-Informationen
console.log('Aktuelle Konversation:', actualConversationId);
console.log('Aktuelles Dokument:', currentDocument?.id);
console.log('Zu löschendes Dokument:', document.id);
// Sicherstellen, dass die zu löschende Version nicht aktuell angezeigt wird
const isCurrentlyDisplayed = currentDocument?.id === document.id;
// Direkter Zugriff auf Supabase
console.log('Versuche direkte Löschung mit Supabase via delete');
// Wir verwenden einen speziellen Trick, um sicherzustellen, dass die Löschung
// Zeit hat, vollständig durchgeführt zu werden, bevor wir weitermachen
await new Promise(resolve => setTimeout(resolve, 500));
try {
// Direktes Löschen über die normale DELETE-Methode
const { error } = await supabase
.from('documents')
.delete()
.eq('id', document.id);
if (error) {
console.error('Fehler beim direkten Löschen:', error);
throw error;
}
console.log('Dokument erfolgreich gelöscht');
// Warten, damit die Datenbank Zeit hat, sich zu aktualisieren
await new Promise(resolve => setTimeout(resolve, 800));
} catch (deleteError) {
console.error('Fehler beim Löschen:', deleteError);
throw deleteError;
}
const success = true; // Wenn wir bis hierher kommen, war es erfolgreich
console.log('Löschvorgang Ergebnis: Erfolgreich');
// Löschen über den Service
console.log('Lösche Dokument über Backend API...');
const success = await deleteDocumentVersion(document.id);
if (success) {
console.log(`Dokumentversion ${document.version} erfolgreich gelöscht`);
// Systemische Nachricht hinzufügen
const messageId = await addMessage(
actualConversationId,
@ -376,44 +314,33 @@ export default function ConversationScreen() {
`Dokumentversion ${document.version} wurde gelöscht.`
);
console.log('System-Nachricht hinzugefügt:', messageId);
// Nachrichten neu laden
const dbMessages = await getMessages(actualConversationId);
setMessages(convertDbToUiMessages(dbMessages));
// Dokumentversionen neu laden mit forcierter Aktualisierung
// Zuerst kurz warten, damit die DB-Änderungen sich vollständig auswirken können
await new Promise(resolve => setTimeout(resolve, 1000));
await loadDocumentData(actualConversationId);
// Dokumentversionen neu laden
await new Promise(resolve => setTimeout(resolve, 500));
await loadDocumentData(actualConversationId);
// Wenn die gerade angezeigte Version gelöscht wurde, zur neuesten wechseln
if (isCurrentlyDisplayed) {
console.log('Aktuell angezeigte Version wurde gelöscht, wechsle zur neuesten');
// Direkter Supabase-Aufruf für die aktuellste Version
const { data: latestData, error: latestError } = await supabase
.from('documents')
.select('*')
.eq('conversation_id', actualConversationId)
.order('version', { ascending: false })
.limit(1)
.maybeSingle();
if (latestError) {
console.error('Fehler beim Laden des neuesten Dokuments:', latestError);
} else if (latestData) {
console.log('Setze neues aktuelles Dokument:', latestData.id);
setCurrentDocument(latestData);
const latestDoc = await getLatestDocument(actualConversationId);
if (latestDoc) {
console.log('Setze neues aktuelles Dokument:', latestDoc.id);
setCurrentDocument(latestDoc);
} else {
console.log('Kein neuestes Dokument gefunden, setze null');
setCurrentDocument(null);
}
}
// Kurze Pause für bessere Benutzererfahrung
setTimeout(() => {
setIsVersionsModalVisible(false);
// Erfolgsmeldung anzeigen
Alert.alert(
"Version gelöscht",
@ -611,27 +538,23 @@ export default function ConversationScreen() {
if ((!modelId || modelId === 'undefined') && !modelData && actualConversationId) {
try {
console.log('Hole Modell aus der Konversation:', actualConversationId);
const { data, error } = await supabase
.from('conversations')
.select('model_id')
.eq('id', actualConversationId)
.single();
if (error) {
console.error('Fehler beim Laden des Modells aus der Konversation:', error);
const convData = await conversationApi.getConversation(actualConversationId);
if (!convData) {
console.error('Fehler beim Laden der Konversation');
Alert.alert('Fehler', 'Modell konnte nicht geladen werden.');
return;
}
if (data && data.model_id) {
console.log('Modell-ID aus der Konversation geladen:', data.model_id);
const fetchedModelId = data.model_id;
if (convData.modelId) {
console.log('Modell-ID aus der Konversation geladen:', convData.modelId);
const fetchedModelId = convData.modelId;
// Setze das Modell für die nächsten API-Aufrufe
const response = await fetch(`/api/models`);
const models = await response.json();
const model = models.find((m: any) => m.id === fetchedModelId);
if (model) {
setModelName(model.name);
setModelData(model);

View file

@ -17,13 +17,13 @@ import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import CustomDrawer from '../components/CustomDrawer';
import {
getConversations,
getMessages,
deleteConversation,
archiveConversation
import {
getConversations,
getMessages,
deleteConversation,
archiveConversation
} from '../services/conversation';
import { supabase } from '../utils/supabase';
import { modelApi } from '../services/api';
// Typendefinitionen für Konversationen
type ConversationItem = {
@ -71,18 +71,14 @@ export default function ConversationsScreen() {
try {
// Lade die Nachrichten der Konversation
const messages = await getMessages(conv.id);
// Lade das Modell aus der Datenbank
const { data: modelData } = await supabase
.from('models')
.select('name')
.eq('id', conv.model_id)
.single();
// Lade das Modell über die Backend API
const modelData = await modelApi.getModel(conv.model_id);
// Finde die letzte Nachricht (die nicht vom System ist)
const lastMessage = messages
.filter(msg => msg.sender !== 'system')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastMessage) {
conversationItems.push({
id: conv.id,
@ -90,7 +86,7 @@ export default function ConversationsScreen() {
title: conv.title || 'Unbenannte Konversation',
lastMessage: lastMessage.message_text,
timestamp: new Date(conv.updated_at),
mode: conv.conversation_mode === 'free' ? 'frei' :
mode: conv.conversation_mode === 'free' ? 'frei' :
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
});
}

View file

@ -1,10 +1,10 @@
import React, { useState, useEffect, useMemo } from 'react';
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
import {
View,
Text,
StyleSheet,
ScrollView,
TouchableOpacity,
ActivityIndicator,
useWindowDimensions,
Platform
@ -12,8 +12,9 @@ import {
import { useTheme } from '@react-navigation/native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { Document } from '../services/document';
import { supabase } from '../utils/supabase';
import { Document, getLatestDocument } from '../services/document';
import { conversationApi } from '../services/api';
import { useAuth } from '../context/AuthProvider';
import Markdown from 'react-native-markdown-display';
type DocumentWithTitle = Document & {
@ -23,11 +24,11 @@ type DocumentWithTitle = Document & {
export default function DocumentsScreen() {
const { colors } = useTheme();
const router = useRouter();
const { user } = useAuth();
const { width } = useWindowDimensions();
const [documents, setDocuments] = useState<DocumentWithTitle[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [userId, setUserId] = useState<string | null>(null);
// Berechne die Anzahl der Spalten basierend auf der Bildschirmbreite
const columnsCount = useMemo(() => {
// Mobile (schmaler Bildschirm)
@ -41,7 +42,7 @@ export default function DocumentsScreen() {
// Desktop oder großes Tablet
return 3;
}, [width]);
// Berechne die Breite jeder Karte basierend auf der Spaltenanzahl
const cardWidth = useMemo(() => {
const padding = 16; // Container-Padding rechts und links
@ -49,85 +50,55 @@ export default function DocumentsScreen() {
const contentWidth = width - (padding * 2);
const gapTotal = gap * (columnsCount - 1);
const availableWidth = contentWidth - gapTotal;
// Verhältnis für schmalere Karten, je nach Spaltenanzahl anpassen
const widthRatio = columnsCount === 1 ? 0.95 : // Fast volle Breite bei 1 Spalte
columnsCount === 2 ? 0.48 : // Etwas schmaler bei 2 Spalten
0.31; // Noch schmaler bei 3 Spalten
return (availableWidth * widthRatio);
}, [width, columnsCount]);
useEffect(() => {
const checkUser = async () => {
const { data } = await supabase.auth.getUser();
if (data?.user) {
setUserId(data.user.id);
} else {
// In einer echten App würden wir hier zur Login-Seite weiterleiten
// Für jetzt verwenden wir eine Test-ID
setUserId('test-user-id');
}
};
checkUser();
}, []);
useEffect(() => {
if (userId) {
if (user?.id) {
loadDocuments();
}
}, [userId]);
}, [user]);
const loadDocuments = async () => {
try {
setIsLoading(true);
// Lade alle Konversationen des Benutzers, die im Dokumentmodus sind
const { data: conversations, error: convError } = await supabase
.from('conversations')
.select('id, title, document_mode')
.eq('user_id', userId)
.eq('document_mode', true);
if (convError) {
console.error('Fehler beim Laden der Konversationen:', convError);
setIsLoading(false);
return;
}
if (!conversations || conversations.length === 0) {
// Lade alle Konversationen des Benutzers über die Backend-API
const conversations = await conversationApi.getConversations();
// Filtere nur Konversationen im Dokumentmodus
const documentConversations = conversations.filter(conv => conv.documentMode);
if (documentConversations.length === 0) {
setDocuments([]);
setIsLoading(false);
return;
}
// Für jede Konversation den neuesten Dokumentstand laden
const latestDocuments: DocumentWithTitle[] = [];
for (const conv of conversations) {
const { data: docData, error: docError } = await supabase
.from('documents')
.select('*')
.eq('conversation_id', conv.id)
.order('version', { ascending: false })
.limit(1)
.single();
if (docError) {
if (docError.code !== 'PGRST116') { // Ignore "No rows found" error
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
for (const conv of documentConversations) {
try {
const docData = await getLatestDocument(conv.id);
if (docData) {
latestDocuments.push({
...docData,
conversation_title: conv.title || 'Unbenannte Konversation'
});
}
continue;
}
if (docData) {
latestDocuments.push({
...docData,
conversation_title: conv.title || 'Unbenannte Konversation'
});
} catch (docError) {
console.error(`Fehler beim Laden des Dokuments für Konversation ${conv.id}:`, docError);
}
}
setDocuments(latestDocuments);
} catch (error) {
console.error('Fehler beim Laden der Dokumente:', error);

View file

@ -11,7 +11,7 @@ import CustomDrawer from '../components/CustomDrawer';
import { useAppTheme } from '../theme/ThemeProvider';
import { getConversations, getMessages, deleteConversation, archiveConversation } from '../services/conversation';
import { getUserSpaces, Space } from '../services/space';
import { supabase } from '../utils/supabase';
import { modelApi } from '../services/api';
// Typendefinitionen für Konversationen
type ConversationItem = {
@ -75,18 +75,14 @@ export default function HomeScreen() {
try {
// Lade die Nachrichten der Konversation
const messages = await getMessages(conv.id);
// Lade das Modell aus der Datenbank
const { data: modelData } = await supabase
.from('models')
.select('name')
.eq('id', conv.model_id)
.single();
// Lade das Modell über die Backend API
const modelData = await modelApi.getModel(conv.model_id);
// Finde die letzte Nachricht (die nicht vom System ist)
const lastMessage = messages
.filter(msg => msg.sender !== 'system')
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())[0];
if (lastMessage) {
conversationItems.push({
id: conv.id,
@ -94,7 +90,7 @@ export default function HomeScreen() {
title: conv.title || 'Unbenannte Konversation',
lastMessage: lastMessage.message_text,
timestamp: new Date(conv.updated_at),
mode: conv.conversation_mode === 'free' ? 'frei' :
mode: conv.conversation_mode === 'free' ? 'frei' :
conv.conversation_mode === 'guided' ? 'geführt' : 'vorlage'
});
}

View file

@ -5,7 +5,6 @@ import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useAuth } from '../context/AuthProvider';
import { useAppTheme } from '../theme/ThemeProvider';
import { supabase } from '../utils/supabase';
// Typendefinitionen für die Token-Nutzung
type ModelUsage = {
@ -44,46 +43,18 @@ export default function ProfileScreen() {
const [selectedPeriod, setSelectedPeriod] = useState<'day' | 'month' | 'year'>('month');
// Funktion zum Laden der Token-Nutzungsdaten
// TODO: Backend-Endpoints für Usage-Statistiken implementieren
const loadUsageData = async () => {
if (!user) return;
setIsLoading(true);
try {
// Lade die Token-Nutzung nach Modell
const { data: modelData, error: modelError } = await supabase
.rpc('get_user_model_usage', { user_id: user.id });
if (modelError) {
console.error('Fehler beim Laden der Modellnutzung:', modelError);
} else if (modelData) {
setModelUsage(modelData as ModelUsage[]);
}
// Lade die Token-Nutzung nach Zeitraum
const { data: periodData, error: periodError } = await supabase
.rpc('get_user_usage_by_period', {
user_id: user.id,
period: selectedPeriod
});
if (periodError) {
console.error('Fehler beim Laden der Zeitraumnutzung:', periodError);
} else if (periodData) {
setPeriodUsage(periodData as UsageByPeriod[]);
}
// Berechne die Zusammenfassung
if (modelData) {
const totalCost = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_cost, 0);
const totalTokens = (modelData as ModelUsage[]).reduce((sum, model) => sum + model.total_tokens, 0);
setSummary({
totalCost,
totalTokens,
modelCount: (modelData as ModelUsage[]).length,
periodCount: periodData ? (periodData as UsageByPeriod[]).length : 0
});
}
// Usage-Statistiken sind noch nicht über die Backend-API verfügbar
// Setze leere Daten und zeige Info-Text an
console.log('Usage-Statistiken: Backend-Endpoints noch nicht implementiert');
setModelUsage([]);
setPeriodUsage([]);
setSummary(null);
} catch (error) {
console.error('Fehler beim Laden der Nutzungsdaten:', error);
} finally {

View file

@ -6,7 +6,6 @@ import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { useRouter } from 'expo-router';
import { createConversation, addMessage } from '../services/conversation';
import { supabase } from '../utils/supabase';
import { useAuth } from '../context/AuthProvider';
import { Template, getTemplates } from '../services/template';

View file

@ -6,7 +6,6 @@ import { useAppTheme } from '../theme/ThemeProvider';
import ModelDropdown from './ModelDropdown';
import { useRouter } from 'expo-router';
import { createConversation, addMessage } from '../services/conversation';
import { supabase } from '../utils/supabase';
import { useAuth } from '../context/AuthProvider';
import { Template, getTemplates } from '../services/template';
import { Space, getUserSpaces } from '../services/space';

View file

@ -14,11 +14,7 @@
"prebuild": "expo prebuild",
"lint": "eslint \"**/*.{js,jsx,ts,tsx}\" && prettier -c \"**/*.{js,jsx,ts,tsx,json}\"",
"format": "eslint \"**/*.{js,jsx,ts,tsx}\" --fix && prettier \"**/*.{js,jsx,ts,tsx,json}\" --write",
"web": "expo start --web",
"supabase:cli": "node --experimental-json-modules scripts/supabase-cli.js",
"supabase:update-models": "node --experimental-json-modules scripts/update_models.js",
"supabase:setup": "node --experimental-json-modules scripts/setup_supabase.js",
"supabase:setup-spaces": "node --experimental-json-modules scripts/spaces/setup_spaces.js"
"web": "expo start --web"
},
"dependencies": {
"@expo/vector-icons": "^14.0.0",
@ -26,7 +22,6 @@
"@react-navigation/bottom-tabs": "^7.0.5",
"@react-navigation/drawer": "^7.0.0",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.38.4",
"expo": "^52.0.39",
"expo-constants": "~17.0.8",
"expo-dev-client": "~5.0.4",

View file

@ -1,35 +0,0 @@
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY || '';
// Überprüfe, ob wir in einer Browser-Umgebung sind
const isBrowser = typeof window !== 'undefined';
// Importiere AsyncStorage nur, wenn wir in einer Browser-Umgebung sind
let AsyncStorage;
if (isBrowser) {
AsyncStorage = require('@react-native-async-storage/async-storage').default;
}
// Erstelle Supabase-Client mit unterschiedlichen Konfigurationen je nach Umgebung
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: isBrowser
? {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
}
: {
// Dummy-Storage für serverseitiges Rendering
storage: {
getItem: () => Promise.resolve(null),
setItem: () => Promise.resolve(),
removeItem: () => Promise.resolve(),
},
autoRefreshToken: false,
persistSession: false,
detectSessionInUrl: false,
},
});

View file

@ -1,9 +1,5 @@
# Mana Core Auth Configuration
PUBLIC_MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Configuration (for database only, not auth)
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
# Chat Backend API
PUBLIC_BACKEND_URL=http://localhost:3002

View file

@ -33,14 +33,11 @@
"@manacore/shared-branding": "workspace:*",
"@manacore/shared-i18n": "workspace:*",
"@manacore/shared-icons": "workspace:*",
"@manacore/shared-supabase": "workspace:*",
"@manacore/shared-tailwind": "workspace:*",
"@manacore/shared-theme": "workspace:*",
"@manacore/shared-theme-ui": "workspace:*",
"@manacore/shared-ui": "workspace:*",
"@manacore/shared-utils": "workspace:*",
"@supabase/ssr": "^0.6.1",
"@supabase/supabase-js": "^2.81.1",
"marked": "^17.0.0"
}
}

View file

@ -1,42 +0,0 @@
/**
* Supabase Client for Chat Web App
* Uses the same Supabase instance as the mobile app
*/
import { createClient } from '@supabase/supabase-js';
import { createBrowserClient, createServerClient } from '@supabase/ssr';
import { env } from '$env/dynamic/public';
import type { Cookies } from '@sveltejs/kit';
const supabaseUrl = env.PUBLIC_SUPABASE_URL || '';
const supabaseAnonKey = env.PUBLIC_SUPABASE_ANON_KEY || '';
/**
* Browser client for client-side operations
*/
export function createSupabaseBrowserClient() {
return createBrowserClient(supabaseUrl, supabaseAnonKey);
}
/**
* Server client for SSR operations
*/
export function createSupabaseServerClient(cookies: Cookies) {
return createServerClient(supabaseUrl, supabaseAnonKey, {
cookies: {
getAll() {
return cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
cookies.set(name, value, { ...options, path: '/' });
});
},
},
});
}
/**
* Simple client for basic operations (no SSR)
*/
export const supabase = createClient(supabaseUrl, supabaseAnonKey);

View file

@ -23,6 +23,7 @@
"clean": "rm -rf dist"
},
"dependencies": {
"@mana-core/nestjs-integration": "workspace:*",
"@manacore/shared-errors": "workspace:*",
"@google-cloud/aiplatform": "^3.34.0",
"@google-cloud/storage": "^7.15.0",

View file

@ -77,8 +77,7 @@ export class CharacterService {
console.error('Error creating character:', error);
return err(
DatabaseError.queryFailed(
'create_character',
error instanceof Error ? error.message : 'Unknown error',
`Failed to create character: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error : undefined,
),
);
@ -107,8 +106,7 @@ export class CharacterService {
console.error('Error getting character:', error);
return err(
DatabaseError.queryFailed(
'get_character',
error instanceof Error ? error.message : 'Unknown error',
`Failed to get character: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error : undefined,
),
);
@ -153,8 +151,7 @@ export class CharacterService {
console.error('Error updating character:', error);
return err(
DatabaseError.queryFailed(
'update_character',
error instanceof Error ? error.message : 'Unknown error',
`Failed to update character: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error : undefined,
),
);
@ -183,8 +180,7 @@ export class CharacterService {
console.error('Error deleting character:', error);
return err(
DatabaseError.queryFailed(
'delete_character',
error instanceof Error ? error.message : 'Unknown error',
`Failed to delete character: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error : undefined,
),
);
@ -206,8 +202,7 @@ export class CharacterService {
console.error('Error listing characters:', error);
return err(
DatabaseError.queryFailed(
'list_characters',
error instanceof Error ? error.message : 'Unknown error',
`Failed to list characters: ${error instanceof Error ? error.message : 'Unknown error'}`,
error instanceof Error ? error : undefined,
),
);

View file

@ -1,5 +1,5 @@
import { Injectable, Logger } from '@nestjs/common';
import * as sharp from 'sharp';
import sharp from 'sharp';
import { encode } from 'blurhash';
export interface OptimizedImages {

View file

@ -10,7 +10,7 @@ import { ImageSupabaseService } from '../../core/services/image-supabase.service
import { CoreService } from '../../core/services/core.service';
import { StoryCharacter, StoryResponse } from '../../core/models/story';
import { StoryError } from '../../core/consts/errors.const';
import { Result } from '../../core/models/error';
import { type Result, isOk } from '@manacore/shared-errors';
import { StoryLogbookService } from '../../core/services/story-logbook.service';
export interface StoryCreationParams {
@ -115,7 +115,7 @@ export class StoryCreationService {
isAnimalStory,
);
if (story.error || !story.data) {
if (!isOk(story)) {
this.logbookService.logError(
storyId,
'generate_story',
@ -124,7 +124,7 @@ export class StoryCreationService {
await this.logStoryError(
userId,
storyId,
story.data,
null,
StoryError.CREATE_STORYLINE,
isAnimalStory,
);
@ -134,12 +134,12 @@ export class StoryCreationService {
// Update logbook with page count
this.logbookService.updateMetadata(storyId, {
pageCount: story.data.pages.length,
pageCount: story.value.pages.length,
});
// 4. Create character descriptions
const storyCharacters = await this.createCharacterDescriptions(
story.data.pages,
story.value.pages,
character,
userId,
storyId,
@ -148,7 +148,7 @@ export class StoryCreationService {
// 5. Generate illustrations
const { illustrationPrompts, images } = await this.generateIllustrations(
story.data.pages,
story.value.pages,
storyCharacters,
finalIllustratorId || '',
userId,
@ -160,7 +160,7 @@ export class StoryCreationService {
// 6. Generate title
const title = await this.generateTitle(
story.data.pages,
story.value.pages,
userId,
storyId,
isAnimalStory,
@ -168,7 +168,7 @@ export class StoryCreationService {
// 7. Translate story
const translatedPages = await this.translateStory(
story.data.pages,
story.value.pages,
illustrationPrompts,
images,
userId,
@ -575,7 +575,7 @@ export class StoryCreationService {
): Promise<string> {
const titleResult = await this.storyService.generateStoryTitle(pages);
if (titleResult.error) {
if (!isOk(titleResult)) {
this.logger.error('Error generating story title');
await this.logStoryError(
userId,
@ -587,7 +587,7 @@ export class StoryCreationService {
return '';
}
return titleResult.data || '';
return titleResult.value;
}
private async translateStory(

View file

@ -1,6 +1,7 @@
{
"compilerOptions": {
"module": "commonjs",
"moduleResolution": "node",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
@ -11,6 +12,8 @@
"baseUrl": "./",
"rootDir": "./",
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"paths": {
"@storyteller/shared-types": ["./src/shared-types/src"],
"@/*": ["./src/*"]

View file

@ -70,47 +70,63 @@ pnpm type-check # Run Astro checks
- **Mobile**: React Native 0.79 + Expo SDK 53, NativeWind, Expo Router, Zustand
- **Web**: SvelteKit 2.x, Svelte 5, Tailwind CSS 4
- **Landing**: Astro 5.x, Tailwind CSS
- **Backend**: NestJS 10, Google Gemini Vision API, Supabase
- **Authentication**: Mana Core Auth (shared with ecosystem)
- **Backend**: NestJS 10, Google Gemini Vision API, PostgreSQL + Drizzle ORM
- **Authentication**: Mana Core Auth (JWT via middleware)
- **Database**: PostgreSQL (via Drizzle ORM), SQLite (mobile offline)
## Architecture
### Backend API Endpoints
All endpoints (except health) require JWT authentication via `Authorization: Bearer <token>` header.
#### Meals API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/health` | GET | Health check |
| `/api/health` | GET | Health check (public) |
| `/api/meals/analyze/image` | POST | Analyze food image with AI |
| `/api/meals/analyze/text` | POST | Analyze food description |
| `/api/meals` | GET | Get user's meals |
| `/api/meals` | POST | Create new meal entry |
| `/api/meals/user/:userId` | GET | Get user's meals |
| `/api/meals/user/:userId/summary` | GET | Get daily nutrition summary |
| `/api/meals/summary` | GET | Get daily nutrition summary |
| `/api/meals/:id` | GET | Get meal by ID |
| `/api/meals/:id` | PUT | Update meal |
| `/api/meals/:id` | DELETE | Delete meal |
#### Sync API
| Endpoint | Method | Description |
|----------|--------|-------------|
| `/api/sync/push` | POST | Push local changes to server |
| `/api/sync/pull` | GET | Pull changes from server |
| `/api/sync/status` | GET | Get sync status |
### Environment Variables
#### Backend (.env)
```
DATABASE_URL=postgresql://nutriphi:password@localhost:5435/nutriphi
GEMINI_API_KEY=your-gemini-api-key
SUPABASE_URL=https://your-project.supabase.co
SUPABASE_SERVICE_KEY=your-service-key
MANACORE_AUTH_URL=https://auth.manacore.de
MANA_CORE_AUTH_URL=http://localhost:3001
S3_ENDPOINT=https://...
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BUCKET_NAME=nutriphi
S3_REGION=eu-central-1
S3_PUBLIC_URL=https://...
PORT=3002
```
#### Mobile (.env)
```
EXPO_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
EXPO_PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
EXPO_PUBLIC_MANA_MIDDLEWARE_URL=https://api.manacore.de
EXPO_PUBLIC_MIDDLEWARE_APP_ID=nutriphi
EXPO_PUBLIC_BACKEND_URL=http://localhost:3002
```
#### Web (.env)
```
PUBLIC_SUPABASE_URL=https://your-project.supabase.co
PUBLIC_SUPABASE_ANON_KEY=your-supabase-anon-key
PUBLIC_NUTRIPHI_MIDDLEWARE_URL=https://api.manacore.de
PUBLIC_MIDDLEWARE_APP_ID=nutriphi
PUBLIC_BACKEND_URL=http://localhost:3002
```
@ -121,6 +137,8 @@ PUBLIC_BACKEND_URL=http://localhost:3002
3. **Daily Tracking**: View daily summaries of calories, protein, carbs, fat, fiber
4. **Meal History**: Browse and edit past meal entries
5. **Health Tips**: Receive personalized nutrition recommendations
6. **Offline-First**: SQLite local storage with cloud sync
7. **Cross-Device Sync**: Meals sync across devices via backend API
## Mobile App Architecture
@ -130,7 +148,14 @@ PUBLIC_BACKEND_URL=http://localhost:3002
- `_layout.tsx` - Root layout with Stack navigation
- `components/` - Reusable UI components
- `store/` - Zustand state management
- `AuthStore.ts` - Authentication state
- `MealStore.ts` - Meal data state
- `AppStore.ts` - App-wide state
- `services/` - API and database services
- `auth/` - Authentication (authService, tokenManager)
- `sync/` - Cloud synchronization (SyncService)
- `database/` - SQLite local storage
- `storage/` - Photo storage
- `hooks/` - Custom React hooks
- `utils/` - Utility functions
@ -139,17 +164,40 @@ PUBLIC_BACKEND_URL=http://localhost:3002
- Components use `className` prop with Tailwind utility classes
### State Management
- Zustand stores for meals, user settings
- Zustand stores for auth, meals, app settings
- SQLite for local offline storage
- Supabase for cloud sync
- Cloud sync via backend API
### Authentication Flow
1. User signs in via Mana Middleware
2. Tokens stored securely in expo-secure-store
3. JWT sent with all API requests
4. Auto-refresh on token expiry
## Backend Architecture
### Authentication Guard
- `JwtAuthGuard` validates tokens against Mana Core Auth
- `CurrentUser` decorator extracts user data from JWT
- All protected endpoints use `@UseGuards(JwtAuthGuard)`
### Database
- PostgreSQL via Drizzle ORM (`@manacore/nutriphi-database` package)
- Schema: `meals`, `nutrition_goals` tables
- User isolation via `userId` field in all queries
### Sync Strategy
- **Push**: Local changes uploaded with version tracking
- **Pull**: Server changes downloaded since last sync
- **Conflict Resolution**: Last-write-wins with client priority
## Shared Packages Used
- `@manacore/nutriphi-database` - Database schema and client
- `@manacore/shared-auth-ui` - Authentication UI components
- `@manacore/shared-branding` - Branding assets
- `@manacore/shared-i18n` - Internationalization
- `@manacore/shared-icons` - Icon library
- `@manacore/shared-supabase` - Supabase client utilities
- `@manacore/shared-tailwind` - Tailwind configuration
- `@manacore/shared-theme` - Theme tokens
- `@manacore/shared-theme-ui` - Theme UI components
@ -167,6 +215,7 @@ PUBLIC_BACKEND_URL=http://localhost:3002
## Important Notes
1. **Security**: API keys are stored in the backend only - never in client apps
2. **Authentication**: Uses Mana Core Auth, shared with ecosystem
3. **Database**: Supabase PostgreSQL with RLS policies
2. **Authentication**: Uses Mana Core Auth via JWT middleware
3. **Database**: PostgreSQL with Drizzle ORM (no Supabase dependency)
4. **Deployment**: Backend runs on port 3002 by default
5. **Offline-First**: Mobile app works offline, syncs when online

View file

@ -5,6 +5,7 @@ import { StorageModule } from './storage/storage.module';
import { HealthModule } from './health/health.module';
import { GeminiModule } from './gemini/gemini.module';
import { MealsModule } from './meals/meals.module';
import { SyncModule } from './sync/sync.module';
@Module({
imports: [
@ -17,6 +18,7 @@ import { MealsModule } from './meals/meals.module';
HealthModule,
GeminiModule,
MealsModule,
SyncModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View file

@ -0,0 +1,66 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -11,9 +11,6 @@ export class AnalyzeMealTextDto {
}
export class CreateMealDto {
@IsString()
userId: string;
@IsString()
foodName: string;
@ -45,9 +42,6 @@ export class UploadMealDto {
@IsString()
imageBase64: string;
@IsString()
userId: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';

View file

@ -9,6 +9,7 @@ import {
Query,
HttpCode,
HttpStatus,
UseGuards,
} from '@nestjs/common';
import { MealsService } from './meals.service';
import {
@ -18,8 +19,11 @@ import {
UpdateMealDto,
UploadMealDto,
} from './dto/analyze-meal.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@Controller('meals')
@UseGuards(JwtAuthGuard)
export class MealsController {
constructor(private readonly mealsService: MealsService) {}
@ -36,44 +40,60 @@ export class MealsController {
}
@Post()
async createMeal(@Body() dto: CreateMealDto) {
return this.mealsService.createMeal(dto);
async createMeal(
@Body() dto: CreateMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.createMeal(dto, user.userId);
}
@Post('upload')
async uploadMeal(@Body() dto: UploadMealDto) {
return this.mealsService.uploadAndAnalyzeMeal(dto);
async uploadMeal(
@Body() dto: UploadMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.uploadAndAnalyzeMeal(dto, user.userId);
}
@Get('user/:userId')
async getMealsByUser(
@Param('userId') userId: string,
@Get()
async getMeals(
@CurrentUser() user: CurrentUserData,
@Query('date') date?: string,
) {
return this.mealsService.getMealsByUser(userId, date);
return this.mealsService.getMealsByUser(user.userId, date);
}
@Get('user/:userId/summary')
@Get('summary')
async getDailySummary(
@Param('userId') userId: string,
@CurrentUser() user: CurrentUserData,
@Query('date') date: string,
) {
return this.mealsService.getDailySummary(userId, date);
return this.mealsService.getDailySummary(user.userId, date);
}
@Get(':id')
async getMealById(@Param('id') id: string) {
return this.mealsService.getMealById(id);
async getMealById(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.getMealById(id, user.userId);
}
@Put(':id')
async updateMeal(@Param('id') id: string, @Body() dto: UpdateMealDto) {
return this.mealsService.updateMeal(id, dto);
async updateMeal(
@Param('id') id: string,
@Body() dto: UpdateMealDto,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.updateMeal(id, dto, user.userId);
}
@Delete(':id')
@HttpCode(HttpStatus.NO_CONTENT)
async deleteMeal(@Param('id') id: string) {
return this.mealsService.deleteMeal(id);
async deleteMeal(
@Param('id') id: string,
@CurrentUser() user: CurrentUserData,
) {
return this.mealsService.deleteMeal(id, user.userId);
}
}

View file

@ -87,8 +87,8 @@ export class MealsService {
/**
* Upload an image to storage, analyze it, and create a meal
*/
async uploadAndAnalyzeMeal(dto: UploadMealDto): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${dto.userId}`);
async uploadAndAnalyzeMeal(dto: UploadMealDto, userId: string): Promise<Meal> {
this.logger.log(`Uploading and analyzing meal for user: ${userId}`);
// Step 1: Upload image to storage
let imageUrl: string | undefined;
@ -114,7 +114,7 @@ export class MealsService {
// Step 3: Create the meal record
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
userId,
foodName: analysis.foodName || 'Unbekanntes Gericht',
imageUrl,
storagePath,
@ -134,11 +134,11 @@ export class MealsService {
return this.mapDbMealToMeal(result);
}
async createMeal(dto: CreateMealDto): Promise<Meal> {
this.logger.log(`Creating meal for user: ${dto.userId}`);
async createMeal(dto: CreateMealDto, userId: string): Promise<Meal> {
this.logger.log(`Creating meal for user: ${userId}`);
const [result] = await this.db.insert(meals).values({
userId: dto.userId,
userId,
foodName: dto.foodName,
imageUrl: dto.imageUrl,
calories: dto.calories,
@ -193,11 +193,11 @@ export class MealsService {
return results.map(this.mapDbMealToMeal);
}
async getMealById(id: string): Promise<Meal> {
async getMealById(id: string, userId: string): Promise<Meal> {
const [result] = await this.db
.select()
.from(meals)
.where(eq(meals.id, id));
.where(and(eq(meals.id, id), eq(meals.userId, userId)));
if (!result) {
throw new NotFoundException(`Meal with id ${id} not found`);
@ -206,8 +206,8 @@ export class MealsService {
return this.mapDbMealToMeal(result);
}
async updateMeal(id: string, dto: UpdateMealDto): Promise<Meal> {
this.logger.log(`Updating meal: ${id}`);
async updateMeal(id: string, dto: UpdateMealDto, userId: string): Promise<Meal> {
this.logger.log(`Updating meal: ${id} for user: ${userId}`);
const updateData: Partial<typeof meals.$inferInsert> = {
updatedAt: new Date(),
@ -228,7 +228,7 @@ export class MealsService {
const [result] = await this.db
.update(meals)
.set(updateData)
.where(eq(meals.id, id))
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
if (!result) {
@ -238,12 +238,12 @@ export class MealsService {
return this.mapDbMealToMeal(result);
}
async deleteMeal(id: string): Promise<void> {
this.logger.log(`Deleting meal: ${id}`);
async deleteMeal(id: string, userId: string): Promise<void> {
this.logger.log(`Deleting meal: ${id} for user: ${userId}`);
const result = await this.db
.delete(meals)
.where(eq(meals.id, id))
.where(and(eq(meals.id, id), eq(meals.userId, userId)))
.returning();
if (result.length === 0) {

View file

@ -0,0 +1,146 @@
import { IsString, IsOptional, IsArray, ValidateNested, IsNumber, IsBoolean } from 'class-validator';
import { Type } from 'class-transformer';
/**
* Local meal data from mobile app
*/
export class LocalMealDto {
@IsNumber()
localId: number;
@IsOptional()
@IsString()
cloudId?: string;
@IsString()
foodName: string;
@IsOptional()
@IsString()
imageUrl?: string;
@IsOptional()
calories?: number;
@IsOptional()
protein?: number;
@IsOptional()
carbohydrates?: number;
@IsOptional()
fat?: number;
@IsOptional()
fiber?: number;
@IsOptional()
sugar?: number;
@IsOptional()
sodium?: number;
@IsOptional()
@IsString()
servingSize?: string;
@IsOptional()
@IsString()
mealType?: 'breakfast' | 'lunch' | 'dinner' | 'snack';
@IsOptional()
@IsString()
analysisStatus?: string;
@IsOptional()
healthScore?: number;
@IsOptional()
@IsString()
healthCategory?: string;
@IsOptional()
@IsString()
notes?: string;
@IsOptional()
userRating?: number;
@IsOptional()
foodItems?: any[];
@IsNumber()
version: number;
@IsString()
createdAt: string;
@IsString()
updatedAt: string;
}
/**
* Push request - local changes to server
*/
export class SyncPushDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => LocalMealDto)
meals: LocalMealDto[];
@IsArray()
@IsString({ each: true })
deletedIds: string[];
@IsOptional()
@IsString()
lastSyncAt?: string;
}
/**
* Push response
*/
export interface SyncPushResponse {
created: { localId: number; cloudId: string }[];
updated: string[];
conflicts: ConflictInfo[];
serverTime: string;
}
/**
* Conflict information
*/
export interface ConflictInfo {
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
}
/**
* Pull query parameters
*/
export class SyncPullQueryDto {
@IsOptional()
@IsString()
since?: string;
}
/**
* Pull response
*/
export interface SyncPullResponse {
meals: any[];
deletedIds: string[];
serverTime: string;
}
/**
* Sync status response
*/
export interface SyncStatusResponse {
lastSyncAt: string | null;
pendingChanges: number;
serverTime: string;
}

View file

@ -0,0 +1,50 @@
import { Controller, Get, Post, Body, Query, UseGuards } from '@nestjs/common';
import { SyncService } from './sync.service';
import {
SyncPushDto,
SyncPushResponse,
SyncPullQueryDto,
SyncPullResponse,
SyncStatusResponse,
} from './dto/sync.dto';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CurrentUser, CurrentUserData } from '../common/decorators/current-user.decorator';
@Controller('sync')
@UseGuards(JwtAuthGuard)
export class SyncController {
constructor(private readonly syncService: SyncService) {}
/**
* Push local changes to server
* POST /api/sync/push
*/
@Post('push')
async pushChanges(
@Body() dto: SyncPushDto,
@CurrentUser() user: CurrentUserData,
): Promise<SyncPushResponse> {
return this.syncService.pushChanges(user.userId, dto);
}
/**
* Pull changes from server
* GET /api/sync/pull?since=2024-01-01T00:00:00Z
*/
@Get('pull')
async pullChanges(
@Query() query: SyncPullQueryDto,
@CurrentUser() user: CurrentUserData,
): Promise<SyncPullResponse> {
return this.syncService.pullChanges(user.userId, query.since);
}
/**
* Get sync status
* GET /api/sync/status
*/
@Get('status')
async getStatus(@CurrentUser() user: CurrentUserData): Promise<SyncStatusResponse> {
return this.syncService.getStatus(user.userId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { SyncController } from './sync.controller';
import { SyncService } from './sync.service';
@Module({
controllers: [SyncController],
providers: [SyncService],
exports: [SyncService],
})
export class SyncModule {}

View file

@ -0,0 +1,251 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import {
type Database,
meals,
eq,
and,
gt,
type Meal as DbMeal,
} from '@manacore/nutriphi-database';
import { DATABASE_TOKEN } from '../database/database.module';
import {
LocalMealDto,
SyncPushDto,
SyncPushResponse,
SyncPullResponse,
SyncStatusResponse,
ConflictInfo,
} from './dto/sync.dto';
@Injectable()
export class SyncService {
private readonly logger = new Logger(SyncService.name);
constructor(@Inject(DATABASE_TOKEN) private readonly db: Database) {}
/**
* Push local changes to server
*/
async pushChanges(userId: string, dto: SyncPushDto): Promise<SyncPushResponse> {
this.logger.log(`Processing sync push for user: ${userId}, ${dto.meals.length} meals`);
const created: { localId: number; cloudId: string }[] = [];
const updated: string[] = [];
const conflicts: ConflictInfo[] = [];
const serverTime = new Date().toISOString();
// Process each meal
for (const localMeal of dto.meals) {
try {
if (localMeal.cloudId) {
// Update existing meal
const result = await this.updateExistingMeal(userId, localMeal);
if (result.conflict) {
conflicts.push(result.conflict);
} else if (result.updated) {
updated.push(localMeal.cloudId);
}
} else {
// Create new meal
const cloudId = await this.createNewMeal(userId, localMeal);
created.push({ localId: localMeal.localId, cloudId });
}
} catch (error) {
this.logger.error(`Error processing meal ${localMeal.localId}:`, error);
}
}
// Process deletions
for (const cloudId of dto.deletedIds) {
try {
await this.db
.delete(meals)
.where(and(eq(meals.id, cloudId), eq(meals.userId, userId)));
this.logger.log(`Deleted meal: ${cloudId}`);
} catch (error) {
this.logger.error(`Error deleting meal ${cloudId}:`, error);
}
}
return { created, updated, conflicts, serverTime };
}
/**
* Pull changes from server since given timestamp
*/
async pullChanges(userId: string, since?: string): Promise<SyncPullResponse> {
this.logger.log(`Processing sync pull for user: ${userId}, since: ${since}`);
const serverTime = new Date().toISOString();
let query;
if (since) {
const sinceDate = new Date(since);
query = this.db
.select()
.from(meals)
.where(and(eq(meals.userId, userId), gt(meals.updatedAt, sinceDate)));
} else {
// Full sync - get all meals
query = this.db.select().from(meals).where(eq(meals.userId, userId));
}
const results = await query;
const mappedMeals = results.map((meal) => this.mapDbMealToSync(meal));
return {
meals: mappedMeals,
deletedIds: [], // TODO: Implement soft deletes to track deleted meals
serverTime,
};
}
/**
* Get sync status
*/
async getStatus(userId: string): Promise<SyncStatusResponse> {
const serverTime = new Date().toISOString();
// Count user's meals
const result = await this.db
.select()
.from(meals)
.where(eq(meals.userId, userId));
return {
lastSyncAt: null, // Could be stored in a user preferences table
pendingChanges: 0,
serverTime,
};
}
/**
* Create a new meal from local data
*/
private async createNewMeal(userId: string, localMeal: LocalMealDto): Promise<string> {
const [result] = await this.db
.insert(meals)
.values({
userId,
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus ?? 'completed',
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
createdAt: new Date(localMeal.createdAt),
updatedAt: new Date(localMeal.updatedAt),
})
.returning();
this.logger.log(`Created meal: ${result.id} for local: ${localMeal.localId}`);
return result.id;
}
/**
* Update existing meal, checking for conflicts
*/
private async updateExistingMeal(
userId: string,
localMeal: LocalMealDto,
): Promise<{ updated: boolean; conflict?: ConflictInfo }> {
// Get current server version
const [serverMeal] = await this.db
.select()
.from(meals)
.where(and(eq(meals.id, localMeal.cloudId!), eq(meals.userId, userId)));
if (!serverMeal) {
this.logger.warn(`Meal not found: ${localMeal.cloudId}`);
return { updated: false };
}
// Simple last-write-wins strategy
// In production, you might want more sophisticated conflict resolution
const localUpdateTime = new Date(localMeal.updatedAt);
const serverUpdateTime = serverMeal.updatedAt;
// If local is newer, update server
if (localUpdateTime >= serverUpdateTime) {
await this.db
.update(meals)
.set({
foodName: localMeal.foodName,
imageUrl: localMeal.imageUrl,
calories: localMeal.calories ?? 0,
protein: localMeal.protein ?? 0,
carbohydrates: localMeal.carbohydrates ?? 0,
fat: localMeal.fat ?? 0,
fiber: localMeal.fiber ?? 0,
sugar: localMeal.sugar ?? 0,
sodium: localMeal.sodium ?? 0,
servingSize: localMeal.servingSize,
mealType: localMeal.mealType,
analysisStatus: localMeal.analysisStatus,
healthScore: localMeal.healthScore,
healthCategory: localMeal.healthCategory,
notes: localMeal.notes,
userRating: localMeal.userRating,
foodItems: localMeal.foodItems ?? [],
updatedAt: new Date(),
})
.where(eq(meals.id, localMeal.cloudId!));
this.logger.log(`Updated meal: ${localMeal.cloudId}`);
return { updated: true };
}
// Server is newer - report conflict
return {
updated: false,
conflict: {
cloudId: localMeal.cloudId!,
localVersion: localMeal.version,
serverVersion: 1, // Would need version tracking in DB
serverData: this.mapDbMealToSync(serverMeal),
message: 'Server has newer data',
},
};
}
/**
* Map database meal to sync format
*/
private mapDbMealToSync(meal: DbMeal): any {
return {
cloudId: meal.id,
userId: meal.userId,
foodName: meal.foodName,
imageUrl: meal.imageUrl,
calories: meal.calories,
protein: meal.protein,
carbohydrates: meal.carbohydrates,
fat: meal.fat,
fiber: meal.fiber,
sugar: meal.sugar,
sodium: meal.sodium,
servingSize: meal.servingSize,
mealType: meal.mealType,
analysisStatus: meal.analysisStatus,
healthScore: meal.healthScore,
healthCategory: meal.healthCategory,
notes: meal.notes,
userRating: meal.userRating,
foodItems: meal.foodItems,
createdAt: meal.createdAt.toISOString(),
updatedAt: meal.updatedAt.toISOString(),
};
}
}

View file

@ -22,8 +22,10 @@
"@react-native-async-storage/async-storage": "2.1.2",
"@react-native-clipboard/clipboard": "^1.16.2",
"@react-navigation/native": "^7.0.3",
"@supabase/supabase-js": "^2.38.4",
"expo": "^53.0.11",
"expo-application": "~6.1.4",
"expo-device": "~7.1.4",
"expo-secure-store": "~14.2.3",
"expo-blur": "^14.1.5",
"expo-camera": "^16.1.8",
"expo-constants": "~17.1.4",

View file

@ -4,6 +4,8 @@ import { SQLiteService } from './database/SQLiteService';
import { PhotoService } from './storage/PhotoService';
import { useMealStore } from '../store/MealStore';
import { useAppStore } from '../store/AppStore';
import { useAuthStore } from '../store/AuthStore';
import { tokenManager } from './auth/tokenManager';
export class DataClearingService {
private static instance: DataClearingService;
@ -46,8 +48,12 @@ export class DataClearingService {
errors.push(`AsyncStorage clearing failed: ${error}`);
}
// Note: Supabase integration will be added later
// For now, we skip Supabase sign out
try {
// 5. Sign out and clear auth tokens
await this.signOutAndClearAuth();
} catch (error) {
errors.push(`Auth clearing failed: ${error}`);
}
return {
success: errors.length === 0,
@ -55,6 +61,13 @@ export class DataClearingService {
};
}
private async signOutAndClearAuth(): Promise<void> {
// Sign out from auth store
await useAuthStore.getState().signOut();
// Clear all tokens
await tokenManager.clearTokens();
}
private async clearDatabase(): Promise<void> {
const db = SQLiteService.getInstance();
@ -120,14 +133,6 @@ export class DataClearingService {
}
}
// TODO: Implement when Supabase is configured
// private async signOutSupabase(): Promise<void> {
// const { error } = await supabase.auth.signOut();
// if (error) {
// throw new Error(`Supabase sign out error: ${error.message}`);
// }
// }
// Optional: Clear everything including theme preference
async clearAllDataIncludingTheme(): Promise<{ success: boolean; errors: string[] }> {
const result = await this.clearAllData();

View file

@ -0,0 +1,439 @@
/**
* Authentication service for Nutriphi Mobile
* Uses Mana middleware for authentication
*/
import * as Device from 'expo-device';
import * as Application from 'expo-application';
import { Platform } from 'react-native';
const MIDDLEWARE_URL = process.env.EXPO_PUBLIC_MANA_MIDDLEWARE_URL || 'https://api.manacore.de';
const APP_ID = process.env.EXPO_PUBLIC_MIDDLEWARE_APP_ID || 'nutriphi';
/**
* Get device information for authentication
*/
function getDeviceInfo() {
return {
deviceId: Application.getIosIdForVendorAsync ?
Application.androidId || `${Platform.OS}-${Date.now()}` :
`${Platform.OS}-${Date.now()}`,
deviceName: Device.deviceName || `${Device.brand} ${Device.modelName}`,
deviceType: Device.isDevice ? 'mobile' : 'simulator',
platform: Platform.OS,
};
}
/**
* Decode JWT token
*/
function decodeToken(token: string): any | null {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Use atob equivalent for React Native
const payload = JSON.parse(
decodeURIComponent(
Array.from(atob(base64))
.map((c) => '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2))
.join('')
)
);
return payload;
} catch (error) {
console.error('Error decoding token:', error);
return null;
}
}
/**
* Check if token is expired
*/
function isTokenExpired(token: string): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) return true;
// Add 10 second buffer
const bufferTime = 10 * 1000;
return Date.now() >= payload.exp * 1000 - bufferTime;
} catch {
return true;
}
}
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
appToken?: string;
refreshToken?: string;
email?: string;
}
export interface UserData {
id: string;
email: string;
role: string;
}
/**
* Authentication service
*/
export const authService = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 401) {
if (
errorData.message?.includes('Firebase user detected') ||
errorData.message?.includes('password reset required')
) {
return {
success: false,
error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED',
};
}
if (
errorData.message?.includes('Email not confirmed') ||
errorData.message?.includes('Email not verified')
) {
return {
success: false,
error: 'EMAIL_NOT_VERIFIED',
};
}
return {
success: false,
error: 'INVALID_CREDENTIALS',
};
}
return {
success: false,
error: errorData.message || 'Sign in failed',
};
}
const { appToken, refreshToken } = await response.json();
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/signup?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return {
success: false,
error: 'This email is already in use',
};
}
return {
success: false,
error: errorData.message || 'Registration failed',
};
}
const responseData = await response.json();
if (responseData.confirmationRequired) {
return {
success: true,
needsVerification: true,
};
}
const { appToken, refreshToken } = responseData;
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during registration',
};
}
},
/**
* Sign in with Google ID token
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/google-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Google Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || payload?.user_metadata?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Google:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Google Sign-In',
};
}
},
/**
* Sign in with Apple ID token
*/
async signInWithApple(idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }): Promise<AuthResult> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/apple-signin?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ token: idToken, user, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return {
success: false,
error: errorData.message || 'Apple Sign-In failed',
};
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
let email = responseData.email || user?.email;
if (!email && appToken) {
const payload = decodeToken(appToken);
email = payload?.email || '';
}
return {
success: true,
appToken,
refreshToken,
email,
};
} catch (error) {
console.error('Error signing in with Apple:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during Apple Sign-In',
};
}
},
/**
* Refresh authentication tokens
*/
async refreshTokens(
currentRefreshToken: string
): Promise<{
appToken: string;
refreshToken: string;
userData?: UserData | null;
}> {
try {
const deviceInfo = getDeviceInfo();
const response = await fetch(`${MIDDLEWARE_URL}/auth/refresh?appId=${APP_ID}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh');
}
let userData: UserData | null = null;
try {
const payload = decodeToken(appToken);
if (payload) {
userData = {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
}
} catch (error) {
console.error('Error decoding refreshed token:', error);
}
return { appToken, refreshToken, userData };
} catch (error) {
console.error('Error refreshing tokens:', error);
throw error;
}
},
/**
* Sign out
*/
async signOut(refreshToken: string): Promise<void> {
try {
await fetch(`${MIDDLEWARE_URL}/auth/logout`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Forgot password
*/
async forgotPassword(email: string): Promise<{ success: boolean; error?: string }> {
try {
const response = await fetch(`${MIDDLEWARE_URL}/auth/forgot-password`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return {
success: false,
error:
'Too many password reset attempts. Please wait a few minutes before trying again.',
};
}
return {
success: false,
error: errorData.message || 'Password reset failed',
};
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Get user data from token
*/
getUserFromToken(appToken: string): UserData | null {
try {
const payload = decodeToken(appToken);
if (!payload) return null;
return {
id: payload.sub,
email: payload.email || '',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error getting user from token:', error);
return null;
}
},
/**
* Check if token is valid locally (without network call)
*/
isTokenValidLocally(token: string): boolean {
return !isTokenExpired(token);
},
};

View file

@ -0,0 +1,120 @@
import * as SecureStore from 'expo-secure-store';
const STORAGE_KEYS = {
APP_TOKEN: 'nutriphi_app_token',
REFRESH_TOKEN: 'nutriphi_refresh_token',
USER_EMAIL: 'nutriphi_user_email',
};
/**
* Token Manager for secure storage of authentication tokens
* Uses Expo SecureStore for encrypted storage on device
*/
export const tokenManager = {
/**
* Get the app token (JWT)
*/
async getAppToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.APP_TOKEN);
} catch (error) {
console.error('Error getting app token:', error);
return null;
}
},
/**
* Set the app token
*/
async setAppToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.APP_TOKEN, token);
} catch (error) {
console.error('Error setting app token:', error);
throw error;
}
},
/**
* Get the refresh token
*/
async getRefreshToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.REFRESH_TOKEN);
} catch (error) {
console.error('Error getting refresh token:', error);
return null;
}
},
/**
* Set the refresh token
*/
async setRefreshToken(token: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.REFRESH_TOKEN, token);
} catch (error) {
console.error('Error setting refresh token:', error);
throw error;
}
},
/**
* Get the user email
*/
async getUserEmail(): Promise<string | null> {
try {
return await SecureStore.getItemAsync(STORAGE_KEYS.USER_EMAIL);
} catch (error) {
console.error('Error getting user email:', error);
return null;
}
},
/**
* Set the user email
*/
async setUserEmail(email: string): Promise<void> {
try {
await SecureStore.setItemAsync(STORAGE_KEYS.USER_EMAIL, email);
} catch (error) {
console.error('Error setting user email:', error);
throw error;
}
},
/**
* Clear all tokens (logout)
*/
async clearTokens(): Promise<void> {
try {
await Promise.all([
SecureStore.deleteItemAsync(STORAGE_KEYS.APP_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.REFRESH_TOKEN),
SecureStore.deleteItemAsync(STORAGE_KEYS.USER_EMAIL),
]);
} catch (error) {
console.error('Error clearing tokens:', error);
throw error;
}
},
/**
* Get Authorization header for API requests
*/
async getAuthHeader(): Promise<{ Authorization: string } | Record<string, never>> {
const token = await this.getAppToken();
if (token) {
return { Authorization: `Bearer ${token}` };
}
return {};
},
/**
* Check if user has tokens stored
*/
async hasTokens(): Promise<boolean> {
const token = await this.getAppToken();
return !!token;
},
};

View file

@ -400,4 +400,179 @@ export class SQLiteService {
if (!this.db) throw new Error('Database not initialized');
return await this.db.runAsync(sql, params);
}
// ==================== Sync Methods ====================
/**
* Get all unsynced meals (sync_status = 'local' or 'pending')
*/
public async getUnsyncedMeals(): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE sync_status IN ('local', 'pending') ORDER BY created_at DESC`
);
}
/**
* Get meal by cloud ID
*/
public async getMealByCloudId(cloudId: string): Promise<Meal | null> {
if (!this.db) throw new Error('Database not initialized');
const result = await this.db.getFirstAsync<Meal>(
'SELECT * FROM meals WHERE cloud_id = ?',
[cloudId]
);
return result || null;
}
/**
* Update cloud_id for a local meal
*/
public async updateCloudId(localId: number, cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync(
`UPDATE meals SET cloud_id = ?, updated_at = datetime('now') WHERE id = ?`,
[cloudId, localId]
);
}
/**
* Mark a meal as synced
*/
public async markSynced(localId: number): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync(
`UPDATE meals SET sync_status = 'synced', last_sync_at = datetime('now'), updated_at = datetime('now') WHERE id = ?`,
[localId]
);
}
/**
* Delete a meal by cloud ID
*/
public async deleteByCloudId(cloudId: string): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
await this.db.runAsync('DELETE FROM meals WHERE cloud_id = ?', [cloudId]);
}
/**
* Create a meal from server data
*/
public async createMealFromServer(serverMeal: any): Promise<number> {
if (!this.db) throw new Error('Database not initialized');
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
const result = await this.db.runAsync(
`INSERT INTO meals (
cloud_id, sync_status, version, last_sync_at,
photo_path, photo_url, timestamp, created_at, updated_at,
meal_type, analysis_result, analysis_status,
total_calories, total_protein, total_carbs, total_fat, total_fiber, total_sugar,
health_score, health_category, user_notes, user_rating
) VALUES (?, ?, ?, datetime('now'), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
serverMeal.cloudId,
'synced',
1,
serverMeal.imageUrl || '',
serverMeal.imageUrl || null,
serverMeal.createdAt,
serverMeal.createdAt,
serverMeal.updatedAt,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
]
);
return result.lastInsertRowId;
}
/**
* Update a local meal from server data
*/
public async updateMealFromServer(localId: number, serverMeal: any): Promise<void> {
if (!this.db) throw new Error('Database not initialized');
const analysisResult = serverMeal.foodItems
? JSON.stringify({
foodName: serverMeal.foodName,
foodItems: serverMeal.foodItems,
})
: null;
await this.db.runAsync(
`UPDATE meals SET
sync_status = 'synced',
last_sync_at = datetime('now'),
photo_url = ?,
meal_type = ?,
analysis_result = ?,
analysis_status = ?,
total_calories = ?,
total_protein = ?,
total_carbs = ?,
total_fat = ?,
total_fiber = ?,
total_sugar = ?,
health_score = ?,
health_category = ?,
user_notes = ?,
user_rating = ?,
updated_at = ?
WHERE id = ?`,
[
serverMeal.imageUrl || null,
serverMeal.mealType || null,
analysisResult,
serverMeal.analysisStatus || 'completed',
serverMeal.calories || null,
serverMeal.protein || null,
serverMeal.carbohydrates || null,
serverMeal.fat || null,
serverMeal.fiber || null,
serverMeal.sugar || null,
serverMeal.healthScore || null,
serverMeal.healthCategory || null,
serverMeal.notes || null,
serverMeal.userRating || null,
serverMeal.updatedAt,
localId,
]
);
}
/**
* Get meals modified since a given timestamp
*/
public async getMealsModifiedSince(since: string): Promise<Meal[]> {
if (!this.db) throw new Error('Database not initialized');
return await this.db.getAllAsync<Meal>(
`SELECT * FROM meals WHERE updated_at > ? ORDER BY updated_at ASC`,
[since]
);
}
}

View file

@ -0,0 +1,347 @@
import { tokenManager } from '../auth/tokenManager';
import { SQLiteService } from '../database/SQLiteService';
import type { Meal } from '../../types/Database';
const BACKEND_URL = process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3002';
export interface SyncResult {
success: boolean;
created: number;
updated: number;
deleted: number;
conflicts: ConflictInfo[];
error?: string;
}
export interface ConflictInfo {
cloudId: string;
localVersion: number;
serverVersion: number;
serverData: any;
message: string;
}
export interface LocalMealForSync {
localId: number;
cloudId?: string;
foodName: string;
imageUrl?: string;
calories?: number;
protein?: number;
carbohydrates?: number;
fat?: number;
fiber?: number;
sugar?: number;
sodium?: number;
servingSize?: string;
mealType?: string;
analysisStatus?: string;
healthScore?: number;
healthCategory?: string;
notes?: string;
userRating?: number;
foodItems?: any[];
version: number;
createdAt: string;
updatedAt: string;
}
/**
* Sync Service for synchronizing local SQLite data with the backend
*/
export class SyncService {
private static instance: SyncService;
private isSyncing = false;
private lastSyncAt: string | null = null;
private constructor() {}
public static getInstance(): SyncService {
if (!SyncService.instance) {
SyncService.instance = new SyncService();
}
return SyncService.instance;
}
/**
* Check if sync is currently in progress
*/
public isSyncInProgress(): boolean {
return this.isSyncing;
}
/**
* Get last sync timestamp
*/
public getLastSyncAt(): string | null {
return this.lastSyncAt;
}
/**
* Perform a full sync (push + pull)
*/
public async fullSync(): Promise<SyncResult> {
if (this.isSyncing) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Sync already in progress',
};
}
this.isSyncing = true;
try {
// First push local changes
const pushResult = await this.pushChanges();
if (!pushResult.success) {
return pushResult;
}
// Then pull server changes
const pullResult = await this.pullChanges();
return {
success: pullResult.success,
created: pushResult.created + pullResult.created,
updated: pushResult.updated + pullResult.updated,
deleted: pullResult.deleted,
conflicts: pushResult.conflicts,
error: pullResult.error,
};
} finally {
this.isSyncing = false;
}
}
/**
* Push local changes to server
*/
public async pushChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
const db = SQLiteService.getInstance();
// Get unsynced meals
const unsyncedMeals = await db.getUnsyncedMeals();
if (unsyncedMeals.length === 0) {
return {
success: true,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
};
}
// Map to sync format
const mealsForSync: LocalMealForSync[] = unsyncedMeals.map((meal) =>
this.mapMealToSyncFormat(meal)
);
// Get deleted meals (meals marked for deletion)
const deletedIds: string[] = []; // TODO: Implement delete tracking
const response = await fetch(`${BACKEND_URL}/api/sync/push`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
body: JSON.stringify({
meals: mealsForSync,
deletedIds,
lastSyncAt: this.lastSyncAt,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Push failed',
};
}
const result = await response.json();
// Update local records with cloud IDs
for (const created of result.created) {
await db.updateCloudId(created.localId, created.cloudId);
await db.markSynced(created.localId);
}
// Mark updated records as synced
for (const cloudId of result.updated) {
const meal = unsyncedMeals.find((m) => m.cloud_id === cloudId);
if (meal && meal.id) {
await db.markSynced(meal.id);
}
}
this.lastSyncAt = result.serverTime;
return {
success: true,
created: result.created.length,
updated: result.updated.length,
deleted: 0,
conflicts: result.conflicts || [],
};
} catch (error) {
console.error('Push sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Push failed',
};
}
}
/**
* Pull changes from server
*/
public async pullChanges(): Promise<SyncResult> {
try {
const authHeader = await tokenManager.getAuthHeader();
if (!authHeader.Authorization) {
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: 'Not authenticated',
};
}
const url = new URL(`${BACKEND_URL}/api/sync/pull`);
if (this.lastSyncAt) {
url.searchParams.set('since', this.lastSyncAt);
}
const response = await fetch(url.toString(), {
method: 'GET',
headers: {
'Content-Type': 'application/json',
...authHeader,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error.message || 'Pull failed',
};
}
const result = await response.json();
const db = SQLiteService.getInstance();
let created = 0;
let updated = 0;
let deleted = 0;
// Process server meals
for (const serverMeal of result.meals) {
const existingMeal = await db.getMealByCloudId(serverMeal.cloudId);
if (existingMeal) {
// Update existing local meal
await db.updateMealFromServer(existingMeal.id!, serverMeal);
updated++;
} else {
// Create new local meal
await db.createMealFromServer(serverMeal);
created++;
}
}
// Process deletions
for (const cloudId of result.deletedIds) {
await db.deleteByCloudId(cloudId);
deleted++;
}
this.lastSyncAt = result.serverTime;
return {
success: true,
created,
updated,
deleted,
conflicts: [],
};
} catch (error) {
console.error('Pull sync error:', error);
return {
success: false,
created: 0,
updated: 0,
deleted: 0,
conflicts: [],
error: error instanceof Error ? error.message : 'Pull failed',
};
}
}
/**
* Map local meal to sync format
*/
private mapMealToSyncFormat(meal: Meal): LocalMealForSync {
return {
localId: meal.id!,
cloudId: meal.cloud_id || undefined,
foodName: meal.analysis_result
? JSON.parse(meal.analysis_result).foodName || 'Unbekanntes Gericht'
: 'Unbekanntes Gericht',
imageUrl: meal.photo_url || undefined,
calories: meal.total_calories || undefined,
protein: meal.total_protein || undefined,
carbohydrates: meal.total_carbs || undefined,
fat: meal.total_fat || undefined,
fiber: meal.total_fiber || undefined,
sugar: meal.total_sugar || undefined,
servingSize: undefined,
mealType: meal.meal_type || undefined,
analysisStatus: meal.analysis_status || 'completed',
healthScore: meal.health_score || undefined,
healthCategory: meal.health_category || undefined,
notes: meal.user_notes || undefined,
userRating: meal.user_rating || undefined,
foodItems: meal.analysis_result
? JSON.parse(meal.analysis_result).foodItems
: [],
version: meal.version || 1,
createdAt: meal.created_at || new Date().toISOString(),
updatedAt: meal.updated_at || new Date().toISOString(),
};
}
}

View file

@ -0,0 +1,300 @@
import { create } from 'zustand';
import { authService, type UserData, type AuthResult } from '../services/auth/authService';
import { tokenManager } from '../services/auth/tokenManager';
interface AuthState {
// State
user: UserData | null;
isAuthenticated: boolean;
isLoading: boolean;
isInitialized: boolean;
error: string | null;
// Actions
initialize: () => Promise<void>;
signIn: (email: string, password: string) => Promise<{ success: boolean; error?: string }>;
signUp: (email: string, password: string) => Promise<{ success: boolean; error?: string; needsVerification?: boolean }>;
signInWithGoogle: (idToken: string) => Promise<{ success: boolean; error?: string }>;
signInWithApple: (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => Promise<{ success: boolean; error?: string }>;
signOut: () => Promise<void>;
forgotPassword: (email: string) => Promise<{ success: boolean; error?: string }>;
refreshAuth: () => Promise<boolean>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((set, get) => ({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: false,
error: null,
/**
* Initialize auth state from stored tokens
*/
initialize: async () => {
if (get().isInitialized) return;
set({ isLoading: true });
try {
const token = await tokenManager.getAppToken();
if (!token) {
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
return;
}
// Check if token is still valid
if (authService.isTokenValidLocally(token)) {
const userData = authService.getUserFromToken(token);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
// Try to refresh token
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
try {
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, isInitialized: true });
return;
}
}
} catch (error) {
console.error('Failed to refresh token on init:', error);
}
}
// Clear invalid tokens
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, isInitialized: true });
} catch (error) {
console.error('Error initializing auth:', error);
set({
user: null,
isAuthenticated: false,
isLoading: false,
isInitialized: true,
error: 'Failed to initialize authentication',
});
}
},
/**
* Sign in with email and password
*/
signIn: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signIn(email, password);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign in failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign in';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign up with email and password
*/
signUp: async (email: string, password: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signUp(email, password);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Sign up failed' });
return { success: false, error: result.error };
}
if (result.needsVerification) {
set({ isLoading: false, error: null });
return { success: true, needsVerification: true };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error during sign up';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign in with Google
*/
signInWithGoogle: async (idToken: string) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signInWithGoogle(idToken);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Google Sign-In failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Google Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign in with Apple
*/
signInWithApple: async (idToken: string, user?: { email?: string; fullName?: { givenName?: string; familyName?: string } }) => {
set({ isLoading: true, error: null });
try {
const result = await authService.signInWithApple(idToken, user);
if (!result.success) {
set({ isLoading: false, error: result.error || 'Apple Sign-In failed' });
return { success: false, error: result.error };
}
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.email) {
await tokenManager.setUserEmail(result.email);
}
const userData = authService.getUserFromToken(result.appToken);
if (userData) {
set({ user: userData, isAuthenticated: true, isLoading: false, error: null });
return { success: true };
}
}
throw new Error('Invalid auth response');
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error during Apple Sign-In';
set({ isLoading: false, error: errorMessage });
return { success: false, error: errorMessage };
}
},
/**
* Sign out
*/
signOut: async () => {
set({ isLoading: true });
try {
const refreshToken = await tokenManager.getRefreshToken();
if (refreshToken) {
await authService.signOut(refreshToken);
}
} catch (error) {
console.error('Error during sign out:', error);
} finally {
await tokenManager.clearTokens();
set({ user: null, isAuthenticated: false, isLoading: false, error: null });
}
},
/**
* Forgot password
*/
forgotPassword: async (email: string) => {
return authService.forgotPassword(email);
},
/**
* Refresh authentication tokens
*/
refreshAuth: async () => {
try {
const refreshToken = await tokenManager.getRefreshToken();
if (!refreshToken) {
return false;
}
const result = await authService.refreshTokens(refreshToken);
if (result.appToken && result.refreshToken) {
await tokenManager.setAppToken(result.appToken);
await tokenManager.setRefreshToken(result.refreshToken);
if (result.userData) {
set({ user: result.userData });
}
return true;
}
return false;
} catch (error) {
console.error('Error refreshing auth:', error);
return false;
}
},
/**
* Clear error state
*/
clearError: () => set({ error: null }),
}));

View file

@ -1,14 +0,0 @@
import AsyncStorage from '@react-native-async-storage/async-storage';
import { createClient } from '@supabase/supabase-js';
const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL;
const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY;
export const supabase = createClient(supabaseUrl, supabaseAnonKey, {
auth: {
storage: AsyncStorage,
autoRefreshToken: true,
persistSession: true,
detectSessionInUrl: false,
},
});

View file

@ -0,0 +1,19 @@
# Server
PORT=3003
NODE_ENV=development
# Database
DATABASE_URL=postgresql://picture:password@localhost:5432/picture
# Mana Core Auth
MANA_CORE_AUTH_URL=http://localhost:3001
# Supabase Storage (Service Role for Backend)
SUPABASE_URL=https://xxx.supabase.co
SUPABASE_SERVICE_ROLE_KEY=eyJ...
# Replicate API
REPLICATE_API_TOKEN=r8_xxx
# Webhook (for Replicate Callbacks in Production)
WEBHOOK_BASE_URL=http://localhost:3003

View file

@ -0,0 +1,13 @@
import { defineConfig } from 'drizzle-kit';
import * as dotenv from 'dotenv';
dotenv.config();
export default defineConfig({
schema: './src/db/schema/index.ts',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgresql://picture:password@localhost:5432/picture',
},
});

View file

@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

View file

@ -0,0 +1,58 @@
{
"name": "@picture/backend",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "nest build",
"start": "nest start",
"dev": "nest start --watch",
"start:dev": "nest start --watch",
"start:debug": "nest start --debug --watch",
"start:prod": "node dist/main",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"type-check": "tsc --noEmit",
"migration:generate": "drizzle-kit generate",
"migration:run": "tsx src/db/migrate.ts",
"db:push": "drizzle-kit push",
"db:studio": "drizzle-kit studio",
"db:seed": "tsx src/db/seed.ts"
},
"dependencies": {
"@manacore/shared-errors": "workspace:*",
"@nestjs/common": "^10.4.15",
"@nestjs/config": "^3.3.0",
"@nestjs/core": "^10.4.15",
"@nestjs/platform-express": "^10.4.15",
"@supabase/supabase-js": "^2.45.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dotenv": "^16.4.7",
"drizzle-kit": "^0.30.2",
"drizzle-orm": "^0.38.3",
"multer": "^1.4.5-lts.1",
"postgres": "^3.4.5",
"reflect-metadata": "^0.2.2",
"replicate": "^0.32.0",
"rxjs": "^7.8.1",
"sharp": "^0.33.0"
},
"devDependencies": {
"@nestjs/cli": "^10.4.9",
"@nestjs/schematics": "^10.2.3",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.11",
"@types/node": "^22.10.2",
"@typescript-eslint/eslint-plugin": "^8.18.1",
"@typescript-eslint/parser": "^8.18.1",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"prettier": "^3.4.2",
"source-map-support": "^0.5.21",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}

View file

@ -0,0 +1,32 @@
import { Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { DatabaseModule } from './db/database.module';
import { HealthModule } from './health/health.module';
import { ModelModule } from './model/model.module';
import { TagModule } from './tag/tag.module';
import { ImageModule } from './image/image.module';
import { BoardModule } from './board/board.module';
import { BoardItemModule } from './board-item/board-item.module';
import { UploadModule } from './upload/upload.module';
import { GenerateModule } from './generate/generate.module';
import { ExploreModule } from './explore/explore.module';
@Module({
imports: [
ConfigModule.forRoot({
isGlobal: true,
envFilePath: '.env',
}),
DatabaseModule,
HealthModule,
ModelModule,
TagModule,
ImageModule,
BoardModule,
BoardItemModule,
UploadModule,
GenerateModule,
ExploreModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,114 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { BoardItemService } from './board-item.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import {
AddImageToBoardDto,
AddTextToBoardDto,
UpdateBoardItemDto,
UpdateBoardItemsDto,
RemoveBoardItemsDto,
ChangeZIndexDto,
} from './dto/board-item.dto';
@Controller('board-items')
@UseGuards(JwtAuthGuard)
export class BoardItemController {
constructor(private readonly boardItemService: BoardItemService) {}
@Get('board/:boardId')
async getBoardItems(
@CurrentUser() user: CurrentUserData,
@Param('boardId') boardId: string,
) {
return this.boardItemService.getBoardItems(boardId, user.userId);
}
@Get(':id')
async getBoardItemById(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardItemService.getBoardItemById(id, user.userId);
}
@Post('image')
async addImageToBoard(
@CurrentUser() user: CurrentUserData,
@Body() dto: AddImageToBoardDto,
) {
return this.boardItemService.addImageToBoard(user.userId, dto);
}
@Post('text')
async addTextToBoard(
@CurrentUser() user: CurrentUserData,
@Body() dto: AddTextToBoardDto,
) {
return this.boardItemService.addTextToBoard(user.userId, dto);
}
@Patch(':id')
async updateBoardItem(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardItemDto,
) {
return this.boardItemService.updateBoardItem(id, user.userId, dto);
}
@Patch('batch')
async updateBoardItems(
@CurrentUser() user: CurrentUserData,
@Body() dto: UpdateBoardItemsDto,
) {
return this.boardItemService.updateBoardItems(user.userId, dto.items);
}
@Delete(':id')
async removeBoardItem(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardItemService.removeBoardItem(id, user.userId);
}
@Delete('batch')
async removeBoardItems(
@CurrentUser() user: CurrentUserData,
@Body() dto: RemoveBoardItemsDto,
) {
return this.boardItemService.removeBoardItems(user.userId, dto.ids);
}
@Patch(':id/z-index')
async changeZIndex(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ChangeZIndexDto,
) {
return this.boardItemService.changeZIndex(id, user.userId, dto.direction);
}
@Get('check/:boardId/:imageId')
async isImageOnBoard(
@CurrentUser() user: CurrentUserData,
@Param('boardId') boardId: string,
@Param('imageId') imageId: string,
) {
const result = await this.boardItemService.isImageOnBoard(boardId, imageId);
return { isOnBoard: result };
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BoardItemController } from './board-item.controller';
import { BoardItemService } from './board-item.service';
@Module({
controllers: [BoardItemController],
providers: [BoardItemService],
exports: [BoardItemService],
})
export class BoardItemModule {}

View file

@ -0,0 +1,515 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { eq, and, max, inArray, gt, lt } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { boards, boardItems, images, type BoardItem } from '../db/schema';
import {
AddImageToBoardDto,
AddTextToBoardDto,
UpdateBoardItemDto,
} from './dto/board-item.dto';
export interface BoardItemWithImage extends BoardItem {
image?: {
id: string;
publicUrl: string | null;
width: number | null;
height: number | null;
prompt: string;
blurhash: string | null;
};
}
@Injectable()
export class BoardItemService {
private readonly logger = new Logger(BoardItemService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getBoardItems(
boardId: string,
userId: string,
): Promise<BoardItemWithImage[]> {
try {
await this.verifyBoardAccess(boardId, userId);
const result = await this.db
.select({
id: boardItems.id,
boardId: boardItems.boardId,
imageId: boardItems.imageId,
itemType: boardItems.itemType,
positionX: boardItems.positionX,
positionY: boardItems.positionY,
scaleX: boardItems.scaleX,
scaleY: boardItems.scaleY,
rotation: boardItems.rotation,
zIndex: boardItems.zIndex,
opacity: boardItems.opacity,
width: boardItems.width,
height: boardItems.height,
textContent: boardItems.textContent,
fontSize: boardItems.fontSize,
color: boardItems.color,
properties: boardItems.properties,
createdAt: boardItems.createdAt,
image: {
id: images.id,
publicUrl: images.publicUrl,
width: images.width,
height: images.height,
prompt: images.prompt,
blurhash: images.blurhash,
},
})
.from(boardItems)
.leftJoin(images, eq(boardItems.imageId, images.id))
.where(eq(boardItems.boardId, boardId))
.orderBy(boardItems.zIndex);
return result as BoardItemWithImage[];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching board items for board ${boardId}`, error);
throw error;
}
}
async getBoardItemById(
id: string,
userId: string,
): Promise<BoardItemWithImage> {
try {
const result = await this.db
.select({
id: boardItems.id,
boardId: boardItems.boardId,
imageId: boardItems.imageId,
itemType: boardItems.itemType,
positionX: boardItems.positionX,
positionY: boardItems.positionY,
scaleX: boardItems.scaleX,
scaleY: boardItems.scaleY,
rotation: boardItems.rotation,
zIndex: boardItems.zIndex,
opacity: boardItems.opacity,
width: boardItems.width,
height: boardItems.height,
textContent: boardItems.textContent,
fontSize: boardItems.fontSize,
color: boardItems.color,
properties: boardItems.properties,
createdAt: boardItems.createdAt,
image: {
id: images.id,
publicUrl: images.publicUrl,
width: images.width,
height: images.height,
prompt: images.prompt,
blurhash: images.blurhash,
},
})
.from(boardItems)
.leftJoin(images, eq(boardItems.imageId, images.id))
.where(eq(boardItems.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
const item = result[0];
await this.verifyBoardAccess(item.boardId, userId);
return item as BoardItemWithImage;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching board item ${id}`, error);
throw error;
}
}
async addImageToBoard(
userId: string,
dto: AddImageToBoardDto,
): Promise<BoardItem> {
try {
await this.verifyBoardOwnership(dto.boardId, userId);
const nextZIndex = await this.getNextZIndex(dto.boardId);
const result = await this.db
.insert(boardItems)
.values({
boardId: dto.boardId,
imageId: dto.imageId,
itemType: 'image',
positionX: dto.positionX || 100,
positionY: dto.positionY || 100,
zIndex: nextZIndex,
})
.returning();
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, dto.boardId));
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error('Error adding image to board', error);
throw error;
}
}
async addTextToBoard(
userId: string,
dto: AddTextToBoardDto,
): Promise<BoardItem> {
try {
await this.verifyBoardOwnership(dto.boardId, userId);
const nextZIndex = await this.getNextZIndex(dto.boardId);
const result = await this.db
.insert(boardItems)
.values({
boardId: dto.boardId,
itemType: 'text',
positionX: dto.positionX || 100,
positionY: dto.positionY || 100,
textContent: dto.content || 'New Text',
fontSize: dto.fontSize || 24,
color: dto.color || '#000000',
properties: dto.properties,
zIndex: nextZIndex,
})
.returning();
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, dto.boardId));
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error('Error adding text to board', error);
throw error;
}
}
async updateBoardItem(
id: string,
userId: string,
dto: UpdateBoardItemDto,
): Promise<BoardItem> {
try {
const item = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.id, id))
.limit(1);
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
await this.verifyBoardOwnership(item[0].boardId, userId);
const result = await this.db
.update(boardItems)
.set({
...(dto.positionX !== undefined && { positionX: dto.positionX }),
...(dto.positionY !== undefined && { positionY: dto.positionY }),
...(dto.scaleX !== undefined && { scaleX: dto.scaleX }),
...(dto.scaleY !== undefined && { scaleY: dto.scaleY }),
...(dto.rotation !== undefined && { rotation: dto.rotation }),
...(dto.zIndex !== undefined && { zIndex: dto.zIndex }),
...(dto.opacity !== undefined && { opacity: dto.opacity }),
...(dto.width !== undefined && { width: dto.width }),
...(dto.height !== undefined && { height: dto.height }),
...(dto.textContent !== undefined && { textContent: dto.textContent }),
...(dto.fontSize !== undefined && { fontSize: dto.fontSize }),
...(dto.color !== undefined && { color: dto.color }),
...(dto.properties !== undefined && { properties: dto.properties }),
})
.where(eq(boardItems.id, id))
.returning();
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, item[0].boardId));
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error updating board item ${id}`, error);
throw error;
}
}
async updateBoardItems(
userId: string,
items: Array<{ id: string } & UpdateBoardItemDto>,
): Promise<void> {
try {
for (const item of items) {
await this.updateBoardItem(item.id, userId, item);
}
} catch (error) {
this.logger.error('Error batch updating board items', error);
throw error;
}
}
async removeBoardItem(id: string, userId: string): Promise<void> {
try {
const item = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.id, id))
.limit(1);
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
await this.verifyBoardOwnership(item[0].boardId, userId);
await this.db.delete(boardItems).where(eq(boardItems.id, id));
// Update board's updatedAt
await this.db
.update(boards)
.set({ updatedAt: new Date() })
.where(eq(boards.id, item[0].boardId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error removing board item ${id}`, error);
throw error;
}
}
async removeBoardItems(userId: string, ids: string[]): Promise<void> {
try {
for (const id of ids) {
await this.removeBoardItem(id, userId);
}
} catch (error) {
this.logger.error('Error batch removing board items', error);
throw error;
}
}
async changeZIndex(
id: string,
userId: string,
direction: 'up' | 'down' | 'top' | 'bottom',
): Promise<BoardItem> {
try {
const item = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.id, id))
.limit(1);
if (item.length === 0) {
throw new NotFoundException(`Board item with id ${id} not found`);
}
await this.verifyBoardOwnership(item[0].boardId, userId);
const currentZIndex = item[0].zIndex;
let newZIndex: number;
if (direction === 'top') {
const maxResult = await this.db
.select({ maxZ: max(boardItems.zIndex) })
.from(boardItems)
.where(eq(boardItems.boardId, item[0].boardId));
newZIndex = (maxResult[0]?.maxZ || 0) + 1;
} else if (direction === 'bottom') {
newZIndex = 0;
// Shift all other items up
await this.db
.update(boardItems)
.set({ zIndex: boardItems.zIndex + 1 } as any)
.where(eq(boardItems.boardId, item[0].boardId));
} else if (direction === 'up') {
// Find the next item above
const above = await this.db
.select()
.from(boardItems)
.where(
and(
eq(boardItems.boardId, item[0].boardId),
gt(boardItems.zIndex, currentZIndex),
),
)
.orderBy(boardItems.zIndex)
.limit(1);
if (above.length > 0) {
// Swap z-indices
await this.db
.update(boardItems)
.set({ zIndex: currentZIndex })
.where(eq(boardItems.id, above[0].id));
newZIndex = above[0].zIndex;
} else {
newZIndex = currentZIndex;
}
} else {
// down
const below = await this.db
.select()
.from(boardItems)
.where(
and(
eq(boardItems.boardId, item[0].boardId),
lt(boardItems.zIndex, currentZIndex),
),
)
.orderBy(boardItems.zIndex)
.limit(1);
if (below.length > 0) {
// Swap z-indices
await this.db
.update(boardItems)
.set({ zIndex: currentZIndex })
.where(eq(boardItems.id, below[0].id));
newZIndex = below[0].zIndex;
} else {
newZIndex = currentZIndex;
}
}
const result = await this.db
.update(boardItems)
.set({ zIndex: newZIndex })
.where(eq(boardItems.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error changing z-index for board item ${id}`, error);
throw error;
}
}
async isImageOnBoard(boardId: string, imageId: string): Promise<boolean> {
try {
const result = await this.db
.select()
.from(boardItems)
.where(
and(eq(boardItems.boardId, boardId), eq(boardItems.imageId, imageId)),
)
.limit(1);
return result.length > 0;
} catch (error) {
this.logger.error(
`Error checking if image ${imageId} is on board ${boardId}`,
error,
);
throw error;
}
}
private async getNextZIndex(boardId: string): Promise<number> {
const result = await this.db
.select({ maxZ: max(boardItems.zIndex) })
.from(boardItems)
.where(eq(boardItems.boardId, boardId));
return (result[0]?.maxZ || 0) + 1;
}
private async verifyBoardAccess(
boardId: string,
userId: string,
): Promise<void> {
const result = await this.db
.select({ userId: boards.userId, isPublic: boards.isPublic })
.from(boards)
.where(eq(boards.id, boardId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${boardId} not found`);
}
if (result[0].userId !== userId && !result[0].isPublic) {
throw new ForbiddenException('Access denied');
}
}
private async verifyBoardOwnership(
boardId: string,
userId: string,
): Promise<void> {
const result = await this.db
.select({ userId: boards.userId })
.from(boards)
.where(eq(boards.id, boardId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${boardId} not found`);
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
}

View file

@ -0,0 +1,134 @@
import {
IsString,
IsOptional,
IsNumber,
IsArray,
ValidateNested,
IsObject,
IsIn,
} from 'class-validator';
import { Type } from 'class-transformer';
import { TextProperties } from '../../db/schema/board-items.schema';
export class AddImageToBoardDto {
@IsString()
boardId: string;
@IsString()
imageId: string;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionY?: number;
}
export class AddTextToBoardDto {
@IsString()
boardId: string;
@IsString()
@IsOptional()
content?: string;
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
fontSize?: number;
@IsString()
@IsOptional()
color?: string;
@IsObject()
@IsOptional()
properties?: TextProperties;
}
export class UpdateBoardItemDto {
@IsNumber()
@IsOptional()
positionX?: number;
@IsNumber()
@IsOptional()
positionY?: number;
@IsNumber()
@IsOptional()
scaleX?: number;
@IsNumber()
@IsOptional()
scaleY?: number;
@IsNumber()
@IsOptional()
rotation?: number;
@IsNumber()
@IsOptional()
zIndex?: number;
@IsNumber()
@IsOptional()
opacity?: number;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsString()
@IsOptional()
textContent?: string;
@IsNumber()
@IsOptional()
fontSize?: number;
@IsString()
@IsOptional()
color?: string;
@IsObject()
@IsOptional()
properties?: TextProperties;
}
export class UpdateBoardItemWithIdDto extends UpdateBoardItemDto {
@IsString()
id: string;
}
export class UpdateBoardItemsDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => UpdateBoardItemWithIdDto)
items: UpdateBoardItemWithIdDto[];
}
export class RemoveBoardItemsDto {
@IsArray()
@IsString({ each: true })
ids: string[];
}
export class ChangeZIndexDto {
@IsString()
@IsIn(['up', 'down', 'top', 'bottom'])
direction: 'up' | 'down' | 'top' | 'bottom';
}

View file

@ -0,0 +1,102 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { BoardService } from './board.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import {
CreateBoardDto,
UpdateBoardDto,
GetBoardsQueryDto,
GenerateThumbnailDto,
ToggleVisibilityDto,
} from './dto/board.dto';
@Controller('boards')
@UseGuards(JwtAuthGuard)
export class BoardController {
constructor(private readonly boardService: BoardService) {}
@Get()
async getBoards(
@CurrentUser() user: CurrentUserData,
@Query() query: GetBoardsQueryDto,
) {
return this.boardService.getBoards(user.userId, query);
}
@Get('public')
async getPublicBoards(@Query() query: GetBoardsQueryDto) {
return this.boardService.getPublicBoards(query);
}
@Get(':id')
async getBoardById(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardService.getBoardById(id, user.userId);
}
@Post()
async createBoard(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateBoardDto,
) {
return this.boardService.createBoard(user.userId, dto);
}
@Patch(':id')
async updateBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: UpdateBoardDto,
) {
return this.boardService.updateBoard(id, user.userId, dto);
}
@Delete(':id')
async deleteBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardService.deleteBoard(id, user.userId);
}
@Post(':id/duplicate')
async duplicateBoard(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.boardService.duplicateBoard(id, user.userId);
}
@Post(':id/thumbnail')
async generateThumbnail(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: GenerateThumbnailDto,
) {
return this.boardService.generateThumbnail(id, user.userId, dto.dataUrl);
}
@Patch(':id/visibility')
async toggleVisibility(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ToggleVisibilityDto,
) {
return this.boardService.toggleVisibility(id, user.userId, dto.isPublic);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { BoardController } from './board.controller';
import { BoardService } from './board.service';
@Module({
controllers: [BoardController],
providers: [BoardService],
exports: [BoardService],
})
export class BoardModule {}

View file

@ -0,0 +1,403 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { eq, and, or, desc, sql } from 'drizzle-orm';
import { ConfigService } from '@nestjs/config';
import { createClient } from '@supabase/supabase-js';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { boards, boardItems, type Board } from '../db/schema';
import { CreateBoardDto, UpdateBoardDto, GetBoardsQueryDto } from './dto/board.dto';
export interface BoardWithCount extends Board {
itemCount: number;
}
@Injectable()
export class BoardService {
private readonly logger = new Logger(BoardService.name);
private supabase: ReturnType<typeof createClient>;
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private configService: ConfigService,
) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
}
}
async getBoards(
userId: string,
query: GetBoardsQueryDto,
): Promise<BoardWithCount[]> {
try {
const { page = 1, limit = 20, includePublic = false } = query;
const offset = (page - 1) * limit;
const conditions = includePublic
? or(eq(boards.userId, userId), eq(boards.isPublic, true))
: eq(boards.userId, userId);
const result = await this.db
.select({
id: boards.id,
userId: boards.userId,
name: boards.name,
description: boards.description,
thumbnailUrl: boards.thumbnailUrl,
canvasWidth: boards.canvasWidth,
canvasHeight: boards.canvasHeight,
backgroundColor: boards.backgroundColor,
isPublic: boards.isPublic,
createdAt: boards.createdAt,
updatedAt: boards.updatedAt,
itemCount: sql<number>`(
SELECT COUNT(*)::int FROM ${boardItems}
WHERE ${boardItems.boardId} = ${boards.id}
)`,
})
.from(boards)
.where(conditions)
.orderBy(desc(boards.updatedAt))
.limit(limit)
.offset(offset);
return result as BoardWithCount[];
} catch (error) {
this.logger.error('Error fetching boards', error);
throw error;
}
}
async getPublicBoards(query: GetBoardsQueryDto): Promise<BoardWithCount[]> {
try {
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
const result = await this.db
.select({
id: boards.id,
userId: boards.userId,
name: boards.name,
description: boards.description,
thumbnailUrl: boards.thumbnailUrl,
canvasWidth: boards.canvasWidth,
canvasHeight: boards.canvasHeight,
backgroundColor: boards.backgroundColor,
isPublic: boards.isPublic,
createdAt: boards.createdAt,
updatedAt: boards.updatedAt,
itemCount: sql<number>`(
SELECT COUNT(*)::int FROM ${boardItems}
WHERE ${boardItems.boardId} = ${boards.id}
)`,
})
.from(boards)
.where(eq(boards.isPublic, true))
.orderBy(desc(boards.updatedAt))
.limit(limit)
.offset(offset);
return result as BoardWithCount[];
} catch (error) {
this.logger.error('Error fetching public boards', error);
throw error;
}
}
async getBoardById(id: string, userId: string): Promise<Board> {
try {
const result = await this.db
.select()
.from(boards)
.where(eq(boards.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
const board = result[0];
// Check access
if (board.userId !== userId && !board.isPublic) {
throw new ForbiddenException('Access denied');
}
return board;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching board ${id}`, error);
throw error;
}
}
async createBoard(userId: string, dto: CreateBoardDto): Promise<Board> {
try {
const result = await this.db
.insert(boards)
.values({
userId,
name: dto.name,
description: dto.description,
canvasWidth: dto.canvasWidth || 2000,
canvasHeight: dto.canvasHeight || 1500,
backgroundColor: dto.backgroundColor || '#ffffff',
isPublic: dto.isPublic || false,
})
.returning();
return result[0];
} catch (error) {
this.logger.error('Error creating board', error);
throw error;
}
}
async updateBoard(
id: string,
userId: string,
dto: UpdateBoardDto,
): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(boards)
.set({
...(dto.name && { name: dto.name }),
...(dto.description !== undefined && { description: dto.description }),
...(dto.canvasWidth && { canvasWidth: dto.canvasWidth }),
...(dto.canvasHeight && { canvasHeight: dto.canvasHeight }),
...(dto.backgroundColor && { backgroundColor: dto.backgroundColor }),
...(dto.isPublic !== undefined && { isPublic: dto.isPublic }),
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error updating board ${id}`, error);
throw error;
}
}
async deleteBoard(id: string, userId: string): Promise<void> {
try {
await this.verifyOwnership(id, userId);
// Delete board items first
await this.db.delete(boardItems).where(eq(boardItems.boardId, id));
// Delete the board
await this.db.delete(boards).where(eq(boards.id, id));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting board ${id}`, error);
throw error;
}
}
async duplicateBoard(id: string, userId: string): Promise<Board> {
try {
// Get original board (user can duplicate public boards too)
const original = await this.db
.select()
.from(boards)
.where(eq(boards.id, id))
.limit(1);
if (original.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
const board = original[0];
// Check access
if (board.userId !== userId && !board.isPublic) {
throw new ForbiddenException('Access denied');
}
// Create new board
const newBoard = await this.db
.insert(boards)
.values({
userId,
name: `${board.name} (Copy)`,
description: board.description,
canvasWidth: board.canvasWidth,
canvasHeight: board.canvasHeight,
backgroundColor: board.backgroundColor,
isPublic: false,
})
.returning();
// Copy board items
const items = await this.db
.select()
.from(boardItems)
.where(eq(boardItems.boardId, id));
if (items.length > 0) {
await this.db.insert(boardItems).values(
items.map((item) => ({
boardId: newBoard[0].id,
imageId: item.imageId,
itemType: item.itemType,
positionX: item.positionX,
positionY: item.positionY,
scaleX: item.scaleX,
scaleY: item.scaleY,
rotation: item.rotation,
zIndex: item.zIndex,
opacity: item.opacity,
width: item.width,
height: item.height,
textContent: item.textContent,
fontSize: item.fontSize,
color: item.color,
properties: item.properties,
})),
);
}
return newBoard[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error duplicating board ${id}`, error);
throw error;
}
}
async generateThumbnail(
id: string,
userId: string,
dataUrl: string,
): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
if (!this.supabase) {
throw new Error('Supabase not configured');
}
// Convert data URL to buffer
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
// Upload to Supabase Storage
const filename = `boards/${id}/thumbnail-${Date.now()}.png`;
const { error: uploadError } = await this.supabase.storage
.from('user-uploads')
.upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
if (uploadError) {
throw uploadError;
}
// Get public URL
const { data: urlData } = this.supabase.storage
.from('user-uploads')
.getPublicUrl(filename);
// Update board with thumbnail URL
const result = await this.db
.update(boards)
.set({
thumbnailUrl: urlData.publicUrl,
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error generating thumbnail for board ${id}`, error);
throw error;
}
}
async toggleVisibility(
id: string,
userId: string,
isPublic: boolean,
): Promise<Board> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(boards)
.set({
isPublic,
updatedAt: new Date(),
})
.where(eq(boards.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error toggling visibility for board ${id}`, error);
throw error;
}
}
private async verifyOwnership(id: string, userId: string): Promise<void> {
const result = await this.db
.select({ userId: boards.userId })
.from(boards)
.where(eq(boards.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Board with id ${id} not found`);
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
}

View file

@ -0,0 +1,80 @@
import { IsString, IsOptional, IsBoolean, IsNumber } from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class GetBoardsQueryDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
includePublic?: boolean = false;
}
export class CreateBoardDto {
@IsString()
name: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber()
@IsOptional()
canvasWidth?: number;
@IsNumber()
@IsOptional()
canvasHeight?: number;
@IsString()
@IsOptional()
backgroundColor?: string;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
}
export class UpdateBoardDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
description?: string;
@IsNumber()
@IsOptional()
canvasWidth?: number;
@IsNumber()
@IsOptional()
canvasHeight?: number;
@IsString()
@IsOptional()
backgroundColor?: string;
@IsBoolean()
@IsOptional()
isPublic?: boolean;
}
export class GenerateThumbnailDto {
@IsString()
dataUrl: string;
}
export class ToggleVisibilityDto {
@IsBoolean()
isPublic: boolean;
}

View file

@ -0,0 +1,15 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export interface CurrentUserData {
userId: string;
email: string;
role: string;
sessionId?: string;
}
export const CurrentUser = createParamDecorator(
(data: unknown, ctx: ExecutionContext): CurrentUserData => {
const request = ctx.switchToHttp().getRequest();
return request.user;
},
);

View file

@ -0,0 +1,66 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(private configService: ConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
// Get Mana Core Auth URL from config
const authUrl =
this.configService.get<string>('MANA_CORE_AUTH_URL') ||
'http://localhost:3001';
// Validate token with Mana Core Auth
const response = await fetch(`${authUrl}/api/v1/auth/validate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token }),
});
if (!response.ok) {
throw new UnauthorizedException('Invalid token');
}
const { valid, payload } = await response.json();
if (!valid || !payload) {
throw new UnauthorizedException('Invalid token');
}
// Attach user to request
request.user = {
userId: payload.sub,
email: payload.email,
role: payload.role,
sessionId: payload.sessionId,
};
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
console.error('Error validating token:', error);
throw new UnauthorizedException('Token validation failed');
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View file

@ -0,0 +1,38 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import * as schema from './schema';
// Use require for postgres to avoid ESM/CommonJS interop issues
// eslint-disable-next-line @typescript-eslint/no-var-requires
const postgres = require('postgres');
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}
export type Database = ReturnType<typeof getDb>;

View file

@ -0,0 +1,28 @@
import { Module, Global, OnModuleDestroy } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { getDb, closeConnection, type Database } from './connection';
export const DATABASE_CONNECTION = 'DATABASE_CONNECTION';
@Global()
@Module({
providers: [
{
provide: DATABASE_CONNECTION,
useFactory: (configService: ConfigService): Database => {
const databaseUrl = configService.get<string>('DATABASE_URL');
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
return getDb(databaseUrl);
},
inject: [ConfigService],
},
],
exports: [DATABASE_CONNECTION],
})
export class DatabaseModule implements OnModuleDestroy {
async onModuleDestroy() {
await closeConnection();
}
}

View file

@ -0,0 +1,26 @@
import * as dotenv from 'dotenv';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { getDb, closeConnection } from './connection';
dotenv.config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const db = getDb(databaseUrl);
console.log('Running migrations...');
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations complete!');
await closeConnection();
}
runMigrations().catch((error) => {
console.error('Migration failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,50 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
real,
jsonb,
pgEnum,
} from 'drizzle-orm/pg-core';
export const itemTypeEnum = pgEnum('item_type', ['image', 'text']);
export interface TextProperties {
fontFamily?: string;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
}
export const boardItems = pgTable('board_items', {
id: uuid('id').primaryKey().defaultRandom(),
boardId: uuid('board_id').notNull(),
imageId: uuid('image_id'),
itemType: itemTypeEnum('item_type').default('image').notNull(),
positionX: real('position_x').default(0).notNull(),
positionY: real('position_y').default(0).notNull(),
scaleX: real('scale_x').default(1).notNull(),
scaleY: real('scale_y').default(1).notNull(),
rotation: real('rotation').default(0).notNull(),
zIndex: integer('z_index').default(0).notNull(),
opacity: real('opacity').default(1).notNull(),
width: integer('width'),
height: integer('height'),
textContent: text('text_content'),
fontSize: integer('font_size'),
color: text('color'),
properties: jsonb('properties').$type<TextProperties>(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
export type BoardItem = typeof boardItems.$inferSelect;
export type NewBoardItem = typeof boardItems.$inferInsert;

View file

@ -0,0 +1,33 @@
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
} from 'drizzle-orm/pg-core';
export const boards = pgTable('boards', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name').notNull(),
description: text('description'),
thumbnailUrl: text('thumbnail_url'),
canvasWidth: integer('canvas_width').default(2000).notNull(),
canvasHeight: integer('canvas_height').default(1500).notNull(),
backgroundColor: text('background_color').default('#ffffff').notNull(),
isPublic: boolean('is_public').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
export type Board = typeof boards.$inferSelect;
export type NewBoard = typeof boards.$inferInsert;

View file

@ -0,0 +1,53 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
real,
pgEnum,
} from 'drizzle-orm/pg-core';
export const generationStatusEnum = pgEnum('generation_status', [
'pending',
'queued',
'processing',
'completed',
'failed',
'cancelled',
]);
export const imageGenerations = pgTable('image_generations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
modelId: uuid('model_id'),
batchId: uuid('batch_id'),
prompt: text('prompt').notNull(),
negativePrompt: text('negative_prompt'),
model: text('model'),
style: text('style'),
sourceImageUrl: text('source_image_url'),
width: integer('width'),
height: integer('height'),
steps: integer('steps'),
guidanceScale: real('guidance_scale'),
seed: integer('seed'),
generationStrength: real('generation_strength'),
status: generationStatusEnum('status').default('pending').notNull(),
replicatePredictionId: text('replicate_prediction_id'),
errorMessage: text('error_message'),
generationTimeSeconds: integer('generation_time_seconds'),
retryCount: integer('retry_count').default(0).notNull(),
priority: integer('priority').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});
export type ImageGeneration = typeof imageGenerations.$inferSelect;
export type NewImageGeneration = typeof imageGenerations.$inferInsert;

View file

@ -0,0 +1,46 @@
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
} from 'drizzle-orm/pg-core';
export const images = pgTable('images', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
generationId: uuid('generation_id'),
sourceImageId: uuid('source_image_id'),
prompt: text('prompt').notNull(),
negativePrompt: text('negative_prompt'),
model: text('model'),
style: text('style'),
publicUrl: text('public_url'),
storagePath: text('storage_path').notNull(),
filename: text('filename').notNull(),
format: text('format'),
width: integer('width'),
height: integer('height'),
fileSize: integer('file_size'),
blurhash: text('blurhash'),
isPublic: boolean('is_public').default(false).notNull(),
isFavorite: boolean('is_favorite').default(false).notNull(),
downloadCount: integer('download_count').default(0).notNull(),
rating: integer('rating'),
archivedAt: timestamp('archived_at', { withTimezone: true }),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
export type Image = typeof images.$inferSelect;
export type NewImage = typeof images.$inferInsert;

View file

@ -0,0 +1,6 @@
export * from './images.schema';
export * from './image-generations.schema';
export * from './boards.schema';
export * from './board-items.schema';
export * from './tags.schema';
export * from './models.schema';

View file

@ -0,0 +1,51 @@
import {
pgTable,
uuid,
text,
timestamp,
boolean,
integer,
real,
} from 'drizzle-orm/pg-core';
export const models = pgTable('models', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
displayName: text('display_name').notNull(),
description: text('description'),
replicateId: text('replicate_id').notNull(),
version: text('version'),
defaultWidth: integer('default_width').default(1024),
defaultHeight: integer('default_height').default(1024),
defaultSteps: integer('default_steps').default(25),
defaultGuidanceScale: real('default_guidance_scale').default(7.5),
minWidth: integer('min_width').default(512),
minHeight: integer('min_height').default(512),
maxWidth: integer('max_width').default(2048),
maxHeight: integer('max_height').default(2048),
maxSteps: integer('max_steps').default(50),
supportsNegativePrompt: boolean('supports_negative_prompt')
.default(true)
.notNull(),
supportsImg2Img: boolean('supports_img2img').default(false).notNull(),
supportsSeed: boolean('supports_seed').default(true).notNull(),
isActive: boolean('is_active').default(true).notNull(),
isDefault: boolean('is_default').default(false).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
costPerGeneration: real('cost_per_generation'),
estimatedTimeSeconds: integer('estimated_time_seconds'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
export type Model = typeof models.$inferSelect;
export type NewModel = typeof models.$inferInsert;

View file

@ -0,0 +1,23 @@
import { pgTable, uuid, text, timestamp } from 'drizzle-orm/pg-core';
export const tags = pgTable('tags', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull().unique(),
color: text('color'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
});
export type Tag = typeof tags.$inferSelect;
export type NewTag = typeof tags.$inferInsert;
export const imageTags = pgTable('image_tags', {
id: uuid('id').primaryKey().defaultRandom(),
imageId: uuid('image_id').notNull(),
tagId: uuid('tag_id').notNull(),
addedAt: timestamp('added_at', { withTimezone: true }).defaultNow().notNull(),
});
export type ImageTag = typeof imageTags.$inferSelect;
export type NewImageTag = typeof imageTags.$inferInsert;

View file

@ -0,0 +1,90 @@
import * as dotenv from 'dotenv';
import { getDb, closeConnection } from './connection';
import { models } from './schema';
dotenv.config();
const defaultModels = [
{
name: 'sdxl',
displayName: 'Stable Diffusion XL',
description: 'High-quality image generation with excellent prompt adherence',
replicateId: 'stability-ai/sdxl',
version: '39ed52f2a78e934b3ba6e2a89f5b1c712de7dfea535525255b1aa35c5565e08b',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 25,
defaultGuidanceScale: 7.5,
supportsNegativePrompt: true,
supportsImg2Img: true,
supportsSeed: true,
isActive: true,
isDefault: true,
sortOrder: 0,
estimatedTimeSeconds: 15,
},
{
name: 'flux-schnell',
displayName: 'FLUX Schnell',
description: 'Fast image generation with good quality',
replicateId: 'black-forest-labs/flux-schnell',
version: 'f2ab8a5bfe79f02f0789a146cf5e73d2a4ff2684a98c2b303d1e1ff3814271db',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 4,
defaultGuidanceScale: 0,
supportsNegativePrompt: false,
supportsImg2Img: false,
supportsSeed: true,
isActive: true,
isDefault: false,
sortOrder: 1,
estimatedTimeSeconds: 5,
},
{
name: 'flux-pro',
displayName: 'FLUX Pro',
description: 'Professional quality image generation',
replicateId: 'black-forest-labs/flux-pro',
version: '7d6fbcd3da3f4e1c1c08d8ab0e7a4c2e0e5e3c9e8f8e8e8e8e8e8e8e8e8e8e8e',
defaultWidth: 1024,
defaultHeight: 1024,
defaultSteps: 25,
defaultGuidanceScale: 3.5,
supportsNegativePrompt: false,
supportsImg2Img: false,
supportsSeed: true,
isActive: true,
isDefault: false,
sortOrder: 2,
estimatedTimeSeconds: 20,
},
];
async function seed() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL is not set');
}
const db = getDb(databaseUrl);
console.log('Seeding models...');
for (const model of defaultModels) {
try {
await db.insert(models).values(model).onConflictDoNothing();
console.log(` - ${model.displayName}`);
} catch (error) {
console.error(` - Error seeding ${model.displayName}:`, error);
}
}
console.log('Seeding complete!');
await closeConnection();
}
seed().catch((error) => {
console.error('Seed failed:', error);
process.exit(1);
});

View file

@ -0,0 +1,34 @@
import { IsString, IsOptional, IsNumber, IsIn } from 'class-validator';
import { Type } from 'class-transformer';
export class GetPublicImagesDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsString()
@IsOptional()
@IsIn(['recent', 'popular', 'trending'])
sortBy?: 'recent' | 'popular' | 'trending' = 'recent';
}
export class SearchPublicImagesDto {
@IsString()
searchTerm: string;
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
}

View file

@ -0,0 +1,20 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { ExploreService } from './explore.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { GetPublicImagesDto, SearchPublicImagesDto } from './dto/explore.dto';
@Controller('explore')
@UseGuards(JwtAuthGuard)
export class ExploreController {
constructor(private readonly exploreService: ExploreService) {}
@Get()
async getPublicImages(@Query() query: GetPublicImagesDto) {
return this.exploreService.getPublicImages(query);
}
@Get('search')
async searchPublicImages(@Query() query: SearchPublicImagesDto) {
return this.exploreService.searchPublicImages(query);
}
}

View file

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ExploreController } from './explore.controller';
import { ExploreService } from './explore.service';
@Module({
controllers: [ExploreController],
providers: [ExploreService],
})
export class ExploreModule {}

View file

@ -0,0 +1,83 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { eq, and, isNull, desc, ilike } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { images, type Image } from '../db/schema';
import { GetPublicImagesDto, SearchPublicImagesDto } from './dto/explore.dto';
@Injectable()
export class ExploreService {
private readonly logger = new Logger(ExploreService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getPublicImages(query: GetPublicImagesDto): Promise<Image[]> {
try {
const { page = 1, limit = 20, sortBy = 'recent' } = query;
const offset = (page - 1) * limit;
const conditions = [
eq(images.isPublic, true),
isNull(images.archivedAt),
];
let orderBy;
switch (sortBy) {
case 'popular':
orderBy = desc(images.downloadCount);
break;
case 'trending':
// For trending, we could implement a more complex algorithm
// For now, just use recent with some weight on downloads
orderBy = desc(images.createdAt);
break;
case 'recent':
default:
orderBy = desc(images.createdAt);
}
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(orderBy)
.limit(limit)
.offset(offset);
return result;
} catch (error) {
this.logger.error('Error fetching public images', error);
throw error;
}
}
async searchPublicImages(query: SearchPublicImagesDto): Promise<Image[]> {
try {
const { searchTerm, page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
if (!searchTerm || searchTerm.trim().length === 0) {
return this.getPublicImages({ page, limit });
}
const conditions = [
eq(images.isPublic, true),
isNull(images.archivedAt),
ilike(images.prompt, `%${searchTerm}%`),
];
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(desc(images.createdAt))
.limit(limit)
.offset(offset);
return result;
} catch (error) {
this.logger.error('Error searching public images', error);
throw error;
}
}
}

View file

@ -0,0 +1,41 @@
import { IsString, IsOptional, IsNumber } from 'class-validator';
export class GenerateImageDto {
@IsString()
prompt: string;
@IsString()
modelId: string;
@IsString()
@IsOptional()
negativePrompt?: string;
@IsNumber()
@IsOptional()
width?: number;
@IsNumber()
@IsOptional()
height?: number;
@IsNumber()
@IsOptional()
steps?: number;
@IsNumber()
@IsOptional()
guidanceScale?: number;
@IsNumber()
@IsOptional()
seed?: number;
@IsString()
@IsOptional()
sourceImageUrl?: string;
@IsNumber()
@IsOptional()
generationStrength?: number;
}

View file

@ -0,0 +1,54 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { GenerateService } from './generate.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { GenerateImageDto } from './dto/generate.dto';
@Controller('generate')
export class GenerateController {
constructor(private readonly generateService: GenerateService) {}
@Post()
@UseGuards(JwtAuthGuard)
async generateImage(
@CurrentUser() user: CurrentUserData,
@Body() dto: GenerateImageDto,
) {
return this.generateService.generateImage(user.userId, dto);
}
@Get(':id/status')
@UseGuards(JwtAuthGuard)
async checkStatus(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.generateService.checkStatus(id, user.userId);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
async cancelGeneration(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.generateService.cancelGeneration(id, user.userId);
}
// Webhook endpoint for Replicate - no auth required
@Post('webhook')
async handleWebhook(@Body() body: any) {
return this.generateService.handleWebhook(body);
}
}

View file

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { GenerateController } from './generate.controller';
import { GenerateService } from './generate.service';
import { ReplicateService } from './replicate.service';
import { UploadModule } from '../upload/upload.module';
@Module({
imports: [UploadModule],
controllers: [GenerateController],
providers: [GenerateService, ReplicateService],
exports: [GenerateService],
})
export class GenerateModule {}

View file

@ -0,0 +1,382 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
imageGenerations,
images,
models,
type ImageGeneration,
type Image,
} from '../db/schema';
import { ReplicateService } from './replicate.service';
import { StorageService } from '../upload/storage.service';
import { GenerateImageDto } from './dto/generate.dto';
@Injectable()
export class GenerateService {
private readonly logger = new Logger(GenerateService.name);
private readonly webhookBaseUrl: string;
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly replicateService: ReplicateService,
private readonly storageService: StorageService,
private configService: ConfigService,
) {
this.webhookBaseUrl =
this.configService.get<string>('WEBHOOK_BASE_URL') ||
'http://localhost:3003';
}
async generateImage(
userId: string,
dto: GenerateImageDto,
): Promise<{ generationId: string; status: string }> {
try {
// Get model info
const modelResult = await this.db
.select()
.from(models)
.where(eq(models.id, dto.modelId))
.limit(1);
if (modelResult.length === 0) {
throw new NotFoundException(`Model with id ${dto.modelId} not found`);
}
const model = modelResult[0];
// Create generation record
const generationResult = await this.db
.insert(imageGenerations)
.values({
userId,
modelId: dto.modelId,
prompt: dto.prompt,
negativePrompt: dto.negativePrompt,
model: model.name,
width: dto.width || model.defaultWidth || 1024,
height: dto.height || model.defaultHeight || 1024,
steps: dto.steps || model.defaultSteps || 25,
guidanceScale: dto.guidanceScale || model.defaultGuidanceScale || 7.5,
seed: dto.seed,
sourceImageUrl: dto.sourceImageUrl,
generationStrength: dto.generationStrength,
status: 'pending',
})
.returning();
const generation = generationResult[0];
// Start the prediction
try {
const webhookUrl = `${this.webhookBaseUrl}/api/generate/webhook`;
const prediction = await this.replicateService.createPrediction(
model.replicateId,
model.version || '',
{
prompt: dto.prompt,
negative_prompt: dto.negativePrompt,
width: dto.width || model.defaultWidth || 1024,
height: dto.height || model.defaultHeight || 1024,
num_inference_steps: dto.steps || model.defaultSteps || 25,
guidance_scale: dto.guidanceScale || model.defaultGuidanceScale || 7.5,
seed: dto.seed,
image: dto.sourceImageUrl,
prompt_strength: dto.generationStrength,
},
webhookUrl,
);
// Update generation with prediction ID
await this.db
.update(imageGenerations)
.set({
replicatePredictionId: prediction.id,
status: 'processing',
})
.where(eq(imageGenerations.id, generation.id));
return {
generationId: generation.id,
status: 'processing',
};
} catch (error) {
// Update generation as failed
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Unknown error',
})
.where(eq(imageGenerations.id, generation.id));
throw error;
}
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error('Error generating image', error);
throw error;
}
}
async checkStatus(
generationId: string,
userId: string,
): Promise<ImageGeneration & { image?: Image }> {
try {
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Generation with id ${generationId} not found`);
}
const generation = result[0];
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// If still processing, check Replicate status
if (
generation.status === 'processing' &&
generation.replicatePredictionId
) {
const prediction = await this.replicateService.getPrediction(
generation.replicatePredictionId,
);
if (prediction.status === 'succeeded' && prediction.output) {
// Process the completed generation
await this.processCompletedGeneration(generation, prediction.output);
// Refetch the updated generation
const updatedResult = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
const updated = updatedResult[0];
// Get the created image
const imageResult = await this.db
.select()
.from(images)
.where(eq(images.generationId, generationId))
.limit(1);
return {
...updated,
image: imageResult[0],
};
} else if (prediction.status === 'failed') {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: prediction.error || 'Generation failed',
})
.where(eq(imageGenerations.id, generationId));
return {
...generation,
status: 'failed',
errorMessage: prediction.error || 'Generation failed',
};
}
}
// Get associated image if completed
if (generation.status === 'completed') {
const imageResult = await this.db
.select()
.from(images)
.where(eq(images.generationId, generationId))
.limit(1);
return {
...generation,
image: imageResult[0],
};
}
return generation;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error checking status for generation ${generationId}`, error);
throw error;
}
}
async cancelGeneration(generationId: string, userId: string): Promise<void> {
try {
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.id, generationId))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Generation with id ${generationId} not found`);
}
const generation = result[0];
if (generation.userId !== userId) {
throw new ForbiddenException('Access denied');
}
if (generation.status !== 'pending' && generation.status !== 'processing') {
return; // Already completed or failed
}
// Cancel on Replicate
if (generation.replicatePredictionId) {
try {
await this.replicateService.cancelPrediction(
generation.replicatePredictionId,
);
} catch (error) {
this.logger.warn('Failed to cancel prediction on Replicate', error);
}
}
// Update status
await this.db
.update(imageGenerations)
.set({
status: 'cancelled',
errorMessage: 'Cancelled by user',
})
.where(eq(imageGenerations.id, generationId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error cancelling generation ${generationId}`, error);
throw error;
}
}
async handleWebhook(body: any): Promise<{ received: boolean }> {
try {
const { id, status, output, error, metrics } = body;
if (!id) {
return { received: false };
}
// Find the generation by prediction ID
const result = await this.db
.select()
.from(imageGenerations)
.where(eq(imageGenerations.replicatePredictionId, id))
.limit(1);
if (result.length === 0) {
this.logger.warn(`No generation found for prediction ${id}`);
return { received: false };
}
const generation = result[0];
if (status === 'succeeded' && output) {
await this.processCompletedGeneration(generation, output);
} else if (status === 'failed') {
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error || 'Generation failed',
})
.where(eq(imageGenerations.id, generation.id));
}
return { received: true };
} catch (error) {
this.logger.error('Error handling webhook', error);
return { received: false };
}
}
private async processCompletedGeneration(
generation: ImageGeneration,
output: string[] | string,
): Promise<void> {
try {
const imageUrl = Array.isArray(output) ? output[0] : output;
if (!imageUrl) {
throw new Error('No output URL from generation');
}
// Download and upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFromUrl(
imageUrl,
generation.userId,
`generated-${generation.id}.png`,
);
// Create image record
await this.db.insert(images).values({
userId: generation.userId,
generationId: generation.id,
prompt: generation.prompt,
negativePrompt: generation.negativePrompt,
model: generation.model,
storagePath,
publicUrl,
filename: `generated-${generation.id}.png`,
width: generation.width,
height: generation.height,
format: 'png',
});
// Update generation as completed
await this.db
.update(imageGenerations)
.set({
status: 'completed',
completedAt: new Date(),
})
.where(eq(imageGenerations.id, generation.id));
} catch (error) {
this.logger.error(
`Error processing completed generation ${generation.id}`,
error,
);
await this.db
.update(imageGenerations)
.set({
status: 'failed',
errorMessage: error instanceof Error ? error.message : 'Processing failed',
})
.where(eq(imageGenerations.id, generation.id));
}
}
}

View file

@ -0,0 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Replicate from 'replicate';
export interface PredictionInput {
prompt: string;
negative_prompt?: string;
width?: number;
height?: number;
num_inference_steps?: number;
guidance_scale?: number;
seed?: number;
image?: string; // For img2img
prompt_strength?: number;
}
export interface Prediction {
id: string;
status: 'starting' | 'processing' | 'succeeded' | 'failed' | 'canceled';
output?: string[] | string;
error?: string;
metrics?: {
predict_time?: number;
};
}
@Injectable()
export class ReplicateService {
private readonly logger = new Logger(ReplicateService.name);
private replicate: Replicate | null = null;
constructor(private configService: ConfigService) {
const apiToken = this.configService.get<string>('REPLICATE_API_TOKEN');
if (apiToken) {
this.replicate = new Replicate({ auth: apiToken });
} else {
this.logger.warn('REPLICATE_API_TOKEN not configured');
}
}
async createPrediction(
modelId: string,
version: string,
input: PredictionInput,
webhookUrl?: string,
): Promise<Prediction> {
if (!this.replicate) {
throw new Error('Replicate not configured');
}
try {
const prediction = await this.replicate.predictions.create({
version,
input,
webhook: webhookUrl,
webhook_events_filter: ['completed'],
});
return {
id: prediction.id,
status: prediction.status as Prediction['status'],
output: prediction.output as string[] | string | undefined,
error: prediction.error as string | undefined,
};
} catch (error) {
this.logger.error('Error creating prediction', error);
throw error;
}
}
async getPrediction(predictionId: string): Promise<Prediction> {
if (!this.replicate) {
throw new Error('Replicate not configured');
}
try {
const prediction = await this.replicate.predictions.get(predictionId);
return {
id: prediction.id,
status: prediction.status as Prediction['status'],
output: prediction.output as string[] | string | undefined,
error: prediction.error as string | undefined,
metrics: prediction.metrics as Prediction['metrics'],
};
} catch (error) {
this.logger.error(`Error getting prediction ${predictionId}`, error);
throw error;
}
}
async cancelPrediction(predictionId: string): Promise<void> {
if (!this.replicate) {
throw new Error('Replicate not configured');
}
try {
await this.replicate.predictions.cancel(predictionId);
} catch (error) {
this.logger.error(`Error canceling prediction ${predictionId}`, error);
throw error;
}
}
async waitForPrediction(
predictionId: string,
timeoutMs: number = 300000, // 5 minutes
pollIntervalMs: number = 2000,
): Promise<Prediction> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
const prediction = await this.getPrediction(predictionId);
if (prediction.status === 'succeeded' || prediction.status === 'failed' || prediction.status === 'canceled') {
return prediction;
}
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
}
throw new Error('Prediction timed out');
}
}

View file

@ -0,0 +1,13 @@
import { Controller, Get } from '@nestjs/common';
@Controller('health')
export class HealthController {
@Get()
check() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
service: 'picture-backend',
};
}
}

View file

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
@Module({
controllers: [HealthController],
})
export class HealthModule {}

View file

@ -0,0 +1,38 @@
import {
IsString,
IsOptional,
IsBoolean,
IsNumber,
IsArray,
} from 'class-validator';
import { Transform, Type } from 'class-transformer';
export class GetImagesQueryDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
archived?: boolean = false;
@IsOptional()
tagIds?: string | string[];
@IsBoolean()
@IsOptional()
@Transform(({ value }) => value === 'true' || value === true)
favoritesOnly?: boolean = false;
}
export class ToggleFavoriteDto {
@IsBoolean()
isFavorite: boolean;
}

View file

@ -0,0 +1,88 @@
import {
Controller,
Get,
Patch,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { ImageService } from './image.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { GetImagesQueryDto, ToggleFavoriteDto } from './dto/image.dto';
@Controller('images')
@UseGuards(JwtAuthGuard)
export class ImageController {
constructor(private readonly imageService: ImageService) {}
@Get()
async getImages(
@CurrentUser() user: CurrentUserData,
@Query() query: GetImagesQueryDto,
) {
return this.imageService.getImages(user.userId, query);
}
@Get(':id')
async getImageById(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.getImageById(id, user.userId);
}
@Patch(':id/archive')
async archiveImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.archiveImage(id, user.userId);
}
@Patch(':id/unarchive')
async unarchiveImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.unarchiveImage(id, user.userId);
}
@Delete(':id')
async deleteImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.deleteImage(id, user.userId);
}
@Patch(':id/publish')
async publishImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.publishImage(id, user.userId);
}
@Patch(':id/unpublish')
async unpublishImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.imageService.unpublishImage(id, user.userId);
}
@Patch(':id/favorite')
async toggleFavorite(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
@Body() dto: ToggleFavoriteDto,
) {
return this.imageService.toggleFavorite(id, user.userId, dto.isFavorite);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ImageController } from './image.controller';
import { ImageService } from './image.service';
@Module({
controllers: [ImageController],
providers: [ImageService],
exports: [ImageService],
})
export class ImageModule {}

View file

@ -0,0 +1,286 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { eq, and, isNull, isNotNull, desc, inArray, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { images, imageTags, type Image } from '../db/schema';
import { GetImagesQueryDto } from './dto/image.dto';
@Injectable()
export class ImageService {
private readonly logger = new Logger(ImageService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getImages(
userId: string,
query: GetImagesQueryDto,
): Promise<Image[]> {
try {
const {
page = 1,
limit = 20,
archived = false,
tagIds,
favoritesOnly = false,
} = query;
const offset = (page - 1) * limit;
// Build base conditions
const conditions = [eq(images.userId, userId)];
if (archived) {
conditions.push(isNotNull(images.archivedAt));
} else {
conditions.push(isNull(images.archivedAt));
}
if (favoritesOnly) {
conditions.push(eq(images.isFavorite, true));
}
// If tag filtering is needed
if (tagIds && tagIds.length > 0) {
const tagIdArray = Array.isArray(tagIds) ? tagIds : tagIds.split(',');
// Get image IDs that have ALL specified tags
const imageIdsWithTags = await this.db
.select({ imageId: imageTags.imageId })
.from(imageTags)
.where(inArray(imageTags.tagId, tagIdArray))
.groupBy(imageTags.imageId)
.having(sql`count(distinct ${imageTags.tagId}) = ${tagIdArray.length}`);
const validImageIds = imageIdsWithTags.map((r) => r.imageId);
if (validImageIds.length === 0) {
return [];
}
conditions.push(inArray(images.id, validImageIds));
}
const result = await this.db
.select()
.from(images)
.where(and(...conditions))
.orderBy(desc(images.createdAt))
.limit(limit)
.offset(offset);
return result;
} catch (error) {
this.logger.error('Error fetching images', error);
throw error;
}
}
async getImageById(id: string, userId: string): Promise<Image> {
try {
const result = await this.db
.select()
.from(images)
.where(eq(images.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Image with id ${id} not found`);
}
const image = result[0];
// Check ownership (allow if public or owned by user)
if (image.userId !== userId && !image.isPublic) {
throw new ForbiddenException('Access denied');
}
return image;
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error fetching image ${id}`, error);
throw error;
}
}
async archiveImage(id: string, userId: string): Promise<Image> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(images)
.set({
archivedAt: new Date(),
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error archiving image ${id}`, error);
throw error;
}
}
async unarchiveImage(id: string, userId: string): Promise<Image> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(images)
.set({
archivedAt: null,
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error unarchiving image ${id}`, error);
throw error;
}
}
async deleteImage(id: string, userId: string): Promise<void> {
try {
await this.verifyOwnership(id, userId);
// Delete image-tag relations first
await this.db.delete(imageTags).where(eq(imageTags.imageId, id));
// Delete the image
await this.db.delete(images).where(eq(images.id, id));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting image ${id}`, error);
throw error;
}
}
async publishImage(id: string, userId: string): Promise<Image> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(images)
.set({
isPublic: true,
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error publishing image ${id}`, error);
throw error;
}
}
async unpublishImage(id: string, userId: string): Promise<Image> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(images)
.set({
isPublic: false,
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error unpublishing image ${id}`, error);
throw error;
}
}
async toggleFavorite(
id: string,
userId: string,
isFavorite: boolean,
): Promise<Image> {
try {
await this.verifyOwnership(id, userId);
const result = await this.db
.update(images)
.set({
isFavorite,
updatedAt: new Date(),
})
.where(eq(images.id, id))
.returning();
return result[0];
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error toggling favorite for image ${id}`, error);
throw error;
}
}
private async verifyOwnership(id: string, userId: string): Promise<void> {
const result = await this.db
.select({ userId: images.userId })
.from(images)
.where(eq(images.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Image with id ${id} not found`);
}
if (result[0].userId !== userId) {
throw new ForbiddenException('Access denied');
}
}
}

View file

@ -0,0 +1,38 @@
import { NestFactory } from '@nestjs/core';
import { ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
// Enable CORS for mobile and web apps
app.enableCors({
origin: [
'http://localhost:3000',
'http://localhost:5173',
'http://localhost:5174',
'http://localhost:8081',
'exp://localhost:8081',
'http://localhost:3001', // Mana Core Auth
],
methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'],
credentials: true,
});
// Enable validation
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
forbidNonWhitelisted: true,
}),
);
// Set global prefix for API routes
app.setGlobalPrefix('api');
const port = process.env.PORT || 3003;
await app.listen(port);
console.log(`Picture backend running on http://localhost:${port}`);
}
bootstrap();

View file

@ -0,0 +1,19 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { ModelService } from './model.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
@Controller('models')
@UseGuards(JwtAuthGuard)
export class ModelController {
constructor(private readonly modelService: ModelService) {}
@Get()
async getActiveModels() {
return this.modelService.getActiveModels();
}
@Get(':id')
async getModelById(@Param('id') id: string) {
return this.modelService.getModelById(id);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { ModelController } from './model.controller';
import { ModelService } from './model.service';
@Module({
controllers: [ModelController],
providers: [ModelService],
exports: [ModelService],
})
export class ModelModule {}

View file

@ -0,0 +1,49 @@
import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common';
import { eq, desc } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { models, type Model } from '../db/schema';
@Injectable()
export class ModelService {
private readonly logger = new Logger(ModelService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getActiveModels(): Promise<Model[]> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.isActive, true))
.orderBy(desc(models.isDefault), models.sortOrder);
return result;
} catch (error) {
this.logger.error('Error fetching active models', error);
throw error;
}
}
async getModelById(id: string): Promise<Model> {
try {
const result = await this.db
.select()
.from(models)
.where(eq(models.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Model with id ${id} not found`);
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error fetching model ${id}`, error);
throw error;
}
}
}

View file

@ -0,0 +1,20 @@
import { IsString, IsOptional } from 'class-validator';
export class CreateTagDto {
@IsString()
name: string;
@IsString()
@IsOptional()
color?: string;
}
export class UpdateTagDto {
@IsString()
@IsOptional()
name?: string;
@IsString()
@IsOptional()
color?: string;
}

View file

@ -0,0 +1,60 @@
import {
Controller,
Get,
Post,
Patch,
Delete,
Param,
Body,
UseGuards,
} from '@nestjs/common';
import { TagService } from './tag.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import { CreateTagDto, UpdateTagDto } from './dto/tag.dto';
@Controller('tags')
@UseGuards(JwtAuthGuard)
export class TagController {
constructor(private readonly tagService: TagService) {}
@Get()
async getAllTags() {
return this.tagService.getAllTags();
}
@Post()
async createTag(@Body() dto: CreateTagDto) {
return this.tagService.createTag(dto);
}
@Patch(':id')
async updateTag(@Param('id') id: string, @Body() dto: UpdateTagDto) {
return this.tagService.updateTag(id, dto);
}
@Delete(':id')
async deleteTag(@Param('id') id: string) {
return this.tagService.deleteTag(id);
}
@Get('image/:imageId')
async getImageTags(@Param('imageId') imageId: string) {
return this.tagService.getImageTags(imageId);
}
@Post('image/:imageId/:tagId')
async addTagToImage(
@Param('imageId') imageId: string,
@Param('tagId') tagId: string,
) {
return this.tagService.addTagToImage(imageId, tagId);
}
@Delete('image/:imageId/:tagId')
async removeTagFromImage(
@Param('imageId') imageId: string,
@Param('tagId') tagId: string,
) {
return this.tagService.removeTagFromImage(imageId, tagId);
}
}

View file

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TagController } from './tag.controller';
import { TagService } from './tag.service';
@Module({
controllers: [TagController],
providers: [TagService],
exports: [TagService],
})
export class TagModule {}

View file

@ -0,0 +1,157 @@
import { Injectable, Inject, NotFoundException, Logger } from '@nestjs/common';
import { eq, and } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { tags, imageTags, images, type Tag } from '../db/schema';
import { CreateTagDto, UpdateTagDto } from './dto/tag.dto';
@Injectable()
export class TagService {
private readonly logger = new Logger(TagService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
async getAllTags(): Promise<Tag[]> {
try {
const result = await this.db
.select()
.from(tags)
.orderBy(tags.name);
return result;
} catch (error) {
this.logger.error('Error fetching tags', error);
throw error;
}
}
async createTag(dto: CreateTagDto): Promise<Tag> {
try {
const result = await this.db
.insert(tags)
.values({
name: dto.name,
color: dto.color,
})
.returning();
return result[0];
} catch (error) {
this.logger.error('Error creating tag', error);
throw error;
}
}
async updateTag(id: string, dto: UpdateTagDto): Promise<Tag> {
try {
const result = await this.db
.update(tags)
.set({
...(dto.name && { name: dto.name }),
...(dto.color !== undefined && { color: dto.color }),
})
.where(eq(tags.id, id))
.returning();
if (result.length === 0) {
throw new NotFoundException(`Tag with id ${id} not found`);
}
return result[0];
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error updating tag ${id}`, error);
throw error;
}
}
async deleteTag(id: string): Promise<void> {
try {
// Delete image-tag relations first
await this.db.delete(imageTags).where(eq(imageTags.tagId, id));
// Delete the tag
const result = await this.db
.delete(tags)
.where(eq(tags.id, id))
.returning();
if (result.length === 0) {
throw new NotFoundException(`Tag with id ${id} not found`);
}
} catch (error) {
if (error instanceof NotFoundException) {
throw error;
}
this.logger.error(`Error deleting tag ${id}`, error);
throw error;
}
}
async getImageTags(imageId: string): Promise<Tag[]> {
try {
const result = await this.db
.select({
id: tags.id,
name: tags.name,
color: tags.color,
createdAt: tags.createdAt,
})
.from(imageTags)
.innerJoin(tags, eq(imageTags.tagId, tags.id))
.where(eq(imageTags.imageId, imageId))
.orderBy(tags.name);
return result;
} catch (error) {
this.logger.error(`Error fetching tags for image ${imageId}`, error);
throw error;
}
}
async addTagToImage(imageId: string, tagId: string): Promise<void> {
try {
// Check if relation already exists
const existing = await this.db
.select()
.from(imageTags)
.where(
and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)),
)
.limit(1);
if (existing.length > 0) {
return; // Already exists
}
await this.db.insert(imageTags).values({
imageId,
tagId,
});
} catch (error) {
this.logger.error(
`Error adding tag ${tagId} to image ${imageId}`,
error,
);
throw error;
}
}
async removeTagFromImage(imageId: string, tagId: string): Promise<void> {
try {
await this.db
.delete(imageTags)
.where(
and(eq(imageTags.imageId, imageId), eq(imageTags.tagId, tagId)),
);
} catch (error) {
this.logger.error(
`Error removing tag ${tagId} from image ${imageId}`,
error,
);
throw error;
}
}
}

View file

@ -0,0 +1,126 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createClient, SupabaseClient } from '@supabase/supabase-js';
@Injectable()
export class StorageService {
private readonly logger = new Logger(StorageService.name);
private supabase: SupabaseClient | null = null;
private readonly bucket = 'user-uploads';
constructor(private configService: ConfigService) {
const supabaseUrl = this.configService.get<string>('SUPABASE_URL');
const supabaseKey = this.configService.get<string>('SUPABASE_SERVICE_ROLE_KEY');
if (supabaseUrl && supabaseKey) {
this.supabase = createClient(supabaseUrl, supabaseKey);
} else {
this.logger.warn('Supabase credentials not configured');
}
}
async uploadFile(
buffer: Buffer,
userId: string,
filename: string,
contentType: string,
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const timestamp = Date.now();
const randomId = Math.random().toString(36).substring(2, 10);
const ext = filename.split('.').pop() || 'jpg';
const storagePath = `${userId}/${timestamp}-${randomId}.${ext}`;
const { error } = await this.supabase.storage
.from(this.bucket)
.upload(storagePath, buffer, {
contentType,
upsert: false,
});
if (error) {
this.logger.error('Error uploading file to storage', error);
throw error;
}
const { data: urlData } = this.supabase.storage
.from(this.bucket)
.getPublicUrl(storagePath);
return {
storagePath,
publicUrl: urlData.publicUrl,
};
}
async uploadFromUrl(
url: string,
userId: string,
filename: string,
): Promise<{ storagePath: string; publicUrl: string }> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
// Download the file
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download file from ${url}`);
}
const buffer = Buffer.from(await response.arrayBuffer());
const contentType = response.headers.get('content-type') || 'image/jpeg';
return this.uploadFile(buffer, userId, filename, contentType);
}
async deleteFile(storagePath: string): Promise<void> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const { error } = await this.supabase.storage
.from(this.bucket)
.remove([storagePath]);
if (error) {
this.logger.error(`Error deleting file ${storagePath}`, error);
throw error;
}
}
async uploadBoardThumbnail(
boardId: string,
dataUrl: string,
): Promise<string> {
if (!this.supabase) {
throw new Error('Supabase not configured');
}
const base64Data = dataUrl.replace(/^data:image\/\w+;base64,/, '');
const buffer = Buffer.from(base64Data, 'base64');
const filename = `boards/${boardId}/thumbnail-${Date.now()}.png`;
const { error } = await this.supabase.storage
.from(this.bucket)
.upload(filename, buffer, {
contentType: 'image/png',
upsert: true,
});
if (error) {
this.logger.error('Error uploading board thumbnail', error);
throw error;
}
const { data: urlData } = this.supabase.storage
.from(this.bucket)
.getPublicUrl(filename);
return urlData.publicUrl;
}
}

View file

@ -0,0 +1,93 @@
import {
Controller,
Post,
Delete,
Param,
UseGuards,
UseInterceptors,
UploadedFile,
UploadedFiles,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { UploadService } from './upload.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_MIME_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
@Controller('upload')
@UseGuards(JwtAuthGuard)
export class UploadController {
constructor(private readonly uploadService: UploadService) {}
@Post()
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
callback(
new BadRequestException(
'Invalid file type. Only JPEG, PNG, and WebP are allowed.',
),
false,
);
return;
}
callback(null, true);
},
}),
)
async uploadImage(
@CurrentUser() user: CurrentUserData,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) {
throw new BadRequestException('No file uploaded');
}
return this.uploadService.uploadImage(user.userId, file);
}
@Post('multiple')
@UseInterceptors(
FilesInterceptor('files', 10, {
limits: { fileSize: MAX_FILE_SIZE },
fileFilter: (req, file, callback) => {
if (!ALLOWED_MIME_TYPES.includes(file.mimetype)) {
callback(
new BadRequestException(
'Invalid file type. Only JPEG, PNG, and WebP are allowed.',
),
false,
);
return;
}
callback(null, true);
},
}),
)
async uploadMultiple(
@CurrentUser() user: CurrentUserData,
@UploadedFiles() files: Express.Multer.File[],
) {
if (!files || files.length === 0) {
throw new BadRequestException('No files uploaded');
}
return this.uploadService.uploadMultiple(user.userId, files);
}
@Delete(':id')
async deleteUploadedImage(
@CurrentUser() user: CurrentUserData,
@Param('id') id: string,
) {
return this.uploadService.deleteUploadedImage(id, user.userId);
}
}

View file

@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { UploadController } from './upload.controller';
import { UploadService } from './upload.service';
import { StorageService } from './storage.service';
@Module({
controllers: [UploadController],
providers: [UploadService, StorageService],
exports: [UploadService, StorageService],
})
export class UploadModule {}

View file

@ -0,0 +1,123 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { eq } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import { images, imageTags, type Image } from '../db/schema';
import { StorageService } from './storage.service';
@Injectable()
export class UploadService {
private readonly logger = new Logger(UploadService.name);
constructor(
@Inject(DATABASE_CONNECTION) private readonly db: Database,
private readonly storageService: StorageService,
) {}
async uploadImage(
userId: string,
file: Express.Multer.File,
): Promise<Image> {
try {
// Upload to storage
const { storagePath, publicUrl } = await this.storageService.uploadFile(
file.buffer,
userId,
file.originalname,
file.mimetype,
);
// Get image dimensions (would need sharp for this)
// For now, we'll skip dimensions
// Create database record
const result = await this.db
.insert(images)
.values({
userId,
prompt: file.originalname, // Use filename as prompt for uploaded images
storagePath,
publicUrl,
filename: file.originalname,
format: file.mimetype.split('/')[1],
fileSize: file.size,
})
.returning();
return result[0];
} catch (error) {
this.logger.error('Error uploading image', error);
throw error;
}
}
async uploadMultiple(
userId: string,
files: Express.Multer.File[],
): Promise<Image[]> {
const results: Image[] = [];
for (const file of files) {
try {
const image = await this.uploadImage(userId, file);
results.push(image);
} catch (error) {
this.logger.error(`Error uploading file ${file.originalname}`, error);
// Continue with other files
}
}
return results;
}
async deleteUploadedImage(id: string, userId: string): Promise<void> {
try {
// Get the image
const result = await this.db
.select()
.from(images)
.where(eq(images.id, id))
.limit(1);
if (result.length === 0) {
throw new NotFoundException(`Image with id ${id} not found`);
}
const image = result[0];
// Verify ownership
if (image.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Delete from storage
try {
await this.storageService.deleteFile(image.storagePath);
} catch (error) {
this.logger.warn(`Failed to delete file from storage: ${image.storagePath}`);
// Continue with database deletion
}
// Delete image-tag relations
await this.db.delete(imageTags).where(eq(imageTags.imageId, id));
// Delete the database record
await this.db.delete(images).where(eq(images.id, id));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting uploaded image ${id}`, error);
throw error;
}
}
}

View file

@ -0,0 +1,23 @@
{
"compilerOptions": {
"module": "commonjs",
"declaration": true,
"removeComments": true,
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"allowSyntheticDefaultImports": true,
"target": "ES2021",
"sourceMap": true,
"outDir": "./dist",
"baseUrl": "./",
"incremental": true,
"skipLibCheck": true,
"strictNullChecks": true,
"noImplicitAny": true,
"strictBindCallApply": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"esModuleInterop": true,
"resolveJsonModule": true
}
}

View file

@ -1,67 +1,66 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
/**
* Board Items API - Now using Backend API instead of direct Supabase calls
*/
type BoardItemRow = Database['public']['Tables']['board_items']['Row'];
type BoardItemInsert = Database['public']['Tables']['board_items']['Insert'];
type BoardItemUpdate = Database['public']['Tables']['board_items']['Update'];
import { fetchApi } from './client';
// ===== BASE TYPES =====
interface BoardItemBase {
id: string;
board_id: string;
item_type: 'image' | 'text';
position_x: number;
position_y: number;
scale_x: number;
scale_y: number;
rotation: number;
z_index: number;
opacity: number;
width: number | null;
height: number | null;
properties: Record<string, any>;
created_at: string;
id: string;
boardId: string;
itemType: 'image' | 'text';
positionX: number;
positionY: number;
scaleX: number;
scaleY: number;
rotation: number;
zIndex: number;
opacity: number;
width: number | null;
height: number | null;
properties: Record<string, any>;
createdAt: string;
}
// ===== IMAGE ITEM =====
export interface BoardImageItem extends BoardItemBase {
item_type: 'image';
image_id: string;
text_content: null;
font_size: null;
color: null;
image?: {
id: string;
public_url: string;
width: number | null;
height: number | null;
prompt: string | null;
blurhash: string | null;
};
itemType: 'image';
imageId: string;
textContent: null;
fontSize: null;
color: null;
image?: {
id: string;
publicUrl: string;
width: number | null;
height: number | null;
prompt: string | null;
blurhash: string | null;
};
}
// ===== TEXT ITEM =====
export interface TextProperties {
fontFamily?: string;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
letterSpacing?: number;
backgroundColor?: string;
padding?: number;
fontFamily?: string;
fontWeight?: 'normal' | 'bold';
fontStyle?: 'normal' | 'italic';
textAlign?: 'left' | 'center' | 'right';
lineHeight?: number;
letterSpacing?: number;
backgroundColor?: string;
padding?: number;
}
export interface BoardTextItem extends BoardItemBase {
item_type: 'text';
image_id: null;
text_content: string;
font_size: number;
color: string;
properties: TextProperties;
itemType: 'text';
imageId: null;
textContent: string;
fontSize: number;
color: string;
properties: TextProperties;
}
// ===== DISCRIMINATED UNION =====
@ -71,341 +70,245 @@ export type BoardItem = BoardImageItem | BoardTextItem;
// ===== TYPE GUARDS =====
export function isImageItem(item: BoardItem): item is BoardImageItem {
return item.item_type === 'image';
return item.itemType === 'image';
}
export function isTextItem(item: BoardItem): item is BoardTextItem {
return item.item_type === 'text';
return item.itemType === 'text';
}
// ===== LEGACY (for backwards compatibility) =====
export interface BoardItemWithImage extends BoardImageItem {
image: {
id: string;
public_url: string;
width: number | null;
height: number | null;
prompt: string | null;
blurhash: string | null;
};
image: {
id: string;
publicUrl: string;
width: number | null;
height: number | null;
prompt: string | null;
blurhash: string | null;
};
}
// ===== HELPER FUNCTIONS =====
// ===== INPUT TYPES =====
async function getNextZIndex(boardId: string): Promise<number> {
const { data: maxZIndex, error } = await supabase
.from('board_items')
.select('z_index')
.eq('board_id', boardId)
.order('z_index', { ascending: false })
.limit(1)
.maybeSingle();
if (error) throw error;
return (maxZIndex?.z_index ?? -1) + 1;
export interface AddImageToBoardInput {
imageId: string;
position?: { x: number; y: number };
}
export interface AddTextToBoardInput {
content?: string;
position?: { x: number; y: number };
fontSize?: number;
color?: string;
fontFamily?: string;
}
export interface UpdateBoardItemInput {
positionX?: number;
positionY?: number;
scaleX?: number;
scaleY?: number;
rotation?: number;
zIndex?: number;
opacity?: number;
width?: number;
height?: number;
textContent?: string;
fontSize?: number;
color?: string;
properties?: Record<string, any>;
}
// ===== API FUNCTIONS =====
/**
* Get all items for a board (images and texts)
*/
export async function getBoardItems(boardId: string): Promise<BoardItem[]> {
const { data, error } = await supabase
.from('board_items')
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.eq('board_id', boardId)
.order('z_index', { ascending: true });
if (error) throw error;
return data as BoardItem[];
}
/**
* Add an image to a board
*/
export async function addImageToBoard(params: {
boardId: string;
imageId: string;
position?: { x: number; y: number };
}): Promise<BoardImageItem> {
const { boardId, imageId, position = { x: 100, y: 100 } } = params;
const item: BoardItemInsert = {
board_id: boardId,
item_type: 'image',
image_id: imageId,
position_x: position.x,
position_y: position.y,
z_index: await getNextZIndex(boardId)
};
const { data, error } = await supabase
.from('board_items')
.insert(item)
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.single();
if (error) throw error;
return data as BoardImageItem;
}
/**
* Add text to a board
*/
export async function addTextToBoard(params: {
boardId: string;
content?: string;
position?: { x: number; y: number };
fontSize?: number;
color?: string;
fontFamily?: string;
}): Promise<BoardTextItem> {
const {
boardId,
content = 'Doppelklick zum Bearbeiten',
position = { x: 100, y: 100 },
fontSize = 24,
color = '#000000',
fontFamily = 'Arial'
} = params;
const item: BoardItemInsert = {
board_id: boardId,
item_type: 'text',
text_content: content,
font_size: fontSize,
color: color,
position_x: position.x,
position_y: position.y,
width: 300, // Default text box width
z_index: await getNextZIndex(boardId),
properties: {
fontFamily,
fontWeight: 'normal',
fontStyle: 'normal',
textAlign: 'left',
lineHeight: 1.2
}
};
const { data, error } = await supabase
.from('board_items')
.insert(item)
.select()
.single();
if (error) throw error;
return data as BoardTextItem;
}
/**
* Legacy function for backwards compatibility
*/
export async function addBoardItem(item: BoardItemInsert) {
const nextZIndex = await getNextZIndex(item.board_id);
const { data, error } = await supabase
.from('board_items')
.insert({
...item,
z_index: nextZIndex
})
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.single();
if (error) throw error;
return data as BoardItem;
}
/**
* Update a board item (position, scale, rotation, text content, etc.)
*/
export async function updateBoardItem(id: string, updates: BoardItemUpdate): Promise<BoardItem> {
const { data, error } = await supabase
.from('board_items')
.update(updates)
.eq('id', id)
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.single();
if (error) throw error;
return data as BoardItem;
}
/**
* Update multiple board items at once (for batch operations)
*/
export async function updateBoardItems(items: Array<{ id: string } & BoardItemUpdate>) {
const promises = items.map(({ id, ...updates }) =>
supabase
.from('board_items')
.update(updates)
.eq('id', id)
);
const results = await Promise.all(promises);
const errors = results.filter(r => r.error).map(r => r.error);
if (errors.length > 0) throw errors[0];
}
/**
* Remove an item from a board
*/
export async function removeBoardItem(id: string) {
const { error } = await supabase
.from('board_items')
.delete()
.eq('id', id);
if (error) throw error;
}
/**
* Remove multiple items from a board
*/
export async function removeBoardItems(ids: string[]) {
const { error } = await supabase
.from('board_items')
.delete()
.in('id', ids);
if (error) throw error;
}
/**
* Change z-index (layer order) of an item
*/
export async function changeBoardItemZIndex(id: string, direction: 'up' | 'down' | 'top' | 'bottom') {
// Get current item
const { data: currentItem, error: currentError } = await supabase
.from('board_items')
.select('*')
.eq('id', id)
.single();
if (currentError) throw currentError;
// Get all items in the same board
const { data: allItems, error: allError } = await supabase
.from('board_items')
.select('id, z_index')
.eq('board_id', currentItem.board_id)
.order('z_index', { ascending: true });
if (allError) throw allError;
const currentIndex = allItems.findIndex(item => item.id === id);
let newZIndex = currentItem.z_index;
switch (direction) {
case 'up':
if (currentIndex < allItems.length - 1) {
newZIndex = allItems[currentIndex + 1].z_index;
// Swap z-indexes
await supabase
.from('board_items')
.update({ z_index: currentItem.z_index })
.eq('id', allItems[currentIndex + 1].id);
}
break;
case 'down':
if (currentIndex > 0) {
newZIndex = allItems[currentIndex - 1].z_index;
// Swap z-indexes
await supabase
.from('board_items')
.update({ z_index: currentItem.z_index })
.eq('id', allItems[currentIndex - 1].id);
}
break;
case 'top':
newZIndex = allItems[allItems.length - 1].z_index + 1;
break;
case 'bottom':
newZIndex = allItems[0].z_index - 1;
break;
}
// Update current item
return updateBoardItem(id, { z_index: newZIndex });
const { data, error } = await fetchApi<BoardItem[]>(`/board-items/board/${boardId}`);
if (error) throw error;
return data || [];
}
/**
* Get a single board item by ID
*/
export async function getBoardItemById(id: string): Promise<BoardItem> {
const { data, error } = await supabase
.from('board_items')
.select(`
*,
image:images(
id,
public_url,
width,
height,
prompt,
blurhash
)
`)
.eq('id', id)
.single();
const { data, error } = await fetchApi<BoardItem>(`/board-items/${id}`);
if (error) throw error;
if (!data) throw new Error('Board item not found');
return data;
}
if (error) throw error;
return data as BoardItem;
/**
* Add an image to a board
*/
export async function addImageToBoard(params: {
boardId: string;
imageId: string;
position?: { x: number; y: number };
}): Promise<BoardImageItem> {
const { boardId, imageId, position = { x: 100, y: 100 } } = params;
const { data, error } = await fetchApi<BoardImageItem>(`/board-items/board/${boardId}/image`, {
method: 'POST',
body: {
imageId,
positionX: position.x,
positionY: position.y,
},
});
if (error) throw error;
if (!data) throw new Error('Failed to add image to board');
return data;
}
/**
* Add text to a board
*/
export async function addTextToBoard(params: {
boardId: string;
content?: string;
position?: { x: number; y: number };
fontSize?: number;
color?: string;
fontFamily?: string;
}): Promise<BoardTextItem> {
const {
boardId,
content = 'Doppelklick zum Bearbeiten',
position = { x: 100, y: 100 },
fontSize = 24,
color = '#000000',
fontFamily = 'Arial',
} = params;
const { data, error } = await fetchApi<BoardTextItem>(`/board-items/board/${boardId}/text`, {
method: 'POST',
body: {
textContent: content,
positionX: position.x,
positionY: position.y,
fontSize,
color,
properties: {
fontFamily,
fontWeight: 'normal',
fontStyle: 'normal',
textAlign: 'left',
lineHeight: 1.2,
},
},
});
if (error) throw error;
if (!data) throw new Error('Failed to add text to board');
return data;
}
/**
* Legacy function for backwards compatibility
*/
export async function addBoardItem(item: {
boardId: string;
itemType: 'image' | 'text';
imageId?: string;
textContent?: string;
positionX?: number;
positionY?: number;
fontSize?: number;
color?: string;
properties?: Record<string, any>;
}): Promise<BoardItem> {
const { data, error } = await fetchApi<BoardItem>(`/board-items/board/${item.boardId}`, {
method: 'POST',
body: item,
});
if (error) throw error;
if (!data) throw new Error('Failed to add board item');
return data;
}
/**
* Update a board item (position, scale, rotation, text content, etc.)
*/
export async function updateBoardItem(id: string, updates: UpdateBoardItemInput): Promise<BoardItem> {
const { data, error } = await fetchApi<BoardItem>(`/board-items/${id}`, {
method: 'PATCH',
body: updates,
});
if (error) throw error;
if (!data) throw new Error('Failed to update board item');
return data;
}
/**
* Update multiple board items at once (for batch operations)
*/
export async function updateBoardItems(
items: Array<{ id: string } & UpdateBoardItemInput>,
): Promise<void> {
const { error } = await fetchApi('/board-items/batch', {
method: 'PATCH',
body: { items },
});
if (error) throw error;
}
/**
* Remove an item from a board
*/
export async function removeBoardItem(id: string): Promise<void> {
const { error } = await fetchApi(`/board-items/${id}`, {
method: 'DELETE',
});
if (error) throw error;
}
/**
* Remove multiple items from a board
*/
export async function removeBoardItems(ids: string[]): Promise<void> {
const { error } = await fetchApi('/board-items/batch', {
method: 'DELETE',
body: { ids },
});
if (error) throw error;
}
/**
* Change z-index (layer order) of an item
*/
export async function changeBoardItemZIndex(
id: string,
direction: 'up' | 'down' | 'top' | 'bottom',
): Promise<BoardItem> {
const { data, error } = await fetchApi<BoardItem>(`/board-items/${id}/z-index`, {
method: 'PATCH',
body: { direction },
});
if (error) throw error;
if (!data) throw new Error('Failed to change z-index');
return data;
}
/**
* Check if an image is already on a board
*/
export async function isImageOnBoard(boardId: string, imageId: string) {
const { data, error } = await supabase
.from('board_items')
.select('id')
.eq('board_id', boardId)
.eq('image_id', imageId)
.maybeSingle();
export async function isImageOnBoard(boardId: string, imageId: string): Promise<boolean> {
const { data, error } = await fetchApi<{ exists: boolean }>(
`/board-items/board/${boardId}/image/${imageId}/exists`,
);
if (error) throw error;
return !!data;
if (error) throw error;
return data?.exists || false;
}

View file

@ -1,226 +1,173 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
/**
* Boards API - Now using Backend API instead of direct Supabase calls
*/
type Board = Database['public']['Tables']['boards']['Row'];
type BoardInsert = Database['public']['Tables']['boards']['Insert'];
type BoardUpdate = Database['public']['Tables']['boards']['Update'];
import { fetchApi } from './client';
export interface Board {
id: string;
userId: string;
name: string;
description?: string;
thumbnailUrl?: string;
canvasWidth: number;
canvasHeight: number;
backgroundColor: string;
isPublic: boolean;
createdAt: string;
updatedAt: string;
}
export interface BoardWithCount extends Board {
item_count: number;
itemCount: number;
}
export interface GetBoardsParams {
userId: string;
page?: number;
limit?: number;
includePublic?: boolean;
page?: number;
limit?: number;
includePublic?: boolean;
}
export interface CreateBoardInput {
name: string;
description?: string;
canvasWidth?: number;
canvasHeight?: number;
backgroundColor?: string;
isPublic?: boolean;
}
export interface UpdateBoardInput {
name?: string;
description?: string;
canvasWidth?: number;
canvasHeight?: number;
backgroundColor?: string;
isPublic?: boolean;
thumbnailUrl?: string;
}
/**
* Get all boards for a user with item counts
* Get all boards for the current user with item counts
*/
export async function getBoards({ userId, page = 1, limit = 20, includePublic = false }: GetBoardsParams) {
const start = (page - 1) * limit;
const end = start + limit - 1;
export async function getBoards({
page = 1,
limit = 20,
includePublic = false,
}: GetBoardsParams = {}): Promise<BoardWithCount[]> {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
includePublic: String(includePublic),
});
let query = supabase
.from('boards')
.select(`
*,
board_items(count)
`)
.eq('user_id', userId)
.order('updated_at', { ascending: false })
.range(start, end);
if (includePublic) {
query = query.or(`user_id.eq.${userId},is_public.eq.true`);
}
const { data, error } = await query;
if (error) throw error;
// Transform the data to include item_count
const boards = data?.map((board: any) => ({
...board,
item_count: board.board_items?.[0]?.count || 0,
board_items: undefined // Remove the nested object
})) as BoardWithCount[];
return boards;
const { data, error } = await fetchApi<BoardWithCount[]>(`/boards?${params}`);
if (error) throw error;
return data || [];
}
/**
* Get a single board by ID
*/
export async function getBoardById(id: string) {
const { data, error } = await supabase
.from('boards')
.select('*')
.eq('id', id)
.single();
if (error) throw error;
return data as Board;
export async function getBoardById(id: string): Promise<Board> {
const { data, error } = await fetchApi<Board>(`/boards/${id}`);
if (error) throw error;
if (!data) throw new Error('Board not found');
return data;
}
/**
* Create a new board
*/
export async function createBoard(board: BoardInsert) {
const { data, error } = await supabase
.from('boards')
.insert(board)
.select()
.single();
if (error) throw error;
return data as Board;
export async function createBoard(board: CreateBoardInput): Promise<Board> {
const { data, error } = await fetchApi<Board>('/boards', {
method: 'POST',
body: board,
});
if (error) throw error;
if (!data) throw new Error('Failed to create board');
return data;
}
/**
* Update an existing board
*/
export async function updateBoard(id: string, updates: BoardUpdate) {
const { data, error } = await supabase
.from('boards')
.update(updates)
.eq('id', id)
.select()
.single();
if (error) throw error;
return data as Board;
export async function updateBoard(id: string, updates: UpdateBoardInput): Promise<Board> {
const { data, error } = await fetchApi<Board>(`/boards/${id}`, {
method: 'PATCH',
body: updates,
});
if (error) throw error;
if (!data) throw new Error('Failed to update board');
return data;
}
/**
* Delete a board (cascade deletes all board_items)
*/
export async function deleteBoard(id: string) {
const { error } = await supabase
.from('boards')
.delete()
.eq('id', id);
if (error) throw error;
export async function deleteBoard(id: string): Promise<void> {
const { error } = await fetchApi(`/boards/${id}`, {
method: 'DELETE',
});
if (error) throw error;
}
/**
* Duplicate a board with all its items
*/
export async function duplicateBoard(boardId: string, userId: string) {
// Get the original board
const board = await getBoardById(boardId);
// Create new board with same settings
const newBoard = await createBoard({
user_id: userId,
name: `${board.name} (Copy)`,
description: board.description,
canvas_width: board.canvas_width,
canvas_height: board.canvas_height,
background_color: board.background_color,
is_public: false
});
// Get all items from original board
const { data: items, error } = await supabase
.from('board_items')
.select('*')
.eq('board_id', boardId);
if (error) throw error;
// Copy items to new board
if (items && items.length > 0) {
const newItems = items.map(item => ({
board_id: newBoard.id,
image_id: item.image_id,
position_x: item.position_x,
position_y: item.position_y,
scale_x: item.scale_x,
scale_y: item.scale_y,
rotation: item.rotation,
z_index: item.z_index,
opacity: item.opacity,
width: item.width,
height: item.height
}));
const { error: insertError } = await supabase
.from('board_items')
.insert(newItems);
if (insertError) throw insertError;
}
return newBoard;
export async function duplicateBoard(boardId: string): Promise<Board> {
const { data, error } = await fetchApi<Board>(`/boards/${boardId}/duplicate`, {
method: 'POST',
});
if (error) throw error;
if (!data) throw new Error('Failed to duplicate board');
return data;
}
/**
* Generate thumbnail for board (exports to storage)
* Generate thumbnail for board (uploads to storage)
*/
export async function generateBoardThumbnail(boardId: string, dataUrl: string) {
// Convert data URL to blob
const response = await fetch(dataUrl);
const blob = await response.blob();
export async function generateBoardThumbnail(boardId: string, dataUrl: string): Promise<string> {
// Convert data URL to blob
const response = await fetch(dataUrl);
const blob = await response.blob();
// Upload to Supabase Storage
const fileName = `board-thumbnails/${boardId}.png`;
const { data, error } = await supabase.storage
.from('images')
.upload(fileName, blob, {
upsert: true,
contentType: 'image/png'
});
// Create form data
const formData = new FormData();
formData.append('thumbnail', blob, 'thumbnail.png');
if (error) throw error;
// Upload via backend API
const { data, error } = await fetchApi<{ thumbnailUrl: string }>(
`/boards/${boardId}/thumbnail`,
{
method: 'POST',
body: formData,
isFormData: true,
},
);
// Get public URL
const { data: urlData } = supabase.storage
.from('images')
.getPublicUrl(fileName);
// Update board with thumbnail URL
await updateBoard(boardId, {
thumbnail_url: urlData.publicUrl
});
return urlData.publicUrl;
if (error) throw error;
if (!data) throw new Error('Failed to generate thumbnail');
return data.thumbnailUrl;
}
/**
* Get public boards for explore/sharing
*/
export async function getPublicBoards(page = 1, limit = 20) {
const start = (page - 1) * limit;
const end = start + limit - 1;
export async function getPublicBoards(page = 1, limit = 20): Promise<BoardWithCount[]> {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
});
const { data, error } = await supabase
.from('boards')
.select(`
*,
board_items(count)
`)
.eq('is_public', true)
.order('updated_at', { ascending: false })
.range(start, end);
if (error) throw error;
const boards = data?.map((board: any) => ({
...board,
item_count: board.board_items?.[0]?.count || 0,
board_items: undefined
})) as BoardWithCount[];
return boards;
const { data, error } = await fetchApi<BoardWithCount[]>(`/boards/public?${params}`);
if (error) throw error;
return data || [];
}
/**
* Toggle board visibility (public/private)
*/
export async function toggleBoardVisibility(id: string, isPublic: boolean) {
return updateBoard(id, { is_public: isPublic });
export async function toggleBoardVisibility(id: string, isPublic: boolean): Promise<Board> {
return updateBoard(id, { isPublic });
}

View file

@ -0,0 +1,162 @@
/**
* API Client for Picture Backend
* Replaces direct Supabase calls with backend API calls.
*/
import { browser } from '$app/environment';
import { env } from '$env/dynamic/public';
const API_BASE = env.PUBLIC_BACKEND_URL || 'http://localhost:3003';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
isFormData?: boolean;
};
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {},
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token, isFormData = false } = options;
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const headers: Record<string, string> = {};
// Don't set Content-Type for FormData - browser sets it automatically with boundary
if (!isFormData) {
headers['Content-Type'] = 'application/json';
}
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api${endpoint}`, {
method,
headers,
body: isFormData ? (body as FormData) : body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
/**
* Upload a file to the backend
*/
export async function uploadFile(
endpoint: string,
file: File,
token?: string,
): Promise<{ data: any; error: Error | null }> {
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const formData = new FormData();
formData.append('file', file);
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api${endpoint}`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Upload error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Upload failed'),
};
}
}
/**
* Upload multiple files to the backend
*/
export async function uploadFiles(
endpoint: string,
files: File[],
token?: string,
): Promise<{ data: any; error: Error | null }> {
let authToken = token;
if (!authToken && browser) {
authToken = localStorage.getItem('@auth/appToken') || undefined;
}
try {
const formData = new FormData();
files.forEach((file) => {
formData.append('files', file);
});
const headers: Record<string, string> = {};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api${endpoint}`, {
method: 'POST',
headers,
body: formData,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `Upload error: ${response.status}`),
};
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Upload failed'),
};
}
}

View file

@ -1,73 +1,49 @@
import { supabase } from '$lib/supabase';
import type { Database } from '@picture/shared/types';
/**
* Explore API - Now using Backend API instead of direct Supabase calls
*/
type Image = Database['public']['Tables']['images']['Row'];
import { fetchApi } from './client';
import type { Image } from './images';
export interface GetPublicImagesParams {
page?: number;
limit?: number;
sortBy?: 'recent' | 'popular' | 'trending';
favoritesOnly?: boolean;
page?: number;
limit?: number;
sortBy?: 'recent' | 'popular' | 'trending';
favoritesOnly?: boolean;
}
export async function getPublicImages({
page = 1,
limit = 20,
sortBy = 'recent',
favoritesOnly = false
}: GetPublicImagesParams) {
const start = (page - 1) * limit;
const end = start + limit - 1;
page = 1,
limit = 20,
sortBy = 'recent',
favoritesOnly = false,
}: GetPublicImagesParams = {}): Promise<Image[]> {
const params = new URLSearchParams({
page: String(page),
limit: String(limit),
sortBy,
favoritesOnly: String(favoritesOnly),
});
let query = supabase
.from('images')
.select('*')
.eq('is_public', true)
.is('archived_at', null);
// Filter by favorites
if (favoritesOnly) {
query = query.eq('is_favorite', true);
}
query = query.range(start, end);
// Sort by different criteria
if (sortBy === 'recent') {
query = query.order('created_at', { ascending: false });
} else if (sortBy === 'popular') {
query = query.order('download_count', { ascending: false });
} else if (sortBy === 'trending') {
// Combine recency and popularity for trending
query = query.order('created_at', { ascending: false });
}
const { data, error } = await query;
if (error) throw error;
return data as Image[];
const { data, error } = await fetchApi<Image[]>(`/explore?${params}`);
if (error) throw error;
return data || [];
}
export async function searchPublicImages(searchTerm: string, page = 1, limit = 20, favoritesOnly = false) {
const start = (page - 1) * limit;
const end = start + limit - 1;
export async function searchPublicImages(
searchTerm: string,
page = 1,
limit = 20,
favoritesOnly = false,
): Promise<Image[]> {
const params = new URLSearchParams({
q: searchTerm,
page: String(page),
limit: String(limit),
favoritesOnly: String(favoritesOnly),
});
let query = supabase
.from('images')
.select('*')
.eq('is_public', true)
.is('archived_at', null)
.ilike('prompt', `%${searchTerm}%`);
// Filter by favorites
if (favoritesOnly) {
query = query.eq('is_favorite', true);
}
const { data, error } = await query
.order('created_at', { ascending: false })
.range(start, end);
if (error) throw error;
return data as Image[];
const { data, error } = await fetchApi<Image[]>(`/explore/search?${params}`);
if (error) throw error;
return data || [];
}

View file

@ -1,17 +1,11 @@
/**
* Async Image Generation API (New Queue-Based System)
* Async Image Generation API (Using Backend API)
*
* This replaces the old synchronous generate.ts with an async, non-blocking approach.
* Uses the job queue system for better scalability and user experience.
* Provides async generation with polling for status updates.
*/
import { supabase } from '$lib/supabase';
import {
startImageGeneration,
subscribeToGeneration,
generateImageWithUpdates,
type GenerateImageJobParams
} from '@picture/shared';
import { fetchApi } from './client';
import type { Image } from './images';
// ============================================================================
// TYPES
@ -27,6 +21,23 @@ export interface GenerationProgress {
export type GenerationCallback = (progress: GenerationProgress) => void;
export interface GenerateImageJobParams {
prompt: string;
modelId: string;
negativePrompt?: string;
width?: number;
height?: number;
numInferenceSteps?: number;
guidanceScale?: number;
}
interface GenerationStatusResponse {
id: string;
status: 'queued' | 'pending' | 'processing' | 'completed' | 'failed';
errorMessage?: string;
image?: Image;
}
// ============================================================================
// MAIN API FUNCTIONS
// ============================================================================
@ -35,148 +46,130 @@ export type GenerationCallback = (progress: GenerationProgress) => void;
* Generate an image (async, non-blocking)
*
* Returns immediately with a generation ID.
* Use subscribeToGenerationUpdates() to monitor progress.
*
* @example
* ```typescript
* // Start generation
* const { generationId } = await generateImageAsync({
* prompt: 'A beautiful sunset',
* model_id: 'black-forest-labs/flux-dev'
* });
*
* // Subscribe to updates
* const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => {
* console.log('Status:', progress.status);
* if (progress.status === 'completed') {
* console.log('Image URL:', progress.imageUrl);
* unsubscribe();
* }
* });
* ```
* Use pollGenerationUpdates() to monitor progress.
*/
export async function generateImageAsync(
params: GenerateImageJobParams
): Promise<{ generationId: string; jobId: string }> {
try {
const result = await startImageGeneration(supabase, params);
return result;
} catch (error: any) {
params: GenerateImageJobParams,
): Promise<{ generationId: string }> {
const { data, error } = await fetchApi<{ generationId: string; status: string }>('/generate', {
method: 'POST',
body: params,
});
if (error) {
console.error('Failed to start image generation:', error);
throw new Error(error.message || 'Failed to start image generation');
}
if (!data) {
throw new Error('No data returned from generation endpoint');
}
return { generationId: data.generationId };
}
/**
* Subscribe to generation progress updates via Realtime
* Poll for generation status updates
*
* @example
* ```typescript
* const unsubscribe = subscribeToGenerationUpdates(generationId, (progress) => {
* const stopPolling = pollGenerationUpdates(generationId, (progress) => {
* console.log(`${progress.status}: ${progress.progress}%`);
*
* if (progress.status === 'completed') {
* displayImage(progress.imageUrl);
* unsubscribe();
* stopPolling();
* }
* });
* ```
*/
export function subscribeToGenerationUpdates(
export function pollGenerationUpdates(
generationId: string,
callback: GenerationCallback
callback: GenerationCallback,
pollInterval = 2000,
): () => void {
return subscribeToGeneration(supabase, generationId, (generation) => {
// Map database status to progress object
const progress: GenerationProgress = {
generationId: generation.id,
status: generation.status,
progress: getProgressPercentage(generation.status),
error: generation.error_message
};
let isPolling = true;
// If completed, fetch the image record
if (generation.status === 'completed') {
fetchGeneratedImage(generationId).then(image => {
if (image) {
progress.imageUrl = image.public_url;
const poll = async () => {
while (isPolling) {
try {
const { data, error } = await fetchApi<GenerationStatusResponse>(
`/generate/${generationId}/status`,
);
if (error) {
callback({
generationId,
status: 'failed',
error: error.message,
});
break;
}
callback(progress);
});
} else {
callback(progress);
if (data) {
const progress: GenerationProgress = {
generationId: data.id,
status: data.status,
progress: getProgressPercentage(data.status),
error: data.errorMessage,
imageUrl: data.image?.publicUrl,
};
callback(progress);
if (data.status === 'completed' || data.status === 'failed') {
break;
}
}
} catch (err) {
callback({
generationId,
status: 'failed',
error: err instanceof Error ? err.message : 'Unknown error',
});
break;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
}
});
};
poll();
return () => {
isPolling = false;
};
}
/**
* All-in-one: Generate image and subscribe to updates
*
* Convenience function that combines generateImageAsync + subscribeToGenerationUpdates.
*
* @example
* ```typescript
* const { generationId, unsubscribe } = await generateWithRealtime(
* { prompt: 'Sunset', model_id: 'flux-dev' },
* (progress) => {
* updateUI(progress);
* if (progress.status === 'completed') {
* showImage(progress.imageUrl);
* unsubscribe();
* }
* }
* );
* ```
* Subscribe to generation progress updates (alias for pollGenerationUpdates)
* Kept for backwards compatibility
*/
export function subscribeToGenerationUpdates(
generationId: string,
callback: GenerationCallback,
): () => void {
return pollGenerationUpdates(generationId, callback);
}
/**
* All-in-one: Generate image and poll for updates
*/
export async function generateWithRealtime(
params: GenerateImageJobParams,
onUpdate: GenerationCallback
): Promise<{ generationId: string; jobId: string; unsubscribe: () => void }> {
const result = await generateImageWithUpdates(supabase, params, (generation) => {
const progress: GenerationProgress = {
generationId: generation.id,
status: generation.status,
progress: getProgressPercentage(generation.status),
error: generation.error_message
};
onUpdate: GenerationCallback,
): Promise<{ generationId: string; unsubscribe: () => void }> {
const { generationId } = await generateImageAsync(params);
if (generation.status === 'completed') {
fetchGeneratedImage(generation.id).then(image => {
if (image) {
progress.imageUrl = image.public_url;
}
onUpdate(progress);
});
} else {
onUpdate(progress);
}
});
const unsubscribe = pollGenerationUpdates(generationId, onUpdate);
return result;
return { generationId, unsubscribe };
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
/**
* Fetch the generated image record
*/
async function fetchGeneratedImage(generationId: string) {
const { data, error } = await supabase
.from('images')
.select('*')
.eq('generation_id', generationId)
.single();
if (error) {
console.error('Failed to fetch generated image:', error);
return null;
}
return data;
}
/**
* Convert status to progress percentage (for UI)
*/
@ -198,53 +191,41 @@ function getProgressPercentage(status: string): number {
}
/**
* Get generation status (one-time check, no subscription)
* Get generation status (one-time check, no polling)
*/
export async function getGenerationStatus(generationId: string): Promise<GenerationProgress | null> {
const { data, error } = await supabase
.from('image_generations')
.select('*')
.eq('id', generationId)
.single();
const { data, error } = await fetchApi<GenerationStatusResponse>(
`/generate/${generationId}/status`,
);
if (error) {
console.error('Failed to get generation status:', error);
return null;
}
const progress: GenerationProgress = {
if (!data) {
return null;
}
return {
generationId: data.id,
status: data.status,
progress: getProgressPercentage(data.status),
error: data.error_message
error: data.errorMessage,
imageUrl: data.image?.publicUrl,
};
if (data.status === 'completed') {
const image = await fetchGeneratedImage(generationId);
if (image) {
progress.imageUrl = image.public_url;
}
}
return progress;
}
/**
* Cancel a pending generation
*/
export async function cancelGeneration(generationId: string): Promise<void> {
// Update generation status
const { error } = await supabase
.from('image_generations')
.update({ status: 'failed', error_message: 'Cancelled by user' })
.eq('id', generationId)
.eq('status', 'pending'); // Only cancel if still pending
const { error } = await fetchApi(`/generate/${generationId}/cancel`, {
method: 'POST',
});
if (error) {
console.error('Failed to cancel generation:', error);
throw new Error('Failed to cancel generation');
}
// Note: The job will still be in queue but will fail when processed
// Could also mark the job as cancelled in job_queue table
}

Some files were not shown because too many files have changed in this diff Show more