From 62713db4a09c548cafc2fea128a60a1d88aab51c Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sun, 28 Jun 2026 12:35:18 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20real=20backend=20for=20notifications=20?= =?UTF-8?q?+=20project=20shares=20=E2=80=94=20no=20more=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/database/DatabaseInterface.ts | 35 ++++++++ backend/src/database/SqliteAdapter.ts | 52 ++++++++++++ backend/src/database/schema.sql | 27 +++++++ backend/src/routes/notifications.ts | 59 ++++++++++++++ backend/src/routes/shares.ts | 71 ++++++++++++++++ backend/src/server.ts | 4 + frontend/src/App.tsx | 3 + frontend/src/components/NotificationPanel.tsx | 81 +++++++++++++------ frontend/src/components/ShareDialog.tsx | 43 ++++++++-- frontend/src/services/api.ts | 60 ++++++++++++++ frontend/src/styles.css | 51 ++++++++++++ frontend/src/types/ui.types.ts | 3 + 12 files changed, 460 insertions(+), 29 deletions(-) create mode 100644 backend/src/routes/notifications.ts create mode 100644 backend/src/routes/shares.ts diff --git a/backend/src/database/DatabaseInterface.ts b/backend/src/database/DatabaseInterface.ts index 8cd7c71..3e2c4be 100644 --- a/backend/src/database/DatabaseInterface.ts +++ b/backend/src/database/DatabaseInterface.ts @@ -79,6 +79,27 @@ export interface DBSession { created_at: string; } +export interface DBNotification { + id: string; + user_id: string; + project_id: string | null; + type: string; + title: string; + message: string | null; + read: number; + created_at: string; +} + +export interface DBProjectShare { + id: string; + project_id: string; + shared_by_user_id: string; + shared_with_email: string; + token: string; + accepted: number; + created_at: string; +} + export interface DatabaseInterface { init(): Promise; close(): void; @@ -132,4 +153,18 @@ export interface DatabaseInterface { getSession(token: string): DBSession | null; deleteSession(token: string): boolean; deleteExpiredSessions(): void; + + // Notifications + listNotifications(userId: string): DBNotification[]; + createNotification(data: Partial): DBNotification; + markNotificationRead(id: string): boolean; + markAllNotificationsRead(userId: string): boolean; + deleteNotification(id: string): boolean; + + // Project Shares + createProjectShare(data: Partial): DBProjectShare; + listProjectShares(projectId: string): DBProjectShare[]; + getProjectShareByToken(token: string): DBProjectShare | null; + acceptProjectShare(token: string): boolean; + deleteProjectShare(id: string): boolean; } diff --git a/backend/src/database/SqliteAdapter.ts b/backend/src/database/SqliteAdapter.ts index 79777f3..bd35be3 100644 --- a/backend/src/database/SqliteAdapter.ts +++ b/backend/src/database/SqliteAdapter.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'crypto'; import type { DatabaseInterface, DBProject, DBDrawing, DBLayer, DBElement, DBBlock, DBSetting, DBUser, DBSession, + DBNotification, DBProjectShare, } from './DatabaseInterface.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -265,4 +266,55 @@ export class SqliteAdapter implements DatabaseInterface { deleteExpiredSessions(): void { this.db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); } + + // ─── Notifications ─────────────────────────────────── + listNotifications(userId: string): DBNotification[] { + return this.db.prepare('SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC').all(userId) as DBNotification[]; + } + + createNotification(data: Partial): DBNotification { + const id = data.id ?? `notif-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + this.db.prepare( + 'INSERT INTO notifications (id, user_id, project_id, type, title, message, read) VALUES (?, ?, ?, ?, ?, ?, 0)', + ).run(id, data.user_id!, data.project_id ?? null, data.type ?? 'info', data.title!, data.message ?? null); + return this.db.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as DBNotification; + } + + markNotificationRead(id: string): boolean { + return this.db.prepare('UPDATE notifications SET read = 1 WHERE id = ?').run(id).changes > 0; + } + + markAllNotificationsRead(userId: string): boolean { + return this.db.prepare('UPDATE notifications SET read = 1 WHERE user_id = ?').run(userId).changes > 0; + } + + deleteNotification(id: string): boolean { + return this.db.prepare('DELETE FROM notifications WHERE id = ?').run(id).changes > 0; + } + + // ─── Project Shares ────────────────────────────────── + createProjectShare(data: Partial): DBProjectShare { + const id = data.id ?? `share-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const token = data.token ?? randomUUID(); + this.db.prepare( + 'INSERT INTO project_shares (id, project_id, shared_by_user_id, shared_with_email, token, accepted) VALUES (?, ?, ?, ?, ?, 0)', + ).run(id, data.project_id!, data.shared_by_user_id!, data.shared_with_email!, token); + return this.db.prepare('SELECT * FROM project_shares WHERE id = ?').get(id) as DBProjectShare; + } + + listProjectShares(projectId: string): DBProjectShare[] { + return this.db.prepare('SELECT * FROM project_shares WHERE project_id = ? ORDER BY created_at DESC').all(projectId) as DBProjectShare[]; + } + + getProjectShareByToken(token: string): DBProjectShare | null { + return (this.db.prepare('SELECT * FROM project_shares WHERE token = ?').get(token) as DBProjectShare) ?? null; + } + + acceptProjectShare(token: string): boolean { + return this.db.prepare('UPDATE project_shares SET accepted = 1 WHERE token = ?').run(token).changes > 0; + } + + deleteProjectShare(id: string): boolean { + return this.db.prepare('DELETE FROM project_shares WHERE id = ?').run(id).changes > 0; + } } diff --git a/backend/src/database/schema.sql b/backend/src/database/schema.sql index 0fb66e7..d286066 100644 --- a/backend/src/database/schema.sql +++ b/backend/src/database/schema.sql @@ -99,6 +99,33 @@ CREATE TABLE IF NOT EXISTS settings ( updated_at TEXT NOT NULL DEFAULT (datetime('now')) ); +-- ─── Notifications ─────────────────────────────────── +CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL, + project_id TEXT, + type TEXT NOT NULL DEFAULT 'info', + title TEXT NOT NULL, + message TEXT, + read INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE +); + +-- ─── Project Shares ────────────────────────────────── +CREATE TABLE IF NOT EXISTS project_shares ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + shared_by_user_id TEXT NOT NULL, + shared_with_email TEXT NOT NULL, + token TEXT NOT NULL UNIQUE, + accepted INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE, + FOREIGN KEY (shared_by_user_id) REFERENCES users(id) ON DELETE CASCADE +); + -- ─── Indexes ──────────────────────────────────────── CREATE INDEX IF NOT EXISTS idx_drawings_project ON drawings(project_id); CREATE INDEX IF NOT EXISTS idx_layers_drawing ON layers(drawing_id); diff --git a/backend/src/routes/notifications.ts b/backend/src/routes/notifications.ts new file mode 100644 index 0000000..158b410 --- /dev/null +++ b/backend/src/routes/notifications.ts @@ -0,0 +1,59 @@ +/** + * Notification Routes – user notifications + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import { requireAuth } from '../auth/authMiddleware.js'; +import type { AuthService } from '../auth/AuthService.js'; + +export function registerNotificationRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // List notifications for current user + fastify.get('/api/notifications', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + return db.listNotifications(user.id); + }); + + // Create a notification + fastify.post('/api/notifications', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const body = request.body as { type?: string; title: string; message?: string; project_id?: string }; + if (!body.title) return reply.code(400).send({ error: 'Title is required' }); + return db.createNotification({ + user_id: user.id, + project_id: body.project_id ?? null, + type: body.type ?? 'info', + title: body.title, + message: body.message ?? null, + }); + }); + + // Mark single notification as read + fastify.put('/api/notifications/:id/read', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const ok = db.markNotificationRead(id); + if (!ok) return reply.code(404).send({ error: 'Notification not found' }); + return { success: true }; + }); + + // Mark all notifications as read + fastify.put('/api/notifications/read-all', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + db.markAllNotificationsRead(user.id); + return { success: true }; + }); + + // Delete a notification + fastify.delete('/api/notifications/:id', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const ok = db.deleteNotification(id); + if (!ok) return reply.code(404).send({ error: 'Notification not found' }); + return { success: true }; + }); +} diff --git a/backend/src/routes/shares.ts b/backend/src/routes/shares.ts new file mode 100644 index 0000000..75df54d --- /dev/null +++ b/backend/src/routes/shares.ts @@ -0,0 +1,71 @@ +/** + * Project Share Routes – invite users to projects + */ +import type { FastifyInstance } from 'fastify'; +import type { DatabaseInterface } from '../database/DatabaseInterface.js'; +import { requireAuth } from '../auth/authMiddleware.js'; +import type { AuthService } from '../auth/AuthService.js'; + +export function registerShareRoutes(fastify: FastifyInstance, db: DatabaseInterface, authService: AuthService) { + // List shares for a project + fastify.get('/api/projects/:projectId/shares', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { projectId } = request.params as { projectId: string }; + return db.listProjectShares(projectId); + }); + + // Create a share / invite by email + fastify.post('/api/projects/:projectId/shares', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { projectId } = request.params as { projectId: string }; + const body = request.body as { email: string }; + if (!body.email) return reply.code(400).send({ error: 'Email is required' }); + const share = db.createProjectShare({ + project_id: projectId, + shared_by_user_id: user.id, + shared_with_email: body.email, + }); + // Create a notification for the invited user if they exist + const invitedUser = db.getUserByEmail(body.email); + if (invitedUser) { + db.createNotification({ + user_id: invitedUser.id, + project_id: projectId, + type: 'share', + title: 'Projekt-Einladung', + message: `${user.name} hat Sie zum Projekt eingeladen`, + }); + } + return share; + }); + + // Get share by token (public, no auth required) + fastify.get('/api/shares/:token', async (request, reply) => { + const { token } = request.params as { token: string }; + const share = db.getProjectShareByToken(token); + if (!share) return reply.code(404).send({ error: 'Share not found' }); + return share; + }); + + // Accept a share by token + fastify.put('/api/shares/:token/accept', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { token } = request.params as { token: string }; + const ok = db.acceptProjectShare(token); + if (!ok) return reply.code(404).send({ error: 'Share not found' }); + return { success: true }; + }); + + // Revoke a share + fastify.delete('/api/shares/:id', async (request, reply) => { + const user = requireAuth(request, reply, authService); + if (!user) return; + const { id } = request.params as { id: string }; + const ok = db.deleteProjectShare(id); + if (!ok) return reply.code(404).send({ error: 'Share not found' }); + return { success: true }; + }); +} diff --git a/backend/src/server.ts b/backend/src/server.ts index d194809..bf741b5 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -57,6 +57,8 @@ export async function createServer(opts: ServerOptions) { const { registerAuthRoutes } = await import('./routes/auth.js'); const { registerUserRoutes } = await import('./routes/users.js'); const { registerAIRoutes } = await import('./routes/ai.js'); + const { registerNotificationRoutes } = await import('./routes/notifications.js'); + const { registerShareRoutes } = await import('./routes/shares.js'); const authService = new AuthService(opts.db); @@ -69,6 +71,8 @@ export async function createServer(opts: ServerOptions) { registerSettingsRoutes(fastify, opts.db, authService); registerUserRoutes(fastify, opts.db, authService); registerAIRoutes(fastify, authService); + registerNotificationRoutes(fastify, opts.db, authService); + registerShareRoutes(fastify, opts.db, authService); // Yjs collaboration WebSocket registerYjsWebSocket(fastify); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index a170e4d..5f0263d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1134,6 +1134,8 @@ const CADEditor: React.FC = ({ projectId, token, onNavigateBack open={shareOpen} onClose={() => setShareOpen(false)} projectName={projectName} + projectId={projectId} + token={token} /> = ({ projectId, token, onNavigateBack setNotifOpen(false)} + token={token} /> ); diff --git a/frontend/src/components/NotificationPanel.tsx b/frontend/src/components/NotificationPanel.tsx index 156cab5..b7d2ba0 100644 --- a/frontend/src/components/NotificationPanel.tsx +++ b/frontend/src/components/NotificationPanel.tsx @@ -1,24 +1,23 @@ import React, { useState, useEffect, useRef } from 'react'; import type { NotificationPanelProps } from '../types/ui.types'; +import { getNotifications, markNotificationRead, markAllNotificationsRead, deleteNotification, type NotificationItem } from '../services/api'; -interface Notification { - id: string; - icon: string; - title: string; - timestamp: string; - read: boolean; -} - -const initialNotifications: Notification[] = [ - { id: 'n1', icon: '💾', title: 'Projekt automatisch gespeichert', timestamp: 'vor 2 Min.', read: false }, - { id: 'n2', icon: '🔄', title: 'Neue Version verfügbar', timestamp: 'vor 15 Min.', read: false }, - { id: 'n3', icon: '👤', title: 'Benutzer ist beigetreten', timestamp: 'vor 1 Std.', read: false }, -]; - -const NotificationPanel: React.FC = ({ open, onClose }) => { - const [notifications, setNotifications] = useState(initialNotifications); +const NotificationPanel: React.FC = ({ open, onClose, token }) => { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(false); const ref = useRef(null); + useEffect(() => { + if (!open) return; + setLoading(true); + getNotifications(token).then((data) => { + setNotifications(data); + setLoading(false); + }).catch(() => { + setLoading(false); + }); + }, [open, token]); + useEffect(() => { if (!open) return; const handleClickOutside = (e: MouseEvent) => { @@ -39,12 +38,39 @@ const NotificationPanel: React.FC = ({ open, onClose }) if (!open) return null; - const handleDismiss = (id: string) => { + const handleDismiss = async (id: string) => { setNotifications((prev) => prev.filter((n) => n.id !== id)); + try { await deleteNotification(token, id); } catch { /* ignore */ } }; - const handleMarkAllRead = () => { - setNotifications((prev) => prev.map((n) => ({ ...n, read: true }))); + const handleMarkAllRead = async () => { + setNotifications((prev) => prev.map((n) => ({ ...n, read: 1 }))); + try { await markAllNotificationsRead(token); } catch { /* ignore */ } + }; + + const handleItemClick = async (id: string) => { + setNotifications((prev) => prev.map((n) => n.id === id ? { ...n, read: 1 } : n)); + try { await markNotificationRead(token, id); } catch { /* ignore */ } + }; + + const formatTime = (iso: string) => { + const d = new Date(iso); + const now = new Date(); + const diff = Math.floor((now.getTime() - d.getTime()) / 1000); + if (diff < 60) return 'vor wenigen Sekunden'; + if (diff < 3600) return `vor ${Math.floor(diff / 60)} Min.`; + if (diff < 86400) return `vor ${Math.floor(diff / 3600)} Std.`; + return d.toLocaleDateString('de-DE'); + }; + + const iconForType = (type: string) => { + switch (type) { + case 'share': return '👥'; + case 'save': return '💾'; + case 'version': return '🔄'; + case 'warning': return '⚠️'; + default: return 'ℹ️'; + } }; return ( @@ -56,17 +82,24 @@ const NotificationPanel: React.FC = ({ open, onClose })
- {notifications.length === 0 ? ( + {loading ? ( +

Laden...

+ ) : notifications.length === 0 ? (

Keine Benachrichtigungen

) : ( notifications.map((n) => ( -
- {n.icon} +
!n.read && handleItemClick(n.id)} + > + {iconForType(n.type)}
{n.title} - {n.timestamp} + {n.message && {n.message}} + {formatTime(n.created_at)}
-
diff --git a/frontend/src/components/ShareDialog.tsx b/frontend/src/components/ShareDialog.tsx index e550c2d..fca7ac8 100644 --- a/frontend/src/components/ShareDialog.tsx +++ b/frontend/src/components/ShareDialog.tsx @@ -1,15 +1,25 @@ import React, { useState } from 'react'; import type { ShareDialogProps } from '../types/ui.types'; +import { createProjectShare, getProjectShares, type ProjectShare } from '../services/api'; -const ShareDialog: React.FC = ({ open, onClose, projectName }) => { +const ShareDialog: React.FC = ({ open, onClose, projectName, projectId, token }) => { const [email, setEmail] = useState(''); const [copied, setCopied] = useState(false); const [invited, setInvited] = useState(false); + const [error, setError] = useState(''); + const [shares, setShares] = useState([]); + + const shareLink = `${window.location.origin}/?project=${encodeURIComponent(projectId)}`; + + React.useEffect(() => { + if (open) { + getProjectShares(token, projectId).then(setShares).catch(() => {}); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open]); if (!open) return null; - const shareLink = `${window.location.origin}/?project=${encodeURIComponent(projectName)}`; - const handleCopyLink = () => { navigator.clipboard.writeText(shareLink).then(() => { setCopied(true); @@ -23,11 +33,18 @@ const ShareDialog: React.FC = ({ open, onClose, projectName }) if (e.key === 'Escape') onClose(); }; - const handleInvite = () => { - if (email.trim()) { + const handleInvite = async () => { + if (!email.trim()) return; + setError(''); + setInvited(false); + try { + const share = await createProjectShare(token, projectId, email.trim()); + setShares((prev) => [...prev, share]); setInvited(true); setEmail(''); setTimeout(() => setInvited(false), 3000); + } catch (err) { + setError(err instanceof Error ? err.message : 'Einladung fehlgeschlagen'); } }; @@ -67,7 +84,23 @@ const ShareDialog: React.FC = ({ open, onClose, projectName })
{invited &&

Einladung gesendet!

} + {error &&

{error}

}
+ {shares.length > 0 && ( +
+ +
    + {shares.map((s) => ( +
  • + {s.shared_with_email} + + {s.accepted ? 'Angenommen' : 'Ausstehend'} + +
  • + ))} +
+
+ )} diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index f3febd3..571f103 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -418,3 +418,63 @@ export async function aiChat( } return res.json(); } + +// ─── Notifications ────────────────────────────────────── +export interface NotificationItem { + id: string; + user_id: string; + project_id: string | null; + type: string; + title: string; + message: string | null; + read: number; + created_at: string; +} + +export async function getNotifications(token: string): Promise { + const res = await fetch(`${API_BASE}/api/notifications`, { headers: authHeaders(token) }); + if (!res.ok) throw new Error('Failed to load notifications'); + return res.json(); +} + +export async function markNotificationRead(token: string, id: string): Promise { + await fetch(`${API_BASE}/api/notifications/${id}/read`, { method: 'PUT', headers: authHeaders(token) }); +} + +export async function markAllNotificationsRead(token: string): Promise { + await fetch(`${API_BASE}/api/notifications/read-all`, { method: 'PUT', headers: authHeaders(token) }); +} + +export async function deleteNotification(token: string, id: string): Promise { + await fetch(`${API_BASE}/api/notifications/${id}`, { method: 'DELETE', headers: authHeaders(token) }); +} + +// ─── Project Shares ───────────────────────────────────── +export interface ProjectShare { + id: string; + project_id: string; + shared_by_user_id: string; + shared_with_email: string; + token: string; + accepted: number; + created_at: string; +} + +export async function getProjectShares(token: string, projectId: string): Promise { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/shares`, { headers: authHeaders(token) }); + if (!res.ok) throw new Error('Failed to load shares'); + return res.json(); +} + +export async function createProjectShare(token: string, projectId: string, email: string): Promise { + const res = await fetch(`${API_BASE}/api/projects/${projectId}/shares`, { + method: 'POST', + headers: authHeaders(token), + body: JSON.stringify({ email }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: 'Failed to create share' })); + throw new Error(err.error || 'Failed to create share'); + } + return res.json(); +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 972a2af..ec72b1b 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -2311,3 +2311,54 @@ body.drawer-open { overflow: hidden; } width: auto; } } + +/* ─── Share list + error ──────────────────────────────── */ +.share-list { + list-style: none; + padding: 0; + margin: 0; +} +.share-list-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 0; + border-bottom: 1px solid var(--color-border); +} +.share-list-email { + font-size: 13px; + color: var(--color-text); +} +.share-list-status { + font-size: 11px; + font-weight: 600; + padding: 3px 8px; + border-radius: 4px; +} +.share-list-status.accepted { + color: #16a34a; + background: rgba(22, 163, 74, 0.1); +} +.share-list-status.pending { + color: var(--color-text-muted); + background: var(--color-surface-2); +} +.share-error-msg { + margin-top: 8px; + font-size: 12px; + color: #dc2626; + font-weight: 500; +} + +/* ─── Notification message ────────────────────────────── */ +.notif-message { + font-size: 12px; + color: var(--color-text-muted); + margin-top: 2px; +} +.notif-item { + cursor: pointer; +} +.notif-item.read { + cursor: default; +} diff --git a/frontend/src/types/ui.types.ts b/frontend/src/types/ui.types.ts index 19411c6..4d54822 100644 --- a/frontend/src/types/ui.types.ts +++ b/frontend/src/types/ui.types.ts @@ -77,6 +77,8 @@ export interface ShareDialogProps { open: boolean; onClose: () => void; projectName: string; + projectId: string; + token: string; } export interface HelpPanelProps { @@ -87,6 +89,7 @@ export interface HelpPanelProps { export interface NotificationPanelProps { open: boolean; onClose: () => void; + token: string; } export interface SettingsModalProps {