Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/termalias/termalias.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/termalias/termalias.go |
| Original SHA-256 | 836222be3a117eb87a6f6bda194345a6e93a2dfe479bb0d2897ee68007fbc761 |
| Git blob SHA | 655ca7045a1242bdeac389ba67c0caff09e31b92 |
| Reading-copy SHA-256 | 836222be3a117eb87a6f6bda194345a6e93a2dfe479bb0d2897ee68007fbc761 |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / internal/modules/termalias/termalias.go | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / internal/modules/termalias/termalias.go | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓193 lines · 6.0 KB
// 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)
}