Stage 7 · Master
Phase 7 — Resident Service
Resident Management
Profiles as PII, not just another CRUD row
What a Resident Profile Owns
A resident is a person, scoped to one tenant, who may or may not currently occupy or own a flat. resident-service knows their name, contact details, and a government ID reference for KYC — it does not know which flat they live in. That link is deliberately a separate concept, built in the Ownership and Relationships lessons later in this module, because a resident can exist for weeks between approval and move-in with no flat link at all.
flat-service hard-deletes a flat row because a flat that stops existing really is gone. A resident row backs a legal and financial history — past maintenance invoices, past occupancy — that must still resolve to a name years later. resident-service never issues a DELETE against the residents table; it sets deactivated_at and every read excludes deactivated rows by default.
Schema: Soft Delete and PII Handling
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
CREATE TABLE residents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL,
full_name TEXT NOT NULL,
email TEXT NOT NULL,
phone TEXT NOT NULL,
-- Never store a government ID in the clear. Keep the last four
-- characters for support-desk lookups and a salted hash of the full
-- value for uniqueness checks; the plaintext never reaches this table.
government_id_last4 TEXT NOT NULL,
government_id_hash TEXT NOT NULL,
deactivated_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Email is unique per tenant only among active residents — a
-- deactivated resident's email must be reusable by a genuinely new
-- profile without a database-level collision.
CREATE UNIQUE INDEX residents_tenant_email_active_key
ON residents (tenant_id, email)
WHERE deactivated_at IS NULL;
CREATE UNIQUE INDEX residents_tenant_govid_hash_active_key
ON residents (tenant_id, government_id_hash)
WHERE deactivated_at IS NULL;
CREATE INDEX residents_tenant_id_idx ON residents (tenant_id);Both uniqueness rules use a partial index scoped to deactivated_at IS NULL. A plain unique index would permanently block re-registering the same email after a resident moves away and is deactivated.
DROP TABLE IF EXISTS residents;Domain Model and Hashing at the Boundary
package domain
import (
"crypto/sha256"
"encoding/hex"
"errors"
"time"
"github.com/google/uuid"
)
type Resident struct {
ID uuid.UUID
TenantID uuid.UUID
FullName string
Email string
Phone string
GovernmentIDLast4 string
GovernmentIDHash string
DeactivatedAt *time.Time
CreatedAt time.Time
UpdatedAt time.Time
}
func (r *Resident) IsActive() bool {
return r.DeactivatedAt == nil
}
// HashGovernmentID converts a raw government ID into the two columns
// residents actually stores. tenantSalt is per-tenant so the same
// government ID hashes differently across HOAs, preventing cross-tenant
// correlation of the same person even if both hashes ever leaked.
func HashGovernmentID(rawID, tenantSalt string) (last4, hash string) {
if len(rawID) >= 4 {
last4 = rawID[len(rawID)-4:]
}
sum := sha256.Sum256([]byte(tenantSalt + rawID))
return last4, hex.EncodeToString(sum[:])
}
var (
ErrResidentNotFound = errors.New("resident: not found")
ErrDuplicateEmail = errors.New("resident: email already registered for this tenant")
ErrDuplicateGovernmentID = errors.New("resident: government id already registered for this tenant")
ErrResidentDeactivated = errors.New("resident: is deactivated")
)The raw government ID never becomes a struct field — HashGovernmentID is called once at the handler boundary and only its outputs are ever passed further into the system.
Repository: Active-Only Reads by Default
package postgres
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/hoa-platform/backend/services/resident-service/internal/domain"
)
type ResidentRepository struct {
pool *pgxpool.Pool
}
func NewResidentRepository(pool *pgxpool.Pool) *ResidentRepository {
return &ResidentRepository{pool: pool}
}
func (r *ResidentRepository) Create(ctx context.Context, res domain.Resident) (domain.Resident, error) {
const q = `
INSERT INTO residents (tenant_id, full_name, email, phone, government_id_last4, government_id_hash)
VALUES ($1, $2, $3, $4, $5, $6)
RETURNING id, created_at, updated_at`
row := r.pool.QueryRow(ctx, q, res.TenantID, res.FullName, res.Email, res.Phone, res.GovernmentIDLast4, res.GovernmentIDHash)
if err := row.Scan(&res.ID, &res.CreatedAt, &res.UpdatedAt); err != nil {
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
if pgErr.ConstraintName == "residents_tenant_govid_hash_active_key" {
return domain.Resident{}, domain.ErrDuplicateGovernmentID
}
return domain.Resident{}, domain.ErrDuplicateEmail
}
return domain.Resident{}, err
}
return res, nil
}
func (r *ResidentRepository) Get(ctx context.Context, tenantID, id uuid.UUID) (domain.Resident, error) {
const q = `
SELECT id, tenant_id, full_name, email, phone, government_id_last4, government_id_hash, deactivated_at, created_at, updated_at
FROM residents
WHERE tenant_id = $1 AND id = $2 AND deactivated_at IS NULL`
var res domain.Resident
row := r.pool.QueryRow(ctx, q, tenantID, id)
err := row.Scan(&res.ID, &res.TenantID, &res.FullName, &res.Email, &res.Phone, &res.GovernmentIDLast4, &res.GovernmentIDHash, &res.DeactivatedAt, &res.CreatedAt, &res.UpdatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return domain.Resident{}, domain.ErrResidentNotFound
}
return res, err
}
func (r *ResidentRepository) Deactivate(ctx context.Context, tenantID, id uuid.UUID) error {
const q = `
UPDATE residents SET deactivated_at = now(), updated_at = now()
WHERE tenant_id = $1 AND id = $2 AND deactivated_at IS NULL`
tag, err := r.pool.Exec(ctx, q, tenantID, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return domain.ErrResidentNotFound
}
return nil
}pgErr.ConstraintName distinguishes which of the two partial unique indexes fired, so a duplicate government ID and a duplicate email return different, actionable domain errors from the same 23505 code.
Creating a Tenant-Scoped Resident
Applied exercise
Reject reactivation as a create, and add real reactivation
Support asks for a way to bring back a resident who was accidentally deactivated, without generating a second row with a new ID and losing the link to their original history.
- Add a POST /residents/{id}/reactivate endpoint that clears deactivated_at on the existing row instead of creating a new one.
- Guard it: reactivation must fail with 409 if an active resident already holds that tenant+email combination (someone re-registered while this one was deactivated).
- Write an integration test proving reactivation preserves the original ID and CreatedAt.
Deliverable
A new handler, service method, and repository method for reactivation, plus the guarding integration test.
Completion checks
- Reactivating a resident does not change their ID.
- Reactivating into a colliding active email returns 409, not 500.
- The reactivated row's CreatedAt is unchanged from its original value.
Why is the uniqueness constraint on (tenant_id, email) a partial index filtered to WHERE deactivated_at IS NULL, rather than a plain unique index?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.