mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
Feat: Refactor postgress
This commit is contained in:
parent
046a0e3fe7
commit
98efa6f6e8
134 changed files with 9459 additions and 1904 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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/*"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
146
apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts
Normal file
146
apps/nutriphi/apps/backend/src/sync/dto/sync.dto.ts
Normal 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;
|
||||
}
|
||||
50
apps/nutriphi/apps/backend/src/sync/sync.controller.ts
Normal file
50
apps/nutriphi/apps/backend/src/sync/sync.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/nutriphi/apps/backend/src/sync/sync.module.ts
Normal file
10
apps/nutriphi/apps/backend/src/sync/sync.module.ts
Normal 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 {}
|
||||
251
apps/nutriphi/apps/backend/src/sync/sync.service.ts
Normal file
251
apps/nutriphi/apps/backend/src/sync/sync.service.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
439
apps/nutriphi/apps/mobile/services/auth/authService.ts
Normal file
439
apps/nutriphi/apps/mobile/services/auth/authService.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
120
apps/nutriphi/apps/mobile/services/auth/tokenManager.ts
Normal file
120
apps/nutriphi/apps/mobile/services/auth/tokenManager.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
|
|
@ -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]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
347
apps/nutriphi/apps/mobile/services/sync/SyncService.ts
Normal file
347
apps/nutriphi/apps/mobile/services/sync/SyncService.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
300
apps/nutriphi/apps/mobile/store/AuthStore.ts
Normal file
300
apps/nutriphi/apps/mobile/store/AuthStore.ts
Normal 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 }),
|
||||
}));
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
19
apps/picture/apps/backend/.env.example
Normal file
19
apps/picture/apps/backend/.env.example
Normal 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
|
||||
13
apps/picture/apps/backend/drizzle.config.ts
Normal file
13
apps/picture/apps/backend/drizzle.config.ts
Normal 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',
|
||||
},
|
||||
});
|
||||
8
apps/picture/apps/backend/nest-cli.json
Normal file
8
apps/picture/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
58
apps/picture/apps/backend/package.json
Normal file
58
apps/picture/apps/backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
32
apps/picture/apps/backend/src/app.module.ts
Normal file
32
apps/picture/apps/backend/src/app.module.ts
Normal 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 {}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {}
|
||||
515
apps/picture/apps/backend/src/board-item/board-item.service.ts
Normal file
515
apps/picture/apps/backend/src/board-item/board-item.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
134
apps/picture/apps/backend/src/board-item/dto/board-item.dto.ts
Normal file
134
apps/picture/apps/backend/src/board-item/dto/board-item.dto.ts
Normal 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';
|
||||
}
|
||||
102
apps/picture/apps/backend/src/board/board.controller.ts
Normal file
102
apps/picture/apps/backend/src/board/board.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/picture/apps/backend/src/board/board.module.ts
Normal file
10
apps/picture/apps/backend/src/board/board.module.ts
Normal 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 {}
|
||||
403
apps/picture/apps/backend/src/board/board.service.ts
Normal file
403
apps/picture/apps/backend/src/board/board.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
80
apps/picture/apps/backend/src/board/dto/board.dto.ts
Normal file
80
apps/picture/apps/backend/src/board/dto/board.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
);
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
38
apps/picture/apps/backend/src/db/connection.ts
Normal file
38
apps/picture/apps/backend/src/db/connection.ts
Normal 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>;
|
||||
28
apps/picture/apps/backend/src/db/database.module.ts
Normal file
28
apps/picture/apps/backend/src/db/database.module.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
26
apps/picture/apps/backend/src/db/migrate.ts
Normal file
26
apps/picture/apps/backend/src/db/migrate.ts
Normal 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);
|
||||
});
|
||||
|
|
@ -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;
|
||||
33
apps/picture/apps/backend/src/db/schema/boards.schema.ts
Normal file
33
apps/picture/apps/backend/src/db/schema/boards.schema.ts
Normal 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;
|
||||
|
|
@ -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;
|
||||
46
apps/picture/apps/backend/src/db/schema/images.schema.ts
Normal file
46
apps/picture/apps/backend/src/db/schema/images.schema.ts
Normal 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;
|
||||
6
apps/picture/apps/backend/src/db/schema/index.ts
Normal file
6
apps/picture/apps/backend/src/db/schema/index.ts
Normal 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';
|
||||
51
apps/picture/apps/backend/src/db/schema/models.schema.ts
Normal file
51
apps/picture/apps/backend/src/db/schema/models.schema.ts
Normal 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;
|
||||
23
apps/picture/apps/backend/src/db/schema/tags.schema.ts
Normal file
23
apps/picture/apps/backend/src/db/schema/tags.schema.ts
Normal 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;
|
||||
90
apps/picture/apps/backend/src/db/seed.ts
Normal file
90
apps/picture/apps/backend/src/db/seed.ts
Normal 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);
|
||||
});
|
||||
34
apps/picture/apps/backend/src/explore/dto/explore.dto.ts
Normal file
34
apps/picture/apps/backend/src/explore/dto/explore.dto.ts
Normal 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;
|
||||
}
|
||||
20
apps/picture/apps/backend/src/explore/explore.controller.ts
Normal file
20
apps/picture/apps/backend/src/explore/explore.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
apps/picture/apps/backend/src/explore/explore.module.ts
Normal file
9
apps/picture/apps/backend/src/explore/explore.module.ts
Normal 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 {}
|
||||
83
apps/picture/apps/backend/src/explore/explore.service.ts
Normal file
83
apps/picture/apps/backend/src/explore/explore.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
41
apps/picture/apps/backend/src/generate/dto/generate.dto.ts
Normal file
41
apps/picture/apps/backend/src/generate/dto/generate.dto.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
13
apps/picture/apps/backend/src/generate/generate.module.ts
Normal file
13
apps/picture/apps/backend/src/generate/generate.module.ts
Normal 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 {}
|
||||
382
apps/picture/apps/backend/src/generate/generate.service.ts
Normal file
382
apps/picture/apps/backend/src/generate/generate.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
124
apps/picture/apps/backend/src/generate/replicate.service.ts
Normal file
124
apps/picture/apps/backend/src/generate/replicate.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
13
apps/picture/apps/backend/src/health/health.controller.ts
Normal file
13
apps/picture/apps/backend/src/health/health.controller.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
apps/picture/apps/backend/src/health/health.module.ts
Normal file
7
apps/picture/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
38
apps/picture/apps/backend/src/image/dto/image.dto.ts
Normal file
38
apps/picture/apps/backend/src/image/dto/image.dto.ts
Normal 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;
|
||||
}
|
||||
88
apps/picture/apps/backend/src/image/image.controller.ts
Normal file
88
apps/picture/apps/backend/src/image/image.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/picture/apps/backend/src/image/image.module.ts
Normal file
10
apps/picture/apps/backend/src/image/image.module.ts
Normal 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 {}
|
||||
286
apps/picture/apps/backend/src/image/image.service.ts
Normal file
286
apps/picture/apps/backend/src/image/image.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
38
apps/picture/apps/backend/src/main.ts
Normal file
38
apps/picture/apps/backend/src/main.ts
Normal 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();
|
||||
19
apps/picture/apps/backend/src/model/model.controller.ts
Normal file
19
apps/picture/apps/backend/src/model/model.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/picture/apps/backend/src/model/model.module.ts
Normal file
10
apps/picture/apps/backend/src/model/model.module.ts
Normal 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 {}
|
||||
49
apps/picture/apps/backend/src/model/model.service.ts
Normal file
49
apps/picture/apps/backend/src/model/model.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
20
apps/picture/apps/backend/src/tag/dto/tag.dto.ts
Normal file
20
apps/picture/apps/backend/src/tag/dto/tag.dto.ts
Normal 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;
|
||||
}
|
||||
60
apps/picture/apps/backend/src/tag/tag.controller.ts
Normal file
60
apps/picture/apps/backend/src/tag/tag.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
apps/picture/apps/backend/src/tag/tag.module.ts
Normal file
10
apps/picture/apps/backend/src/tag/tag.module.ts
Normal 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 {}
|
||||
157
apps/picture/apps/backend/src/tag/tag.service.ts
Normal file
157
apps/picture/apps/backend/src/tag/tag.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
126
apps/picture/apps/backend/src/upload/storage.service.ts
Normal file
126
apps/picture/apps/backend/src/upload/storage.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
93
apps/picture/apps/backend/src/upload/upload.controller.ts
Normal file
93
apps/picture/apps/backend/src/upload/upload.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
11
apps/picture/apps/backend/src/upload/upload.module.ts
Normal file
11
apps/picture/apps/backend/src/upload/upload.module.ts
Normal 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 {}
|
||||
123
apps/picture/apps/backend/src/upload/upload.service.ts
Normal file
123
apps/picture/apps/backend/src/upload/upload.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
23
apps/picture/apps/backend/tsconfig.json
Normal file
23
apps/picture/apps/backend/tsconfig.json
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
162
apps/picture/apps/web/src/lib/api/client.ts
Normal file
162
apps/picture/apps/web/src/lib/api/client.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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 || [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue