// Package termalias is the admin surface for the acronym and term expansion layer: list the // global GovCon seed plus the tenant's own additions, add a tenant term, remove a tenant term. // The RLS policies on term_expansions make the global seed read only to every tenant: an insert or // delete can only ever touch rows carrying the caller's own organization id. package termalias import ( "context" "encoding/json" "errors" "fmt" "io" "net/http" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/missionworkforce/missionmatch/internal/platform/security" "github.com/missionworkforce/missionmatch/internal/platform/tenant" ) var ( ErrBadRequest = errors.New("termalias: bad request") ErrNoDatabase = errors.New("termalias: no database configured") ErrNotFound = errors.New("termalias: not found") ) type Service struct { db *tenant.DB } func NewService(db *tenant.DB) *Service { return &Service{db: db} } // TermView is one alias row. Global seed rows carry global true and cannot be deleted by a tenant. type TermView struct { ID uuid.UUID `json:"id"` Alias string `json:"alias"` Expansion string `json:"expansion"` Global bool `json:"global"` CreatedAt time.Time `json:"createdAt"` } func (s *Service) List(ctx context.Context) ([]TermView, error) { if s.db == nil { return nil, ErrNoDatabase } out := []TermView{} err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { rows, err := tx.Query(ctx, ` SELECT id, alias, expansion, organization_id IS NULL, created_at FROM term_expansions ORDER BY organization_id NULLS FIRST, lower(alias), lower(expansion)`) if err != nil { return err } defer rows.Close() for rows.Next() { var v TermView if err := rows.Scan(&v.ID, &v.Alias, &v.Expansion, &v.Global, &v.CreatedAt); err != nil { return err } out = append(out, v) } return rows.Err() }) return out, err } func (s *Service) Add(ctx context.Context, alias, expansion string) (TermView, error) { if s.db == nil { return TermView{}, ErrNoDatabase } alias, expansion = strings.TrimSpace(alias), strings.TrimSpace(expansion) if alias == "" || len(alias) > 80 || len(expansion) < 2 || len(expansion) > 200 { return TermView{}, fmt.Errorf("%w: alias must be 1 to 80 characters and expansion 2 to 200", ErrBadRequest) } var actor *uuid.UUID if p, ok := tenant.UserFromContext(ctx); ok { if id, err := uuid.Parse(p.UserID); err == nil { actor = &id } } var out TermView err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { return tx.QueryRow(ctx, ` INSERT INTO term_expansions (organization_id, alias, expansion, created_by) VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid, $1, $2, $3) ON CONFLICT DO NOTHING RETURNING id, alias, expansion, false, created_at`, alias, expansion, actor, ).Scan(&out.ID, &out.Alias, &out.Expansion, &out.Global, &out.CreatedAt) }) if errors.Is(err, pgx.ErrNoRows) { return TermView{}, fmt.Errorf("%w: that alias and expansion pair already exists", ErrBadRequest) } return out, err } func (s *Service) Delete(ctx context.Context, id uuid.UUID) error { if s.db == nil { return ErrNoDatabase } return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error { // RLS: the delete policy reaches only the caller org's rows, so a global seed id (or another // tenant's id) deletes zero rows and reports not found, never touching the row. tag, err := tx.Exec(ctx, `DELETE FROM term_expansions WHERE id = $1`, id) if err != nil { return err } if tag.RowsAffected() == 0 { return ErrNotFound } return nil }) } // ---------- HTTP ---------- type Handler struct { svc *Service } func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} } // Routes: reading the table is open to anyone who can run a screen (the expansions explain // matches); adding and removing tenant terms is an org settings write, the admin gate. func (h *Handler) Routes(r chi.Router) { r.With(security.RequireCapability(security.CapSearchRun)).Get("/aliases", h.list) r.With(security.RequireCapability(security.CapOrgSettings)).Post("/aliases", h.add) r.With(security.RequireCapability(security.CapOrgSettings)).Delete("/aliases/{id}", h.remove) } func (h *Handler) list(w http.ResponseWriter, r *http.Request) { out, err := h.svc.List(r.Context()) if err != nil { h.err(w, err) return } h.json(w, http.StatusOK, map[string]any{"terms": out}) } func (h *Handler) add(w http.ResponseWriter, r *http.Request) { var req struct { Alias string `json:"alias"` Expansion string `json:"expansion"` } if err := json.NewDecoder(io.LimitReader(r.Body, 8*1024)).Decode(&req); err != nil { h.json(w, http.StatusBadRequest, map[string]string{"error": "invalid json body"}) return } out, err := h.svc.Add(r.Context(), req.Alias, req.Expansion) if err != nil { h.err(w, err) return } h.json(w, http.StatusCreated, out) } func (h *Handler) remove(w http.ResponseWriter, r *http.Request) { id, err := uuid.Parse(chi.URLParam(r, "id")) if err != nil { h.json(w, http.StatusBadRequest, map[string]string{"error": "invalid id"}) return } if err := h.svc.Delete(r.Context(), id); err != nil { h.err(w, err) return } w.WriteHeader(http.StatusNoContent) } func (h *Handler) err(w http.ResponseWriter, err error) { switch { case errors.Is(err, ErrBadRequest): h.json(w, http.StatusBadRequest, map[string]string{"error": err.Error()}) case errors.Is(err, ErrNotFound): h.json(w, http.StatusNotFound, map[string]string{"error": "no such tenant term (the global seed cannot be removed)"}) case errors.Is(err, ErrNoDatabase): h.json(w, http.StatusServiceUnavailable, map[string]string{"error": "no database configured"}) default: h.json(w, http.StatusInternalServerError, map[string]string{"error": "internal error"}) } } func (h *Handler) json(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) }