package sharing import ( "context" "errors" "time" "github.com/google/uuid" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" ) type PostgresStore struct { pool *pgxpool.Pool } func NewPostgresStore(ctx context.Context, dataSource string) (*PostgresStore, error) { pool, err := pgxpool.New(ctx, dataSource) if err != nil { return nil, err } if err := pool.Ping(ctx); err != nil { pool.Close() return nil, err } return &PostgresStore{pool: pool}, nil } func (s *PostgresStore) GetPolicyByProject(ctx context.Context, projectID string) (PolicyRecord, error) { return scanPolicy(s.pool.QueryRow(ctx, ` SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at FROM project_share_policies WHERE project_id = $1 `, projectID)) } func (s *PostgresStore) GetPolicyByTokenHash(ctx context.Context, tokenHash []byte) (PolicyRecord, error) { return scanPolicy(s.pool.QueryRow(ctx, ` SELECT project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at FROM project_share_policies WHERE token_hash = $1 `, tokenHash)) } func (s *PostgresStore) CreatePolicy(ctx context.Context, policy PolicyRecord) (PolicyRecord, error) { created, err := scanPolicy(s.pool.QueryRow(ctx, ` INSERT INTO project_share_policies ( project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) ON CONFLICT (project_id) DO NOTHING RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at `, policy.ProjectID, toPGUUID(policy.OwnerID), policy.TokenHash, policy.TokenCiphertext, string(policy.LinkPermission), policy.Version, policy.CreatedAt, policy.UpdatedAt)) if err == nil { return created, nil } if errors.Is(err, ErrNotFound) { return s.GetPolicyByProject(ctx, policy.ProjectID) } return PolicyRecord{}, err } func (s *PostgresStore) UpdatePolicyPermission(ctx context.Context, projectID string, ownerID string, permission Permission, updatedAt time.Time) (PolicyRecord, error) { return scanPolicy(s.pool.QueryRow(ctx, ` UPDATE project_share_policies SET link_permission = $3, policy_version = policy_version + 1, updated_at = $4 WHERE project_id = $1 AND owner_id = $2 RETURNING project_id, owner_id, token_hash, token_ciphertext, link_permission, policy_version, created_at, updated_at `, projectID, toPGUUID(ownerID), string(permission), updatedAt)) } func (s *PostgresStore) DeleteAll(ctx context.Context, projectID string, ownerID string) error { result, err := s.pool.Exec(ctx, `DELETE FROM project_share_policies WHERE project_id = $1 AND owner_id = $2`, projectID, toPGUUID(ownerID)) if err != nil { return err } if result.RowsAffected() == 0 { if _, getErr := s.GetPolicyByProject(ctx, projectID); getErr == nil { return ErrForbidden } else if !errors.Is(getErr, ErrNotFound) { return getErr } } return nil } func (s *PostgresStore) ListMembers(ctx context.Context, projectID string) ([]MemberRecord, error) { rows, err := s.pool.Query(ctx, ` SELECT id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at FROM project_share_members WHERE project_id = $1 ORDER BY created_at ASC `, projectID) if err != nil { return nil, err } defer rows.Close() items := make([]MemberRecord, 0) for rows.Next() { member, scanErr := scanMember(rows) if scanErr != nil { return nil, scanErr } items = append(items, member) } return items, rows.Err() } func (s *PostgresStore) CreateMember(ctx context.Context, member MemberRecord) (MemberRecord, error) { created, err := scanMember(s.pool.QueryRow(ctx, ` INSERT INTO project_share_members ( id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at `, toPGUUID(member.ID), member.ProjectID, member.IdentifierType, member.IdentifierHash, member.IdentifierCiphertext, string(member.Permission), member.CreatedAt, member.UpdatedAt)) if err != nil { var postgresErr *pgconn.PgError if errors.As(err, &postgresErr) && postgresErr.Code == "23505" { return MemberRecord{}, ErrMemberExists } return MemberRecord{}, err } return created, nil } func (s *PostgresStore) UpdateMember(ctx context.Context, projectID string, memberID string, permission Permission, updatedAt time.Time) (MemberRecord, error) { return scanMember(s.pool.QueryRow(ctx, ` UPDATE project_share_members SET permission = $3, updated_at = $4 WHERE project_id = $1 AND id = $2 RETURNING id, project_id, identifier_type, identifier_hash, identifier_ciphertext, permission, created_at, updated_at `, projectID, toPGUUID(memberID), string(permission), updatedAt)) } func (s *PostgresStore) DeleteMember(ctx context.Context, projectID string, memberID string) error { result, err := s.pool.Exec(ctx, `DELETE FROM project_share_members WHERE project_id = $1 AND id = $2`, projectID, toPGUUID(memberID)) if err != nil { return err } if result.RowsAffected() == 0 { return ErrNotFound } return nil } func (s *PostgresStore) CountVisitors(ctx context.Context, projectID string) (int64, error) { var count int64 err := s.pool.QueryRow(ctx, `SELECT COUNT(*) FROM project_share_visits WHERE project_id = $1`, projectID).Scan(&count) return count, err } func (s *PostgresStore) RecordVisit(ctx context.Context, visit VisitRecord) error { var userID any if visit.UserID != "" { userID = toPGUUID(visit.UserID) } _, err := s.pool.Exec(ctx, ` INSERT INTO project_share_visits ( id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at ) VALUES ($1, $2, $3, $4, 1, $5, $6) ON CONFLICT (project_id, visitor_hash) DO UPDATE SET user_id = COALESCE(EXCLUDED.user_id, project_share_visits.user_id), visit_count = project_share_visits.visit_count + 1, last_seen_at = EXCLUDED.last_seen_at `, visit.ID, visit.ProjectID, visit.VisitorHash, userID, visit.FirstSeenAt, visit.LastSeenAt) return err } func (s *PostgresStore) ListVisitors(ctx context.Context, projectID string, limit int64) ([]VisitRecord, error) { rows, err := s.pool.Query(ctx, ` SELECT id, project_id, visitor_hash, user_id, visit_count, first_seen_at, last_seen_at FROM project_share_visits WHERE project_id = $1 ORDER BY last_seen_at DESC LIMIT $2 `, projectID, limit) if err != nil { return nil, err } defer rows.Close() items := make([]VisitRecord, 0) for rows.Next() { var visit VisitRecord var userID pgtype.UUID if err := rows.Scan(&visit.ID, &visit.ProjectID, &visit.VisitorHash, &userID, &visit.VisitCount, &visit.FirstSeenAt, &visit.LastSeenAt); err != nil { return nil, err } if userID.Valid { visit.UserID = uuid.UUID(userID.Bytes).String() } items = append(items, visit) } return items, rows.Err() } func (s *PostgresStore) Close() error { if s != nil && s.pool != nil { s.pool.Close() } return nil } type rowScanner interface { Scan(dest ...any) error } func scanPolicy(row rowScanner) (PolicyRecord, error) { var policy PolicyRecord var ownerID pgtype.UUID if err := row.Scan(&policy.ProjectID, &ownerID, &policy.TokenHash, &policy.TokenCiphertext, &policy.LinkPermission, &policy.Version, &policy.CreatedAt, &policy.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return PolicyRecord{}, ErrNotFound } return PolicyRecord{}, err } if ownerID.Valid { policy.OwnerID = uuid.UUID(ownerID.Bytes).String() } return policy, nil } func scanMember(row rowScanner) (MemberRecord, error) { var member MemberRecord var id pgtype.UUID if err := row.Scan(&id, &member.ProjectID, &member.IdentifierType, &member.IdentifierHash, &member.IdentifierCiphertext, &member.Permission, &member.CreatedAt, &member.UpdatedAt); err != nil { if errors.Is(err, pgx.ErrNoRows) { return MemberRecord{}, ErrNotFound } return MemberRecord{}, err } if id.Valid { member.ID = uuid.UUID(id.Bytes).String() } return member, nil } func toPGUUID(value string) pgtype.UUID { parsed, err := uuid.Parse(value) if err != nil { return pgtype.UUID{} } return pgtype.UUID{Bytes: parsed, Valid: true} }