diff --git a/ka-note/VERSION b/ka-note/VERSION index f7af8a7..e324560 100644 --- a/ka-note/VERSION +++ b/ka-note/VERSION @@ -1 +1 @@ -1.0.28 \ No newline at end of file +1.0.34 \ No newline at end of file diff --git a/ka-note/client/src/lib/components/Sidebar.svelte b/ka-note/client/src/lib/components/Sidebar.svelte index 3661ab8..0bfc746 100644 --- a/ka-note/client/src/lib/components/Sidebar.svelte +++ b/ka-note/client/src/lib/components/Sidebar.svelte @@ -10,8 +10,10 @@ import type { AgendaContext, ContextType } from '@ka-note/shared'; import { account, logout, login } from '$lib/auth/authStore.js'; import { syncStatus, lastSyncAt, fullSync } from '$lib/sync/syncService'; + import { deletedItemCount } from '$lib/stores/agenda'; const isDev = import.meta.env.DEV; + const trashCount$ = deletedItemCount(); let appVersion = $state('…'); fetch('/version.json').then(r => r.json()).then(d => appVersion = d.version).catch(() => appVersion = 'dev'); @@ -287,6 +289,19 @@ {/if} +
+ +
+
diff --git a/ka-note/client/src/lib/db/repositories.ts b/ka-note/client/src/lib/db/repositories.ts index 31cea86..032e179 100644 --- a/ka-note/client/src/lib/db/repositories.ts +++ b/ka-note/client/src/lib/db/repositories.ts @@ -1,5 +1,6 @@ import { db } from './schema'; import { newId, now, today } from './helpers'; +import { getAccessToken } from '$lib/auth/authStore'; import type { AgendaContext, Topic, HistoryEntry, Rating, ContextType, TopicStatus, ProjectMeta, PersonMeta, CompanyMeta } from '@ka-note/shared'; // --- Contexts --- @@ -328,3 +329,71 @@ export async function softDeleteRating(id: string): Promise { await db.ratings.update(id, { deletedAt: now(), updatedAt: now(), version: rating.version + 1 }); } } + +// --- Trash --- + +export type TrashEntityType = 'context' | 'topic' | 'history' | 'rating'; + +export interface TrashEntityRef { + type: TrashEntityType; + id: string; +} + +export async function restoreEntities(entities: TrashEntityRef[]): Promise { + const timestamp = now(); + for (const { type, id } of entities) { + if (type === 'context') { + const item = await db.contexts.get(id); + if (item) await db.contexts.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); + } else if (type === 'topic') { + const item = await db.topics.get(id); + if (item) await db.topics.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); + } else if (type === 'history') { + const item = await db.historyEntries.get(id); + if (item) await db.historyEntries.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); + } else if (type === 'rating') { + const item = await db.ratings.get(id); + if (item) await db.ratings.update(id, { deletedAt: null, updatedAt: timestamp, version: item.version + 1 }); + } + } +} + +async function resolveLocalCascade(entities: TrashEntityRef[]) { + const contextIds = new Set(entities.filter(e => e.type === 'context').map(e => e.id)); + const topicIds = new Set(entities.filter(e => e.type === 'topic').map(e => e.id)); + const historyIds = new Set(entities.filter(e => e.type === 'history').map(e => e.id)); + const ratingIds = new Set(entities.filter(e => e.type === 'rating').map(e => e.id)); + + if (contextIds.size > 0) { + const childTopics = await db.topics.where('contextId').anyOf([...contextIds]).toArray(); + for (const t of childTopics) topicIds.add(t.id); + } + if (topicIds.size > 0) { + const childHistory = await db.historyEntries.where('topicId').anyOf([...topicIds]).toArray(); + for (const h of childHistory) historyIds.add(h.id); + } + if (historyIds.size > 0) { + const childRatings = await db.ratings.where('historyEntryId').anyOf([...historyIds]).toArray(); + for (const r of childRatings) ratingIds.add(r.id); + } + + return { contextIds, topicIds, historyIds, ratingIds }; +} + +export async function hardDeleteEntities(entities: TrashEntityRef[]): Promise { + const token = await getAccessToken(); + const res = await fetch('/api/trash', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` }, + body: JSON.stringify({ entities }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Server error ${res.status}: ${body}`); + } + const { contextIds, topicIds, historyIds, ratingIds } = await resolveLocalCascade(entities); + await db.ratings.bulkDelete([...ratingIds]); + await db.historyEntries.bulkDelete([...historyIds]); + await db.topics.bulkDelete([...topicIds]); + await db.contexts.bulkDelete([...contextIds]); +} diff --git a/ka-note/client/src/lib/db/seed.ts b/ka-note/client/src/lib/db/seed.ts index 453ad4e..e9bf427 100644 --- a/ka-note/client/src/lib/db/seed.ts +++ b/ka-note/client/src/lib/db/seed.ts @@ -6,14 +6,32 @@ const today = new Date().toISOString().split('T')[0]; const yesterday = new Date(Date.now() - 86400000).toISOString().split('T')[0]; const lastWeek = new Date(Date.now() - 604800000).toISOString().split('T')[0]; +/** Creates the mandatory daily-log context if missing. Safe to call in production. */ export async function seedIfEmpty(): Promise { const count = await db.contexts.count(); if (count > 0) return; + const ts = now(); + const dailyLog: AgendaContext = { + id: 'daily-log', name: 'Daily Log / Inbox', type: 'meeting', sortOrder: 0, + meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 + }; + const journalTopic: Topic = { + id: 'daily-log-journal', contextId: 'daily-log', title: '__journal__', status: 'active', + snoozeUntil: null, sortOrder: -1, isNew: false, updatedAt: ts, deletedAt: null, version: 1 + }; + await db.transaction('rw', [db.contexts, db.topics], async () => { + await db.contexts.put(dailyLog); + await db.topics.put(journalTopic); + }); + console.log('Bootstrap: daily-log created'); +} + +/** Loads demo data. Only for development — never call in production. */ +export async function seedDevData(): Promise { const ts = now(); const contexts: AgendaContext[] = [ - { id: 'daily-log', name: 'Daily Log / Inbox', type: 'meeting', sortOrder: 0, meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 }, { id: 'jf-sysadmins', name: 'JF Team Sysadmins', type: 'meeting', sortOrder: 1, meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 }, { id: 'jf-devs', name: 'JF Developer', type: 'meeting', sortOrder: 2, meta: null, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 }, { id: 'p-tisax', name: 'Project TISAX', type: 'project', sortOrder: 0, meta: { status: '#stInArbeit', owner: 'STEFE', links: 'FileServer/Projects/TISAX' }, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 }, @@ -25,7 +43,6 @@ export async function seedIfEmpty(): Promise { { id: 'f-vendor-x', name: 'Firma VENDOR-X', type: 'company', sortOrder: 0, meta: { website: 'https://vendor-x.com', address: 'Musterstr. 1, 12345 Berlin' }, archivedAt: null, isFavorite: true, updatedAt: ts, deletedAt: null, version: 1 } ]; - // Topic IDs const t201 = newId(), t202 = newId(), t203 = newId(), t204 = newId(); const t1 = newId(), t2 = newId(), t101 = newId(); @@ -39,35 +56,23 @@ export async function seedIfEmpty(): Promise { { id: t101, contextId: 'jf-devs', title: 'API Refactoring', status: 'active', snoozeUntil: null, sortOrder: 0, isNew: false, updatedAt: ts, deletedAt: null, version: 1 } ]; - // Journal topic for daily-log - const journalTopicId = 'daily-log-journal'; - const journalTopic: Topic = { - id: journalTopicId, contextId: 'daily-log', title: '__journal__', status: 'active', - snoozeUntil: null, sortOrder: -1, isNew: false, updatedAt: ts, deletedAt: null, version: 1 - }; - const history: HistoryEntry[] = [ - { id: newId(), topicId: t201, date: today, text: 'Hr. Müller angerufen. Lizenzserver ist down.\n- Ersatzticket erstellt: #INC-999\n- Eskalation an -> VENDOR-X\n- Info an Team -> STEFE\n- @VENDOR-X war sehr hilfsbereit bei der Umgehung.\n- Betrifft @F:VENDOR-X', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t202, date: yesterday, text: 'Kosten laufen aus dem Ruder.\n- Idee: S3 Glacier Deep Archive nutzen?\n- Prüfen -> CHFI @P:CLOUD-MIGRATION\n- @CHFI hat super Ideen geliefert.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t203, date: today, text: 'Papierstau behoben. Toner bestellt.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t204, date: lastWeek, text: '- Ziele für Q1 prüfen\n- Schulungsbedarf klären\n- -> CHFI', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t1, date: today, text: '- Entscheidung: Keine Ausnahmen mehr.\n- Umsetzung startet nächste Woche @P:TISAX', sortOrder: 1, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t1, date: lastWeek, text: '- Maßnahmen definiert:\n - Benutzergruppe für kein Internet -> STEFE @P:TISAX\n - VLAN „shared" nutzen -> PHILO\n - IP-Range prüfen', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t2, date: yesterday, text: 'Priorisierung nötig -> ERAY @P:Security', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: t101, date: today, text: 'Neues Schema prüfen @P:TISAX\n- @ChrKl hat das verbockt.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - // Journal entries - { id: newId(), topicId: journalTopicId, date: today, text: 'Standup: Alle Tickets im Sprint on track.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: journalTopicId, date: today, text: 'Lizenzthema mit Vendor X besprochen, Eskalation läuft.', sortOrder: 1, linkedContextId: 'jf-sysadmins', doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, - { id: newId(), topicId: journalTopicId, date: yesterday, text: 'Cloud-Migration Review vorbereitet.', sortOrder: 0, linkedContextId: null, doneAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t201, date: today, text: 'Hr. Müller angerufen. Lizenzserver ist down.\n- Ersatzticket erstellt: #INC-999\n- Eskalation an -> VENDOR-X\n- Info an Team -> STEFE\n- @VENDOR-X war sehr hilfsbereit bei der Umgehung.\n- Betrifft @F:VENDOR-X', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t202, date: yesterday, text: 'Kosten laufen aus dem Ruder.\n- Idee: S3 Glacier Deep Archive nutzen?\n- Prüfen -> CHFI @P:CLOUD-MIGRATION\n- @CHFI hat super Ideen geliefert.', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t203, date: today, text: 'Papierstau behoben. Toner bestellt.', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t204, date: lastWeek, text: '- Ziele für Q1 prüfen\n- Schulungsbedarf klären\n- -> CHFI', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t1, date: today, text: '- Entscheidung: Keine Ausnahmen mehr.\n- Umsetzung startet nächste Woche @P:TISAX', sortOrder: 1, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t1, date: lastWeek, text: '- Maßnahmen definiert:\n - Benutzergruppe für kein Internet -> STEFE @P:TISAX\n - VLAN „shared" nutzen -> PHILO\n - IP-Range prüfen', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t2, date: yesterday, text: 'Priorisierung nötig -> ERAY @P:Security', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, + { id: newId(), topicId: t101, date: today, text: 'Neues Schema prüfen @P:TISAX\n- @ChrKl hat das verbockt.', sortOrder: 0, linkedContextId: null, doneAt: null, wiedervorlageDate: null, wiedervorlageResolvedAt: null, updatedAt: ts, deletedAt: null, version: 1 }, ]; await db.transaction('rw', [db.contexts, db.topics, db.historyEntries], async () => { await db.contexts.bulkPut(contexts); - await db.topics.bulkPut([...topics, journalTopic]); + await db.topics.bulkPut(topics); await db.historyEntries.bulkPut(history); }); - - console.log('Seed data loaded'); + console.log('Dev seed data loaded'); } export async function resetAndReseed(): Promise { @@ -79,4 +84,5 @@ export async function resetAndReseed(): Promise { await db.syncMeta.clear(); }); await seedIfEmpty(); + await seedDevData(); } diff --git a/ka-note/client/src/lib/stores/agenda.ts b/ka-note/client/src/lib/stores/agenda.ts index 682af3d..e0fe501 100644 --- a/ka-note/client/src/lib/stores/agenda.ts +++ b/ka-note/client/src/lib/stores/agenda.ts @@ -119,6 +119,27 @@ export function ratingsForPerson(personName: string) { ); } +export function deletedItems() { + return liveQuery(async () => ({ + contexts: await db.contexts.filter(c => !!c.deletedAt).toArray(), + topics: await db.topics.filter(t => !!t.deletedAt).toArray(), + history: await db.historyEntries.filter(h => !!h.deletedAt).toArray(), + ratings: await db.ratings.filter(r => !!r.deletedAt).toArray(), + })); +} + +export function deletedItemCount() { + return liveQuery(async () => { + const [c, t, h, r] = await Promise.all([ + db.contexts.filter(x => !!x.deletedAt).count(), + db.topics.filter(x => !!x.deletedAt).count(), + db.historyEntries.filter(x => !!x.deletedAt).count(), + db.ratings.filter(x => !!x.deletedAt).count(), + ]); + return c + t + h + r; + }); +} + export function pendingWiedervorlage(date: string) { return liveQuery(() => db.historyEntries diff --git a/ka-note/client/src/routes/trash/+page.svelte b/ka-note/client/src/routes/trash/+page.svelte new file mode 100644 index 0000000..40280eb --- /dev/null +++ b/ka-note/client/src/routes/trash/+page.svelte @@ -0,0 +1,275 @@ + + +
+

Papierkorb

+ + +
+ {#each (['all', 'context', 'topic', 'history', 'rating'] as FilterType[]) as f} + + {/each} +
+ + +
+ + + +
+ + + {#if deleteError} +
+ +

{deleteError}

+ +
+ {/if} + + + {#if confirming} +
+

{selected.size} Objekt(e) endgültig löschen? Diese Aktion kann nicht rückgängig gemacht werden.

+
+ + +
+
+ {/if} + + + {#if filtered.length === 0} +

Keine gelöschten Objekte.

+ {:else} +
    + {#each filtered as item (item.key)} + {@const expanded = expandedKey === item.key} +
  • + +
    toggleExpand(item.key)}> + toggleItem(item.key)} + onclick={(e) => e.stopPropagation()} + class="accent-accent shrink-0" + /> + {typeIcon(item.type)} + {typeLabel(item.type)} + {item.label} + {formatDate(item.deletedAt)} +
    + + +
    + {expanded ? '▲' : '▼'} +
    + + + {#if expanded} +
    + {#if item.type === 'context'} + {@const ctx = item.raw as AgendaContext} +
    +
    ID
    {ctx.id}
    +
    Typ
    {ctx.type}
    + {#if ctx.archivedAt}
    Archiviert
    {formatDate(ctx.archivedAt)}
    {/if} +
    Version
    {ctx.version}
    +
    Geändert
    {formatDate(ctx.updatedAt)}
    +
    + {:else if item.type === 'topic'} + {@const t = item.raw as Topic} +
    +
    ID
    {t.id}
    +
    Context
    {t.contextId}
    +
    Status
    {t.status}
    +
    Version
    {t.version}
    +
    Geändert
    {formatDate(t.updatedAt)}
    +
    + {:else if item.type === 'history'} + {@const h = item.raw as HistoryEntry} +
    +
    ID
    {h.id}
    +
    Topic
    {h.topicId}
    +
    Datum
    {h.date}
    + {#if h.linkedContextId}
    Kontext
    {h.linkedContextId}
    {/if} + {#if h.wiedervorlageDate}
    Wiedervorlage
    {h.wiedervorlageDate}
    {/if} +
    Version
    {h.version}
    +
    + {#if h.text} +
    {h.text}
    + {/if} + {:else if item.type === 'rating'} + {@const r = item.raw as Rating} +
    +
    ID
    {r.id}
    +
    Person
    {r.personName}
    +
    Wert
    {'★'.repeat(r.value)}{'☆'.repeat(4 - r.value)}
    + {#if r.comment}
    Kommentar
    {r.comment}
    {/if} +
    History
    {r.historyEntryId}
    +
    Version
    {r.version}
    +
    + {/if} +
    + {/if} +
  • + {/each} +
+ {/if} +
diff --git a/ka-note/server/src/index.ts b/ka-note/server/src/index.ts index e2bddc9..82c3175 100644 --- a/ka-note/server/src/index.ts +++ b/ka-note/server/src/index.ts @@ -9,6 +9,7 @@ import { db } from './db/connection.js'; import { authMiddleware } from './middleware/auth.js'; import syncRoutes from './routes/sync.js'; import aiRoutes from './routes/ai-export.js'; +import trashRoutes from './routes/trash.js'; const app = new Hono(); @@ -45,6 +46,9 @@ app.route('/api/sync', syncRoutes); app.use('/api/ai/*', authMiddleware); app.route('/api/ai', aiRoutes); +app.use('/api/trash/*', authMiddleware); +app.route('/api/trash', trashRoutes); + // Static file serving in production if (process.env.NODE_ENV === 'production') { app.use('*', serveStatic({ root: './public' })); diff --git a/ka-note/server/src/routes/trash.ts b/ka-note/server/src/routes/trash.ts new file mode 100644 index 0000000..fbf47bd --- /dev/null +++ b/ka-note/server/src/routes/trash.ts @@ -0,0 +1,80 @@ +import { Hono } from 'hono'; +import { db } from '../db/connection.js'; +import { contexts, topics, historyEntries, ratings } from '../db/schema.js'; +import { and, eq, inArray } from 'drizzle-orm'; +import type { AuthEnv } from '../middleware/auth.js'; + +const trash = new Hono(); + +interface EntityRef { + type: 'context' | 'topic' | 'history' | 'rating'; + id: string; +} + +/** Resolves all dependent IDs that must be deleted alongside the given entities. */ +async function resolveCascade(entities: EntityRef[], userId: string) { + const contextIds = new Set(entities.filter(e => e.type === 'context').map(e => e.id)); + const topicIds = new Set(entities.filter(e => e.type === 'topic').map(e => e.id)); + const historyIds = new Set(entities.filter(e => e.type === 'history').map(e => e.id)); + const ratingIds = new Set(entities.filter(e => e.type === 'rating').map(e => e.id)); + + // context → topics + if (contextIds.size > 0) { + const childTopics = await db + .select({ id: topics.id }) + .from(topics) + .where(and(eq(topics.userId, userId), inArray(topics.contextId, [...contextIds]))); + for (const t of childTopics) topicIds.add(t.id); + } + + // topics → history entries + if (topicIds.size > 0) { + const childHistory = await db + .select({ id: historyEntries.id }) + .from(historyEntries) + .where(and(eq(historyEntries.userId, userId), inArray(historyEntries.topicId, [...topicIds]))); + for (const h of childHistory) historyIds.add(h.id); + } + + // history entries → ratings + if (historyIds.size > 0) { + const childRatings = await db + .select({ id: ratings.id }) + .from(ratings) + .where(and(eq(ratings.userId, userId), inArray(ratings.historyEntryId, [...historyIds]))); + for (const r of childRatings) ratingIds.add(r.id); + } + + return { contextIds, topicIds, historyIds, ratingIds }; +} + +trash.delete('/', async (c) => { + const { userId } = c.get('auth'); + const body = await c.req.json<{ entities: EntityRef[] }>(); + const { entities } = body; + + if (!Array.isArray(entities) || entities.length === 0) { + return c.json({ error: 'No entities provided' }, 400); + } + + const { contextIds, topicIds, historyIds, ratingIds } = await resolveCascade(entities, userId); + + // Delete in FK-safe order: ratings → history → topics → contexts + if (ratingIds.size > 0) { + await db.delete(ratings).where(and(eq(ratings.userId, userId), inArray(ratings.id, [...ratingIds]))); + } + if (historyIds.size > 0) { + await db.delete(historyEntries).where(and(eq(historyEntries.userId, userId), inArray(historyEntries.id, [...historyIds]))); + } + if (topicIds.size > 0) { + await db.delete(topics).where(and(eq(topics.userId, userId), inArray(topics.id, [...topicIds]))); + } + if (contextIds.size > 0) { + await db.delete(contexts).where(and(eq(contexts.userId, userId), inArray(contexts.id, [...contextIds]))); + } + + const total = ratingIds.size + historyIds.size + topicIds.size + contextIds.size; + return c.json({ deleted: total }); +}); + +export default trash;