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;