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
414 lines
12 KiB
Go
414 lines
12 KiB
Go
package particle
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"go.jetify.com/typeid"
|
|
)
|
|
|
|
var errNotFound = errors.New("not found")
|
|
var errAccessDenied = errors.New("access denied")
|
|
|
|
type particleIDPrefix struct{}
|
|
|
|
func (particleIDPrefix) Prefix() string { return "particle" }
|
|
|
|
type particleID struct {
|
|
typeid.TypeID[particleIDPrefix]
|
|
}
|
|
|
|
func newParticleID() (particleID, error) {
|
|
return typeid.New[particleID]()
|
|
}
|
|
|
|
type repository interface {
|
|
create(ctx context.Context, p *Particle) (*Particle, error)
|
|
getByID(ctx context.Context, id string) (*Particle, error)
|
|
update(ctx context.Context, id string, data json.RawMessage, updatedAt time.Time) error
|
|
delete(ctx context.Context, id string) error
|
|
|
|
list(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, limit int, cursor *Cursor) ([]*Particle, error)
|
|
|
|
setVisibility(ctx context.Context, id string, mode VisibilityMode) error
|
|
addMembers(ctx context.Context, particleID string, emails []string) error
|
|
removeMembers(ctx context.Context, particleID string, emails []string) error
|
|
getMembers(ctx context.Context, particleID string) ([]string, error)
|
|
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
|
|
|
// getAncestorChain returns the particle followed by its ancestors, in order.
|
|
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
|
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
|
|
|
// Seen tracking
|
|
markSeen(ctx context.Context, particleID, email string) error
|
|
getSeenMap(ctx context.Context, particleIDs []string, email string) (map[string]bool, error)
|
|
getUnseenCounts(ctx context.Context, streamIDs []string, email string) (map[string]int, error)
|
|
|
|
// Ack tracking
|
|
ack(ctx context.Context, particleID, email string) error
|
|
getAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
|
}
|
|
|
|
type repositoryImpl struct {
|
|
pool *pgxpool.Pool
|
|
}
|
|
|
|
func newRepository(pool *pgxpool.Pool) repository {
|
|
return &repositoryImpl{pool: pool}
|
|
}
|
|
|
|
func (r *repositoryImpl) create(ctx context.Context, p *Particle) (*Particle, error) {
|
|
id, err := newParticleID()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var result Particle
|
|
err = r.pool.QueryRow(ctx,
|
|
`INSERT INTO particles (id, type, network_id, parent_id, created_by_email, visibility, data)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
RETURNING id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at`,
|
|
id.String(), p.Type, p.NetworkID, p.ParentID, p.CreatedByEmail, p.Visibility, p.Data,
|
|
).Scan(&result.ID, &result.Type, &result.NetworkID, &result.ParentID, &result.CreatedByEmail,
|
|
&result.Visibility, &result.Data, &result.UpdatedAt, &result.CreatedAt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &result, nil
|
|
}
|
|
|
|
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Particle, error) {
|
|
var p Particle
|
|
err := r.pool.QueryRow(ctx,
|
|
`SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
|
FROM particles WHERE id = $1`,
|
|
id,
|
|
).Scan(&p.ID, &p.Type, &p.NetworkID, &p.ParentID, &p.CreatedByEmail,
|
|
&p.Visibility, &p.Data, &p.UpdatedAt, &p.CreatedAt)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, errNotFound
|
|
}
|
|
return nil, err
|
|
}
|
|
return &p, nil
|
|
}
|
|
|
|
func (r *repositoryImpl) update(ctx context.Context, id string, data json.RawMessage, updatedAt time.Time) error {
|
|
result, err := r.pool.Exec(ctx,
|
|
`UPDATE particles SET data = $1, updated_at = $2 WHERE id = $3`,
|
|
data, updatedAt, 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 particles WHERE id = $1`, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return errNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *repositoryImpl) list(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, limit int, cursor *Cursor) ([]*Particle, error) {
|
|
query := `SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
|
FROM particles p WHERE p.network_id = $1`
|
|
|
|
args := []any{networkID}
|
|
argIdx := 2
|
|
|
|
if parentID != nil {
|
|
query += ` AND p.parent_id = $` + string(rune('0'+argIdx))
|
|
args = append(args, *parentID)
|
|
argIdx++
|
|
} else {
|
|
query += ` AND p.parent_id IS NULL`
|
|
}
|
|
|
|
// Filter by visibility: include if network_all, inherited, OR user is a member
|
|
query += ` AND (p.visibility = 'network_all' OR p.visibility = 'inherited' OR EXISTS (SELECT 1 FROM particle_members pm WHERE pm.particle_id = p.id AND pm.email = $` + string(rune('0'+argIdx)) + `))`
|
|
args = append(args, requesterEmail)
|
|
argIdx++
|
|
|
|
if len(filter.Types) > 0 {
|
|
query += ` AND p.type = ANY($` + string(rune('0'+argIdx)) + `)`
|
|
typeStrings := make([]string, len(filter.Types))
|
|
for i, t := range filter.Types {
|
|
typeStrings[i] = string(t)
|
|
}
|
|
args = append(args, typeStrings)
|
|
argIdx++
|
|
}
|
|
|
|
if cursor != nil {
|
|
if cursor.Direction == "before" {
|
|
query += ` AND p.updated_at > $` + string(rune('0'+argIdx))
|
|
} else {
|
|
query += ` AND p.updated_at < $` + string(rune('0'+argIdx))
|
|
}
|
|
args = append(args, cursor.Position)
|
|
argIdx++
|
|
}
|
|
|
|
query += ` ORDER BY p.updated_at DESC LIMIT $` + string(rune('0'+argIdx))
|
|
args = append(args, limit)
|
|
|
|
rows, err := r.pool.Query(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
return scanParticles(rows)
|
|
}
|
|
|
|
func scanParticles(rows pgx.Rows) ([]*Particle, error) {
|
|
var particles []*Particle
|
|
for rows.Next() {
|
|
var p Particle
|
|
if err := rows.Scan(&p.ID, &p.Type, &p.NetworkID, &p.ParentID, &p.CreatedByEmail,
|
|
&p.Visibility, &p.Data, &p.UpdatedAt, &p.CreatedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
particles = append(particles, &p)
|
|
}
|
|
return particles, rows.Err()
|
|
}
|
|
|
|
func (r *repositoryImpl) setVisibility(ctx context.Context, id string, mode VisibilityMode) error {
|
|
result, err := r.pool.Exec(ctx,
|
|
`UPDATE particles SET visibility = $1, updated_at = NOW() WHERE id = $2`,
|
|
mode, id,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result.RowsAffected() == 0 {
|
|
return errNotFound
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *repositoryImpl) addMembers(ctx context.Context, particleID string, emails []string) error {
|
|
for _, email := range emails {
|
|
_, err := r.pool.Exec(ctx,
|
|
`INSERT INTO particle_members (particle_id, email) VALUES ($1, $2)
|
|
ON CONFLICT (particle_id, email) DO NOTHING`,
|
|
particleID, email,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *repositoryImpl) removeMembers(ctx context.Context, particleID string, emails []string) error {
|
|
for _, email := range emails {
|
|
_, err := r.pool.Exec(ctx,
|
|
`DELETE FROM particle_members WHERE particle_id = $1 AND email = $2`,
|
|
particleID, email,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *repositoryImpl) getMembers(ctx context.Context, particleID string) ([]string, error) {
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT email FROM particle_members WHERE particle_id = $1`,
|
|
particleID,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var emails []string
|
|
for rows.Next() {
|
|
var email string
|
|
if err := rows.Scan(&email); err != nil {
|
|
return nil, err
|
|
}
|
|
emails = append(emails, email)
|
|
}
|
|
return emails, rows.Err()
|
|
}
|
|
|
|
func (r *repositoryImpl) getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) {
|
|
if len(particleIDs) == 0 {
|
|
return map[string][]string{}, nil
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT particle_id, email FROM particle_members WHERE particle_id = ANY($1)`,
|
|
particleIDs,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string][]string)
|
|
for rows.Next() {
|
|
var particleID, email string
|
|
if err := rows.Scan(&particleID, &email); err != nil {
|
|
return nil, err
|
|
}
|
|
result[particleID] = append(result[particleID], email)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (r *repositoryImpl) getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error) {
|
|
rows, err := r.pool.Query(ctx, `
|
|
WITH RECURSIVE ancestors AS (
|
|
SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
|
FROM particles WHERE id = $1
|
|
UNION ALL
|
|
SELECT p.id, p.type, p.network_id, p.parent_id, p.created_by_email, p.visibility, p.data, p.updated_at, p.created_at
|
|
FROM particles p JOIN ancestors a ON p.id = a.parent_id
|
|
)
|
|
SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
|
FROM ancestors;
|
|
`, particleID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
particles, err := scanParticles(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(particles) == 0 {
|
|
return nil, errNotFound
|
|
}
|
|
return particles, nil
|
|
}
|
|
|
|
func (r *repositoryImpl) isMemberOf(ctx context.Context, particleID, email string) (bool, error) {
|
|
var isMember bool
|
|
err := r.pool.QueryRow(ctx, `
|
|
SELECT EXISTS(SELECT 1 FROM particle_members WHERE particle_id = $1 AND email = $2)
|
|
`, particleID, email).Scan(&isMember)
|
|
return isMember, err
|
|
}
|
|
|
|
func (r *repositoryImpl) markSeen(ctx context.Context, particleID, email string) error {
|
|
_, err := r.pool.Exec(ctx,
|
|
`INSERT INTO particle_seen (particle_id, email) VALUES ($1, $2)
|
|
ON CONFLICT (particle_id, email) DO NOTHING`,
|
|
particleID, email,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (r *repositoryImpl) getSeenMap(ctx context.Context, particleIDs []string, email string) (map[string]bool, error) {
|
|
if len(particleIDs) == 0 {
|
|
return map[string]bool{}, nil
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT particle_id FROM particle_seen WHERE particle_id = ANY($1) AND email = $2`,
|
|
particleIDs, email,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string]bool)
|
|
for rows.Next() {
|
|
var id string
|
|
if err := rows.Scan(&id); err != nil {
|
|
return nil, err
|
|
}
|
|
result[id] = true
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (r *repositoryImpl) getUnseenCounts(ctx context.Context, streamIDs []string, email string) (map[string]int, error) {
|
|
if len(streamIDs) == 0 {
|
|
return map[string]int{}, nil
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx, `
|
|
SELECT p.parent_id, COUNT(*)
|
|
FROM particles p
|
|
WHERE p.parent_id = ANY($1)
|
|
AND NOT EXISTS (SELECT 1 FROM particle_seen ps WHERE ps.particle_id = p.id AND ps.email = $2)
|
|
AND (p.visibility = 'network_all' OR p.visibility = 'inherited'
|
|
OR EXISTS (SELECT 1 FROM particle_members pm WHERE pm.particle_id = p.id AND pm.email = $2))
|
|
GROUP BY p.parent_id
|
|
`, streamIDs, email)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string]int)
|
|
for rows.Next() {
|
|
var parentID string
|
|
var count int
|
|
if err := rows.Scan(&parentID, &count); err != nil {
|
|
return nil, err
|
|
}
|
|
result[parentID] = count
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func (r *repositoryImpl) ack(ctx context.Context, particleID, email string) error {
|
|
_, err := r.pool.Exec(ctx,
|
|
`INSERT INTO particle_acks (particle_id, email) VALUES ($1, $2)
|
|
ON CONFLICT (particle_id, email) DO NOTHING`,
|
|
particleID, email,
|
|
)
|
|
return err
|
|
}
|
|
|
|
func (r *repositoryImpl) getAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) {
|
|
if len(particleIDs) == 0 {
|
|
return map[string][]AckInfo{}, nil
|
|
}
|
|
|
|
rows, err := r.pool.Query(ctx,
|
|
`SELECT particle_id, email, acked_at FROM particle_acks WHERE particle_id = ANY($1) ORDER BY acked_at`,
|
|
particleIDs,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
result := make(map[string][]AckInfo)
|
|
for rows.Next() {
|
|
var particleID string
|
|
var info AckInfo
|
|
if err := rows.Scan(&particleID, &info.Email, &info.AckedAt); err != nil {
|
|
return nil, err
|
|
}
|
|
result[particleID] = append(result[particleID], info)
|
|
}
|
|
return result, rows.Err()
|
|
}
|