Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Complete MVP implementation of the Cast GHL Conversation Provider bridge: - Go module setup with chi router and mongo-driver dependencies - Config loading with env var validation and defaults - MongoDB token store with upsert, get, update, delete operations - Cast.ph SMS client with 429 retry logic and typed errors - Phone number normalization (E.164 ↔ Philippine local format) - GHL OAuth 2.0 install/callback/refresh flow - GHL webhook handler with ECDSA signature verification (async dispatch) - GHL API client for message status updates and inbound message stubs - Multi-stage Dockerfile, docker-compose with MongoDB, Woodpecker CI pipeline - Unit tests for phone normalization, Cast client, GHL webhook, and OAuth handlers Co-Authored-By: SideKx <sidekx.ai@sds.dev>
94 lines
2.6 KiB
Go
94 lines
2.6 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.mongodb.org/mongo-driver/v2/mongo"
|
|
"go.mongodb.org/mongo-driver/v2/mongo/options"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
type Store struct {
|
|
client *mongo.Client
|
|
collection *mongo.Collection
|
|
}
|
|
|
|
func NewStore(ctx context.Context, uri string) (*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}, nil
|
|
}
|
|
|
|
func (s *Store) SaveToken(ctx context.Context, record *TokenRecord) error {
|
|
record.UpdatedAt = time.Now()
|
|
filter := bson.D{{Key: "location_id", Value: record.LocationID}}
|
|
opts := options.Replace().SetUpsert(true)
|
|
_, err := s.collection.ReplaceOne(ctx, filter, record, 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 err == mongo.ErrNoDocuments {
|
|
return nil, nil
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
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
|
|
}
|
|
|
|
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)
|
|
}
|