package store import ( "context" "fmt" "strings" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" ) // List is the projection of a list row. type List struct { ID uuid.UUID `json:"id"` Name string `json:"name"` OwnerID uuid.UUID `json:"owner_id"` InviteCode string `json:"invite_code,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` DeletedAt *time.Time `json:"deleted_at,omitempty"` HLCTS int64 `json:"hlc_ts"` } // ListStore provides read/write access to the lists projection. type ListStore struct { pool *pgxpool.Pool } // NewListStore creates a ListStore backed by the given pool. func NewListStore(pool *pgxpool.Pool) *ListStore { return &ListStore{pool: pool} } // CreateList inserts a new list owned by ownerID and automatically adds an entry into list_members. func (s *ListStore) CreateList(ctx context.Context, ownerID uuid.UUID, name string) (*List, error) { tx, err := s.pool.Begin(ctx) if err != nil { return nil, fmt.Errorf("liststore: begin tx: %w", err) } defer tx.Rollback(ctx) inviteCode := strings.ToUpper(uuid.New().String()[:8]) var l List err = tx.QueryRow(ctx, ` INSERT INTO lists (name, owner_id, invite_code) VALUES ($1, $2, $3) RETURNING id, name, owner_id, COALESCE(invite_code, ''), created_at, updated_at, hlc_ts`, name, ownerID, inviteCode, ).Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS) if err != nil { return nil, fmt.Errorf("liststore: create list: %w", err) } _, err = tx.Exec(ctx, ` INSERT INTO list_members (list_id, user_id, role) VALUES ($1, $2, 'owner') ON CONFLICT DO NOTHING`, l.ID, ownerID, ) if err != nil { return nil, fmt.Errorf("liststore: insert owner member: %w", err) } if err := tx.Commit(ctx); err != nil { return nil, fmt.Errorf("liststore: commit tx: %w", err) } return &l, nil } // GetLists returns all non-deleted lists accessible by userID (as owner or member), newest first. func (s *ListStore) GetLists(ctx context.Context, userID uuid.UUID) ([]List, error) { rows, err := s.pool.Query(ctx, ` SELECT l.id, l.name, l.owner_id, COALESCE(l.invite_code, ''), l.created_at, l.updated_at, l.hlc_ts FROM lists l WHERE l.deleted_at IS NULL AND ( l.owner_id = $1 OR EXISTS ( SELECT 1 FROM list_members lm WHERE lm.list_id = l.id AND lm.user_id = $1 ) ) ORDER BY l.created_at DESC`, userID, ) if err != nil { return nil, fmt.Errorf("liststore: get lists: %w", err) } defer rows.Close() var lists []List for rows.Next() { var l List if err := rows.Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS); err != nil { return nil, fmt.Errorf("liststore: scan list row: %w", err) } lists = append(lists, l) } return lists, rows.Err() } // GetList returns a single non-deleted list if userID is owner or member. func (s *ListStore) GetList(ctx context.Context, listID, userID uuid.UUID) (*List, error) { var l List err := s.pool.QueryRow(ctx, ` SELECT l.id, l.name, l.owner_id, COALESCE(l.invite_code, ''), l.created_at, l.updated_at, l.hlc_ts FROM lists l WHERE l.id = $1 AND l.deleted_at IS NULL AND ( l.owner_id = $2 OR EXISTS ( SELECT 1 FROM list_members lm WHERE lm.list_id = l.id AND lm.user_id = $2 ) )`, listID, userID, ).Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS) if err != nil { return nil, fmt.Errorf("liststore: get list: %w", err) } return &l, nil } // GetInviteCode returns the invite code for a list if userID has access. func (s *ListStore) GetInviteCode(ctx context.Context, listID, userID uuid.UUID) (string, error) { l, err := s.GetList(ctx, listID, userID) if err != nil { return "", err } if l.InviteCode != "" { return l.InviteCode, nil } // Generate if missing code := strings.ToUpper(uuid.New().String()[:8]) _, err = s.pool.Exec(ctx, `UPDATE lists SET invite_code = $1 WHERE id = $2`, code, listID) if err != nil { return "", fmt.Errorf("liststore: generate invite code: %w", err) } return code, nil } // JoinByInviteCode adds userID as a member to the list identified by inviteCode. func (s *ListStore) JoinByInviteCode(ctx context.Context, userID uuid.UUID, inviteCode string) (*List, error) { cleanCode := strings.ToUpper(strings.TrimSpace(inviteCode)) var l List err := s.pool.QueryRow(ctx, ` SELECT id, name, owner_id, COALESCE(invite_code, ''), created_at, updated_at, hlc_ts FROM lists WHERE invite_code = $1 AND deleted_at IS NULL`, cleanCode, ).Scan(&l.ID, &l.Name, &l.OwnerID, &l.InviteCode, &l.CreatedAt, &l.UpdatedAt, &l.HLCTS) if err != nil { return nil, fmt.Errorf("liststore: list not found for invite code: %w", err) } _, err = s.pool.Exec(ctx, ` INSERT INTO list_members (list_id, user_id, role) VALUES ($1, $2, 'member') ON CONFLICT DO NOTHING`, l.ID, userID, ) if err != nil { return nil, fmt.Errorf("liststore: add member: %w", err) } return &l, nil }