Files
llink/go/internal/network/repository.go
T
Arjun Patel d262f734f0 Mobile notifications for iOS (#210)
* mobile: wire notification registration and listener

* implement backend components for push notifications

* refactor: agentic comment cleanup

* docs: use proper module name for particle processor

* set required env variables for push notifications

* bump version

* fix: always upsert push token on mobile start

* Revert "fix: always upsert push token on mobile start"

This reverts commit 90ff18a788.

* send push notifications regardless of online status
2026-05-18 12:44:31 -07:00

348 lines
9.1 KiB
Go

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
}