d262f734f0
* 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
348 lines
9.1 KiB
Go
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
|
|
}
|