mirror of
https://github.com/warmbly/warmbly.git
synced 2026-08-20 08:01:26 +00:00
359 lines
10 KiB
Go
359 lines
10 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
"github.com/warmbly/warmbly/internal/models"
|
|
)
|
|
|
|
// DangerZoneRepository persists scheduled deletions and toggles the
|
|
// matching deletion timestamp on the parent table.
|
|
type DangerZoneRepository interface {
|
|
// CreatePending inserts a new pending deletion and stamps the parent
|
|
// resource's deletion_scheduled_* columns in the same transaction.
|
|
// Returns ErrPendingDeletionExists if one already exists.
|
|
CreatePending(ctx context.Context, d *models.ScheduledDeletion) error
|
|
|
|
// Cancel marks a deletion as cancelled and clears the parent's
|
|
// deletion timestamps. No-op if status != pending.
|
|
Cancel(ctx context.Context, id uuid.UUID, cancelledByUserID uuid.UUID, reason string) error
|
|
|
|
// GetActive returns the active (pending) deletion for a resource, if any.
|
|
GetActive(ctx context.Context, rt models.DeletionResourceType, resourceID uuid.UUID) (*models.ScheduledDeletion, error)
|
|
|
|
// GetByID returns any deletion by id.
|
|
GetByID(ctx context.Context, id uuid.UUID) (*models.ScheduledDeletion, error)
|
|
|
|
// ListDue returns pending deletions whose execute_after is <= now.
|
|
ListDue(ctx context.Context, now time.Time, limit int) ([]models.ScheduledDeletion, error)
|
|
|
|
// ListPendingForReminders returns pending deletions whose
|
|
// execute_after is within the next windowEnd duration and that have
|
|
// not yet had bit set in notifications_sent.
|
|
ListPendingForReminders(ctx context.Context, within time.Duration, notSetBit int, limit int) ([]models.ScheduledDeletion, error)
|
|
|
|
// MarkExecuting flips status from pending to executing (atomic guard).
|
|
MarkExecuting(ctx context.Context, id uuid.UUID) (bool, error)
|
|
|
|
// MarkCompleted records the finalized hard delete.
|
|
MarkCompleted(ctx context.Context, id uuid.UUID) error
|
|
|
|
// MarkFailed records an execution error so the next tick can retry.
|
|
MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error
|
|
|
|
// SetNotifBit OR's bit into notifications_sent (idempotent reminder gate).
|
|
SetNotifBit(ctx context.Context, id uuid.UUID, bit int) error
|
|
|
|
// HardDeleteOrganization removes the organization row, relying on
|
|
// existing ON DELETE CASCADE FKs to clean up dependents.
|
|
HardDeleteOrganization(ctx context.Context, orgID uuid.UUID) error
|
|
|
|
// HardDeleteUser removes the user row.
|
|
HardDeleteUser(ctx context.Context, userID uuid.UUID) error
|
|
}
|
|
|
|
// ErrPendingDeletionExists is returned when CreatePending is called for
|
|
// a resource that already has an active pending deletion.
|
|
var ErrPendingDeletionExists = errors.New("pending deletion already exists for this resource")
|
|
|
|
type dangerZoneRepository struct {
|
|
db *pgxpool.Pool
|
|
}
|
|
|
|
// NewDangerZoneRepository constructs a DangerZoneRepository.
|
|
func NewDangerZoneRepository(db *pgxpool.Pool) DangerZoneRepository {
|
|
return &dangerZoneRepository{db: db}
|
|
}
|
|
|
|
const scheduledDeletionColumns = `
|
|
id, resource_type, resource_id, organization_id,
|
|
requested_by_user_id, reason,
|
|
scheduled_at, execute_after, grace_days, status,
|
|
cancelled_at, cancelled_by_user_id, cancelled_reason,
|
|
executed_at, execution_error,
|
|
notifications_sent, last_reminder_at
|
|
`
|
|
|
|
func scanScheduledDeletion(row pgx.Row) (*models.ScheduledDeletion, error) {
|
|
var d models.ScheduledDeletion
|
|
var status string
|
|
err := row.Scan(
|
|
&d.ID, &d.ResourceType, &d.ResourceID, &d.OrganizationID,
|
|
&d.RequestedByUserID, &d.Reason,
|
|
&d.ScheduledAt, &d.ExecuteAfter, &d.GraceDays, &status,
|
|
&d.CancelledAt, &d.CancelledByUserID, &d.CancelledReason,
|
|
&d.ExecutedAt, &d.ExecutionError,
|
|
&d.NotificationsSent, &d.LastReminderAt,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
d.Status = models.DeletionStatus(status)
|
|
return &d, nil
|
|
}
|
|
|
|
func (r *dangerZoneRepository) CreatePending(ctx context.Context, d *models.ScheduledDeletion) error {
|
|
tx, err := r.db.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
const insertQ = `
|
|
INSERT INTO scheduled_deletions (
|
|
id, resource_type, resource_id, organization_id,
|
|
requested_by_user_id, reason,
|
|
scheduled_at, execute_after, grace_days, status,
|
|
notifications_sent
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', 0)
|
|
`
|
|
|
|
_, err = tx.Exec(ctx, insertQ,
|
|
d.ID, d.ResourceType, d.ResourceID, d.OrganizationID,
|
|
d.RequestedByUserID, d.Reason,
|
|
d.ScheduledAt, d.ExecuteAfter, d.GraceDays,
|
|
)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
return ErrPendingDeletionExists
|
|
}
|
|
return err
|
|
}
|
|
|
|
// Stamp the parent row so list endpoints can show "pending deletion"
|
|
// without joining the scheduled_deletions table on every read.
|
|
switch d.ResourceType {
|
|
case models.DeletionResourceOrganization:
|
|
_, err = tx.Exec(ctx,
|
|
`UPDATE organizations
|
|
SET deletion_scheduled_at = $2,
|
|
deletion_scheduled_for = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
d.ResourceID, d.ScheduledAt, d.ExecuteAfter,
|
|
)
|
|
case models.DeletionResourceUser:
|
|
_, err = tx.Exec(ctx,
|
|
`UPDATE users
|
|
SET deletion_scheduled_at = $2,
|
|
deletion_scheduled_for = $3,
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
d.ResourceID, d.ScheduledAt, d.ExecuteAfter,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *dangerZoneRepository) Cancel(ctx context.Context, id uuid.UUID, cancelledByUserID uuid.UUID, reason string) error {
|
|
tx, err := r.db.Begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback(ctx)
|
|
|
|
var resourceType string
|
|
var resourceID uuid.UUID
|
|
err = tx.QueryRow(ctx, `
|
|
UPDATE scheduled_deletions
|
|
SET status = 'cancelled',
|
|
cancelled_at = NOW(),
|
|
cancelled_by_user_id = $2,
|
|
cancelled_reason = NULLIF($3, '')
|
|
WHERE id = $1 AND status = 'pending'
|
|
RETURNING resource_type, resource_id
|
|
`, id, cancelledByUserID, reason).Scan(&resourceType, &resourceID)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
// Not pending; nothing to do.
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
|
|
switch models.DeletionResourceType(resourceType) {
|
|
case models.DeletionResourceOrganization:
|
|
_, err = tx.Exec(ctx,
|
|
`UPDATE organizations
|
|
SET deletion_scheduled_at = NULL,
|
|
deletion_scheduled_for = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
resourceID,
|
|
)
|
|
case models.DeletionResourceUser:
|
|
_, err = tx.Exec(ctx,
|
|
`UPDATE users
|
|
SET deletion_scheduled_at = NULL,
|
|
deletion_scheduled_for = NULL,
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
resourceID,
|
|
)
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return tx.Commit(ctx)
|
|
}
|
|
|
|
func (r *dangerZoneRepository) GetActive(ctx context.Context, rt models.DeletionResourceType, resourceID uuid.UUID) (*models.ScheduledDeletion, error) {
|
|
row := r.db.QueryRow(ctx, `
|
|
SELECT `+scheduledDeletionColumns+`
|
|
FROM scheduled_deletions
|
|
WHERE resource_type = $1 AND resource_id = $2 AND status = 'pending'
|
|
LIMIT 1
|
|
`, rt, resourceID)
|
|
d, err := scanScheduledDeletion(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (r *dangerZoneRepository) GetByID(ctx context.Context, id uuid.UUID) (*models.ScheduledDeletion, error) {
|
|
row := r.db.QueryRow(ctx, `
|
|
SELECT `+scheduledDeletionColumns+`
|
|
FROM scheduled_deletions WHERE id = $1
|
|
`, id)
|
|
d, err := scanScheduledDeletion(row)
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
func (r *dangerZoneRepository) ListDue(ctx context.Context, now time.Time, limit int) ([]models.ScheduledDeletion, error) {
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT `+scheduledDeletionColumns+`
|
|
FROM scheduled_deletions
|
|
WHERE status = 'pending' AND execute_after <= $1
|
|
ORDER BY execute_after ASC
|
|
LIMIT $2
|
|
`, now, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.ScheduledDeletion
|
|
for rows.Next() {
|
|
d, err := scanScheduledDeletion(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *d)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *dangerZoneRepository) ListPendingForReminders(ctx context.Context, within time.Duration, notSetBit int, limit int) ([]models.ScheduledDeletion, error) {
|
|
deadline := time.Now().Add(within)
|
|
rows, err := r.db.Query(ctx, `
|
|
SELECT `+scheduledDeletionColumns+`
|
|
FROM scheduled_deletions
|
|
WHERE status = 'pending'
|
|
AND execute_after > NOW()
|
|
AND execute_after <= $1
|
|
AND (notifications_sent & $2) = 0
|
|
ORDER BY execute_after ASC
|
|
LIMIT $3
|
|
`, deadline, notSetBit, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var out []models.ScheduledDeletion
|
|
for rows.Next() {
|
|
d, err := scanScheduledDeletion(rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, *d)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (r *dangerZoneRepository) MarkExecuting(ctx context.Context, id uuid.UUID) (bool, error) {
|
|
tag, err := r.db.Exec(ctx, `
|
|
UPDATE scheduled_deletions
|
|
SET status = 'executing'
|
|
WHERE id = $1 AND status = 'pending'
|
|
`, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
return tag.RowsAffected() == 1, nil
|
|
}
|
|
|
|
func (r *dangerZoneRepository) MarkCompleted(ctx context.Context, id uuid.UUID) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE scheduled_deletions
|
|
SET status = 'completed', executed_at = NOW(), execution_error = NULL
|
|
WHERE id = $1
|
|
`, id)
|
|
return err
|
|
}
|
|
|
|
func (r *dangerZoneRepository) MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE scheduled_deletions
|
|
SET status = 'pending',
|
|
execution_error = $2
|
|
WHERE id = $1
|
|
`, id, errMsg)
|
|
return err
|
|
}
|
|
|
|
func (r *dangerZoneRepository) SetNotifBit(ctx context.Context, id uuid.UUID, bit int) error {
|
|
_, err := r.db.Exec(ctx, `
|
|
UPDATE scheduled_deletions
|
|
SET notifications_sent = notifications_sent | $2,
|
|
last_reminder_at = NOW()
|
|
WHERE id = $1
|
|
`, id, bit)
|
|
return err
|
|
}
|
|
|
|
func (r *dangerZoneRepository) HardDeleteOrganization(ctx context.Context, orgID uuid.UUID) error {
|
|
_, err := r.db.Exec(ctx, `DELETE FROM organizations WHERE id = $1`, orgID)
|
|
return err
|
|
}
|
|
|
|
func (r *dangerZoneRepository) HardDeleteUser(ctx context.Context, userID uuid.UUID) error {
|
|
_, err := r.db.Exec(ctx, `DELETE FROM users WHERE id = $1`, userID)
|
|
return err
|
|
}
|
|
|
|
// isUniqueViolation detects Postgres SQLSTATE 23505 (unique_violation).
|
|
func isUniqueViolation(err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
type pgErr interface {
|
|
SQLState() string
|
|
}
|
|
if pe, ok := err.(pgErr); ok {
|
|
return pe.SQLState() == "23505"
|
|
}
|
|
return false
|
|
}
|