Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/matching/candidate_documents.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | internal/modules/matching/candidate_documents.go |
| Original SHA-256 | f4c8c4fca812683886e4064ea0ac4d5921d4b170727c7641e3a54da390e90393 |
| Git blob SHA | 48054b033da1b9d7b455a4ee36b0d85a9854ea39 |
| Reading-copy SHA-256 | f4c8c4fca812683886e4064ea0ac4d5921d4b170727c7641e3a54da390e90393 |
Recovered source transcript
Download complete source text ↓351 lines · 13.7 KB
package matching
// CANDIDATE DOCUMENTS. The file on a person: the resume the agency formatted, the original the candidate
// sent, a right to represent, a certification, a reference.
//
// It did not exist. opportunity_documents did, and nothing equivalent existed for the record type the entire
// product is about, so a recruiter could not attach a resume to a candidate. That is a hole you hit on the
// first day of a real client, which is why it lands before the frontend rather than after it.
//
// THE ORDER IS THE DESIGN, and it is copied deliberately from the opportunities document service rather than
// reinvented: validate, then scan, then store the bytes, then record the row. A row that exists before its
// bytes is a row a reader will fail on; stored bytes with no row are garbage nobody can find.
//
// CLEARANCE EVIDENCE IS A LABEL, NEVER A VERIFICATION. A candidate supplied document does not make a
// clearance verified. The real check is the hiring client's FSO through DISS at onboarding, and this stores
// the file so it can be found, never so it can be treated as proof.
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/storage"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
var (
ErrNoStorage = errors.New("matching: document storage is not configured on this node")
ErrDocEmpty = errors.New("matching: the file is empty")
ErrDocTooLarge = errors.New("matching: the file exceeds the maximum size")
ErrDocMediaType = errors.New("matching: that file type is not accepted")
ErrDocTypeInvalid = errors.New("matching: unknown document type")
ErrDocDuplicate = errors.New("matching: that exact file is already on this candidate")
ErrCandidateUnknown = errors.New("matching: no such candidate in this organization")
ErrDocNotFound = errors.New("matching: no such document")
)
// MaxCandidateDocBytes bounds a single upload. A resume is tens of kilobytes; twenty megabytes is generous
// for a scanned certification and small enough that a mistaken upload cannot fill a volume.
const MaxCandidateDocBytes = 20 << 20
// candidateMediaTypes is a CLOSED allowlist mapping an accepted type to the extension used in the storage
// key. An allowlist rather than a denylist, because the interesting question is what a recruiter legitimately
// sends, and that list is short.
var candidateMediaTypes = map[string]string{
"application/pdf": ".pdf",
"application/msword": ".doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": ".docx",
"application/rtf": ".rtf",
"text/plain": ".txt",
"image/png": ".png",
"image/jpeg": ".jpg",
}
var candidateDocTypes = map[string]bool{
"resume": true, "resume_original": true, "resume_formatted": true, "cover_letter": true,
"certification": true, "clearance_evidence": true, "right_to_represent": true,
"reference": true, "work_sample": true, "onboarding": true, "other": true,
}
// CandidateDocument is the metadata row. IT NEVER CARRIES THE BYTES, so a list of forty documents is forty
// small rows and no caller can accidentally serialise a candidate's whole file into a response.
type CandidateDocument struct {
ID string `json:"id"`
CandidateID string `json:"candidate_id"`
DocumentType string `json:"document_type"`
FileName string `json:"file_name"`
MediaType string `json:"media_type"`
SizeBytes int64 `json:"size_bytes"`
SHA256 string `json:"sha256"`
UploadedBy string `json:"uploaded_by_user_id,omitempty"`
UploaderName string `json:"uploaded_by_name,omitempty"`
IsCurrent bool `json:"is_current"`
CreatedAt time.Time `json:"created_at"`
}
// CandidateDocInput is one upload.
type CandidateDocInput struct {
CandidateID string
DocumentType string
FileName string
MediaType string
Bytes []byte
}
// WithDocumentStore injects object storage. Nil means the routes report themselves unavailable rather than
// accepting an upload and losing it, which is the same rule the opportunity document surface already follows.
func (s *Service) WithDocumentStore(store storage.Storer) *Service {
s.docStore = store
return s
}
func (s *Service) UploadCandidateDocument(ctx context.Context, in CandidateDocInput, ip, ua string) (CandidateDocument, error) {
var out CandidateDocument
if s.db == nil {
return out, ErrNoDatabase
}
if s.docStore == nil {
return out, ErrNoStorage
}
// THE ORG COMES FROM THE VALIDATED CONTEXT, never from a parameter. A caller who could name the tenant
// would be the authority on tenancy, which is exactly what row level security exists to prevent.
orgStr, ok := tenant.OrgFromContext(ctx)
if !ok {
return out, tenant.ErrNoTenant
}
orgID, err := uuid.Parse(orgStr)
if err != nil {
return out, tenant.ErrNoTenant
}
candID, err := uuid.Parse(strings.TrimSpace(in.CandidateID))
if err != nil {
return out, ErrCandidateUnknown
}
if len(in.Bytes) == 0 {
return out, ErrDocEmpty
}
if len(in.Bytes) > MaxCandidateDocBytes {
return out, fmt.Errorf("%w: %d bytes, the cap is %d", ErrDocTooLarge, len(in.Bytes), MaxCandidateDocBytes)
}
mt := normalizeCandidateMediaType(in.MediaType, in.FileName)
ext, ok := candidateMediaTypes[mt]
if !ok {
return out, fmt.Errorf("%w: %q", ErrDocMediaType, mt)
}
docType := strings.TrimSpace(in.DocumentType)
if docType == "" {
docType = "resume"
}
if !candidateDocTypes[docType] {
return out, fmt.Errorf("%w: %q", ErrDocTypeInvalid, docType)
}
sum := storage.SHA256Hex(in.Bytes)
key := fmt.Sprintf("org/%s/candidate/%s/%s%s", orgID, candID, sum, ext)
actor := actorUUIDPtr(ctx)
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
// The candidate must exist FOR THIS ORG, asked inside the tenant transaction so RLS answers rather
// than the application believing.
var exists bool
if e := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM candidates WHERE id=$1 AND deleted_at IS NULL)`,
candID).Scan(&exists); e != nil {
return e
}
if !exists {
return ErrCandidateUnknown
}
// DUPLICATE DETECTION BY CONTENT, scoped to the candidate. Recruiters re upload the same resume
// constantly, and the same file legitimately appears on two different people, so a global unique
// would be wrong.
var dup string
e := tx.QueryRow(ctx, `SELECT id::text FROM candidate_documents
WHERE candidate_id=$1 AND sha256=$2 AND is_current LIMIT 1`,
candID, sum).Scan(&dup)
if e == nil {
return fmt.Errorf("%w: %s", ErrDocDuplicate, dup)
}
if !errors.Is(e, pgx.ErrNoRows) {
return e
}
// THE BYTES LAND BEFORE THE ROW. A row whose bytes are missing is worse than a stored blob with no
// row, because the first is a broken record a person will open and the second is only wasted space.
if e := s.docStore.Put(ctx, key, in.Bytes); e != nil {
return fmt.Errorf("matching: store candidate document: %w", e)
}
e = tx.QueryRow(ctx, `
INSERT INTO candidate_documents
(organization_id, candidate_id, document_type, file_name, media_type, storage_key,
size_bytes, sha256, uploaded_by_user_id, extraction_status)
VALUES (NULLIF(current_setting('app.current_org_id', true), '')::uuid,
$1,$2,$3,$4,$5,$6,$7,$8,'pending')
RETURNING id::text, created_at`,
candID, docType, safeFileName(in.FileName), mt, key, int64(len(in.Bytes)), sum, actor).
Scan(&out.ID, &out.CreatedAt)
if e != nil {
return e
}
if s.aud != nil {
// THE BYTES ARE NOT IN THE AUDIT ROW. What is recorded is that a document of a named type was
// attached to a named candidate, by whom, with its content hash. The hash is the integrity stamp
// that lets somebody prove later that the file they are holding is the file that was uploaded.
body, _ := json.Marshal(map[string]any{
"candidate_id": candID.String(), "document_type": docType,
"file_name": safeFileName(in.FileName), "sha256": sum, "size_bytes": len(in.Bytes),
})
return s.aud.OnTx(ctx, tx, audit.Event{
Action: "candidate.document.uploaded", ActorUserID: actor,
EntityType: "candidate", EntityID: candID.String(), NewValues: body,
IPAddress: ip, UserAgent: ua,
})
}
return nil
})
if err != nil {
return CandidateDocument{}, err
}
out.CandidateID, out.DocumentType, out.FileName = candID.String(), docType, safeFileName(in.FileName)
out.MediaType, out.SizeBytes, out.SHA256, out.IsCurrent = mt, int64(len(in.Bytes)), sum, true
if actor != nil {
out.UploadedBy = actor.String()
}
return out, nil
}
func (s *Service) ListCandidateDocuments(ctx context.Context, candidateID uuid.UUID) ([]CandidateDocument, error) {
if s.db == nil {
return nil, ErrNoDatabase
}
out := make([]CandidateDocument, 0, 8)
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
rows, e := tx.Query(ctx, `
SELECT d.id::text, d.candidate_id::text, d.document_type, d.file_name, d.media_type,
d.size_bytes, d.sha256, coalesce(d.uploaded_by_user_id::text,''),
coalesce(u.full_name,''), d.is_current, d.created_at
FROM candidate_documents d
LEFT JOIN users u ON u.id = d.uploaded_by_user_id
WHERE d.candidate_id = $1
ORDER BY d.is_current DESC, d.created_at DESC`, candidateID)
if e != nil {
return e
}
defer rows.Close()
for rows.Next() {
var d CandidateDocument
if e := rows.Scan(&d.ID, &d.CandidateID, &d.DocumentType, &d.FileName, &d.MediaType,
&d.SizeBytes, &d.SHA256, &d.UploadedBy, &d.UploaderName, &d.IsCurrent, &d.CreatedAt); e != nil {
return e
}
out = append(out, d)
}
return rows.Err()
})
return out, err
}
// DownloadCandidateDocument returns the bytes and the metadata needed to serve them safely. The storage key
// is resolved from the ROW, never from anything a caller supplied, so a request cannot reach a path of its
// own choosing.
func (s *Service) DownloadCandidateDocument(ctx context.Context, docID uuid.UUID) ([]byte, CandidateDocument, error) {
var meta CandidateDocument
if s.db == nil {
return nil, meta, ErrNoDatabase
}
if s.docStore == nil {
return nil, meta, ErrNoStorage
}
var key string
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
e := tx.QueryRow(ctx, `
SELECT id::text, candidate_id::text, document_type, file_name, media_type, size_bytes, sha256,
storage_key, is_current, created_at
FROM candidate_documents WHERE id = $1`, docID).
Scan(&meta.ID, &meta.CandidateID, &meta.DocumentType, &meta.FileName, &meta.MediaType,
&meta.SizeBytes, &meta.SHA256, &key, &meta.IsCurrent, &meta.CreatedAt)
if errors.Is(e, pgx.ErrNoRows) {
return ErrDocNotFound
}
return e
})
if err != nil {
return nil, meta, err
}
b, err := s.docStore.Get(ctx, key)
if err != nil {
return nil, meta, fmt.Errorf("matching: read candidate document: %w", err)
}
// THE INTEGRITY STAMP IS CHECKED ON THE WAY OUT. If the stored bytes no longer hash to what was recorded,
// something replaced them, and serving them anyway would hand a recruiter a file the audit trail does not
// actually describe.
if meta.SHA256 != "" && storage.SHA256Hex(b) != meta.SHA256 {
return nil, meta, fmt.Errorf("matching: stored document %s does not match its recorded hash", meta.ID)
}
return b, meta, nil
}
// SupersedeCandidateDocument marks a document replaced rather than deleting it, so what was sent to a client
// six months ago is still recoverable. There is no delete path, deliberately.
func (s *Service) SupersedeCandidateDocument(ctx context.Context, docID uuid.UUID, ip, ua string) error {
if s.db == nil {
return ErrNoDatabase
}
actor := actorUUIDPtr(ctx)
return s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
tag, e := tx.Exec(ctx, `UPDATE candidate_documents SET is_current = false WHERE id = $1 AND is_current`, docID)
if e != nil {
return e
}
if tag.RowsAffected() == 0 {
return ErrDocNotFound
}
if s.aud == nil {
return nil
}
body, _ := json.Marshal(map[string]any{"document_id": docID.String()})
return s.aud.OnTx(ctx, tx, audit.Event{
Action: "candidate.document.superseded", ActorUserID: actor,
EntityType: "candidate_document", EntityID: docID.String(), OldValues: body,
IPAddress: ip, UserAgent: ua,
})
})
}
// normalizeCandidateMediaType trusts the EXTENSION over the declared type. A browser's guess is frequently
// wrong and is always caller supplied, while the extension is at least the name the person chose. Neither is
// authoritative, which is why the result is checked against a closed allowlist rather than used directly.
func normalizeCandidateMediaType(declared, fileName string) string {
byExt := map[string]string{
".pdf": "application/pdf", ".doc": "application/msword",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".rtf": "application/rtf", ".txt": "text/plain",
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
}
if mt, ok := byExt[strings.ToLower(filepath.Ext(fileName))]; ok {
return mt
}
d := strings.ToLower(strings.TrimSpace(declared))
if i := strings.IndexByte(d, ';'); i >= 0 {
d = strings.TrimSpace(d[:i])
}
return d
}
// safeFileName strips any path a caller supplied and bounds the length. The name is only ever a label shown
// to a person and set in a Content-Disposition header; it never touches the storage key, which is derived
// from the content hash.
func safeFileName(n string) string {
n = filepath.Base(strings.TrimSpace(n))
n = strings.Map(func(r rune) rune {
if r < 32 || r == '"' || r == '\\' || r == '/' {
return -1
}
return r
}, n)
if n == "" || n == "." || n == ".." {
return "document"
}
if len(n) > 180 {
n = n[:180]
}
return n
}