package network import ( "context" "errors" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" "go.jetify.com/typeid" ) // dbtx is the subset of pgx's query API shared by *pgxpool.Pool and pgx.Tx, // so repository helpers can run standalone or inside a transaction. type dbtx interface { Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error) QueryRow(ctx context.Context, sql string, args ...any) pgx.Row } var errNotFound = errors.New("not found") type networkIDPrefix struct{} func (networkIDPrefix) Prefix() string { return "net" } type networkID struct { typeid.TypeID[networkIDPrefix] } func newNetworkID() (networkID, error) { return typeid.New[networkID]() } type repository interface { create(ctx context.Context, name, adminHumanId string) (*Network, error) getByID(ctx context.Context, id string) (*Network, error) updateName(ctx context.Context, id, name string) error delete(ctx context.Context, id string) error addMember(ctx context.Context, db dbtx, networkID, humanId string) error removeMember(ctx context.Context, db dbtx, networkID, humanId string) error getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) countSeats(ctx context.Context, db dbtx, networkID string) (int, error) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) isMember(ctx context.Context, networkID, humanId string) (bool, error) listAll(ctx context.Context) ([]*Network, error) // listAllMemberships returns humanId -> networkIds for every human with at // least one membership. Humans with zero memberships are absent from the map. listAllMemberships(ctx context.Context) (map[string][]string, error) // Invitations createInvitation(ctx context.Context, networkID, email string) error getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error } // Centralized so SELECTs and scanNetwork stay in sync. const networkColumns = `id, name, admin_human_id, created_at` func scanNetwork(row pgx.Row, n *Network) error { return row.Scan(&n.ID, &n.Name, &n.AdminHumanId, &n.CreatedAt) } type repositoryImpl struct { pool *pgxpool.Pool } func newRepository(pool *pgxpool.Pool) repository { return &repositoryImpl{pool: pool} } func (r *repositoryImpl) create(ctx context.Context, name, adminHumanId string) (*Network, error) { id, err := newNetworkID() if err != nil { return nil, err } var n Network row := r.pool.QueryRow(ctx, `INSERT INTO networks (id, name, admin_human_id) VALUES ($1, $2, $3) RETURNING `+networkColumns, id.String(), name, adminHumanId, ) if err := scanNetwork(row, &n); err != nil { return nil, err } n.MemberHumanIds = []string{} return &n, nil } func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) { var n Network row := r.pool.QueryRow(ctx, `SELECT `+networkColumns+` FROM networks WHERE id = $1`, id, ) if err := scanNetwork(row, &n); err != nil { if errors.Is(err, pgx.ErrNoRows) { return nil, errNotFound } return nil, err } memberIds, err := r.getMemberHumanIds(ctx, id) if err != nil { return nil, err } n.MemberHumanIds = memberIds return &n, nil } func (r *repositoryImpl) updateName(ctx context.Context, id, name string) error { result, err := r.pool.Exec(ctx, `UPDATE networks SET name = $1 WHERE id = $2`, name, id, ) if err != nil { return err } if result.RowsAffected() == 0 { return errNotFound } return nil } func (r *repositoryImpl) delete(ctx context.Context, id string) error { result, err := r.pool.Exec(ctx, `DELETE FROM networks WHERE id = $1`, id) if err != nil { return err } if result.RowsAffected() == 0 { return errNotFound } return nil } func (r *repositoryImpl) addMember(ctx context.Context, db dbtx, networkID, humanId string) error { _, err := db.Exec(ctx, `INSERT INTO network_members (network_id, human_id) VALUES ($1, $2) ON CONFLICT (network_id, human_id) DO NOTHING`, networkID, humanId, ) return err } func (r *repositoryImpl) removeMember(ctx context.Context, db dbtx, networkID, humanId string) error { _, err := db.Exec(ctx, `DELETE FROM network_members WHERE network_id = $1 AND human_id = $2`, networkID, humanId, ) return err } func (r *repositoryImpl) countSeats(ctx context.Context, db dbtx, networkID string) (int, error) { var count int err := db.QueryRow(ctx, `SELECT COUNT(*) FROM network_members WHERE network_id = $1`, networkID, ).Scan(&count) return count, err } func (r *repositoryImpl) getMemberHumanIds(ctx context.Context, networkID string) ([]string, error) { rows, err := r.pool.Query(ctx, `SELECT human_id FROM network_members WHERE network_id = $1`, networkID, ) if err != nil { return nil, err } defer rows.Close() var humanIds []string for rows.Next() { var humanId string if err := rows.Scan(&humanId); err != nil { return nil, err } humanIds = append(humanIds, humanId) } return humanIds, rows.Err() } func (r *repositoryImpl) getNetworksForHuman(ctx context.Context, humanId string) ([]*Network, error) { rows, err := r.pool.Query(ctx, `SELECT `+networkColumns+` FROM networks WHERE admin_human_id = $1 OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = id AND nm.human_id = $1)`, humanId, ) if err != nil { return nil, err } defer rows.Close() var networks []*Network for rows.Next() { var n Network if err := scanNetwork(rows, &n); err != nil { return nil, err } networks = append(networks, &n) } if err := rows.Err(); err != nil { return nil, err } for _, n := range networks { n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID) if err != nil { return nil, err } } return networks, nil } func (r *repositoryImpl) isMember(ctx context.Context, networkID, humanId string) (bool, error) { var isMember bool err := r.pool.QueryRow(ctx, ` SELECT EXISTS( SELECT 1 FROM networks n LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.human_id = $2 WHERE n.id = $1 AND (n.admin_human_id = $2 OR nm.human_id IS NOT NULL) ) `, networkID, humanId).Scan(&isMember) return isMember, err } func (r *repositoryImpl) listAllMemberships(ctx context.Context) (map[string][]string, error) { rows, err := r.pool.Query(ctx, `SELECT human_id, network_id FROM network_members`) if err != nil { return nil, err } defer rows.Close() out := map[string][]string{} for rows.Next() { var humanId, networkId string if err := rows.Scan(&humanId, &networkId); err != nil { return nil, err } out[humanId] = append(out[humanId], networkId) } return out, rows.Err() } func (r *repositoryImpl) listAll(ctx context.Context) ([]*Network, error) { rows, err := r.pool.Query(ctx, `SELECT `+networkColumns+` FROM networks`, ) if err != nil { return nil, err } defer rows.Close() var networks []*Network for rows.Next() { var n Network if err := scanNetwork(rows, &n); err != nil { return nil, err } networks = append(networks, &n) } if err := rows.Err(); err != nil { return nil, err } for _, n := range networks { n.MemberHumanIds, err = r.getMemberHumanIds(ctx, n.ID) if err != nil { return nil, err } } return networks, nil } func (r *repositoryImpl) createInvitation(ctx context.Context, networkID, email string) error { _, err := r.pool.Exec(ctx, `INSERT INTO network_invitations (network_id, email) VALUES ($1, $2) ON CONFLICT (network_id, email) DO NOTHING`, networkID, email, ) return err } func (r *repositoryImpl) getInvitationsByEmail(ctx context.Context, email string) ([]*Invitation, error) { rows, err := r.pool.Query(ctx, `SELECT ni.network_id, n.name, ni.email, ni.created_at FROM network_invitations ni JOIN networks n ON n.id = ni.network_id WHERE ni.email = $1`, email, ) if err != nil { return nil, err } defer rows.Close() var invitations []*Invitation for rows.Next() { var inv Invitation if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil { return nil, err } invitations = append(invitations, &inv) } return invitations, rows.Err() } func (r *repositoryImpl) getInvitationsByNetwork(ctx context.Context, networkID string) ([]*Invitation, error) { rows, err := r.pool.Query(ctx, `SELECT ni.network_id, n.name, ni.email, ni.created_at FROM network_invitations ni JOIN networks n ON n.id = ni.network_id WHERE ni.network_id = $1`, networkID, ) if err != nil { return nil, err } defer rows.Close() var invitations []*Invitation for rows.Next() { var inv Invitation if err := rows.Scan(&inv.NetworkID, &inv.NetworkName, &inv.Email, &inv.CreatedAt); err != nil { return nil, err } invitations = append(invitations, &inv) } return invitations, rows.Err() } func (r *repositoryImpl) deleteInvitation(ctx context.Context, db dbtx, networkID, email string) error { _, err := db.Exec(ctx, `DELETE FROM network_invitations WHERE network_id = $1 AND email = $2`, networkID, email, ) return err }