diff --git a/api/drizzle.config.ts b/api/drizzle.config.ts new file mode 100644 index 0000000..3c9b6a9 --- /dev/null +++ b/api/drizzle.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'drizzle-kit'; +export default defineConfig({ + dialect: 'postgresql', // 'mysql' | 'sqlite' | 'turso' + schema: './src/db/schema.ts', +}); diff --git a/api/src/db/schema.ts b/api/src/db/schema.ts index 9ec6fd0..10143c0 100644 --- a/api/src/db/schema.ts +++ b/api/src/db/schema.ts @@ -1,3 +1,4 @@ +import { relations } from 'drizzle-orm'; import * as t from 'drizzle-orm/pg-core'; import { pgEnum, pgTable as table } from 'drizzle-orm/pg-core'; @@ -24,10 +25,75 @@ export const deck = table( inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(), updated: t.timestamp('updated', { mode: 'date' }).defaultNow(), }, - table => [t.uniqueIndex('deck_idx').on(table.id)], + (table) => [t.uniqueIndex('deck_idx').on(table.id)], ); export type InsertDeck = typeof deck.$inferInsert; export type SelectDeck = typeof deck.$inferSelect; + +export const decks_table = table('decks', { + id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(), + name: t.varchar('name').notNull(), + boxOrder: t.varchar('box_order').notNull().default('shuffle'), + user: t.varchar('user').notNull(), + inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(), + updated: t.timestamp('updated', { mode: 'date' }).defaultNow(), +}); +export type InsertDecks = typeof decks_table.$inferInsert; +export type SelectDecks = typeof decks_table.$inferSelect; + +export const images_table = table('images', { + id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(), + deckId: t.integer('deck_id').references(() => decks_table.id), + name: t.varchar('name').notNull(), + bildid: t.varchar('bildid').notNull(), + inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(), + updated: t.timestamp('updated', { mode: 'date' }).defaultNow(), +}); +export type InsertImages = typeof images_table.$inferInsert; +export type SelectImages = typeof images_table.$inferSelect; + +export const boxes_table = table('boxes', { + id: t.integer('id').primaryKey().generatedAlwaysAsIdentity(), + imageId: t.integer('image_id').references(() => images_table.id), + x1: t.real('x1').notNull(), + x2: t.real('x2').notNull(), + y1: t.real('y1').notNull(), + y2: t.real('y2').notNull(), + due: t.integer('due'), + ivl: t.real('ivl'), + factor: t.real('factor'), + reps: t.integer('reps'), + lapses: t.integer('lapses'), + isGraduated: t.integer('is_graduated').notNull().default(0), + inserted: t.timestamp('inserted', { mode: 'date' }).defaultNow(), + updated: t.timestamp('updated', { mode: 'date' }).defaultNow(), +}); +export type InsertBoxes = typeof boxes_table.$inferInsert; +export type SelectBoxes = typeof boxes_table.$inferSelect; + +// Relations (optional, aber hilfreich für Abfragen) +export const decksRelations = relations(decks_table, ({ many }) => ({ + images: many(images_table), +})); + +export const imagesRelations = relations(images_table, ({ one, many }) => ({ + deck: one(decks_table, { + fields: [images_table.deckId], + references: [decks_table.id], + }), + boxes: many(boxes_table), +})); + +export const boxesRelations = relations(boxes_table, ({ one }) => ({ + image: one(images_table, { + fields: [boxes_table.imageId], + references: [images_table.id], + }), +})); + +// ------------------------------------- +// USERS +// ------------------------------------- export const users = table( 'users', { @@ -39,7 +105,7 @@ export const users = table( lastLogin: t.timestamp('lastLogin', { mode: 'date' }).defaultNow(), numberOfLogins: t.integer('numberOfLogins').default(1), // Neue Spalte }, - table => [t.uniqueIndex('users_idx').on(table.id)], + (table) => [t.uniqueIndex('users_idx').on(table.id)], ); export type InsertUser = typeof users.$inferInsert; export type SelectUser = typeof users.$inferSelect; diff --git a/api/src/decks.controller.ts b/api/src/decks.controller.ts index 1961715..ba8a160 100644 --- a/api/src/decks.controller.ts +++ b/api/src/decks.controller.ts @@ -25,36 +25,7 @@ export class DecksController { async getDecks(@Request() req) { const user: User = req['user']; const entries = await this.drizzleService.getDecks(user); - const decks = {}; - for (const entry of entries) { - const deckname = entry.deckname!!; - if (!decks[deckname]) { - decks[deckname] = { - name: deckname, - images: [], - }; - } - if (entry.bildname && entry.bildid) { - decks[deckname].images.push({ - name: entry.bildname, - bildid: entry.bildid, - id: entry.id, - x1: entry.x1, - x2: entry.x2, - y1: entry.y1, - y2: entry.y2, - due: entry.due, - ivl: entry.ivl, - factor: entry.factor, - reps: entry.reps, - lapses: entry.lapses, - isGraduated: Boolean(entry.isGraduated), - inserted: new Date(entry.inserted!!), - updated: new Date(entry.updated!!), - }); - } - } - return Object.values(decks); + return entries; } @Post() @@ -66,43 +37,6 @@ export class DecksController { return this.drizzleService.createDeck(data.deckname, user); } - @Get(':deckname') - async getDeck(@Request() req, @Param('deckname') deckname: string) { - const user: User = req['user']; - const entries = await this.drizzleService.getDeckByName(deckname, user); - if (entries.length === 0) { - throw new HttpException('Deck not found', HttpStatus.NOT_FOUND); - } - - const deck = { - name: deckname, - images: [] as any, - }; - - for (const entry of entries) { - if (entry.bildname && entry.bildid) { - deck.images.push({ - name: entry.bildname, - bildid: entry.bildid, - id: entry.id, - x1: entry.x1, - x2: entry.x2, - y1: entry.y1, - y2: entry.y2, - due: entry.due, - ivl: entry.ivl, - factor: entry.factor, - reps: entry.reps, - lapses: entry.lapses, - isGraduated: Boolean(entry.isGraduated), - inserted: new Date(entry.inserted!!), - updated: new Date(entry.updated!!), - }); - } - } - return deck; - } - @Delete(':deckname') async deleteDeck(@Request() req, @Param('deckname') deckname: string) { const user: User = req['user']; @@ -122,9 +56,21 @@ export class DecksController { ); } const user: User = req['user']; - return this.drizzleService.renameDeck(oldDeckname, data.newDeckName, user); + return this.drizzleService.updateDeck( + oldDeckname, + { newName: data.newDeckName }, + user, + ); + } + @Put(':oldDeckname/update') + async updateDeck( + @Request() req, + @Param('oldDeckname') oldDeckname: string, + @Body() data: { newDeckName: string; boxOrder?: 'shuffle' | 'position' }, + ) { + const user: User = req['user']; + return this.drizzleService.updateDeck(oldDeckname, data, user); } - @Post('image') async updateImage(@Request() req, @Body() data: any) { if (!data) { diff --git a/api/src/drizzle.service.ts b/api/src/drizzle.service.ts index 98cdf78..a42fd4a 100644 --- a/api/src/drizzle.service.ts +++ b/api/src/drizzle.service.ts @@ -1,8 +1,15 @@ // drizzle.service.ts import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; -import { and, eq, sql } from 'drizzle-orm'; +import { and, asc, desc, eq, inArray, sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/node-postgres'; -import { deck, InsertUser, SelectDeck, User, users } from './db/schema'; +import { + boxes_table, + decks_table, + images_table, + InsertUser, + User, + users, +} from './db/schema'; import { SqlLoggerService } from './sql-logger.service'; @Injectable() @@ -43,6 +50,75 @@ export class DrizzleService { * - für "pro" maximal 10 Decks und 40 Bilder pro Deck * zurückgegeben. */ + // async getDecks(user: User) { + // const role = await this.getUserRole(user.email); + // const maxDecks = + // role === 'guest' ? 2 : role === 'pro' ? 10 : Number.MAX_SAFE_INTEGER; + // const maxImages = + // role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER; + + // // 1. User Decks abrufen + // const userDecks: Array = await this.db + // .select() + // .from(decks_table) + // .where(eq(decks_table.user, user.email)) + // .limit(maxDecks); + + // // 2. Hierarchische Struktur aufbauen + // const result = await Promise.all( + // userDecks.map(async (deck) => { + // // Images für dieses Deck laden + // const deckImages: Array = await this.db + // .select() + // .from(images_table) + // .where(eq(images_table.deckId, deck.id)) + // .limit(maxImages); + + // // Boxes für jedes Image laden + // const imagesWithBoxes = await Promise.all( + // deckImages.map(async (image) => { + // const boxes: Array = await this.db + // .select() + // .from(boxes_table) + // .where(eq(boxes_table.imageId, image.id)); + + // return { + // name: image.name, + // bildid: image.bildid, + // boxes: boxes.map((box) => ({ + // id: box.id, + // x1: box.x1, + // x2: box.x2, + // y1: box.y1, + // y2: box.y2, + // due: box.due, + // ivl: box.ivl, + // factor: box.factor, + // reps: box.reps, + // lapses: box.lapses, + // isGraduated: Boolean(box.isGraduated), + // inserted: box.inserted, + // updated: box.updated, + // })), + // }; + // }), + // ); + + // return { + // name: deck.name, + // boxOrder: deck.boxOrder, + // images: imagesWithBoxes, + // }; + // }), + // ); + + // // 3. Nach inserted Datum sortieren (neueste zuerst) + // return result.sort( + // (a, b) => + // new Date(b['inserted'] || 0).getTime() - + // new Date(a['inserted'] || 0).getTime(), + // ); + // } async getDecks(user: User) { const role = await this.getUserRole(user.email); const maxDecks = @@ -50,66 +126,87 @@ export class DrizzleService { const maxImages = role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER; - // Abrufen der Deck-Header (d.h. Einträge ohne Bilddaten) - const deckHeaders = await this.db + // 1. Decks mit begrenzter Anzahl abrufen + const userDecks = await this.db .select() - .from(deck) - .where(and(eq(deck.user, user.email), sql`bildid IS NULL`)) + .from(decks_table) + .where(eq(decks_table.user, user.email)) + .orderBy(desc(decks_table.inserted)) .limit(maxDecks); - // Ergebnis-Array, das später zurückgegeben wird - let result: Array = []; + // 2. Alle relevanten Images in einer Abfrage holen + const deckIds = userDecks.map((deck) => deck.id); + const allImages = + deckIds.length > 0 + ? await this.db + .select() + .from(images_table) + .where(inArray(images_table.deckId, deckIds)) + .orderBy(asc(images_table.deckId), desc(images_table.inserted)) + : []; - // Für jedes Deck werden nun die zugehörigen Bilder (Einträge mit bildid) abgerufen. - await Promise.all( - deckHeaders.map(async (header: any) => { - const images: Array = await this.db - .select() - .from(deck) - .where( - and( - eq(deck.user, user.email), - eq(deck.deckname, header.deckname), - sql`bildid IS NOT NULL`, - ), - ); + // 3. Images nach Deck gruppieren und limitieren + const imagesByDeck = new Map(); + for (const image of allImages) { + if (!imagesByDeck.has(image.deckId)) { + imagesByDeck.set(image.deckId, []); + } + const deckImages = imagesByDeck.get(image.deckId)!; + if (deckImages.length < maxImages) { + deckImages.push(image); + } + } - if (images.length === 0) { - // Wenn keine Bilder vorhanden sind, füge den Header selbst hinzu - result.push(header); - } else { - // Wenn Bilder vorhanden sind, verarbeite sie wie zuvor - const selectedBildnamen: Array = []; - // Sammle die ersten maxImages verschiedenen Bildnamen - for (const item of images) { - if ( - !selectedBildnamen.includes(item.bildname) && - selectedBildnamen.length < maxImages - ) { - selectedBildnamen.push(item.bildname); - } - } - // Filtere das Array, um nur Einträge mit diesen Bildnamen zu behalten - const filteredImages = images.filter((item) => - selectedBildnamen.includes(item.bildname), - ); - // Füge die gefilterten Bilder zum Ergebnis hinzu - result = result.concat(filteredImages); - } - }), - ); + // 4. Alle relevanten Boxes in einer Abfrage holen + const imageIds = allImages.map((img) => img.id); + const allBoxes = + imageIds.length > 0 + ? await this.db + .select() + .from(boxes_table) + .where(inArray(boxes_table.imageId, imageIds)) + : []; - // Sortiere das Ergebnis nach dem Einfügedatum (absteigend, neueste zuerst) - result.sort((a, b) => { - // Wenn inserted null oder undefined ist, setze es ans Ende - if (!a.inserted) return 1; - if (!b.inserted) return -1; + // 5. Boxes nach Image gruppieren + const boxesByImage = new Map(); + for (const box of allBoxes) { + if (!boxesByImage.has(box.imageId)) { + boxesByImage.set(box.imageId, []); + } + boxesByImage.get(box.imageId)!.push(box); + } - // Vergleiche die Daten (neuere Einträge zuerst) - return new Date(a.inserted).getTime() - new Date(b.inserted).getTime(); - }); + // 6. Hierarchische Struktur aufbauen + return userDecks + .map((deck) => { + const images = (imagesByDeck.get(deck.id) || []).map((image) => ({ + name: image.name, + bildid: image.bildid, + boxes: (boxesByImage.get(image.id) || []).map((box) => ({ + id: box.id, + x1: box.x1, + x2: box.x2, + y1: box.y1, + y2: box.y2, + due: box.due, + ivl: box.ivl, + factor: box.factor, + reps: box.reps, + lapses: box.lapses, + isGraduated: Boolean(box.isGraduated), + inserted: box.inserted, + updated: box.updated, + })), + })); - return result; + return { + name: deck.name, + boxOrder: deck.boxOrder, + images, + inserted: deck.inserted, // Für Sortierung behalten + }; + }) + .sort((a, b) => a.inserted.getTime() - b.inserted.getTime()); } /** @@ -122,12 +219,13 @@ export class DrizzleService { const maxDecks = role === 'guest' ? 2 : role === 'pro' ? 10 : Number.MAX_SAFE_INTEGER; - // Zähle nur die Deck-Header (Einträge ohne Bilddaten) + // Anzahl der vorhandenen Decks prüfen const deckCountResult = await this.db .select({ count: sql`count(*) as count` }) - .from(deck) - .where(and(eq(deck.user, user.email), sql`bildid IS NULL`)); + .from(decks_table) + .where(eq(decks_table.user, user.email)); const deckCount = Number(deckCountResult[0].count); + if (deckCount >= maxDecks) { throw new HttpException( `Maximale Anzahl an Decks (${maxDecks}) erreicht für Rolle "${role}"`, @@ -135,15 +233,24 @@ export class DrizzleService { ); } - // 'inserted' und 'updated' werden automatisch von der Datenbank gesetzt + // Neues Deck erstellen const result = await this.db - .insert(deck) + .insert(decks_table) .values({ - deckname, + name: deckname, user: user.email, + boxOrder: 'shuffle', // Default-Wert }) .returning(); - return { status: 'success', deck: result }; + + return { + status: 'success', + deck: { + name: result[0].name, + boxOrder: result[0].boxOrder, + images: [], // Leere Images-Array für Konsistenz + }, + }; } /** @@ -152,54 +259,198 @@ export class DrizzleService { * Daher wird hier nicht die Limitierung angewendet.) */ async getDeckByName(deckname: string, user: User) { - return this.db + // 1. Deck abfragen + const deckResult = await this.db .select() - .from(deck) - .where(and(eq(deck.deckname, deckname), eq(deck.user, user.email))); + .from(decks_table) + .where( + and(eq(decks_table.name, deckname), eq(decks_table.user, user.email)), + ); + + if (deckResult.length === 0) { + return []; + } + + const deck = deckResult[0]; + + // 2. Zugehörige Images abfragen + const images = await this.db + .select() + .from(images_table) + .where(eq(images_table.deckId, deck.id)); + + // 3. Boxes für jedes Image abfragen + const imagesWithBoxes = await Promise.all( + images.map(async (image) => { + const boxes = await this.db + .select() + .from(boxes_table) + .where(eq(boxes_table.imageId, image.id)); + + return { + name: image.name, + bildid: image.bildid, + boxes: boxes.map((box) => ({ + id: box.id, + x1: box.x1, + x2: box.x2, + y1: box.y1, + y2: box.y2, + due: box.due, + ivl: box.ivl, + factor: box.factor, + reps: box.reps, + lapses: box.lapses, + isGraduated: Boolean(box.isGraduated), + inserted: box.inserted, + updated: box.updated, + })), + }; + }), + ); + + // 4. Hierarchisches Objekt zurückgeben + return [ + { + name: deck.name, + boxOrder: deck.boxOrder, + images: imagesWithBoxes, + }, + ]; } /** * Methode zum Löschen eines Decks. */ async deleteDeck(deckname: string, user: User) { - const existingDeck = await this.getDeckByName(deckname, user); + // 1. Existenz des Decks prüfen + const existingDeck = await this.db + .select() + .from(decks_table) + .where( + and(eq(decks_table.name, deckname), eq(decks_table.user, user.email)), + ); + if (existingDeck.length === 0) { throw new HttpException('Deck not found', HttpStatus.NOT_FOUND); } - await this.db - .delete(deck) - .where(and(eq(deck.deckname, deckname), eq(deck.user, user.email))); + // 2. Transaktion für kaskadierendes Löschen + await this.db.transaction(async (tx) => { + // Alle Images des Decks finden + const deckImages = await tx + .select({ id: images_table.id }) + .from(images_table) + .where(eq(images_table.deckId, existingDeck[0].id)); + + // Boxes der Images löschen (nur wenn Images existieren) + if (deckImages.length > 0) { + await tx.delete(boxes_table).where( + inArray( + boxes_table.imageId, // imageId statt image_id + deckImages.map((img) => img.id), + ), + ); + } + + // Images löschen + await tx + .delete(images_table) + .where(eq(images_table.deckId, existingDeck[0].id)); + + // Deck löschen + await tx + .delete(decks_table) + .where(eq(decks_table.id, existingDeck[0].id)); + }); + return { status: 'success' }; } /** * Methode zum Umbenennen eines Decks. */ - async renameDeck(oldDeckname: string, newDeckname: string, user: User) { - const existingDeck = await this.getDeckByName(oldDeckname, user); + async updateDeck( + deckname: string, + updateData: { + newName?: string; + boxOrder?: 'shuffle' | 'position'; + }, + user: User, + ) { + // 1. Existenz des Decks prüfen + const existingDeck = await this.db + .select() + .from(decks_table) + .where( + and(eq(decks_table.name, deckname), eq(decks_table.user, user.email)), + ); + if (existingDeck.length === 0) { throw new HttpException('Deck not found', HttpStatus.NOT_FOUND); } - const existingNewDeck = await this.getDeckByName(newDeckname, user); - if (existingNewDeck.length > 0) { - throw new HttpException( - 'Deck with the new name already exists', - HttpStatus.CONFLICT, - ); + const deckId = existingDeck[0].id; + + // 2. Prüfen ob neuer Name bereits existiert (falls umbenannt wird) + if (updateData.newName && updateData.newName !== deckname) { + const nameConflict = await this.db + .select() + .from(decks_table) + .where( + and( + eq(decks_table.name, updateData.newName), + eq(decks_table.user, user.email), + ), + ); + + if (nameConflict.length > 0) { + throw new HttpException( + 'Deck with the new name already exists', + HttpStatus.CONFLICT, + ); + } } + // 3. Update-Daten vorbereiten + const updatePayload: Record = { + updated: new Date(), + }; + + if (updateData.newName) updatePayload.name = updateData.newName; + if (updateData.boxOrder) updatePayload.boxOrder = updateData.boxOrder; + + // 4. Deck aktualisieren await this.db - .update(deck) - .set({ - deckname: newDeckname, - updated: new Date(), - }) - .where(and(eq(deck.deckname, oldDeckname), eq(deck.user, user.email))); + .update(decks_table) + .set(updatePayload) + .where(eq(decks_table.id, deckId)); + + // 5. Aktualisiertes Deck mit Images abrufen + const updatedDeck = await this.db + .select() + .from(decks_table) + .where(eq(decks_table.id, deckId)); + + const deckImages = await this.db + .select() + .from(images_table) + .where(eq(images_table.deckId, deckId)); + return { status: 'success', - message: 'Deck renamed successfully', + message: updateData.newName + ? `Deck renamed from "${deckname}" to "${updateData.newName}"` + : 'Deck updated successfully', + deck: { + name: updateData.newName || deckname, + boxOrder: updateData.boxOrder || existingDeck[0].boxOrder, + images: deckImages.map((image) => ({ + name: image.name, + bildid: image.bildid, + boxes: [], // Leeres Array, kann bei Bedarf gefüllt werden + })), + }, }; } @@ -223,38 +474,46 @@ export class DrizzleService { }, user: User, ) { - // Schritt 1: Überprüfen, ob das Deck existiert - const existingDecks: SelectDeck[] = await this.getDeckByName( - data.deckname, - user, - ); - if (existingDecks.length === 0) { + // 1. Deck existenz prüfen + const deck = await this.db + .select() + .from(decks_table) + .where( + and( + eq(decks_table.name, data.deckname), + eq(decks_table.user, user.email), + ), + ); + + if (deck.length === 0) { throw new HttpException('Deck not found', HttpStatus.NOT_FOUND); } + const deckId = deck[0].id; - // Rollenbasierte Limitierung: max. Bilder pro Deck + // 2. Rollenbasierte Limitierung const role = await this.getUserRole(user.email); const maxImages = role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER; - // Prüfen, ob für die angegebene bildid bereits Einträge existieren. - const imageGroupExists = existingDecks.some( - (row) => row.bildid === data.bildid, - ); - if (!imageGroupExists) { - // Zähle die unterschiedlichen Bilder (distinct bildid, wobei nur Einträge mit gesetzter bildid zählen) - const distinctImagesResult = await this.db - .select({ count: sql`count(distinct bildid) as count` }) - .from(deck) - .where( - and( - eq(deck.user, user.email), - eq(deck.deckname, data.deckname), - sql`bildid IS NOT NULL`, - ), - ); - const distinctImageCount = Number(distinctImagesResult[0].count); - if (distinctImageCount >= maxImages) { + // 3. Prüfen ob Image existiert + const existingImage = await this.db + .select() + .from(images_table) + .where( + and( + eq(images_table.bildid, data.bildid), + eq(images_table.deckId, deckId), + ), + ); + + // 4. Image Limit prüfen + if (existingImage.length === 0) { + const imageCount = await this.db + .select({ count: sql`count(*)` }) + .from(images_table) + .where(eq(images_table.deckId, deckId)); + + if (imageCount[0].count >= maxImages) { throw new HttpException( `Maximale Anzahl an Bildern (${maxImages}) für Deck "${data.deckname}" erreicht`, HttpStatus.FORBIDDEN, @@ -262,166 +521,274 @@ export class DrizzleService { } } - // Schritt 2: Trennen der neuen und bestehenden Einträge - const newEntries = data.boxes.filter((b) => !b.id); - const existingEntries = data.boxes.filter((b) => b.id); + // 5. Transaktion für alle Änderungen + return await this.db.transaction(async (tx) => { + let imageId: number; - // Schritt 3: Einfügen neuer Einträge - const insertedImages: any = []; - for (let index = 0; index < newEntries.length; index++) { - const box = newEntries[index]; - const result = await this.db - .insert(deck) - .values({ - deckname: data.deckname, - bildname: data.bildname, - bildid: data.bildid, - x1: box.x1, - x2: box.x2, - y1: box.y1, - y2: box.y2, - user: user.email, - }) - .returning(); - insertedImages.push(result); - } - - // Schritt 4: Aktualisieren bestehender Einträge - for (let index = 0; index < existingEntries.length; index++) { - const box = existingEntries[index]; - const existingDeck = existingDecks.find((d) => d.id === box.id); - if ( - existingDeck && - (existingDeck.x1 !== box.x1 || - existingDeck.x2 !== box.x2 || - existingDeck.y1 !== box.y1 || - existingDeck.y2 !== box.y2) - ) { - const result = await this.db - .update(deck) - .set({ - x1: box.x1, - x2: box.x2, - y1: box.y1, - y2: box.y2, - updated: new Date(), + // Image erstellen falls nicht existiert + if (existingImage.length === 0) { + const [newImage] = await tx + .insert(images_table) + .values({ + deckId: deckId, + name: data.bildname, + bildid: data.bildid, }) - .where( - and( - eq(deck.user, user.email), - eq(deck.bildid, data.bildid), - eq(deck.deckname, data.deckname), - eq(deck.id, box.id!), - ), - ); - if (result.rowsAffected === 0) { - throw new HttpException( - `Box with id ${box.id} not found`, - HttpStatus.NOT_FOUND, - ); + .returning(); + imageId = newImage.id; + } else { + imageId = existingImage[0].id; + // Image Namen aktualisieren falls geändert + if (existingImage[0].name !== data.bildname) { + await tx + .update(images_table) + .set({ name: data.bildname }) + .where(eq(images_table.id, imageId)); } } - } - // Schritt 5: Löschen von nicht mehr vorhandenen Einträgen - const existingIdsInDb = existingDecks - .filter( - (entry) => - entry.bildid === data.bildid && entry.deckname === data.deckname, - ) - .map((entry) => entry.id); - const incomingIds = existingEntries.map((entry) => entry.id); - const idsToDelete = existingIdsInDb.filter( - (id) => !incomingIds.includes(id), - ); + // 6. Bestehende Boxes abrufen + const existingBoxes = await tx + .select() + .from(boxes_table) + .where(eq(boxes_table.imageId, imageId)); - if (idsToDelete.length > 0) { - await this.db - .delete(deck) - .where( - and( - eq(deck.deckname, data.deckname), - eq(deck.bildid, data.bildid), - eq(deck.user, user.email), - sql`id in ${idsToDelete}`, - ), - ); - } + // 7. Boxes aufteilen + const newBoxes = data.boxes.filter((b) => !b.id); + const updatedBoxes = data.boxes.filter((b) => b.id); - return { status: 'success', inserted_images: insertedImages }; + // 8. Neue Boxes einfügen + const insertedBoxes = await Promise.all( + newBoxes.map((box) => + tx + .insert(boxes_table) + .values({ + imageId: imageId, + x1: box.x1, + x2: box.x2, + y1: box.y1, + y2: box.y2, + }) + .returning(), + ), + ); + + // 9. Bestehende Boxes aktualisieren + const updatedBoxResults = await Promise.all( + updatedBoxes.map((box) => { + const existing = existingBoxes.find((b) => b.id === box.id); + if (!existing) { + throw new HttpException( + `Box with id ${box.id} not found`, + HttpStatus.NOT_FOUND, + ); + } + return tx + .update(boxes_table) + .set({ + x1: box.x1, + x2: box.x2, + y1: box.y1, + y2: box.y2, + updated: new Date(), + }) + .where(eq(boxes_table.id, box.id!)); + }), + ); + + // 10. Nicht mehr vorhandene Boxes löschen + const existingBoxIds = existingBoxes.map((b) => b.id); + const incomingBoxIds = updatedBoxes.map((b) => b.id!); + const boxesToDelete = existingBoxIds.filter( + (id) => !incomingBoxIds.includes(id), + ); + + if (boxesToDelete.length > 0) { + await tx + .delete(boxes_table) + .where(inArray(boxes_table.id, boxesToDelete)); + } + + return { + status: 'success', + inserted_boxes: insertedBoxes, + updated_boxes: updatedBoxResults.length, + }; + }); } - /** * Methode zum Löschen von Bildern anhand der bildid. */ async deleteImagesByBildId(bildid: string, user: User) { - const affectedDecks = await this.db - .select({ deckname: deck.deckname }) - .from(deck) - .where(and(eq(deck.bildid, bildid), eq(deck.user, user.email))); - if (affectedDecks.length === 0) { + // 1. Prüfen ob Image existiert und zum User gehört + const imageToDelete = await this.db + .select({ + id: images_table.id, + deck_id: images_table.deckId, + deck_name: decks_table.name, + }) + .from(images_table) + .innerJoin(decks_table, eq(images_table.deckId, decks_table.id)) + .where( + and(eq(images_table.bildid, bildid), eq(decks_table.user, user.email)), + ); + + if (imageToDelete.length === 0) { throw new HttpException( 'No entries found for the given image ID', HttpStatus.NOT_FOUND, ); } - await this.db - .delete(deck) - .where(and(eq(deck.bildid, bildid), eq(deck.user, user.email))); + + // 2. Transaktion für sicheres Löschen + await this.db.transaction(async (tx) => { + // Zuerst alle Boxes des Images löschen + await tx + .delete(boxes_table) + .where(eq(boxes_table.imageId, imageToDelete[0].id)); + + // Dann das Image selbst löschen + await tx + .delete(images_table) + .where(eq(images_table.id, imageToDelete[0].id)); + }); + return { status: 'success', - message: `All entries for image ID "${bildid}" have been deleted.`, + message: `Image with ID "${bildid}" and all associated boxes have been deleted from deck "${imageToDelete[0].deck_name}".`, + deleted_image: { + id: imageToDelete[0].id, + bildid, + deck_id: imageToDelete[0].deck_id, + }, }; } - /** * Methode zum Verschieben eines Bildes in ein anderes Deck. */ - async moveImage(bildid: string, targetDeckId: string, user: User) { - const existingImages = await this.db - .select() - .from(deck) - .where(and(eq(deck.bildid, bildid), eq(deck.user, user.email))); - if (existingImages.length === 0) { + async moveImage(bildid: string, targetDeckName: string, user: User) { + // 1. Prüfen ob das Image existiert und zum User gehört + const sourceImage = await this.db + .select({ + imageId: images_table.id, + currentDeckId: images_table.deckId, + currentDeckName: decks_table.name, + }) + .from(images_table) + .innerJoin(decks_table, eq(images_table.deckId, decks_table.id)) + .where( + and(eq(images_table.bildid, bildid), eq(decks_table.user, user.email)), + ) + .limit(1); + + if (sourceImage.length === 0) { throw new HttpException( 'No entries found for the given image ID', HttpStatus.NOT_FOUND, ); } + + // 2. Ziel-Deck prüfen + const targetDeck = await this.db + .select() + .from(decks_table) + .where( + and( + eq(decks_table.name, targetDeckName), + eq(decks_table.user, user.email), + ), + ) + .limit(1); + + if (targetDeck.length === 0) { + throw new HttpException( + 'Target deck not found or not owned by user', + HttpStatus.NOT_FOUND, + ); + } + + // 3. Rollenbasierte Limitierung prüfen + const role = await this.getUserRole(user.email); + const maxImages = + role === 'guest' ? 20 : role === 'pro' ? 40 : Number.MAX_SAFE_INTEGER; + + const imageCountInTarget = await this.db + .select({ count: sql`count(*)` }) + .from(images_table) + .where(eq(images_table.deckId, targetDeck[0].id)); + + if (imageCountInTarget[0].count >= maxImages) { + throw new HttpException( + `Maximum number of images (${maxImages}) reached in target deck`, + HttpStatus.FORBIDDEN, + ); + } + + // 4. Image verschieben await this.db - .update(deck) + .update(images_table) .set({ - deckname: targetDeckId, + deckId: targetDeck[0].id, updated: new Date(), }) - .where(and(eq(deck.bildid, bildid), eq(deck.user, user.email))); - return { status: 'success', moved_entries: existingImages.length }; - } + .where(eq(images_table.id, sourceImage[0].imageId)); + return { + status: 'success', + message: `Image moved from "${sourceImage[0].currentDeckName}" to "${targetDeckName}"`, + moved_image: { + id: sourceImage[0].imageId, + bildid, + from_deck: sourceImage[0].currentDeckId, + to_deck: targetDeck[0].id, + }, + }; + } /** * Methode zum Umbenennen eines Bildes. */ async renameImage(bildId: string, newImagename: string, user: User) { - const existingImages = await this.db - .select() - .from(deck) - .where(and(eq(deck.bildid, bildId), eq(deck.user, user.email))); - if (existingImages.length === 0) { - throw new HttpException('Deck not found', HttpStatus.NOT_FOUND); + // 1. Image mit zugehörigem Deck finden (User-Zugehörigkeit prüfen) + const imageToRename = await this.db + .select({ + imageId: images_table.id, + currentName: images_table.name, + deckName: decks_table.name, + }) + .from(images_table) + .innerJoin(decks_table, eq(images_table.deckId, decks_table.id)) + .where( + and(eq(images_table.bildid, bildId), eq(decks_table.user, user.email)), + ); + + if (imageToRename.length === 0) { + throw new HttpException( + 'Image not found or not owned by user', + HttpStatus.NOT_FOUND, + ); } - await this.db - .update(deck) + + // 2. Image umbenennen + const result = await this.db + .update(images_table) .set({ - bildname: newImagename, + name: newImagename, updated: new Date(), }) - .where(and(eq(deck.bildid, bildId), eq(deck.user, user.email))); + .where(eq(images_table.bildid, bildId)) + .returning(); + return { status: 'success', - message: 'Image Entries renamed successfully', + message: `Image renamed from "${imageToRename[0].currentName}" to "${newImagename}" in deck "${imageToRename[0].deckName}"`, + renamed_image: { + id: imageToRename[0].imageId, + bildid: bildId, + old_name: imageToRename[0].currentName, + new_name: newImagename, + }, }; } - /** * Methode zum Aktualisieren einer Box. */ @@ -438,42 +805,75 @@ export class DrizzleService { }, user: User, ) { - const updateData: any = { ...data }; + // 1. Box mit zugehörigem Deck finden (User-Zugehörigkeit prüfen) + const boxToUpdate = await this.db + .select({ + boxId: boxes_table.id, + }) + .from(boxes_table) + .innerJoin(images_table, eq(boxes_table.imageId, images_table.id)) + .innerJoin(decks_table, eq(images_table.deckId, decks_table.id)) + .where(and(eq(boxes_table.id, id), eq(decks_table.user, user.email))); + + if (boxToUpdate.length === 0) { + throw new HttpException( + 'Box not found or not owned by user', + HttpStatus.NOT_FOUND, + ); + } + + // 2. Update-Daten vorbereiten + const updateData: Record = { + updated: new Date(), + }; + + if (data.due !== undefined) updateData.due = data.due; + if (data.ivl !== undefined) updateData.ivl = data.ivl; + if (data.factor !== undefined) updateData.factor = data.factor; + if (data.reps !== undefined) updateData.reps = data.reps; + if (data.lapses !== undefined) updateData.lapses = data.lapses; + if (typeof data.isGraduated === 'boolean') { - updateData.isGraduated = Number(data.isGraduated); + updateData.is_graduated = data.isGraduated ? 1 : 0; } - updateData.updated = new Date(); - updateData.inserted = new Date(data.inserted as string); + if (data.inserted) { + updateData.inserted = new Date(data.inserted); + } + + // 3. Box aktualisieren const result = await this.db - .update(deck) + .update(boxes_table) .set(updateData) - .where(and(eq(deck.id, id), eq(deck.user, user.email))); - if (result.rowsAffected === 0) { - throw new HttpException('Box not found', HttpStatus.NOT_FOUND); - } - return { status: 'success' }; - } + .where(eq(boxes_table.id, id)); + return { + status: 'success', + updated_box: { + id, + ...updateData, + }, + }; + } /** * Methode zum Abrufen aller eindeutigen Bild-IDs aus der Datenbank. */ - async getDistinctBildIds(user: User): Promise { - try { - const result = await this.db.selectDistinct([deck.bildid]).from(deck); - const usedIds = result - .map((row: any) => row['0']) - .filter((id: string | null) => id !== null) as string[]; - console.log(usedIds); - return usedIds; - } catch (error) { - this.sqlLogger.logQuery('Error fetching distinct bildids', []); - throw new HttpException( - `Fehler beim Abrufen der Bild-IDs - ${error}`, - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } + // async getDistinctBildIds(user: User): Promise { + // try { + // const result = await this.db.selectDistinct([deck.bildid]).from(deck); + // const usedIds = result + // .map((row: any) => row['0']) + // .filter((id: string | null) => id !== null) as string[]; + // console.log(usedIds); + // return usedIds; + // } catch (error) { + // this.sqlLogger.logQuery('Error fetching distinct bildids', []); + // throw new HttpException( + // `Fehler beim Abrufen der Bild-IDs - ${error}`, + // HttpStatus.INTERNAL_SERVER_ERROR, + // ); + // } + // } /** * Führt den Login-Vorgang durch: diff --git a/api/src/proxy.controller.ts b/api/src/proxy.controller.ts index 44139f6..9e18820 100644 --- a/api/src/proxy.controller.ts +++ b/api/src/proxy.controller.ts @@ -76,58 +76,58 @@ export class ProxyController { // -------------------- // Cleanup Endpoint // -------------------- - @Post('cleanup') - async cleanupEndpoint( - @Body() data: { dryrun?: boolean }, - @Res() res: express.Response, - ) { - try { - const user = res.req['user']; // Benutzerinformationen aus dem Request + // @Post('cleanup') + // async cleanupEndpoint( + // @Body() data: { dryrun?: boolean }, + // @Res() res: express.Response, + // ) { + // try { + // const user = res.req['user']; // Benutzerinformationen aus dem Request - // 2. Nur Benutzer mit der spezifischen E-Mail dürfen fortfahren - if (user.email !== 'andreas.knuth@gmail.com') { - throw new HttpException('Zugriff verweigert.', HttpStatus.FORBIDDEN); - } + // // 2. Nur Benutzer mit der spezifischen E-Mail dürfen fortfahren + // if (user.email !== 'andreas.knuth@gmail.com') { + // throw new HttpException('Zugriff verweigert.', HttpStatus.FORBIDDEN); + // } - // 1. Abrufen der distinct bildid aus der Datenbank - const usedIds = await this.drizzleService.getDistinctBildIds(user); - // 3. Verarbeitung des dryrun Parameters - const dryrun = data.dryrun !== undefined ? data.dryrun : true; - if (typeof dryrun !== 'boolean') { - throw new HttpException( - "'dryrun' muss ein boolescher Wert sein.", - HttpStatus.BAD_REQUEST, - ); - } + // // 1. Abrufen der distinct bildid aus der Datenbank + // const usedIds = await this.drizzleService.getDistinctBildIds(user); + // // 3. Verarbeitung des dryrun Parameters + // const dryrun = data.dryrun !== undefined ? data.dryrun : true; + // if (typeof dryrun !== 'boolean') { + // throw new HttpException( + // "'dryrun' muss ein boolescher Wert sein.", + // HttpStatus.BAD_REQUEST, + // ); + // } - // 4. Aufruf des Flask-Backend-Endpunkts - const response = await fetch('http://localhost:5000/api/cleanup', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ dryrun, usedIds }), - }); + // // 4. Aufruf des Flask-Backend-Endpunkts + // const response = await fetch('http://localhost:5000/api/cleanup', { + // method: 'POST', + // headers: { + // 'Content-Type': 'application/json', + // }, + // body: JSON.stringify({ dryrun, usedIds }), + // }); - const result = await response.json(); + // const result = await response.json(); - if (!response.ok) { - throw new HttpException( - result.error || 'Cleanup failed', - response.status, - ); - } + // if (!response.ok) { + // throw new HttpException( + // result.error || 'Cleanup failed', + // response.status, + // ); + // } - // 5. Rückgabe der Ergebnisse an den Client - return res.status(HttpStatus.OK).json(result); - } catch (error) { - if (error instanceof HttpException) { - throw error; - } - throw new HttpException( - 'Interner Serverfehler', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } + // // 5. Rückgabe der Ergebnisse an den Client + // return res.status(HttpStatus.OK).json(result); + // } catch (error) { + // if (error instanceof HttpException) { + // throw error; + // } + // throw new HttpException( + // 'Interner Serverfehler', + // HttpStatus.INTERNAL_SERVER_ERROR, + // ); + // } + // } } diff --git a/src/app/deck-list.component.html b/src/app/deck-list.component.html index b8b152d..dd72274 100644 --- a/src/app/deck-list.component.html +++ b/src/app/deck-list.component.html @@ -1,6 +1,6 @@
-
+
@@ -47,7 +47,20 @@

{{ activeDeck.name }}

- ({{ activeDeck.images.length }} images) + +
+ Shuffle + +
- +
  • @@ -138,7 +151,7 @@ - +
diff --git a/src/app/deck-list.component.ts b/src/app/deck-list.component.ts index a0d9dca..fdcc517 100644 --- a/src/app/deck-list.component.ts +++ b/src/app/deck-list.component.ts @@ -230,7 +230,15 @@ export class DeckListComponent implements OnInit { this.trainingsDeck = null; this.loadDecks(); } - + async changeBoxPosition(val: boolean) { + this.activeDeck.boxOrder = val ? 'position' : 'shuffle'; + this.deckService.updateDeck(this.activeDeck.name, { boxOrder: this.activeDeck.boxOrder }).subscribe({ + next: () => { + this.loadDecks(); + }, + error: err => console.error('Error renaming image', err), + }); + } // Method to open the create deck modal openCreateDeckModal(): void { this.createDeckModal.open(); @@ -502,7 +510,7 @@ export class DeckListComponent implements OnInit { //const futureDueDates = dueDates.filter(date => date && date >= now); if (dueDates.length > 0) { const nextDate = dueDates.reduce((a, b) => (a < b ? a : b)); - return nextDate image.boxes.map(box => (box.due ? box.due : null))).filter(e=>e<=nextTraining).length; + return deck.images.flatMap(image => image.boxes.map(box => (box.due ? box.due : null))).filter(e => e <= nextTraining).length; } getTodayInDays(): number { const epoch = new Date(1970, 0, 1); // Anki uses UNIX epoch diff --git a/src/app/services/deck.service.ts b/src/app/services/deck.service.ts index 03ca29d..2afedb1 100644 --- a/src/app/services/deck.service.ts +++ b/src/app/services/deck.service.ts @@ -1,9 +1,10 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; -import { map, Observable } from 'rxjs'; +import { Observable } from 'rxjs'; export interface Deck { name: string; + boxOrder: 'shuffle' | 'position'; images: DeckImage[]; } @@ -62,14 +63,7 @@ export class DeckService { constructor(private http: HttpClient) {} getDecks(): Observable { - return this.http.get(this.apiUrl).pipe( - map(decks => - decks.map(deck => ({ - name: deck.name, - images: this.groupImagesByName(deck.images), - })), - ), - ); + return this.http.get(this.apiUrl); } private groupImagesByName(images: any[]): DeckImage[] { @@ -103,9 +97,9 @@ export class DeckService { return Object.values(imageMap); } - getDeck(deckname: string): Observable { - return this.http.get(`${this.apiUrl}/${deckname}/images`); - } + // getDeck(deckname: string): Observable { + // return this.http.get(`${this.apiUrl}/${deckname}/images`); + // } createDeck(deckname: string): Observable { return this.http.post(this.apiUrl, { deckname }); @@ -117,6 +111,9 @@ export class DeckService { renameDeck(oldDeckName: string, newDeckName: string): Observable { return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/rename`, { newDeckName }); } + updateDeck(oldDeckName: string, updateData: { newName?: string; boxOrder?: 'shuffle' | 'position' }): Observable { + return this.http.put(`${this.apiUrl}/${encodeURIComponent(oldDeckName)}/update`, updateData); + } renameImage(bildid: string, newImageName: string): Observable { return this.http.put(`${this.apiUrl}/image/${encodeURIComponent(bildid)}/rename`, { newImageName }); } diff --git a/src/app/training/training.component.html b/src/app/training/training.component.html index dad2bc5..32d6c4b 100644 --- a/src/app/training/training.component.html +++ b/src/app/training/training.component.html @@ -1,7 +1,7 @@

Training: {{ deck.name }}

- +
diff --git a/src/app/training/training.component.ts b/src/app/training/training.component.ts index ce465b6..bbf9333 100644 --- a/src/app/training/training.component.ts +++ b/src/app/training/training.component.ts @@ -25,6 +25,7 @@ const EASY_INTERVAL = 4 * 1440; // 4 days in minutes }) export class TrainingComponent implements OnInit { @Input() deck!: Deck; + @Input() boxOrder: 'shuffle' | 'position' = 'shuffle'; // Standardmäßig shuffle @Output() close = new EventEmitter(); @ViewChild('canvas', { static: false }) canvasRef!: ElementRef; @@ -92,8 +93,13 @@ export class TrainingComponent implements OnInit { return; } - // Shuffle the boxes randomly - this.boxesToReview = this.shuffleArray(this.boxesToReview); + // Order the boxes based on the input parameter + if (this.boxOrder === 'position') { + this.boxesToReview = this.sortBoxesByPosition(this.boxesToReview); + } else { + // Default: shuffle + this.boxesToReview = this.shuffleArray(this.boxesToReview); + } // Initialize the array to track revealed boxes this.boxRevealed = new Array(this.boxesToReview.length).fill(false); @@ -165,7 +171,43 @@ export class TrainingComponent implements OnInit { this.close.emit(); }; } + /** + * Sorts boxes by their position (left to right, top to bottom) + */ + /** + * Sorts boxes by their position (left to right, top to bottom) with better row detection + */ + sortBoxesByPosition(boxes: Box[]): Box[] { + // First create a copy of the array + const boxesCopy = [...boxes]; + // Determine the average box height to use for row grouping tolerance + const avgHeight = boxesCopy.reduce((sum, box) => sum + (box.y2 - box.y1), 0) / boxesCopy.length; + const rowTolerance = avgHeight * 0.4; // 40% of average box height as tolerance + + // Group boxes into rows + const rows: Box[][] = []; + + boxesCopy.forEach(box => { + // Find an existing row within tolerance + const row = rows.find(r => Math.abs(r[0].y1 - box.y1) < rowTolerance || Math.abs(r[0].y2 - box.y2) < rowTolerance); + + if (row) { + row.push(box); + } else { + rows.push([box]); + } + }); + + // Sort each row by x1 (left to right) + rows.forEach(row => row.sort((a, b) => a.x1 - b.x1)); + + // Sort rows by y1 (top to bottom) + rows.sort((a, b) => a[0].y1 - b[0].y1); + + // Flatten the rows into a single array + return rows.flat(); + } /** * Shuffles an array randomly. * @param array The array to shuffle