feat(ui,api,db): implement notifications and custom templates with hand-drawn aesthetic

This commit introduces a significant update to both the frontend and backend, focusing on enhanced user engagement and a consistent visual identity.

Key changes include:

- **Frontend UI/UX Refactor**:
  - Implemented a "hand-drawn" aesthetic across the entire application using CSS overrides, custom SVG charts, and specific border/shadow styles to match the Excalidraw experience.
  - Added a new notification system in the Header to display user updates.
  - Enhanced the Template Picker with more variety and improved interaction models.
  - Added a "Presentation Mode" in the Editor.
  - Improved Dashboard visualizations with hand-drawn style sparklines and charts.
  - Added modal dialogs for creating drawings and templates with custom names.

- **Backend & API Enhancements**:
  - Implemented full CRUD support for custom templates, allowing users to save their drawings as reusable templates.
  - Added a notification service with endpoints to list, mark as read, and mark all as read.
  - Updated the API client to handle more robust JSON responses and error states.
  - Improved CORS/Origin validation in the HTTP middleware to handle proxy headers (`X-Forwarded-Host`, `X-Forwarded-Proto`) more reliably.

- **Database & Infrastructure**:
  - Added a new PostgreSQL migration for the `notifications` table.
  - Updated the data models in the workspace to support templates (including snapshot storage) and notifications.
  - Updated `.gitignore` to exclude graphify cache and AST files.
This commit is contained in:
Tomas Dvorak
2026-05-01 15:07:38 +02:00
parent f3f9e99a97
commit 462a70933d
28 changed files with 26645 additions and 289 deletions
+99 -6
View File
@@ -72,12 +72,17 @@ func (a *API) Routes() chi.Router {
r.Post("/drawings/{drawingID}/links", a.handleCreateLink)
r.Get("/drawings/{drawingID}/thumbnail", a.handleThumbnail)
r.Get("/templates", a.handleListTemplates)
r.Post("/templates", a.handleCreateTemplate)
r.Delete("/templates/{templateID}", a.handleDeleteTemplate)
r.Get("/activity", a.handleListActivity)
r.Get("/stats", a.handleStats)
r.Get("/folders", a.handleListFolders)
r.Post("/folders", a.handleCreateFolder)
r.Get("/projects", a.handleListProjects)
r.Post("/projects", a.handleCreateProject)
r.Get("/notifications", a.handleListNotifications)
r.Post("/notifications/{notificationID}/read", a.handleMarkNotificationRead)
r.Post("/notifications/read-all", a.handleMarkAllNotificationsRead)
})
return r
@@ -134,11 +139,26 @@ func requireSameOriginMutation(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
return
}
expectedHTTP := "http://" + r.Host
expectedHTTPS := "https://" + r.Host
if origin != expectedHTTP && origin != expectedHTTPS {
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
return
host := r.Host
if fwd := r.Header.Get("X-Forwarded-Host"); fwd != "" {
host = fwd
}
proto := "http"
if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" {
proto = fwd
} else if r.TLS != nil {
proto = "https"
}
expected := proto + "://" + host
if origin != expected {
// also allow without port in case proxy strips it
expectedNoPort := proto + "://" + strings.SplitN(host, ":", 2)[0]
originNoPort := strings.SplitN(origin, "://", 2)[1]
originNoPort = strings.SplitN(originNoPort, ":", 2)[0]
if originNoPort != expectedNoPort {
writeError(w, http.StatusForbidden, "Cross-origin mutation denied")
return
}
}
next.ServeHTTP(w, r)
})
@@ -342,7 +362,7 @@ func (a *API) handleArchiveDrawing(w http.ResponseWriter, r *http.Request) {
writeLookupError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleListRevisions(w http.ResponseWriter, r *http.Request) {
@@ -493,6 +513,30 @@ func (a *API) handleListTemplates(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, templates)
}
func (a *API) handleCreateTemplate(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
var req CreateTemplateRequest
if !decodeJSON(w, r, &req, 5<<20) {
return
}
template, err := a.store.CreateTemplate(r.Context(), user.ID, req)
if err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusCreated, template)
}
func (a *API) handleDeleteTemplate(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
templateID := chi.URLParam(r, "templateID")
if err := a.store.DeleteTemplate(r.Context(), user.ID, templateID); err != nil {
writeLookupError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleListActivity(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
teamID := strings.TrimSpace(r.URL.Query().Get("team_id"))
@@ -644,6 +688,55 @@ func clearSessionCookie(w http.ResponseWriter, r *http.Request) {
})
}
// Notification handlers
func (a *API) handleListNotifications(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
ctx := r.Context()
notifications, err := a.store.ListNotifications(ctx, user.ID, 50)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, notifications)
}
func (a *API) handleMarkNotificationRead(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
ctx := r.Context()
notificationID := chi.URLParam(r, "notificationID")
if err := a.store.MarkNotificationRead(ctx, user.ID, notificationID); err != nil {
if errors.Is(err, ErrNotFound) {
writeJSON(w, http.StatusNotFound, map[string]any{"error": "not found"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func (a *API) handleMarkAllNotificationsRead(w http.ResponseWriter, r *http.Request) {
user, _ := currentUser(r)
if user == nil {
writeJSON(w, http.StatusUnauthorized, map[string]any{"error": "unauthorized"})
return
}
ctx := r.Context()
if err := a.store.MarkAllNotificationsRead(ctx, user.ID); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
func isSecureRequest(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
+21
View File
@@ -143,6 +143,14 @@ type Template struct {
PreviewURL *string `json:"preview_url,omitempty"`
}
type CreateTemplateRequest struct {
TeamID string `json:"team_id"`
Name string `json:"name"`
Description string `json:"description"`
Snapshot json.RawMessage `json:"snapshot"`
Metadata map[string]any `json:"metadata"`
}
type ActivityEvent struct {
ID string `json:"id"`
ActorUserID *string `json:"actor_user_id"`
@@ -204,6 +212,19 @@ type LinkReference struct {
CreatedAt time.Time `json:"created_at"`
}
type Notification struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Type string `json:"type"`
Title string `json:"title"`
Description string `json:"description"`
ResourceType string `json:"resource_type,omitempty"`
ResourceID string `json:"resource_id,omitempty"`
Read bool `json:"read"`
MetadataJSON map[string]any `json:"metadata_json"`
CreatedAt time.Time `json:"created_at"`
}
type WorkspaceStats struct {
Teams int `json:"teams"`
Members int `json:"members"`
+157 -1
View File
@@ -586,7 +586,7 @@ func (s *Store) GetDrawing(ctx context.Context, userID, drawingID string) (*Draw
u.id, u.name, u.username, u.email, u.avatar_url, u.locale, u.timezone, u.created_at, u.updated_at
FROM workspace_drawings d
JOIN workspace_users u ON u.id = d.owner_user_id
WHERE d.id = ? AND d.deleted_at IS NULL`, drawingID)
WHERE d.id = ?`, drawingID)
return scanDrawing(row)
}
@@ -733,6 +733,82 @@ func (s *Store) ListTemplates(ctx context.Context, userID, teamID string) ([]Tem
return templates, rows.Err()
}
func (s *Store) CreateTemplate(ctx context.Context, userID string, req CreateTemplateRequest) (*Template, error) {
teamID := strings.TrimSpace(req.TeamID)
if teamID == "" {
teamID, _ = s.defaultTeamID(ctx, userID)
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return nil, ErrForbidden
}
name := strings.TrimSpace(req.Name)
if name == "" || len(name) > 120 {
return nil, fmt.Errorf("template name must be between 1 and 120 characters")
}
if len(req.Snapshot) == 0 || !json.Valid(req.Snapshot) {
return nil, fmt.Errorf("snapshot must be valid JSON")
}
now := time.Now().UTC()
template := &Template{
ID: newID(),
TeamID: &teamID,
Scope: "team",
Type: "custom",
Name: name,
Description: ptr(strings.TrimSpace(req.Description)),
SnapshotPath: fmt.Sprintf("teams/%s/templates/%s.json", teamID, newID()),
MetadataJSON: req.Metadata,
CreatedBy: userID,
CreatedAt: now,
UpdatedAt: now,
}
metadata, _ := json.Marshal(template.MetadataJSON)
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_templates
(id, team_id, scope, type, name, description, snapshot_path, metadata_json, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
template.ID, template.TeamID, template.Scope, template.Type, template.Name, template.Description,
template.SnapshotPath, string(metadata), template.CreatedBy, template.CreatedAt, template.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = s.insertActivity(ctx, &userID, &teamID, "template", template.ID, "template_created", map[string]any{"name": template.Name})
return template, nil
}
func (s *Store) DeleteTemplate(ctx context.Context, userID, templateID string) error {
var teamID, createdBy string
err := s.db.QueryRowContext(ctx, `SELECT team_id, created_by FROM workspace_templates WHERE id = ?`, templateID).Scan(&teamID, &createdBy)
if err != nil {
if err == sql.ErrNoRows {
return ErrNotFound
}
return err
}
if ok, err := s.UserCanAccessTeam(ctx, userID, teamID); err != nil || !ok {
return ErrForbidden
}
// Only creator or team admin can delete
if createdBy != userID {
// Check if user is admin
var role string
err := s.db.QueryRowContext(ctx, `SELECT role FROM workspace_team_memberships WHERE team_id = ? AND user_id = ?`, teamID, userID).Scan(&role)
if err != nil || (role != "admin" && role != "owner") {
return ErrForbidden
}
}
_, err = s.db.ExecContext(ctx, `DELETE FROM workspace_templates WHERE id = ?`, templateID)
return err
}
func (s *Store) ListActivity(ctx context.Context, userID, teamID string, limit int) ([]ActivityEvent, error) {
if limit <= 0 || limit > 100 {
limit = 50
@@ -1162,6 +1238,86 @@ func uniqueTeamSlug(ctx context.Context, tx *dbpostgres.Tx, base string) string
var nonSlugChars = regexp.MustCompile(`[^a-z0-9]+`)
// Notifications
func (s *Store) ListNotifications(ctx context.Context, userID string, limit int) ([]Notification, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `SELECT id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at
FROM workspace_notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT ?`, userID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
notifications := []Notification{}
for rows.Next() {
var n Notification
var metadata string
var resourceType sql.NullString
var resourceID sql.NullString
if err := rows.Scan(&n.ID, &n.UserID, &n.Type, &n.Title, &n.Description, &resourceType, &resourceID, &n.Read, &metadata, &n.CreatedAt); err != nil {
return nil, err
}
if resourceType.Valid {
n.ResourceType = resourceType.String
}
if resourceID.Valid {
n.ResourceID = resourceID.String
}
_ = json.Unmarshal([]byte(metadata), &n.MetadataJSON)
if n.MetadataJSON == nil {
n.MetadataJSON = map[string]any{}
}
notifications = append(notifications, n)
}
return notifications, rows.Err()
}
func (s *Store) MarkNotificationRead(ctx context.Context, userID, notificationID string) error {
res, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE id = ? AND user_id = ?`, notificationID, userID)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
func (s *Store) MarkAllNotificationsRead(ctx context.Context, userID string) error {
_, err := s.db.ExecContext(ctx, `UPDATE workspace_notifications SET read = TRUE WHERE user_id = ? AND read = FALSE`, userID)
return err
}
func (s *Store) CreateNotification(ctx context.Context, userID, nType, title, description, resourceType, resourceID string, metadata map[string]any) (*Notification, error) {
metadataJSON := []byte("{}")
if metadata != nil {
b, _ := json.Marshal(metadata)
metadataJSON = b
}
now := time.Now().UTC()
n := &Notification{
ID: newID(),
UserID: userID,
Type: nType,
Title: title,
Description: description,
ResourceType: resourceType,
ResourceID: resourceID,
Read: false,
MetadataJSON: map[string]any{},
CreatedAt: now,
}
_, err := s.db.ExecContext(ctx, `INSERT INTO workspace_notifications (id, user_id, type, title, description, resource_type, resource_id, read, metadata_json, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
n.ID, n.UserID, n.Type, n.Title, n.Description, resourceType, resourceID, false, string(metadataJSON), now)
if err != nil {
return nil, err
}
_ = json.Unmarshal(metadataJSON, &n.MetadataJSON)
return n, nil
}
func slugify(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = nonSlugChars.ReplaceAllString(value, "-")