package store import ( "context" "errors" "fmt" "log/slog" "time" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" "go.mongodb.org/mongo-driver/v2/mongo/options" ) // ErrLocationNotFound is returned when a location ID has no stored token record. var ErrLocationNotFound = errors.New("location not found") type TokenRecord struct { LocationID string `bson:"location_id"` CompanyID string `bson:"company_id"` AccessToken string `bson:"access_token"` RefreshToken string `bson:"refresh_token"` ExpiresAt time.Time `bson:"expires_at"` InstalledAt time.Time `bson:"installed_at"` UpdatedAt time.Time `bson:"updated_at"` SenderID string `bson:"sender_id,omitempty"` // per-location Cast sender ID; overrides global default CastAPIKey string `bson:"cast_api_key,omitempty"` // per-location Cast API key; overrides global CAST_API_KEY (stored encrypted when cipher is set) } // Cipher encrypts and decrypts string values at rest. type Cipher interface { Encrypt(plaintext string) (string, error) Decrypt(value string) (string, error) } type Store struct { client *mongo.Client collection *mongo.Collection cipher Cipher // nil = no encryption (plain text storage) } func NewStore(ctx context.Context, uri string) (*Store, error) { return NewStoreWithCipher(ctx, uri, nil) } func NewStoreWithCipher(ctx context.Context, uri string, cipher Cipher) (*Store, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() client, err := mongo.Connect(options.Client().ApplyURI(uri)) if err != nil { return nil, err } if err := client.Ping(ctx, nil); err != nil { return nil, err } col := client.Database("cast-ghl").Collection("oauth_tokens") indexModel := mongo.IndexModel{ Keys: bson.D{{Key: "location_id", Value: 1}}, Options: options.Index().SetUnique(true), } if _, err := col.Indexes().CreateOne(ctx, indexModel); err != nil { return nil, err } return &Store{client: client, collection: col, cipher: cipher}, nil } // encryptKey encrypts a Cast API key before storage. No-op if cipher is nil or key is empty. func (s *Store) encryptKey(key string) (string, error) { if s.cipher == nil || key == "" { return key, nil } encrypted, err := s.cipher.Encrypt(key) if err != nil { return "", fmt.Errorf("store: encrypt cast_api_key: %w", err) } return encrypted, nil } // decryptKey decrypts a stored Cast API key. No-op if cipher is nil or key is empty. func (s *Store) decryptKey(key string) string { if s.cipher == nil || key == "" { return key } plaintext, err := s.cipher.Decrypt(key) if err != nil { // Log but don't fail — return raw value so the caller can surface the error gracefully. slog.Error("store: failed to decrypt cast_api_key", "err", err) return "" } return plaintext } func (s *Store) SaveToken(ctx context.Context, record *TokenRecord) error { encKey, err := s.encryptKey(record.CastAPIKey) if err != nil { return err } toStore := *record toStore.CastAPIKey = encKey toStore.UpdatedAt = time.Now() filter := bson.D{{Key: "location_id", Value: record.LocationID}} opts := options.Replace().SetUpsert(true) _, err = s.collection.ReplaceOne(ctx, filter, toStore, opts) return err } func (s *Store) GetToken(ctx context.Context, locationID string) (*TokenRecord, error) { filter := bson.D{{Key: "location_id", Value: locationID}} var record TokenRecord err := s.collection.FindOne(ctx, filter).Decode(&record) if errors.Is(err, mongo.ErrNoDocuments) { return nil, nil } if err != nil { return nil, err } record.CastAPIKey = s.decryptKey(record.CastAPIKey) return &record, nil } func (s *Store) UpdateToken(ctx context.Context, locationID, accessToken, refreshToken string, expiresAt time.Time) error { filter := bson.D{{Key: "location_id", Value: locationID}} update := bson.D{{Key: "$set", Value: bson.D{ {Key: "access_token", Value: accessToken}, {Key: "refresh_token", Value: refreshToken}, {Key: "expires_at", Value: expiresAt}, {Key: "updated_at", Value: time.Now()}, }}} _, err := s.collection.UpdateOne(ctx, filter, update) return err } // UpdateLocationConfig sets the per-location Cast sender ID and API key. // Either field may be empty to clear it (falling back to the global default). func (s *Store) UpdateLocationConfig(ctx context.Context, locationID, senderID, castAPIKey string) error { encKey, err := s.encryptKey(castAPIKey) if err != nil { return err } filter := bson.D{{Key: "location_id", Value: locationID}} update := bson.D{{Key: "$set", Value: bson.D{ {Key: "sender_id", Value: senderID}, {Key: "cast_api_key", Value: encKey}, {Key: "updated_at", Value: time.Now()}, }}} res, err := s.collection.UpdateOne(ctx, filter, update) if err != nil { return err } if res.MatchedCount == 0 { return ErrLocationNotFound } return nil } // ListTokens returns all installed location records (without OAuth tokens for safety). func (s *Store) ListTokens(ctx context.Context) ([]*TokenRecord, error) { cursor, err := s.collection.Find(ctx, bson.D{}) if err != nil { return nil, err } defer func() { _ = cursor.Close(ctx) }() var records []*TokenRecord if err := cursor.All(ctx, &records); err != nil { return nil, err } for _, rec := range records { rec.CastAPIKey = s.decryptKey(rec.CastAPIKey) } return records, nil } func (s *Store) DeleteToken(ctx context.Context, locationID string) error { filter := bson.D{{Key: "location_id", Value: locationID}} _, err := s.collection.DeleteOne(ctx, filter) return err } func (s *Store) Close(ctx context.Context) error { return s.client.Disconnect(ctx) }