m/paliad#54 (t-paliad-221) — fix 92780cf added a status=upcoming option
for appointments and made it the default, but DeadlineFilterUpcoming
only narrowed deadlines. The appointment query had no matching case, so
the bucket fell through to the unfiltered path and past events leaked
into "Ab heute" / "From today".
- Drop the 'upcoming' option from STATUS_OPTIONS_APPOINTMENT — confusing
label that never delivered.
- Default appointments to the 'today' bucket (matches the dashboard
tile; sane lawyer-relevant view).
- Keep 'Alle (auch vergangene)' as the explicit opt-in at the bottom
of the list.
- Defensive backend fix: map DeadlineFilterUpcoming to start_at >= today
in bucketAppointmentWindow so any persisted ?status=upcoming bookmarks
stop leaking past events.
562 lines
20 KiB
Go
562 lines
20 KiB
Go
package services
|
|
|
|
// EventService is the unified read facade over Deadlines + Appointments.
|
|
// /deadlines and /appointments both render one EventsPage that calls
|
|
// /api/events?type=deadline|appointment|all — this service is what backs
|
|
// that endpoint and the matching summary counts.
|
|
//
|
|
// Visibility, validation, and event_type hydration are all delegated to
|
|
// DeadlineService / AppointmentService — this layer adds nothing on top
|
|
// other than the projection to EventListItem and the bucket math used by
|
|
// SummaryCounts. Mutations stay on the type-specific services; the
|
|
// handlers call them directly. See docs/design-events-unification-2026-05-04.md
|
|
// (t-paliad-109) for the design rationale.
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"mgit.msbls.de/m/paliad/internal/models"
|
|
)
|
|
|
|
// EventTypeFilter selects which side of the union ListVisibleForUser
|
|
// returns. Empty string means "both"; the constants spell out the
|
|
// allowed values.
|
|
type EventTypeFilter string
|
|
|
|
const (
|
|
EventTypeAll EventTypeFilter = ""
|
|
EventTypeDeadline EventTypeFilter = "deadline"
|
|
EventTypeAppointment EventTypeFilter = "appointment"
|
|
)
|
|
|
|
// EventService wraps the deadline + appointment services.
|
|
type EventService struct {
|
|
db *sqlx.DB
|
|
deadlines *DeadlineService
|
|
appointments *AppointmentService
|
|
}
|
|
|
|
func NewEventService(db *sqlx.DB, deadlines *DeadlineService, appointments *AppointmentService) *EventService {
|
|
return &EventService{db: db, deadlines: deadlines, appointments: appointments}
|
|
}
|
|
|
|
// EventListFilter narrows ListVisibleForUser. Most fields are type-specific;
|
|
// passing them with Type=Appointment (or vice versa) is a no-op rather than
|
|
// an error so the handler can stay shape-stable across type switches.
|
|
//
|
|
// PersonalOnly narrows BOTH rails to rows whose `created_by` matches the
|
|
// caller — backs the "Nur persönliche" filter on /events (t-paliad-128).
|
|
// When set, ProjectID is ignored (the two are contradictory: personal
|
|
// means "items I created", not "items in some project I picked").
|
|
//
|
|
// DirectOnly narrows the ProjectID filter from "this project + every
|
|
// descendant" (t-paliad-139 subtree default) to "this project only"
|
|
// (t-paliad-152). Backs the "direkt / inkl. Unterprojekte" toggle on
|
|
// /projects/{id} Fristen + Termine. No effect when ProjectID is nil or
|
|
// PersonalOnly is set.
|
|
type EventListFilter struct {
|
|
Type EventTypeFilter
|
|
|
|
// Deadline-only. AppointmentType applies only to appointments.
|
|
Status DeadlineStatusFilter
|
|
EventTypeIDs []uuid.UUID
|
|
IncludeUntyped bool
|
|
AppointmentType *string
|
|
|
|
// Common.
|
|
ProjectID *uuid.UUID
|
|
From *time.Time
|
|
To *time.Time
|
|
PersonalOnly bool
|
|
DirectOnly bool
|
|
}
|
|
|
|
// EventListItem is one row of the unified events list. Type-specific
|
|
// columns are pointers so the JSON shape carries only the fields that
|
|
// apply; the frontend type-narrows on `type`.
|
|
type EventListItem struct {
|
|
Type string `json:"type"` // "deadline" | "appointment"
|
|
ID uuid.UUID `json:"id"`
|
|
Title string `json:"title"`
|
|
Description *string `json:"description,omitempty"`
|
|
EventDate time.Time `json:"event_date"` // canonical sort key (deadline: due_date 00:00 UTC; appointment: start_at)
|
|
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
|
ProjectReference *string `json:"project_reference,omitempty"`
|
|
ProjectTitle *string `json:"project_title,omitempty"`
|
|
ProjectType *string `json:"project_type,omitempty"`
|
|
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
|
|
|
|
// Approval workflow (t-paliad-138). ApprovalStatus is "approved"
|
|
// (default), "pending" (in-flight 4-eye request — pill rendered on
|
|
// every list surface), or "legacy" (pre-4-eye row, no pill).
|
|
ApprovalStatus *string `json:"approval_status,omitempty"`
|
|
// RequesterKind is the kind of the in-flight approval request when
|
|
// approval_status='pending': 'user' or 'agent' (Paliadin-drafted —
|
|
// t-paliad-161). NULL otherwise. Drives the ✨ glyph alongside 👀.
|
|
RequesterKind *string `json:"requester_kind,omitempty"`
|
|
|
|
// Deadline-only.
|
|
DueDate *string `json:"due_date,omitempty"` // YYYY-MM-DD
|
|
Status *string `json:"status,omitempty"`
|
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
|
Source *string `json:"source,omitempty"`
|
|
RuleID *uuid.UUID `json:"rule_id,omitempty"`
|
|
RuleCode *string `json:"rule_code,omitempty"`
|
|
RuleName *string `json:"rule_name,omitempty"`
|
|
RuleNameEN *string `json:"rule_name_en,omitempty"`
|
|
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
|
|
|
// Appointment-only.
|
|
StartAt *time.Time `json:"start_at,omitempty"`
|
|
EndAt *time.Time `json:"end_at,omitempty"`
|
|
Location *string `json:"location,omitempty"`
|
|
AppointmentType *string `json:"appointment_type,omitempty"`
|
|
}
|
|
|
|
// ListVisibleForUser returns events the user can see, sorted by event_date
|
|
// ascending. Deadlines and appointments are merged when Type is "" / "all".
|
|
func (s *EventService) ListVisibleForUser(ctx context.Context, userID uuid.UUID, filter EventListFilter) ([]EventListItem, error) {
|
|
wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline
|
|
wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment
|
|
|
|
out := make([]EventListItem, 0, 64)
|
|
|
|
if wantDeadlines {
|
|
df := ListFilter{
|
|
Status: filter.Status,
|
|
ProjectID: filter.ProjectID,
|
|
EventTypeIDs: filter.EventTypeIDs,
|
|
IncludeUntyped: filter.IncludeUntyped,
|
|
DirectOnly: filter.DirectOnly,
|
|
}
|
|
if filter.PersonalOnly {
|
|
uid := userID
|
|
df.CreatedBy = &uid
|
|
df.ProjectID = nil
|
|
}
|
|
rows, err := s.deadlines.ListVisibleForUser(ctx, userID, df)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
if !inDateWindow(r.DueDate, filter.From, filter.To) {
|
|
continue
|
|
}
|
|
out = append(out, projectDeadline(r))
|
|
}
|
|
}
|
|
|
|
if wantAppointments {
|
|
// Status is a deadline-only filter, but it doubles as the bucket
|
|
// click-through target on the unified events page. So when the
|
|
// caller set a bucket-style status (today / this_week / next_week
|
|
// / later), apply the matching date window to the appointment side
|
|
// too — clicking "Heute" then shows today's deadlines AND today's
|
|
// appointments. For overdue/completed (no appointment analogue),
|
|
// skip the appointment query entirely.
|
|
if shouldExcludeAppointmentsForStatus(filter.Status) {
|
|
// no-op
|
|
} else {
|
|
af := AppointmentListFilter{
|
|
ProjectID: filter.ProjectID,
|
|
From: filter.From,
|
|
To: filter.To,
|
|
Type: filter.AppointmentType,
|
|
DirectOnly: filter.DirectOnly,
|
|
}
|
|
if filter.PersonalOnly {
|
|
uid := userID
|
|
af.CreatedBy = &uid
|
|
af.ProjectID = nil
|
|
}
|
|
bounds := computeDeadlineBucketBounds(time.Now().UTC())
|
|
from, to := bucketAppointmentWindow(filter.Status, bounds)
|
|
af.From = pickLater(af.From, from)
|
|
af.To = pickEarlier(af.To, to)
|
|
rows, err := s.appointments.ListVisibleForUser(ctx, userID, af)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, r := range rows {
|
|
out = append(out, projectAppointment(r))
|
|
}
|
|
}
|
|
}
|
|
|
|
sort.SliceStable(out, func(i, j int) bool {
|
|
if out[i].EventDate.Equal(out[j].EventDate) {
|
|
// Stable tiebreaker: deadlines before appointments on the same
|
|
// instant, then alphabetic by title — matches AgendaService.
|
|
if out[i].Type != out[j].Type {
|
|
return out[i].Type == "deadline"
|
|
}
|
|
return out[i].Title < out[j].Title
|
|
}
|
|
return out[i].EventDate.Before(out[j].EventDate)
|
|
})
|
|
|
|
return out, nil
|
|
}
|
|
|
|
// projectDeadline projects a DeadlineWithProject row into the union shape.
|
|
func projectDeadline(d models.DeadlineWithProject) EventListItem {
|
|
pid := d.ProjectID
|
|
pt := d.ProjectTitle
|
|
ptype := d.ProjectType
|
|
due := d.DueDate.Format("2006-01-02")
|
|
status := d.Status
|
|
src := d.Source
|
|
approvalStatus := d.ApprovalStatus
|
|
|
|
return EventListItem{
|
|
Type: "deadline",
|
|
ID: d.ID,
|
|
Title: d.Title,
|
|
Description: d.Description,
|
|
EventDate: time.Date(d.DueDate.Year(), d.DueDate.Month(), d.DueDate.Day(), 0, 0, 0, 0, time.UTC),
|
|
ProjectID: &pid,
|
|
ProjectReference: d.ProjectReference,
|
|
ProjectTitle: &pt,
|
|
ProjectType: &ptype,
|
|
CreatedBy: d.CreatedBy,
|
|
ApprovalStatus: &approvalStatus,
|
|
RequesterKind: d.RequesterKind,
|
|
DueDate: &due,
|
|
Status: &status,
|
|
CompletedAt: d.CompletedAt,
|
|
Source: &src,
|
|
RuleID: d.RuleID,
|
|
RuleCode: d.RuleCode,
|
|
RuleName: d.RuleName,
|
|
RuleNameEN: d.RuleNameEN,
|
|
EventTypeIDs: d.EventTypeIDs,
|
|
}
|
|
}
|
|
|
|
// projectAppointment projects an AppointmentWithProject row into the union shape.
|
|
func projectAppointment(a models.AppointmentWithProject) EventListItem {
|
|
startCopy := a.StartAt
|
|
approvalStatus := a.ApprovalStatus
|
|
return EventListItem{
|
|
Type: "appointment",
|
|
ID: a.ID,
|
|
Title: a.Title,
|
|
Description: a.Description,
|
|
EventDate: a.StartAt,
|
|
ProjectID: a.ProjectID,
|
|
ProjectReference: a.ProjectReference,
|
|
ProjectTitle: a.ProjectTitle,
|
|
ProjectType: a.ProjectType,
|
|
CreatedBy: a.CreatedBy,
|
|
ApprovalStatus: &approvalStatus,
|
|
RequesterKind: a.RequesterKind,
|
|
StartAt: &startCopy,
|
|
EndAt: a.EndAt,
|
|
Location: a.Location,
|
|
AppointmentType: a.AppointmentType,
|
|
}
|
|
}
|
|
|
|
// shouldExcludeAppointmentsForStatus returns true for deadline-status
|
|
// values that have no appointment analogue (overdue, completed). When
|
|
// the user sets one of those, the appointment rail collapses to empty.
|
|
func shouldExcludeAppointmentsForStatus(status DeadlineStatusFilter) bool {
|
|
switch status {
|
|
case DeadlineFilterOverdue, DeadlineFilterCompleted:
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// bucketAppointmentWindow returns the [from, to) date window that
|
|
// matches a bucket-style deadline status — used to filter the
|
|
// appointment side when the user clicks a card on the unified events
|
|
// page. Returns (nil, nil) for non-bucket statuses (pending / all /
|
|
// "" / overdue / completed — those are handled separately).
|
|
//
|
|
// DeadlineFilterUpcoming maps to "start_at >= today" so legacy
|
|
// `?status=upcoming` URLs hide past appointments instead of falling
|
|
// through to the unfiltered query (m/paliad#54 — the UI option that
|
|
// surfaced this status has been removed, but bookmarks may persist).
|
|
func bucketAppointmentWindow(status DeadlineStatusFilter, b deadlineBucketBounds) (*time.Time, *time.Time) {
|
|
switch status {
|
|
case DeadlineFilterToday:
|
|
t := b.tomorrow
|
|
return &b.today, &t
|
|
case DeadlineFilterThisWeek:
|
|
t := b.nextMonday
|
|
return &b.tomorrow, &t
|
|
case DeadlineFilterNextWeek:
|
|
t := b.weekAfter
|
|
return &b.nextMonday, &t
|
|
case DeadlineFilterLater:
|
|
return &b.weekAfter, nil
|
|
case DeadlineFilterUpcoming:
|
|
return &b.today, nil
|
|
}
|
|
return nil, nil
|
|
}
|
|
|
|
// pickLater returns whichever of the two times is later (nil propagates
|
|
// the non-nil value; both nil → nil). Used to intersect bucket-derived
|
|
// windows with caller-supplied from filters.
|
|
func pickLater(a, b *time.Time) *time.Time {
|
|
if a == nil {
|
|
return b
|
|
}
|
|
if b == nil {
|
|
return a
|
|
}
|
|
if a.After(*b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// pickEarlier returns whichever of the two times is earlier (nil
|
|
// propagates the non-nil value; both nil → nil).
|
|
func pickEarlier(a, b *time.Time) *time.Time {
|
|
if a == nil {
|
|
return b
|
|
}
|
|
if b == nil {
|
|
return a
|
|
}
|
|
if a.Before(*b) {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
// inDateWindow returns true when due is inside [from, to]. Both ends are
|
|
// optional. The deadline ListFilter has no date-range support today, so we
|
|
// post-filter in memory — fine because the per-user deadline set is small.
|
|
func inDateWindow(due time.Time, from, to *time.Time) bool {
|
|
if from != nil && due.Before(from.UTC()) {
|
|
return false
|
|
}
|
|
if to != nil && due.After(to.UTC()) {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// EventSummaryFilter narrows SummaryCounts. Today only `Type`,
|
|
// `ProjectID`, and `PersonalOnly` matter; status/event_type filters
|
|
// intentionally don't shape the bucket counts (the cards are global
|
|
// "what's coming?" indicators).
|
|
//
|
|
// PersonalOnly mirrors EventListFilter.PersonalOnly — narrows both
|
|
// bucket queries to rows the caller created. ProjectID is ignored when
|
|
// PersonalOnly is set.
|
|
//
|
|
// DirectOnly mirrors EventListFilter.DirectOnly — narrows ProjectID from
|
|
// "this project + every descendant" to "this project only". No effect
|
|
// when ProjectID is nil or PersonalOnly is set.
|
|
type EventSummaryFilter struct {
|
|
Type EventTypeFilter
|
|
ProjectID *uuid.UUID
|
|
PersonalOnly bool
|
|
DirectOnly bool
|
|
}
|
|
|
|
// EventSummary is the response shape of /api/events/summary. Either side
|
|
// is omitted when the matching Type filter excludes it; the frontend reads
|
|
// presence and renders the appropriate rail.
|
|
//
|
|
// The four universal cards are Heute / Diese Woche / Nächste Woche /
|
|
// Später. Überfällig is deadline-only and conditional (count > 0). Erledigt
|
|
// stays in the response so the dropdown filter can render the unread badge
|
|
// but is no longer rendered as a card (t-paliad-110, supersedes t-106).
|
|
type EventSummary struct {
|
|
Deadlines *DeadlineBuckets `json:"deadlines,omitempty"`
|
|
Appointments *AppointmentBuckets `json:"appointments,omitempty"`
|
|
}
|
|
|
|
// DeadlineBuckets counts deadlines across the five disjoint pending
|
|
// buckets plus the all-time completed total.
|
|
type DeadlineBuckets struct {
|
|
Overdue int `json:"overdue" db:"overdue"`
|
|
Today int `json:"today" db:"today"`
|
|
ThisWeek int `json:"this_week" db:"this_week"`
|
|
NextWeek int `json:"next_week" db:"next_week"`
|
|
Later int `json:"later" db:"later"`
|
|
Completed int `json:"completed" db:"completed"`
|
|
Total int `json:"total" db:"total"`
|
|
}
|
|
|
|
// AppointmentBuckets counts appointments by start-date bucket. Past
|
|
// appointments do not get a bucket (per t-paliad-110 §F Q14: appointments
|
|
// have no completed_at; past ones are reachable via filter / pagination
|
|
// but don't contribute to a card).
|
|
type AppointmentBuckets struct {
|
|
Today int `json:"today" db:"today"`
|
|
ThisWeek int `json:"this_week" db:"this_week"`
|
|
NextWeek int `json:"next_week" db:"next_week"`
|
|
Later int `json:"later" db:"later"`
|
|
Total int `json:"total" db:"total"`
|
|
}
|
|
|
|
// SummaryCounts returns the bucket counts for the user's visible events.
|
|
//
|
|
// The five disjoint buckets share their cutoffs with computeDeadlineBucketBounds
|
|
// (deadline_service.go) so /api/events/summary, /api/deadlines/summary, and
|
|
// the dashboard's deadline rail can never disagree.
|
|
//
|
|
// Overdue — pending AND due_date < today (deadlines only)
|
|
// Today — pending AND due_date = today (deadlines)
|
|
// start_at within [today, tomorrow) (appointments)
|
|
// ThisWeek — pending AND tomorrow <= due_date <= upcoming Sunday (deadlines)
|
|
// tomorrow <= start_at < Mon-next-week (appointments)
|
|
// NextWeek — Mon-next-week <= due_date < Mon-week-after (deadlines)
|
|
// Mon-next-week <= start_at < Mon-week-after (appointments)
|
|
// Later — due_date >= Mon-week-after (deadlines)
|
|
// start_at >= Mon-week-after (appointments)
|
|
// Completed — status='completed' (deadlines only; all-time count)
|
|
func (s *EventService) SummaryCounts(ctx context.Context, userID uuid.UUID, filter EventSummaryFilter) (*EventSummary, error) {
|
|
user, err := s.deadlines.users().GetByID(ctx, userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if user == nil {
|
|
return &EventSummary{}, nil
|
|
}
|
|
|
|
out := &EventSummary{}
|
|
wantDeadlines := filter.Type == EventTypeAll || filter.Type == EventTypeDeadline
|
|
wantAppointments := filter.Type == EventTypeAll || filter.Type == EventTypeAppointment
|
|
|
|
bounds := computeDeadlineBucketBounds(time.Now().UTC())
|
|
|
|
projectID := filter.ProjectID
|
|
if filter.PersonalOnly {
|
|
// PersonalOnly is mutually exclusive with ProjectID — see the
|
|
// EventSummaryFilter doc comment.
|
|
projectID = nil
|
|
}
|
|
|
|
if wantDeadlines {
|
|
buckets, err := s.deadlineBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.Deadlines = buckets
|
|
}
|
|
|
|
if wantAppointments {
|
|
buckets, err := s.appointmentBuckets(ctx, userID, projectID, bounds, filter.PersonalOnly, filter.DirectOnly)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out.Appointments = buckets
|
|
}
|
|
|
|
return out, nil
|
|
}
|
|
|
|
func (s *EventService) deadlineBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly, directOnly bool) (*DeadlineBuckets, error) {
|
|
conds := []string{visibilityPredicate("p")}
|
|
args := map[string]any{
|
|
"user_id": userID,
|
|
"today": b.today,
|
|
"tomorrow": b.tomorrow,
|
|
"next_monday": b.nextMonday,
|
|
"week_after": b.weekAfter,
|
|
}
|
|
if projectID != nil {
|
|
if directOnly {
|
|
conds = append(conds, `f.project_id = :project_id`)
|
|
} else {
|
|
conds = append(conds, projectDescendantPredicate("p"))
|
|
}
|
|
args["project_id"] = *projectID
|
|
}
|
|
if personalOnly {
|
|
conds = append(conds, `f.created_by = :user_id`)
|
|
}
|
|
|
|
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) AS today,
|
|
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :tomorrow AND f.due_date < :next_monday) AS this_week,
|
|
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :next_monday AND f.due_date < :week_after) AS next_week,
|
|
COUNT(*) FILTER (WHERE f.status = 'pending' AND f.due_date >= :week_after) AS later,
|
|
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 deadline summary: %w", err)
|
|
}
|
|
defer stmt.Close()
|
|
|
|
var c DeadlineBuckets
|
|
if err := stmt.GetContext(ctx, &c, args); err != nil {
|
|
return nil, fmt.Errorf("event deadline summary: %w", err)
|
|
}
|
|
return &c, nil
|
|
}
|
|
|
|
func (s *EventService) appointmentBuckets(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, b deadlineBucketBounds, personalOnly, directOnly bool) (*AppointmentBuckets, error) {
|
|
visibility := `(
|
|
(t.project_id IS NULL AND t.created_by = :user_id)
|
|
OR (t.project_id IS NOT NULL AND ` + visibilityPredicate("p") + `)
|
|
)`
|
|
conds := []string{visibility}
|
|
args := map[string]any{
|
|
"user_id": userID,
|
|
"today": b.today,
|
|
"tomorrow": b.tomorrow,
|
|
"next_monday": b.nextMonday,
|
|
"week_after": b.weekAfter,
|
|
}
|
|
if projectID != nil {
|
|
if directOnly {
|
|
conds = append(conds, `t.project_id = :project_id`)
|
|
} else {
|
|
conds = append(conds, projectDescendantPredicate("p"))
|
|
}
|
|
args["project_id"] = *projectID
|
|
}
|
|
if personalOnly {
|
|
// Narrow to rows the caller created. AND-joined with the visibility
|
|
// predicate above so an appointment a user created on a team they
|
|
// have since left still doesn't leak through.
|
|
conds = append(conds, `t.created_by = :user_id`)
|
|
}
|
|
|
|
query := `
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE t.start_at >= :today AND t.start_at < :tomorrow) AS today,
|
|
COUNT(*) FILTER (WHERE t.start_at >= :tomorrow AND t.start_at < :next_monday) AS this_week,
|
|
COUNT(*) FILTER (WHERE t.start_at >= :next_monday AND t.start_at < :week_after) AS next_week,
|
|
COUNT(*) FILTER (WHERE t.start_at >= :week_after) AS later,
|
|
COUNT(*) FILTER (WHERE t.start_at >= :today) AS total
|
|
FROM paliad.appointments t
|
|
LEFT JOIN paliad.projects p ON p.id = t.project_id
|
|
WHERE ` + strings.Join(conds, " AND ")
|
|
|
|
stmt, err := s.db.PrepareNamedContext(ctx, query)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("prepare appointment summary: %w", err)
|
|
}
|
|
defer stmt.Close()
|
|
|
|
var c AppointmentBuckets
|
|
if err := stmt.GetContext(ctx, &c, args); err != nil {
|
|
return nil, fmt.Errorf("event appointment summary: %w", err)
|
|
}
|
|
return &c, nil
|
|
}
|