chore(t-paliad-081): doc + dead-code batch (F-5/F-10/F-11/F-15/F-16/F-17/F-18)
Bundle of small audit findings, all doc-only or dead-code: - F-5: refresh stale escalation-contact comment in models.User — Settings UI dropdown shipped 2026-04-29 (t-paliad-066). - F-10: add "OBSOLETED by migration 018" note to migrations 004/005/006 so readers stop hunting for the live shape in obsolete files. - F-11: document the data-loss semantics of dropping paliad.partner_unit_events on the 027 down — audit rows are append-only telemetry, accepted loss on rollback. - F-15: drop the patholo_session / patholo_refresh cookie fallback added during the 2026-04-16 rebrand. Active users have long since been re-authed through the upgrade path; inactive users hit the normal /login flow. - F-16: refresh stale /api/departments comment in team_pages.go to /api/partner-units (renamed in t-paliad-070). - F-17: move internal/db/migrations/_dev/mock_supabase_auth.sql to internal/db/devtools/ so a future loosening of the //go:embed pattern can't accidentally ship the dev-only fixture. - F-18: update docs/project-status.md "Audit polish-2" entry — the batch shipped via t-paliad-067 / 068 / 073, follow-ups are now tracked under the 2026-04-30 re-audit + t-paliad-074. go build / vet / test clean.
This commit is contained in:
@@ -58,7 +58,7 @@ To run migrations against a local Postgres:
|
|||||||
docker run -d --name paliad-pg -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16-alpine
|
docker run -d --name paliad-pg -e POSTGRES_PASSWORD=test -p 5432:5432 postgres:16-alpine
|
||||||
# bootstrap a mock auth schema (auth.users + auth.uid()) — required because
|
# bootstrap a mock auth schema (auth.users + auth.uid()) — required because
|
||||||
# the migrations reference Supabase-provided objects:
|
# the migrations reference Supabase-provided objects:
|
||||||
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/migrations/_dev/mock_supabase_auth.sql
|
psql postgres://postgres:test@localhost:5432/postgres -f internal/db/devtools/mock_supabase_auth.sql
|
||||||
DATABASE_URL='postgres://postgres:test@localhost:5432/postgres?sslmode=disable' \
|
DATABASE_URL='postgres://postgres:test@localhost:5432/postgres?sslmode=disable' \
|
||||||
SUPABASE_URL=stub SUPABASE_ANON_KEY=stub \
|
SUPABASE_URL=stub SUPABASE_ANON_KEY=stub \
|
||||||
go run ./cmd/server
|
go run ./cmd/server
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Phases A–G shipped (April 2026): schema + RLS, services, Fristenrechner→DB,
|
|||||||
## Open follow-ups
|
## Open follow-ups
|
||||||
|
|
||||||
- **Settings → Notifications: escalation contact dropdown** — migration 025 ships `paliad.users.escalation_contact_id` (FK to `paliad.users`, nullable, ON DELETE SET NULL). NULL means "fall back to global_admins for the escalation channel"; setting it lets a user designate a specific colleague as their escalation contact. UI shipped t-paliad-066 on 2026-04-29.
|
- **Settings → Notifications: escalation contact dropdown** — migration 025 ships `paliad.users.escalation_contact_id` (FK to `paliad.users`, nullable, ON DELETE SET NULL). NULL means "fall back to global_admins for the escalation channel"; setting it lets a user designate a specific colleague as their escalation contact. UI shipped t-paliad-066 on 2026-04-29.
|
||||||
- **Audit polish-2** — ~25 BATCH-level findings from `docs/audit-polish-2026-04-27.md` not yet shipped (PR-A..PR-E covered the rest). Triage in flight as t-paliad-067.
|
- **Audit polish-2** — shipped 2026-04-30 across t-paliad-067 / t-paliad-068 / t-paliad-073 (BATCH-level findings + DEFER list). Follow-ups from the 2026-04-30 re-audit (`docs/improvement-audit-2026-04-30.md`) are tracked under t-paliad-074 and downstream task IDs.
|
||||||
- **KanzlAI infra retirement** — Dokploy shutdown, `kanzlai` schema drop, Gitea archive. Pending m + head coordination.
|
- **KanzlAI infra retirement** — Dokploy shutdown, `kanzlai` schema drop, Gitea archive. Pending m + head coordination.
|
||||||
|
|
||||||
## Historical naming
|
## Historical naming
|
||||||
|
|||||||
@@ -16,17 +16,13 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
// SessionCookieName + RefreshCookieName are the canonical cookie names
|
// SessionCookieName + RefreshCookieName are the canonical cookie names
|
||||||
// issued after the patholo → paliad rename (2026-04-18). New logins and
|
// issued after the patholo → paliad rename (2026-04-16). The legacy
|
||||||
// refreshes always write these; old patholo_* cookies are still read via
|
// patholo_* fallback was removed in t-paliad-081 (2026-04-30) — any
|
||||||
// the legacy fallback below so existing users stay logged in through the
|
// user who held a legacy cookie has long since been re-authed through
|
||||||
// deploy. Remove the legacy names after 2026-05-18 (30-day cookie max age).
|
// the upgrade path and now carries paliad_* names.
|
||||||
SessionCookieName = "paliad_session"
|
SessionCookieName = "paliad_session"
|
||||||
RefreshCookieName = "paliad_refresh"
|
RefreshCookieName = "paliad_refresh"
|
||||||
|
|
||||||
// Legacy cookie names — read-only fallback during the rename grace period.
|
|
||||||
LegacySessionCookieName = "patholo_session"
|
|
||||||
LegacyRefreshCookieName = "patholo_refresh"
|
|
||||||
|
|
||||||
CookieMaxAge = 30 * 24 * 60 * 60 // 30 days
|
CookieMaxAge = 30 * 24 * 60 * 60 // 30 days
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -205,25 +201,19 @@ func (c *Client) VerifyToken(token string) (*VerifiedClaims, error) {
|
|||||||
return &VerifiedClaims{Sub: sub, Email: email, Exp: exp}, nil
|
return &VerifiedClaims{Sub: sub, Email: email, Exp: exp}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSessionCookie returns the value of the session cookie and whether the
|
// readSessionCookie returns the value of the session cookie, or "" if the
|
||||||
// value came from the legacy patholo_session fallback. Empty string if
|
// caller did not present one.
|
||||||
// neither cookie is present.
|
func readSessionCookie(r *http.Request) string {
|
||||||
func readSessionCookie(r *http.Request) (value string, fromLegacy bool) {
|
|
||||||
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
|
if c, err := r.Cookie(SessionCookieName); err == nil && c.Value != "" {
|
||||||
return c.Value, false
|
return c.Value
|
||||||
}
|
}
|
||||||
if c, err := r.Cookie(LegacySessionCookieName); err == nil && c.Value != "" {
|
return ""
|
||||||
return c.Value, true
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readRefreshCookie mirrors readSessionCookie for the refresh token.
|
// readRefreshCookie mirrors readSessionCookie for the refresh token.
|
||||||
func readRefreshCookie(r *http.Request) string {
|
func readRefreshCookie(r *http.Request) string {
|
||||||
for _, name := range []string{RefreshCookieName, LegacyRefreshCookieName} {
|
if c, err := r.Cookie(RefreshCookieName); err == nil && c.Value != "" {
|
||||||
if c, err := r.Cookie(name); err == nil && c.Value != "" {
|
return c.Value
|
||||||
return c.Value
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -232,7 +222,7 @@ func readRefreshCookie(r *http.Request) string {
|
|||||||
// Browser requests get a 302 to /login; API requests get a 401 JSON response.
|
// Browser requests get a 302 to /login; API requests get a 401 JSON response.
|
||||||
func (c *Client) Middleware(next http.Handler) http.Handler {
|
func (c *Client) Middleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
sessionValue, fromLegacy := readSessionCookie(r)
|
sessionValue := readSessionCookie(r)
|
||||||
if sessionValue == "" {
|
if sessionValue == "" {
|
||||||
rejectUnauthenticated(w, r)
|
rejectUnauthenticated(w, r)
|
||||||
return
|
return
|
||||||
@@ -240,16 +230,6 @@ func (c *Client) Middleware(next http.Handler) http.Handler {
|
|||||||
|
|
||||||
claims, err := c.VerifyToken(sessionValue)
|
claims, err := c.VerifyToken(sessionValue)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// If the request authenticated via the legacy patholo_session
|
|
||||||
// cookie, upgrade the browser to paliad_session so it stops
|
|
||||||
// depending on the legacy fallback.
|
|
||||||
if fromLegacy {
|
|
||||||
refresh := ""
|
|
||||||
if rc, rerr := r.Cookie(LegacyRefreshCookieName); rerr == nil {
|
|
||||||
refresh = rc.Value
|
|
||||||
}
|
|
||||||
SetAuthCookies(w, r, &TokenResponse{AccessToken: sessionValue, RefreshToken: refresh})
|
|
||||||
}
|
|
||||||
ctx := withVerifiedClaims(r.Context(), claims)
|
ctx := withVerifiedClaims(r.Context(), claims)
|
||||||
next.ServeHTTP(w, r.WithContext(ctx))
|
next.ServeHTTP(w, r.WithContext(ctx))
|
||||||
return
|
return
|
||||||
@@ -306,9 +286,7 @@ func isAPIRequest(r *http.Request) bool {
|
|||||||
return strings.HasPrefix(r.URL.Path, "/api/")
|
return strings.HasPrefix(r.URL.Path, "/api/")
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetAuthCookies writes session and refresh token cookies under the current
|
// SetAuthCookies writes session and refresh token cookies.
|
||||||
// paliad_* names and expires any legacy patholo_* cookies from the rename
|
|
||||||
// grace period in the same response.
|
|
||||||
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
|
func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenResponse) {
|
||||||
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
secure := r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
||||||
for _, c := range []*http.Cookie{
|
for _, c := range []*http.Cookie{
|
||||||
@@ -325,23 +303,11 @@ func SetAuthCookies(w http.ResponseWriter, r *http.Request, tokens *TokenRespons
|
|||||||
Secure: secure,
|
Secure: secure,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
// Expire legacy cookies now that the browser has the paliad_* pair.
|
|
||||||
for _, name := range []string{LegacySessionCookieName, LegacyRefreshCookieName} {
|
|
||||||
http.SetCookie(w, &http.Cookie{
|
|
||||||
Name: name,
|
|
||||||
Value: "",
|
|
||||||
Path: "/",
|
|
||||||
MaxAge: -1,
|
|
||||||
HttpOnly: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAuthCookies removes session and refresh token cookies under both the
|
// ClearAuthCookies removes session and refresh token cookies.
|
||||||
// current and legacy names so a stale patholo_* cookie can't resurrect a
|
|
||||||
// logged-out session.
|
|
||||||
func ClearAuthCookies(w http.ResponseWriter) {
|
func ClearAuthCookies(w http.ResponseWriter) {
|
||||||
for _, name := range []string{SessionCookieName, RefreshCookieName, LegacySessionCookieName, LegacyRefreshCookieName} {
|
for _, name := range []string{SessionCookieName, RefreshCookieName} {
|
||||||
http.SetCookie(w, &http.Cookie{
|
http.SetCookie(w, &http.Cookie{
|
||||||
Name: name,
|
Name: name,
|
||||||
Value: "",
|
Value: "",
|
||||||
|
|||||||
@@ -97,83 +97,6 @@ func TestVerifyToken_Garbage(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestMiddleware_LegacyCookieAccepted covers the patholo_session → paliad_session
|
|
||||||
// rename grace period: a user whose browser still holds the legacy cookie must
|
|
||||||
// stay authenticated and receive the new cookie in the response.
|
|
||||||
func TestMiddleware_LegacyCookieAccepted(t *testing.T) {
|
|
||||||
c := &Client{JWTSecret: testSecret}
|
|
||||||
token := sign(t, testSecret, jwt.MapClaims{
|
|
||||||
"sub": "11111111-1111-1111-1111-111111111111",
|
|
||||||
"exp": time.Now().Add(time.Hour).Unix(),
|
|
||||||
})
|
|
||||||
|
|
||||||
var nextHit bool
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
nextHit = true
|
|
||||||
if _, ok := verifiedClaimsFromContext(r.Context()); !ok {
|
|
||||||
t.Error("handler reached without verified claims in ctx")
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/anything", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: token})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
c.Middleware(next).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if !nextHit {
|
|
||||||
t.Fatalf("legacy cookie should authenticate; got status %d", rec.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Response should upgrade the caller to paliad_session and expire the legacy one.
|
|
||||||
var sawNew, sawLegacyExpiry bool
|
|
||||||
for _, cookie := range rec.Result().Cookies() {
|
|
||||||
if cookie.Name == SessionCookieName && cookie.Value == token && cookie.MaxAge > 0 {
|
|
||||||
sawNew = true
|
|
||||||
}
|
|
||||||
if cookie.Name == LegacySessionCookieName && cookie.MaxAge < 0 {
|
|
||||||
sawLegacyExpiry = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !sawNew {
|
|
||||||
t.Error("expected paliad_session to be set on upgrade path")
|
|
||||||
}
|
|
||||||
if !sawLegacyExpiry {
|
|
||||||
t.Error("expected patholo_session to be expired on upgrade path")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiddleware_NewCookiePreferredOverLegacy(t *testing.T) {
|
|
||||||
c := &Client{JWTSecret: testSecret}
|
|
||||||
good := sign(t, testSecret, jwt.MapClaims{
|
|
||||||
"sub": "aaaa",
|
|
||||||
"exp": time.Now().Add(time.Hour).Unix(),
|
|
||||||
})
|
|
||||||
bad := "garbage"
|
|
||||||
|
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
})
|
|
||||||
|
|
||||||
req := httptest.NewRequest("GET", "/api/anything", nil)
|
|
||||||
req.AddCookie(&http.Cookie{Name: SessionCookieName, Value: good})
|
|
||||||
req.AddCookie(&http.Cookie{Name: LegacySessionCookieName, Value: bad})
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
c.Middleware(next).ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("expected 200 using paliad_session; got %d", rec.Code)
|
|
||||||
}
|
|
||||||
// When the new cookie authenticated the request, the legacy cookie must
|
|
||||||
// not be re-copied on top of it (would clobber a valid session with a
|
|
||||||
// stale one).
|
|
||||||
for _, cookie := range rec.Result().Cookies() {
|
|
||||||
if cookie.Name == SessionCookieName && cookie.Value == bad {
|
|
||||||
t.Fatal("legacy cookie value was copied over the valid paliad_session — rename upgrade is unsafe")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMiddleware_NoCookieRejected(t *testing.T) {
|
func TestMiddleware_NoCookieRejected(t *testing.T) {
|
||||||
c := &Client{JWTSecret: testSecret}
|
c := &Client{JWTSecret: testSecret}
|
||||||
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
-- Phase A: paliad.akten — the central Akte (matter) entity.
|
-- Phase A: paliad.akten — the central Akte (matter) entity.
|
||||||
--
|
--
|
||||||
|
-- OBSOLETED by migration 018 (data model v2): paliad.akten is dropped and
|
||||||
|
-- replaced by paliad.projects. The effective shape lives in 018; this file
|
||||||
|
-- is kept only so a fresh database can replay the migration history.
|
||||||
|
--
|
||||||
-- Office-scoped visibility columns (per design §2):
|
-- Office-scoped visibility columns (per design §2):
|
||||||
-- owning_office — the office the Akte belongs to
|
-- owning_office — the office the Akte belongs to
|
||||||
-- collaborators — uuid[] of users with explicit access (cross-office)
|
-- collaborators — uuid[] of users with explicit access (cross-office)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
-- Phase A: child tables of paliad.akten.
|
-- Phase A: child tables of paliad.akten.
|
||||||
-- All inherit visibility from their parent Akte via RLS policies in migration 007.
|
-- All inherit visibility from their parent Akte via RLS policies in migration 007.
|
||||||
|
--
|
||||||
|
-- OBSOLETED by migration 018 (data model v2): these tables are renamed/rewired
|
||||||
|
-- to hang off paliad.projects (parteien→parties, etc.) and later renamed to
|
||||||
|
-- English in migration 020. The effective shape lives in 018+020; this file
|
||||||
|
-- is kept only so a fresh database can replay the migration history.
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- parteien (parties to an Akte)
|
-- parteien (parties to an Akte)
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
-- Phase A: paliad.can_see_akte(akte_id) — single source of truth for
|
-- Phase A: paliad.can_see_akte(akte_id) — single source of truth for
|
||||||
-- office-scoped Akten visibility (design §2).
|
-- office-scoped Akten visibility (design §2).
|
||||||
--
|
--
|
||||||
|
-- OBSOLETED by migration 018 (data model v2): can_see_akte() is dropped and
|
||||||
|
-- replaced by paliad.can_see_project(project_id) with team-based visibility
|
||||||
|
-- (direct + inherited up the project tree). The effective shape lives in 018;
|
||||||
|
-- this file is kept only so a fresh database can replay the migration history.
|
||||||
|
--
|
||||||
-- A user can see an Akte iff ANY of:
|
-- A user can see an Akte iff ANY of:
|
||||||
-- - the Akte is flagged firm_wide_visible
|
-- - the Akte is flagged firm_wide_visible
|
||||||
-- - the Akte's owning_office matches the user's office
|
-- - the Akte's owning_office matches the user's office
|
||||||
|
|||||||
@@ -16,6 +16,12 @@
|
|||||||
-- data restorations.
|
-- data restorations.
|
||||||
|
|
||||||
-- 1. Drop the audit table.
|
-- 1. Drop the audit table.
|
||||||
|
--
|
||||||
|
-- DATA LOSS: paliad.partner_unit_events has no pre-027 equivalent, so its
|
||||||
|
-- rows cannot be migrated forward on a re-up. Any audit history accumulated
|
||||||
|
-- since 027 was applied will be permanently lost on rollback. This is
|
||||||
|
-- accepted because audits are append-only telemetry, not authoritative
|
||||||
|
-- state — losing them does not corrupt the rest of the schema.
|
||||||
DROP TABLE IF EXISTS paliad.partner_unit_events;
|
DROP TABLE IF EXISTS paliad.partner_unit_events;
|
||||||
|
|
||||||
-- 2. Rename RLS policies back.
|
-- 2. Rename RLS policies back.
|
||||||
|
|||||||
@@ -301,19 +301,12 @@ func handleSuggestionCount(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// extractEmailFromCookie decodes the user's email from the session JWT.
|
// extractEmailFromCookie decodes the user's email from the session JWT.
|
||||||
// Checks the current paliad_session cookie, falling back to the legacy
|
|
||||||
// patholo_session during the rename grace period (see internal/auth).
|
|
||||||
func extractEmailFromCookie(r *http.Request) string {
|
func extractEmailFromCookie(r *http.Request) string {
|
||||||
var value string
|
c, err := r.Cookie(auth.SessionCookieName)
|
||||||
for _, name := range []string{auth.SessionCookieName, auth.LegacySessionCookieName} {
|
if err != nil || c.Value == "" {
|
||||||
if c, err := r.Cookie(name); err == nil && c.Value != "" {
|
|
||||||
value = c.Value
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if value == "" {
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
value := c.Value
|
||||||
parts := strings.Split(value, ".")
|
parts := strings.Split(value, ".")
|
||||||
if len(parts) != 3 {
|
if len(parts) != 3 {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ package handlers
|
|||||||
|
|
||||||
import "net/http"
|
import "net/http"
|
||||||
|
|
||||||
// GET /team — directory of all Paliad users grouped by office or department.
|
// GET /team — directory of all Paliad users grouped by office or partner unit.
|
||||||
// Server-rendered shell; the client (assets/team.js) hydrates from /api/users
|
// Server-rendered shell; the client bundle hydrates from /api/users and
|
||||||
// and /api/departments?include=members.
|
// /api/partner-units?include=members.
|
||||||
func handleTeamPage(w http.ResponseWriter, r *http.Request) {
|
func handleTeamPage(w http.ResponseWriter, r *http.Request) {
|
||||||
http.ServeFile(w, r, "dist/team.html")
|
http.ServeFile(w, r, "dist/team.html")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ type User struct {
|
|||||||
ReminderWarningOffsetDays int `db:"reminder_warning_offset_days" json:"reminder_warning_offset_days"`
|
ReminderWarningOffsetDays int `db:"reminder_warning_offset_days" json:"reminder_warning_offset_days"`
|
||||||
// EscalationContactID is an optional override of the escalation channel
|
// EscalationContactID is an optional override of the escalation channel
|
||||||
// for overdue / DRINGEND mail. NULL means "fall back to global_admins".
|
// for overdue / DRINGEND mail. NULL means "fall back to global_admins".
|
||||||
// The Settings UI dropdown is deferred (see CLAUDE.md); set via SQL today.
|
// Settings UI dropdown shipped 2026-04-29 (t-paliad-066).
|
||||||
EscalationContactID *uuid.UUID `db:"escalation_contact_id" json:"escalation_contact_id,omitempty"`
|
EscalationContactID *uuid.UUID `db:"escalation_contact_id" json:"escalation_contact_id,omitempty"`
|
||||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||||
|
|||||||
Reference in New Issue
Block a user