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.
540 lines
17 KiB
Go
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
|
|
}
|