diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index 476f9b0..5e85c49 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -121,6 +121,7 @@ export interface DBGlobalBlock { name: string; block_data: string; svg_data: string | null; + is_favorite: boolean; created_at: string; updated_at: string; } diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index e85efb8..f19a17f 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -35,6 +35,11 @@ export class SqliteAdapter implements DatabaseInterface { if (!columns.some(col => col.name === 'folder_id')) { this.db.exec('ALTER TABLE projects ADD COLUMN folder_id TEXT REFERENCES project_folders(id) ON DELETE SET NULL'); } + // Add is_favorite column to global_blocks table for existing databases (Task F2) + const gbColumns = this.db.prepare("PRAGMA table_info('global_blocks')").all() as { name: string }[]; + if (!gbColumns.some(col => col.name === 'is_favorite')) { + this.db.exec('ALTER TABLE global_blocks ADD COLUMN is_favorite INTEGER NOT NULL DEFAULT 0'); + } } close(): void { @@ -423,25 +428,31 @@ export class SqliteAdapter implements DatabaseInterface { } // ─── Global Blocks ───────────────────────────────────── + // Map DB row to DBGlobalBlock (is_favorite 0/1 → boolean, Task F2) + private mapGlobalBlockRow(row: Record): DBGlobalBlock { + return { ...(row as unknown as DBGlobalBlock), is_favorite: Number(row.is_favorite ?? 0) === 1 }; + } + listGlobalBlocks(folderId?: string | null): DBGlobalBlock[] { if (folderId === undefined) { - return this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as DBGlobalBlock[]; + return (this.db.prepare('SELECT * FROM global_blocks ORDER BY updated_at DESC').all() as Record[]).map(r => this.mapGlobalBlockRow(r)); } if (folderId === null) { - return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as DBGlobalBlock[]; + return (this.db.prepare('SELECT * FROM global_blocks WHERE folder_id IS NULL ORDER BY updated_at DESC').all() as Record[]).map(r => this.mapGlobalBlockRow(r)); } - return this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as DBGlobalBlock[]; + return (this.db.prepare('SELECT * FROM global_blocks WHERE folder_id = ? ORDER BY updated_at DESC').all(folderId) as Record[]).map(r => this.mapGlobalBlockRow(r)); } getGlobalBlock(id: string): DBGlobalBlock | null { - return (this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as DBGlobalBlock) ?? null; + const row = this.db.prepare('SELECT * FROM global_blocks WHERE id = ?').get(id) as Record | undefined; + return row ? this.mapGlobalBlockRow(row) : null; } createGlobalBlock(data: Partial): DBGlobalBlock { const id = data.id ?? `gblock-${randomUUID().slice(0, 8)}`; this.db.prepare( - 'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data) VALUES (?, ?, ?, ?, ?)', - ).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null); + 'INSERT INTO global_blocks (id, folder_id, name, block_data, svg_data, is_favorite) VALUES (?, ?, ?, ?, ?, ?)', + ).run(id, data.folder_id ?? null, data.name ?? 'Global Block', data.block_data ?? '{}', data.svg_data ?? null, data.is_favorite ? 1 : 0); return this.getGlobalBlock(id)!; } @@ -452,6 +463,7 @@ export class SqliteAdapter implements DatabaseInterface { if (data.folder_id !== undefined) { sets.push('folder_id = ?'); vals.push(data.folder_id); } if (data.block_data !== undefined) { sets.push('block_data = ?'); vals.push(data.block_data); } if (data.svg_data !== undefined) { sets.push('svg_data = ?'); vals.push(data.svg_data); } + if (data.is_favorite !== undefined) { sets.push('is_favorite = ?'); vals.push(data.is_favorite ? 1 : 0); } sets.push("updated_at = datetime('now')"); this.db.prepare(`UPDATE global_blocks SET ${sets.join(', ')} WHERE id = ?`).run(...vals, id); return this.getGlobalBlock(id); diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index bb4dd12..dae4b63 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -171,6 +171,7 @@ CREATE TABLE IF NOT EXISTS global_blocks ( name TEXT NOT NULL, block_data TEXT NOT NULL DEFAULT '{}', svg_data TEXT, + is_favorite INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (folder_id) REFERENCES global_block_folders(id) ON DELETE SET NULL diff --git a/backend/src/routes/globalBlocks.ts b/backend/src/routes/globalBlocks.ts index 0cebe5c..000253c 100644 --- a/backend/src/routes/globalBlocks.ts +++ b/backend/src/routes/globalBlocks.ts @@ -134,11 +134,14 @@ export function registerGlobalBlockRoutes(fastify: FastifyInstance, db: Database const { id } = request.params as { id: string }; const idErr = validateIdParam(id, 'id'); if (idErr) return reply.code(400).send({ error: idErr }); - const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string }; + const body = request.body as { name?: string; folder_id?: string | null; block_data?: string; svg_data?: string; is_favorite?: boolean }; if (body.name !== undefined) { const nameErr = validateName(body.name, 'name'); if (nameErr) return reply.code(400).send({ error: nameErr }); } + if (body.is_favorite !== undefined && typeof body.is_favorite !== 'boolean') { + return reply.code(400).send({ error: 'is_favorite must be a boolean' }); + } if (body.folder_id !== undefined && body.folder_id !== null) { const fidErr = validateIdParam(body.folder_id, 'folder_id'); if (fidErr) return reply.code(400).send({ error: fidErr }); diff --git a/backend/tests/globalBlocks.test.ts b/backend/tests/globalBlocks.test.ts index cafad0b..209a916 100644 --- a/backend/tests/globalBlocks.test.ts +++ b/backend/tests/globalBlocks.test.ts @@ -525,3 +525,90 @@ describe('Global Blocks API – F1 export/import', () => { expect(res.statusCode).toBe(400); }); }); + +// ─── Task F2: Block-Favoriten ───────────────────────────── +describe('Global Blocks API – F2 favorite', () => { + let app: FastifyInstance; + let db: SqliteAdapter; + let authToken: string; + let blockId: string; + + beforeAll(async () => { + db = new SqliteAdapter(':memory:'); + await db.init(); + app = await createServer({ db, port: 0 }); + await app.ready(); + + const regRes = await app.inject({ + method: 'POST', + url: '/api/auth/register', + payload: { email: 'fav-test@example.com', password: 'Password123!', name: 'Fav Test', role: 'admin' }, + }); + authToken = JSON.parse(regRes.body).session.token; + + const blockRes = await app.inject({ + method: 'POST', + url: '/api/global-blocks', + headers: { authorization: `Bearer ${authToken}` }, + payload: { name: 'Fav-Block', block_data: '{"type":"block"}' }, + }); + blockId = JSON.parse(blockRes.body).id; + }); + + afterAll(async () => { + await app.close(); + db.close(); + }); + + it('neuer Block startet mit is_favorite=false', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/global-blocks/${blockId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).is_favorite).toBe(false); + }); + + it('PATCH is_favorite:true setzt Favorit', async () => { + const res = await app.inject({ + method: 'PATCH', + url: `/api/global-blocks/${blockId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { is_favorite: true }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).is_favorite).toBe(true); + }); + + it('is_favorite ist persistent nach GET', async () => { + const res = await app.inject({ + method: 'GET', + url: `/api/global-blocks/${blockId}`, + headers: { authorization: `Bearer ${authToken}` }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).is_favorite).toBe(true); + }); + + it('PATCH is_favorite:false hebt Favorit auf', async () => { + const res = await app.inject({ + method: 'PATCH', + url: `/api/global-blocks/${blockId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { is_favorite: false }, + }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).is_favorite).toBe(false); + }); + + it('PATCH mit nicht-boolean is_favorite liefert 400', async () => { + const res = await app.inject({ + method: 'PATCH', + url: `/api/global-blocks/${blockId}`, + headers: { authorization: `Bearer ${authToken}` }, + payload: { is_favorite: 'yes' }, + }); + expect(res.statusCode).toBe(400); + }); +});