package devices import ( "context" "database/sql" "fmt" "time" dbx "github.com/wangjia/pangolin/server/internal/db" ) // DeviceRow mirrors a `devices` table row. type DeviceRow struct { ID int64 UUID string UserID int64 Name string Platform string LastSeen sql.NullTime CreatedAt time.Time } // effSub is an active-or-expired subscription joined with its plan, used by the // pure plan resolver. expiry filtering is performed in Go (resolveEffectivePlan) // so the UTC boundary logic is unit-testable without a database. type effSub struct { PlanCode string MaxDevices int DailyMinutes sql.NullInt64 AdGate bool ExpiresAt time.Time Source string } // Store wraps a *sql.DB and exposes the database operations the devices module // needs. Methods that take a *sql.Tx run within that transaction. type Store struct { db *sql.DB dialect dbx.Dialect } // NewStore creates a Store backed by the given connection pool (MySQL or SQLite). func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(db)} } // BeginTx starts a transaction at Read Committed isolation. Per-user // serialization for device mutations is achieved by locking the users row // (SELECT ... FOR UPDATE) inside the transaction. func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) { return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted}) } // -------------------------------------------------------------------------- // Device queries // -------------------------------------------------------------------------- // ListByUser returns all devices for userID ordered by creation time. func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, error) { rows, err := s.db.QueryContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at FROM devices WHERE user_id=? ORDER BY created_at ASC`, userID) if err != nil { return nil, fmt.Errorf("store.ListByUser: %w", err) } defer rows.Close() var out []DeviceRow for rows.Next() { var d DeviceRow if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err != nil { return nil, fmt.Errorf("store.ListByUser scan: %w", err) } out = append(out, d) } return out, rows.Err() } // findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx. // Returns (nil, nil) when the device does not exist. func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) { row := tx.QueryRowContext(ctx, `SELECT id, uuid, user_id, name, platform, last_seen, created_at FROM devices WHERE uuid=? `+s.dialect.LockForUpdate(), uuid) var d DeviceRow if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err == sql.ErrNoRows { return nil, nil } else if err != nil { return nil, fmt.Errorf("store.findDeviceByUUIDTx: %w", err) } return &d, nil } // lockUser locks the users row to serialize per-user device mutations and // returns the user's status. Returns (false, "", nil) when the user is absent. func (s *Store) lockUser(ctx context.Context, tx *sql.Tx, userID int64) (exists bool, status string, err error) { row := tx.QueryRowContext(ctx, `SELECT status FROM users WHERE id=? `+s.dialect.LockForUpdate(), userID) if e := row.Scan(&status); e == sql.ErrNoRows { return false, "", nil } else if e != nil { return false, "", fmt.Errorf("store.lockUser: %w", e) } return true, status, nil } // countDevicesTx counts a user's devices inside tx. func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (int, error) { var n int if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM devices WHERE user_id=?`, userID).Scan(&n); err != nil { return 0, fmt.Errorf("store.countDevicesTx: %w", err) } return n, nil } // insertDeviceTx inserts a new device row inside tx and returns it. func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform string) (*DeviceRow, error) { now := time.Now().UTC() res, err := tx.ExecContext(ctx, `INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at) VALUES (?, ?, ?, ?, ?, ?)`, uuid, userID, name, platform, now, now) if err != nil { return nil, fmt.Errorf("store.insertDeviceTx: %w", err) } id, _ := res.LastInsertId() return &DeviceRow{ID: id, UUID: uuid, UserID: userID, Name: name, Platform: platform}, nil } // touchLastSeenTx updates a device's last_seen to now inside tx. func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64) error { _, err := tx.ExecContext(ctx, `UPDATE devices SET last_seen=? WHERE id=?`, time.Now().UTC(), deviceID) if err != nil { return fmt.Errorf("store.touchLastSeenTx: %w", err) } return nil } // deleteDeviceTx hard-deletes a device row inside tx. func (s *Store) deleteDeviceTx(ctx context.Context, tx *sql.Tx, deviceID int64) error { if _, err := tx.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, deviceID); err != nil { return fmt.Errorf("store.deleteDeviceTx: %w", err) } return nil } // -------------------------------------------------------------------------- // Subscription / plan queries (for the subscription middleware & /me summary) // -------------------------------------------------------------------------- // GetUserStatus returns a user's account status ("active"/"banned"). // exists is false when no such user row is present. func (s *Store) GetUserStatus(ctx context.Context, userID int64) (status string, exists bool, err error) { row := s.db.QueryRowContext(ctx, `SELECT status FROM users WHERE id=?`, userID) if e := row.Scan(&status); e == sql.ErrNoRows { return "", false, nil } else if e != nil { return "", false, fmt.Errorf("store.GetUserStatus: %w", e) } return status, true, nil } // GetSubscriptions returns all subscriptions for userID joined with their plan. // Expiry filtering is intentionally left to resolveEffectivePlan. func (s *Store) GetSubscriptions(ctx context.Context, userID int64) ([]effSub, error) { rows, err := s.db.QueryContext(ctx, `SELECT p.code, p.max_devices, p.daily_minutes, p.ad_gate, s.expires_at, s.source FROM subscriptions s JOIN plans p ON p.id=s.plan_id WHERE s.user_id=?`, userID) if err != nil { return nil, fmt.Errorf("store.GetSubscriptions: %w", err) } defer rows.Close() var out []effSub for rows.Next() { var e effSub if err := rows.Scan(&e.PlanCode, &e.MaxDevices, &e.DailyMinutes, &e.AdGate, &e.ExpiresAt, &e.Source); err != nil { return nil, fmt.Errorf("store.GetSubscriptions scan: %w", err) } out = append(out, e) } return out, rows.Err() } // GetFreePlan loads the free plan row (the no-subscription fallback). func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) { row := s.db.QueryRowContext(ctx, `SELECT max_devices, daily_minutes, ad_gate FROM plans WHERE code='free'`) var maxDevices int var dailyMinutes sql.NullInt64 var adGate bool if err := row.Scan(&maxDevices, &dailyMinutes, &adGate); err != nil { return Plan{}, fmt.Errorf("store.GetFreePlan: %w", err) } p := Plan{ PlanCode: "free", MaxDevices: maxDevices, AdGate: adGate, Source: "free", } if dailyMinutes.Valid { m := int(dailyMinutes.Int64) p.DailyMinutes = &m } return p, nil } // -------------------------------------------------------------------------- // Audit log // -------------------------------------------------------------------------- // writeAuditLogTx inserts an audit_log row inside tx. func (s *Store) writeAuditLogTx(ctx context.Context, tx *sql.Tx, actor, action, target, metaJSON string) error { if metaJSON == "" { metaJSON = "null" } _, err := tx.ExecContext(ctx, `INSERT INTO audit_log (actor, action, target, meta, at) VALUES (?, ?, ?, ?, ?)`, actor, action, target, metaJSON, time.Now().UTC()) if err != nil { return fmt.Errorf("store.writeAuditLogTx: %w", err) } return nil }