Files
guestguard/internal/api/activity.go
T
Kwaku Danso 3973e4058d feat(tier2): multi-host / collaborators — Block C
Events can now have multiple users with distinct roles:
  owner   — manage collaborators, delete event, full access
  editor  — manage guests, tokens, CSV import, patch event
  viewer  — read-only access to everything

Schema (migration 0008)
- collaborator_role ENUM + event_collaborators + collaborator_invites
- Backfill: every existing events.host_id becomes an owner row
- EventRepo.Create seeds the owner row in the same transaction so
  no future event can exist without one

Authz
- New requireRole(eventID, userID, minRole) helper. Non-members 404;
  insufficient role 403. Replaces requireEventOwner across every
  shared-role handler (events.get/update, guests CRUD, tokens issue/
  rotate/bulk, csv preview/commit/template, activity, ws-ticket)
- events.delete + collaborator management stay owner-only
- GET /events lists every event the user has any role on
- /events/{id} response now embeds your_role for UI branching

Collaborator endpoints
- GET    /events/{id}/collaborators           (viewer+)
- POST   /events/{id}/collaborators           (owner)  — sends invite email
- PATCH  /events/{id}/collaborators/{user_id} (owner)  — role change
- DELETE /events/{id}/collaborators/{user_id} (owner)  — refuses last owner
- DELETE /events/{id}/collaborators/pending   (owner)  — cancel invite
- GET    /invites/{token}                     (public) — preview summary
- POST   /invites/{token}/accept              (authed) — atomic accept

Invitations
- SHA-256 hashed in DB; raw value only lives in the email link
- 7-day TTL, single-use, email-bound (caller's email must match)
- New SendCollaboratorInvite on auth.EmailSender + Resend/SMTP/SES
  senders + log stub; collaborator_invite.html/txt branded template

Frontend
- TeamCard.vue on the event detail page: lists collaborators with
  inline role-change + remove, pending-invites with cancel, invite
  modal (email + role). Owner-only actions hidden for editors/viewers
- /invites/[token] accept page: shows invite summary, prompts signup
  or sign-in with pre-filled email, refuses mismatched accounts

Tests (all 6 pass on the existing testcontainers harness)
- backfill: legacy host gets owner role
- role enforcement: viewer can read, editor can write guests but not
  delete/manage team, non-member 404s everywhere
- last-owner removal refused (400)
- shared events show up in collaborator's /events list
- invite flow: create → preview → accept → role granted → replay 410
- email mismatch on accept returns 403
- expired invite returns 410

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-17 22:14:50 +01:00

126 lines
3.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"net/http"
"sort"
"time"
"github.com/alchemistkay/guestguard/internal/domain"
"github.com/alchemistkay/guestguard/internal/storage"
)
// activityHandler serves the combined RSVP + access-check history for an
// event. The WebSocket hub only fans out *live* events to currently-
// connected dashboards; this endpoint is the catch-up channel for hosts
// who weren't watching when activity happened.
type activityHandler struct {
events *storage.EventRepo
collabs *storage.CollaboratorRepo
rsvps *storage.RSVPRepo
accessLogs *storage.AccessLogRepo
}
type activityItem struct {
Type string `json:"type"` // "rsvp" | "access_check"
Timestamp time.Time `json:"ts"`
GuestID string `json:"guest_id"`
GuestName string `json:"guest_name"`
// RSVP-only
Response string `json:"response,omitempty"`
PlusOnes int `json:"plus_ones,omitempty"`
// Access-check-only
Score int `json:"score,omitempty"`
Band string `json:"band,omitempty"`
Blocked bool `json:"blocked,omitempty"`
}
// GET /events/{id}/activity?limit=50
//
// Returns the most recent N activity items (RSVPs + scored access checks)
// for an event, sorted newest first. Frontends use this on dashboard mount
// to backfill the live monitor with history.
func (h *activityHandler) list(w http.ResponseWriter, r *http.Request) {
hostID, ok := hostFromContext(w, r)
if !ok {
return
}
eventID, ok := parseIDParam(w, r, "id")
if !ok {
return
}
if _, _, ok := requireRole(w, r, h.events, h.collabs, eventID, hostID, domain.RoleViewer); !ok {
return
}
limit := atoiOr(r.URL.Query().Get("limit"), 50)
if limit <= 0 || limit > 200 {
limit = 50
}
// Pull from each source. We grab `limit` from each so that after
// merging we still have at least `limit` of the truly newest items.
rsvps, err := h.rsvps.ListRecentByEvent(r.Context(), eventID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load activity")
return
}
checks, err := h.accessLogs.ListRecentScoredByEvent(r.Context(), eventID, limit)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load activity")
return
}
items := make([]activityItem, 0, len(rsvps)+len(checks))
for _, a := range rsvps {
items = append(items, activityItem{
Type: "rsvp",
Timestamp: a.SubmittedAt,
GuestID: a.GuestID.String(),
GuestName: a.GuestName,
Response: a.Response,
PlusOnes: a.PlusOnes,
})
}
for _, c := range checks {
items = append(items, activityItem{
Type: "access_check",
Timestamp: c.CreatedAt,
GuestID: c.GuestID.String(),
GuestName: c.GuestName,
Score: c.Score,
Band: bandFromScore(c.Score),
Blocked: c.Score >= 80,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].Timestamp.After(items[j].Timestamp)
})
if len(items) > limit {
items = items[:limit]
}
writeJSON(w, http.StatusOK, map[string]any{
"activity": items,
})
}
// bandFromScore mirrors the friendly buckets used by the live WebSocket
// pipeline so backfilled items and live items render the same way in the
// dashboard feed. Thresholds match the fraud engine's intent: 029 looks
// normal, 3059 worth a glance, 6079 suspicious, ≥80 blocked.
func bandFromScore(score int) string {
switch {
case score >= 80:
return "block"
case score >= 60:
return "high"
case score >= 30:
return "medium"
default:
return "low"
}
}