Files
paliad/internal/services/deadline_service.go
m 3f0c26fd3a feat(frontend): PWA mobile BottomNav + Quick-Add sheet (t-paliad-041)
Phone-first bottom navigation per pwa-baseline.md. Renders only at
<768px; tablets and desktop are unchanged.

Slots: Start / Projekte / [+] Anlegen / Agenda / Menü.

- Center [+] opens a slide-up <dialog> sheet with three rows: Frist,
  Termin, Projekt. Native showModal() + ::backdrop, ESC and backdrop-tap
  dismiss, transform-based slide-up transition.
- Right Menü slot reuses the existing Sidebar mobile drawer via a new
  exported toggleMobileSidebar() (DRY with the legacy hamburger handler).
- Agenda slot carries a red-dot badge: count = today + overdue pending
  deadlines (live via /api/deadlines/summary, refreshed every 60s). Pulse
  animation when overdue > 0 — m: "Due is the latest we can do, OVERDUE
  is a catastrophy."
- visualViewport resize watcher hides the bar when the on-screen keyboard
  opens (>100px height shrink) so it doesn't cover form fields.
- safe-area-inset-bottom padding on the bar; main padding-bottom adjusts
  on phones so the last row stays above the bar.

PWA shell groundwork (defers manifest/SW/install-prompt to follow-ups):
- viewport-fit=cover on every page (required for safe-area to register)
- theme-color #65a30d (lime), apple-mobile-web-app-capable, status-bar
  style — all 30 page heads updated in one sweep.

Backend: deadline_service.SummaryCounts gains a `today` bucket so the
Agenda badge can distinguish "due today" from "this week" without a new
endpoint.

Files added:
  frontend/src/components/BottomNav.tsx
  frontend/src/client/bottom-nav.ts

Verified visually via headless chromium at 375x812, 800x600, 1280x800:
phone shows BottomNav (5 slots, lime [+] elevated), tablet shows the
existing hamburger only, desktop sidebar untouched. go build/vet/test
and bun run build all clean.
2026-04-26 10:32:00 +02:00

540 lines
17 KiB
Go

package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/patholo/internal/models"
)
// DeadlineService reads and writes paliad.deadlines. Visibility inherits from the
// parent Project via ProjectService.GetByID — every read or write goes through
// that gate first.
//
// Audit: every mutation appends a paliad.project_events row via
// insertProjectEvent so the Project verlauf shows what changed.
type DeadlineService struct {
db *sqlx.DB
projects *ProjectService
}
// NewDeadlineService wires the service.
func NewDeadlineService(db *sqlx.DB, projects *ProjectService) *DeadlineService {
return &DeadlineService{db: db, projects: projects}
}
const fristColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at`
// CreateFristInput is the payload for Create / bulk create entries.
type CreateFristInput struct {
Title string `json:"title"`
Description *string `json:"description,omitempty"`
DueDate string `json:"due_date"` // YYYY-MM-DD
OriginalDueDate *string `json:"original_due_date,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
Source string `json:"source,omitempty"` // default "manual"
Notes *string `json:"notes,omitempty"`
}
// UpdateFristInput is the partial-update payload for PATCH.
type UpdateFristInput struct {
Title *string `json:"title,omitempty"`
Description *string `json:"description,omitempty"`
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
Notes *string `json:"notes,omitempty"`
Status *string `json:"status,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
type DeadlineStatusFilter string
const (
DeadlineFilterAll DeadlineStatusFilter = "all"
DeadlineFilterOverdue DeadlineStatusFilter = "overdue"
DeadlineFilterThisWeek DeadlineStatusFilter = "this_week"
DeadlineFilterUpcoming DeadlineStatusFilter = "upcoming"
DeadlineFilterCompleted DeadlineStatusFilter = "completed"
DeadlineFilterPending DeadlineStatusFilter = "pending"
)
// ListFilter narrows ListVisibleForUser results.
type ListFilter struct {
Status DeadlineStatusFilter
ProjectID *uuid.UUID
}
// ListVisibleForUser returns Deadlines on every Project the user can see,
// joined with parent-Project display fields. Sorted by due_date ascending.
func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter ListFilter) ([]models.DeadlineWithProject, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return []models.DeadlineWithProject{}, nil
}
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
}
if filter.ProjectID != nil {
conds = append(conds, `f.project_id = :project_id`)
args["project_id"] = *filter.ProjectID
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
endOfWeek := today.AddDate(0, 0, 7)
switch filter.Status {
case DeadlineFilterOverdue:
conds = append(conds, `f.status = 'pending' AND f.due_date < :today`)
args["today"] = today
case DeadlineFilterThisWeek:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek`)
args["today"] = today
args["endweek"] = endOfWeek
case DeadlineFilterUpcoming:
conds = append(conds, `f.status = 'pending' AND f.due_date >= :endweek`)
args["endweek"] = endOfWeek
case DeadlineFilterCompleted:
conds = append(conds, `f.status = 'completed'`)
case DeadlineFilterPending:
conds = append(conds, `f.status = 'pending'`)
case DeadlineFilterAll, "":
// no-op
default:
return nil, fmt.Errorf("%w: unknown status filter %q", ErrInvalidInput, filter.Status)
}
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
p.reference AS project_reference,
p.title AS project_title,
p.type AS project_type,
r.code AS rule_code
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
LEFT JOIN paliad.deadline_rules r ON r.id = f.rule_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY f.due_date ASC, f.created_at DESC`
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare list deadlines: %w", err)
}
defer stmt.Close()
rows := []models.DeadlineWithProject{}
if err := stmt.SelectContext(ctx, &rows, args); err != nil {
return nil, fmt.Errorf("list deadlines: %w", err)
}
return rows, nil
}
// ListForProjekt returns Deadlines for a specific Project (visibility-checked).
func (s *DeadlineService) ListForProjekt(ctx context.Context, userID, projektID uuid.UUID) ([]models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
rows := []models.Deadline{}
if err := s.db.SelectContext(ctx, &rows,
`SELECT `+fristColumns+`
FROM paliad.deadlines
WHERE project_id = $1
ORDER BY due_date ASC, created_at DESC`, projektID); err != nil {
return nil, fmt.Errorf("list deadlines for project: %w", err)
}
return rows, nil
}
// GetByID returns a single Deadline, with parent Project visibility checked.
func (s *DeadlineService) GetByID(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) {
projektID, err := s.parentProjectID(ctx, fristID)
if err != nil {
return nil, err
}
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
var f models.Deadline
if err := s.db.GetContext(ctx, &f,
`SELECT `+fristColumns+` FROM paliad.deadlines WHERE id = $1`, fristID); err != nil {
return nil, fmt.Errorf("fetch deadline: %w", err)
}
return &f, nil
}
// Create inserts a single Deadline under a Project.
func (s *DeadlineService) Create(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (*models.Deadline, error) {
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
id, err := s.insert(ctx, userID, projektID, input)
if err != nil {
return nil, err
}
return s.GetByID(ctx, userID, id)
}
// CreateBulk inserts multiple Deadlines under one Project in a single
// transaction (Fristenrechner "Als Deadline(en) speichern" flow).
func (s *DeadlineService) CreateBulk(ctx context.Context, userID, projektID uuid.UUID, inputs []CreateFristInput) ([]models.Deadline, error) {
if len(inputs) == 0 {
return nil, fmt.Errorf("%w: at least one Deadline is required", ErrInvalidInput)
}
if _, err := s.projects.GetByID(ctx, userID, projektID); err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
ids := make([]uuid.UUID, 0, len(inputs))
for _, in := range inputs {
id, err := s.insertTx(ctx, tx, userID, projektID, in)
if err != nil {
return nil, err
}
ids = append(ids, id)
}
desc := fmt.Sprintf("%d Deadlines aus Fristenrechner übernommen", len(inputs))
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projektID, userID, "deadlines_imported", "Deadlines imported", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit bulk create: %w", err)
}
out := make([]models.Deadline, 0, len(ids))
for _, id := range ids {
f, err := s.GetByID(ctx, userID, id)
if err != nil {
return nil, err
}
out = append(out, *f)
}
return out, nil
}
// Update applies a partial update to a Deadline.
func (s *DeadlineService) Update(ctx context.Context, userID, fristID uuid.UUID, input UpdateFristInput) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
return nil, err
}
sets := []string{}
args := []any{}
next := 1
appendSet := func(col string, val any) {
sets = append(sets, fmt.Sprintf("%s = $%d", col, next))
args = append(args, val)
next++
}
if input.Title != nil {
title := strings.TrimSpace(*input.Title)
if title == "" {
return nil, fmt.Errorf("%w: title cannot be empty", ErrInvalidInput)
}
appendSet("title", title)
}
if input.Description != nil {
appendSet("description", *input.Description)
}
if input.DueDate != nil {
due, err := time.Parse("2006-01-02", *input.DueDate)
if err != nil {
return nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
appendSet("due_date", due)
}
if input.Notes != nil {
appendSet("notes", *input.Notes)
}
if input.Status != nil {
if !isValidFristStatus(*input.Status) {
return nil, fmt.Errorf("%w: invalid status %q", ErrInvalidInput, *input.Status)
}
appendSet("status", *input.Status)
if *input.Status == "completed" && current.CompletedAt == nil {
appendSet("completed_at", time.Now().UTC())
} else if *input.Status != "completed" {
appendSet("completed_at", nil)
}
}
if len(sets) == 0 {
return current, nil
}
appendSet("updated_at", time.Now().UTC())
args = append(args, fristID)
query := fmt.Sprintf("UPDATE paliad.deadlines SET %s WHERE id = $%d",
strings.Join(sets, ", "), next)
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, query, args...); err != nil {
return nil, fmt.Errorf("update deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C geändert", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_updated", "Deadline updated", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit update deadline: %w", err)
}
return s.GetByID(ctx, userID, fristID)
}
// Complete marks a Deadline as completed.
func (s *DeadlineService) Complete(ctx context.Context, userID, fristID uuid.UUID) (*models.Deadline, error) {
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
return nil, err
}
if current.Status == "completed" {
return current, nil
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'completed', completed_at = $1, updated_at = $1
WHERE id = $2`, now, fristID); err != nil {
return nil, fmt.Errorf("complete deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C als erledigt markiert", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_completed", "Deadline completed", descPtr); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit complete: %w", err)
}
return s.GetByID(ctx, userID, fristID)
}
// Delete hard-deletes a Deadline. Partner/admin only.
func (s *DeadlineService) Delete(ctx context.Context, userID, fristID uuid.UUID) error {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return err
}
if user == nil {
return ErrNotVisible
}
if user.Role != "partner" && user.Role != "admin" {
return fmt.Errorf("%w: only partners/admins can delete Deadlines", ErrForbidden)
}
current, err := s.GetByID(ctx, userID, fristID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.deadlines WHERE id = $1`, fristID); err != nil {
return fmt.Errorf("delete deadline: %w", err)
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C gelöscht", current.Title)
descPtr := &desc
if err := insertProjectEvent(ctx, tx, current.ProjectID, userID, "deadline_deleted", "Deadline deleted", descPtr); err != nil {
return err
}
return tx.Commit()
}
// SummaryCounts returns traffic-light counts across the user's visible Deadlines.
type SummaryCounts struct {
Overdue int `json:"overdue" db:"overdue"`
Today int `json:"today" db:"today"`
ThisWeek int `json:"this_week" db:"this_week"`
Upcoming int `json:"upcoming" db:"upcoming"`
Completed int `json:"completed" db:"completed"`
Total int `json:"total" db:"total"`
}
// SummaryCounts aggregates Deadlines by due-date bucket for the user's visible
// projects, optionally scoped to a single Project.
func (s *DeadlineService) SummaryCounts(ctx context.Context, userID uuid.UUID, projektID *uuid.UUID) (*SummaryCounts, error) {
user, err := s.users().GetByID(ctx, userID)
if err != nil {
return nil, err
}
if user == nil {
return &SummaryCounts{}, nil
}
now := time.Now().UTC()
today := now.Truncate(24 * time.Hour)
tomorrow := today.AddDate(0, 0, 1)
endWeek := today.AddDate(0, 0, 7)
conds := []string{visibilityPredicate("p")}
args := map[string]any{
"user_id": userID,
"role": user.Role,
"today": today,
"tomorrow": tomorrow,
"endweek": endWeek,
}
if projektID != nil {
conds = append(conds, `f.project_id = :project_id`)
args["project_id"] = *projektID
}
query := `
SELECT
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date < :today) AS overdue,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :tomorrow) AS today,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :today AND f.due_date < :endweek) AS this_week,
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :endweek) AS upcoming,
COUNT(*) FILTER (WHERE f.status = 'completed') AS completed,
COUNT(*) AS total
FROM paliad.deadlines f
JOIN paliad.projects p ON p.id = f.project_id
WHERE ` + strings.Join(conds, " AND ")
stmt, err := s.db.PrepareNamedContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("prepare summary: %w", err)
}
defer stmt.Close()
var c SummaryCounts
if err := stmt.GetContext(ctx, &c, args); err != nil {
return nil, fmt.Errorf("deadline summary: %w", err)
}
return &c, nil
}
// insert performs one INSERT in its own transaction.
func (s *DeadlineService) insert(ctx context.Context, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return uuid.Nil, fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback()
id, err := s.insertTx(ctx, tx, userID, projektID, input)
if err != nil {
return uuid.Nil, err
}
desc := fmt.Sprintf("Deadline \u201E%s\u201C angelegt", strings.TrimSpace(input.Title))
descPtr := &desc
if err := insertProjectEvent(ctx, tx, projektID, userID, "deadline_created", "Deadline created", descPtr); err != nil {
return uuid.Nil, err
}
if err := tx.Commit(); err != nil {
return uuid.Nil, fmt.Errorf("commit insert deadline: %w", err)
}
return id, nil
}
// insertTx writes one deadlines row in an existing transaction.
func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, projektID uuid.UUID, input CreateFristInput) (uuid.UUID, error) {
title := strings.TrimSpace(input.Title)
if title == "" {
return uuid.Nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
}
if input.DueDate == "" {
return uuid.Nil, fmt.Errorf("%w: due_date is required", ErrInvalidInput)
}
due, err := time.Parse("2006-01-02", input.DueDate)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: due_date must be YYYY-MM-DD", ErrInvalidInput)
}
var orig *time.Time
if input.OriginalDueDate != nil && *input.OriginalDueDate != "" {
o, err := time.Parse("2006-01-02", *input.OriginalDueDate)
if err != nil {
return uuid.Nil, fmt.Errorf("%w: original_due_date must be YYYY-MM-DD", ErrInvalidInput)
}
orig = &o
}
source := input.Source
if source == "" {
source = "manual"
}
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, description, due_date, original_due_date,
source, rule_id, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'pending', $9, $10, $11, $11)`,
id, projektID, title, input.Description, due, orig,
source, input.RuleID, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
}
return id, nil
}
// parentProjectID resolves a Deadline's parent Project ID without a visibility
// check. Internal only — callers must then gate via ProjectService.GetByID.
func (s *DeadlineService) parentProjectID(ctx context.Context, fristID uuid.UUID) (uuid.UUID, error) {
var projektID uuid.UUID
err := s.db.GetContext(ctx, &projektID,
`SELECT project_id FROM paliad.deadlines WHERE id = $1`, fristID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, ErrNotVisible
}
if err != nil {
return uuid.Nil, fmt.Errorf("lookup deadline parent: %w", err)
}
return projektID, nil
}
// users returns the shared user service via the ProjectService handle.
func (s *DeadlineService) users() *UserService {
return s.projects.Users()
}
func isValidFristStatus(st string) bool {
switch st {
case "pending", "completed", "cancelled", "waived":
return true
}
return false
}