migrate orion repo into monorepo structure
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/genproto/aero"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.jetify.com/typeid"
|
||||
)
|
||||
|
||||
const (
|
||||
sessionExpiry = time.Hour * 24 * 15
|
||||
extendSessionThreshold = time.Hour * 24 * 3
|
||||
codeExpiry = time.Minute * 15
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInvalidCode = errors.New("invalid code")
|
||||
ErrSessionNotFound = errors.New("session not found")
|
||||
)
|
||||
|
||||
type sessionTokenPrefix struct{}
|
||||
|
||||
func (sessionTokenPrefix) Prefix() string { return "session" }
|
||||
|
||||
type sessionToken struct {
|
||||
typeid.TypeID[sessionTokenPrefix]
|
||||
}
|
||||
|
||||
func newSessionToken() (sessionToken, error) {
|
||||
return typeid.New[sessionToken]()
|
||||
}
|
||||
|
||||
type AuthService interface {
|
||||
// RequestSignInCode generates a code and emails it to the provided email.
|
||||
// To retrieve a session, client must verify with VerifySignInCode.
|
||||
RequestSignInCode(ctx context.Context, email string) error
|
||||
// VerifySignInCode returns ErrInvalidCode if incorrect code
|
||||
VerifySignInCode(ctx context.Context, email, code string) (sessionToken string, err error)
|
||||
// GetSession returns ErrSessionNotFound if no valid session
|
||||
GetSession(ctx context.Context, sessionToken string) (email string, err error)
|
||||
// ExtendSession returns ErrSessionNotFound if no valid session
|
||||
ExtendSession(ctx context.Context, sessionToken string) error
|
||||
SignOut(ctx context.Context, sessionToken string) error
|
||||
|
||||
IsSystemAdmin(ctx context.Context, email string) bool
|
||||
}
|
||||
|
||||
type authServiceImpl struct {
|
||||
redisClient *redis.Client
|
||||
aeroSvc pbaero.PrimaryClient
|
||||
}
|
||||
|
||||
func NewAuthService(redisClient *redis.Client, aeroSvc pbaero.PrimaryClient) AuthService {
|
||||
return &authServiceImpl{redisClient: redisClient, aeroSvc: aeroSvc}
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) IsSystemAdmin(ctx context.Context, email string) bool {
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
slog.Error("problem validating email", "error", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if strings.Contains(formattedEmail, "@flowylabs.ai") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) RequestSignInCode(ctx context.Context, email string) error {
|
||||
if email == "" {
|
||||
return errors.New("email is required")
|
||||
}
|
||||
|
||||
code := utils.RandomStringNumbers(4)
|
||||
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return errors.New("email is not valid")
|
||||
}
|
||||
|
||||
err = a.redisClient.Set(ctx, formattedEmail, code, codeExpiry).Err()
|
||||
if err != nil {
|
||||
slog.Error("error setting code in redis", "error", err)
|
||||
return fmt.Errorf("error storing sign-in code: %w", err)
|
||||
}
|
||||
|
||||
message := fmt.Sprintf("Here is your one-time code for signing into Flowy: %s\n\nPlease do not share this with anyone.\n\nBest, \nFlowy Team", code)
|
||||
subject := fmt.Sprintf("Sign In - Your One-Time Code for Flowy.llink")
|
||||
_, err = a.aeroSvc.ShootEmail(ctx, &pbaero.ShootEmailRequest{
|
||||
ToEmails: []string{formattedEmail},
|
||||
Subject: subject,
|
||||
TemplateData: &pbaero.ShootEmailRequest_SimpleTextData{
|
||||
SimpleTextData: &pbaero.SimpleTextData{
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("an error occurred while sending the email: %w", err)
|
||||
}
|
||||
|
||||
slog.Info("sent sign in code", "email", formattedEmail)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) VerifySignInCode(ctx context.Context, email, code string) (string, error) {
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
|
||||
storedCode, err := a.redisClient.Get(ctx, formattedEmail).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
slog.Error("error getting code from redis", "error", err)
|
||||
return "", fmt.Errorf("error verifying code: %w", err)
|
||||
}
|
||||
|
||||
if storedCode != code {
|
||||
return "", ErrInvalidCode
|
||||
}
|
||||
|
||||
if err := a.redisClient.Del(ctx, formattedEmail).Err(); err != nil {
|
||||
slog.Error("error deleting code from redis", "error", err)
|
||||
}
|
||||
|
||||
token, err := a.createSession(ctx, formattedEmail)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) GetSession(ctx context.Context, token string) (string, error) {
|
||||
email, err := a.redisClient.Get(ctx, token).Result()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return "", ErrSessionNotFound
|
||||
}
|
||||
return "", fmt.Errorf("error getting session: %w", err)
|
||||
}
|
||||
|
||||
return email, nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) ExtendSession(ctx context.Context, token string) error {
|
||||
ttl, err := a.redisClient.TTL(ctx, token).Result()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error checking session TTL: %w", err)
|
||||
}
|
||||
|
||||
if ttl < 0 {
|
||||
return ErrSessionNotFound
|
||||
}
|
||||
|
||||
if ttl < extendSessionThreshold {
|
||||
if err := a.redisClient.Expire(ctx, token, sessionExpiry).Err(); err != nil {
|
||||
return fmt.Errorf("error extending session: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) SignOut(ctx context.Context, token string) error {
|
||||
if err := a.redisClient.Del(ctx, token).Err(); err != nil {
|
||||
return fmt.Errorf("error deleting session: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *authServiceImpl) createSession(ctx context.Context, email string) (string, error) {
|
||||
formattedEmail, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("error generating session token: %w", err)
|
||||
}
|
||||
|
||||
if err := a.redisClient.Set(ctx, token.String(), formattedEmail, sessionExpiry).Err(); err != nil {
|
||||
return "", fmt.Errorf("error storing session: %w", err)
|
||||
}
|
||||
|
||||
return token.String(), nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var db *pgxpool.Pool
|
||||
|
||||
func Cleanup() {
|
||||
db.Close()
|
||||
}
|
||||
|
||||
func Pool() *pgxpool.Pool {
|
||||
return db
|
||||
}
|
||||
|
||||
func Init() {
|
||||
connString := os.Getenv("LLINK_POSTGRES_CONNECTION_URL")
|
||||
if connString == "" {
|
||||
slog.Error("must provide LLINK_POSTGRES_CONNECTION_URL in env")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbpool, err := pgxpool.New(context.Background(), connString)
|
||||
if err != nil {
|
||||
slog.Error("unable to create connection pool", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
var greeting string
|
||||
err = dbpool.QueryRow(context.Background(), "select 'Hello, world!'").Scan(&greeting)
|
||||
if err != nil {
|
||||
slog.Error("queryRow failed", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("successfully connected to database", "greeting", greeting)
|
||||
|
||||
db = dbpool
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package depot
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("object not found")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
@@ -0,0 +1,38 @@
|
||||
package depot
|
||||
|
||||
import "time"
|
||||
|
||||
// Object represents a stored object in the depot
|
||||
type Object struct {
|
||||
ID string
|
||||
Name string
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
BucketName string
|
||||
ObjectKey string
|
||||
ContainsContent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// PrepareUploadInput represents the input for preparing an upload
|
||||
type PrepareUploadInput struct {
|
||||
Prefix string // Optional prefix for organizing objects (e.g., network_id)
|
||||
Name string
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
}
|
||||
|
||||
// PrepareUploadResult represents the result of preparing an upload
|
||||
type PrepareUploadResult struct {
|
||||
ObjectID string
|
||||
UploadURL string
|
||||
UploadHeaders map[string]string
|
||||
}
|
||||
|
||||
// Config holds configuration for the depot service
|
||||
type Config struct {
|
||||
GoogleServiceAccountEmail string
|
||||
BucketName string
|
||||
UploadURLExpiry time.Duration
|
||||
DownloadURLExpiry time.Duration
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package depot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.jetify.com/typeid"
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
type depotIDPrefix struct{}
|
||||
|
||||
func (depotIDPrefix) Prefix() string { return "dpo" }
|
||||
|
||||
type depotID struct {
|
||||
typeid.TypeID[depotIDPrefix]
|
||||
}
|
||||
|
||||
func newDepotID() (depotID, error) {
|
||||
return typeid.New[depotID]()
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
create(ctx context.Context, obj *Object) (*Object, error)
|
||||
getByID(ctx context.Context, id string) (*Object, error)
|
||||
setContainsContent(ctx context.Context, id string, containsContent bool) error
|
||||
delete(ctx context.Context, id string) error
|
||||
exists(ctx context.Context, id string) (bool, error)
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) create(ctx context.Context, obj *Object) (*Object, error) {
|
||||
id, err := newDepotID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result Object
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, name, content_type, content_length, bucket_name, object_key, contains_content, created_at`,
|
||||
id.String(), obj.Name, obj.ContentType, obj.ContentLength, obj.BucketName, obj.ObjectKey, obj.ContainsContent,
|
||||
).Scan(&result.ID, &result.Name, &result.ContentType, &result.ContentLength,
|
||||
&result.BucketName, &result.ObjectKey, &result.ContainsContent, &result.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Object, error) {
|
||||
var obj Object
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, name, content_type, content_length, bucket_name, object_key, contains_content, created_at
|
||||
FROM depot_objects WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&obj.ID, &obj.Name, &obj.ContentType, &obj.ContentLength,
|
||||
&obj.BucketName, &obj.ObjectKey, &obj.ContainsContent, &obj.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &obj, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) setContainsContent(ctx context.Context, id string, containsContent bool) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE depot_objects SET contains_content = $1 WHERE id = $2`,
|
||||
containsContent, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) delete(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) exists(ctx context.Context, id string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
|
||||
id,
|
||||
).Scan(&exists)
|
||||
return exists, err
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package depot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"cloud.google.com/go/storage"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultUploadURLExpiry = 15 * time.Minute
|
||||
defaultDownloadURLExpiry = 24 * time.Hour
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error)
|
||||
ConfirmUpload(ctx context.Context, objectID string) (*Object, error)
|
||||
GetByID(ctx context.Context, objectID string) (*Object, error)
|
||||
GetDownloadURL(ctx context.Context, objectID string) (string, error)
|
||||
Delete(ctx context.Context, objectID string) error
|
||||
Exists(ctx context.Context, objectID string) (bool, error)
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
storageClient *storage.Client
|
||||
bucketName string
|
||||
uploadURLExpiry time.Duration
|
||||
downloadURLExpiry time.Duration
|
||||
googleServiceAccountEmail string
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, storageClient *storage.Client, config Config) Service {
|
||||
uploadExpiry := config.UploadURLExpiry
|
||||
if uploadExpiry == 0 {
|
||||
uploadExpiry = defaultUploadURLExpiry
|
||||
}
|
||||
|
||||
downloadExpiry := config.DownloadURLExpiry
|
||||
if downloadExpiry == 0 {
|
||||
downloadExpiry = defaultDownloadURLExpiry
|
||||
}
|
||||
|
||||
if config.GoogleServiceAccountEmail == "" {
|
||||
slog.Error("GoogleServiceAccountEmail is not set in config. Signed URLs may not work if the storage client is not properly authenticated with a service account.")
|
||||
panic("GoogleServiceAccountEmail is required for signed URL generation")
|
||||
}
|
||||
|
||||
return &serviceImpl{
|
||||
repo: newRepository(pool),
|
||||
storageClient: storageClient,
|
||||
bucketName: config.BucketName,
|
||||
uploadURLExpiry: uploadExpiry,
|
||||
downloadURLExpiry: downloadExpiry,
|
||||
googleServiceAccountEmail: config.GoogleServiceAccountEmail,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) PrepareUpload(ctx context.Context, input PrepareUploadInput) (*PrepareUploadResult, error) {
|
||||
if input.Name == "" {
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("name is required"))
|
||||
}
|
||||
if input.ContentType == "" {
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_type is required"))
|
||||
}
|
||||
if input.ContentLength <= 0 {
|
||||
return nil, errors.Join(ErrInvalidInput, errors.New("content_length must be positive"))
|
||||
}
|
||||
|
||||
// Generate object key: {prefix}/{uuid}/{filename}
|
||||
objectKey := fmt.Sprintf("%s/%s/%s", input.Prefix, uuid.New().String(), input.Name)
|
||||
|
||||
// Create the database record (contains_content = false initially)
|
||||
obj := &Object{
|
||||
Name: input.Name,
|
||||
ContentType: input.ContentType,
|
||||
ContentLength: input.ContentLength,
|
||||
BucketName: s.bucketName,
|
||||
ObjectKey: objectKey,
|
||||
ContainsContent: false,
|
||||
}
|
||||
|
||||
created, err := s.repo.create(ctx, obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Generate a signed URL for uploading with Content-Length enforcement
|
||||
// The Headers field specifies headers that MUST be included in the upload request
|
||||
contentLengthHeader := fmt.Sprintf("Content-Length:%d", input.ContentLength)
|
||||
uploadURL, err := s.storageClient.Bucket(s.bucketName).SignedURL(objectKey, &storage.SignedURLOptions{
|
||||
GoogleAccessID: s.googleServiceAccountEmail,
|
||||
Method: "PUT",
|
||||
Expires: time.Now().Add(s.uploadURLExpiry),
|
||||
ContentType: input.ContentType,
|
||||
Headers: []string{contentLengthHeader},
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to generate signed upload URL", "error", err, "bucket", s.bucketName, "object_key", objectKey)
|
||||
// Clean up the database record if we can't generate the URL
|
||||
if delErr := s.repo.delete(ctx, created.ID); delErr != nil {
|
||||
slog.Warn("failed to cleanup db record after signed URL failure", "error", delErr, "object_id", created.ID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PrepareUploadResult{
|
||||
ObjectID: created.ID,
|
||||
UploadURL: uploadURL,
|
||||
UploadHeaders: map[string]string{
|
||||
"Content-Type": input.ContentType,
|
||||
"Content-Length": fmt.Sprintf("%d", input.ContentLength),
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ConfirmUpload(ctx context.Context, objectID string) (*Object, error) {
|
||||
obj, err := s.repo.getByID(ctx, objectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify the object exists in GCS and check its size matches expected
|
||||
attrs, err := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Attrs(ctx)
|
||||
if err != nil {
|
||||
if errors.Is(err, storage.ErrObjectNotExist) {
|
||||
return nil, errors.Join(ErrNotFound, errors.New("object not found in storage"))
|
||||
}
|
||||
slog.Error("failed to get GCS object attrs", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Verify content length matches what was declared
|
||||
if attrs.Size != obj.ContentLength {
|
||||
return nil, errors.Join(ErrInvalidInput, fmt.Errorf("content length mismatch: expected %d, got %d", obj.ContentLength, attrs.Size))
|
||||
}
|
||||
|
||||
// Mark as containing content
|
||||
if err := s.repo.setContainsContent(ctx, objectID, true); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Fetch and return the updated object
|
||||
return s.repo.getByID(ctx, objectID)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetByID(ctx context.Context, objectID string) (*Object, error) {
|
||||
obj, err := s.repo.getByID(ctx, objectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetDownloadURL(ctx context.Context, objectID string) (string, error) {
|
||||
obj, err := s.repo.getByID(ctx, objectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return "", ErrNotFound
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Generate a signed URL for downloading
|
||||
downloadURL, err := s.storageClient.Bucket(obj.BucketName).SignedURL(obj.ObjectKey, &storage.SignedURLOptions{
|
||||
GoogleAccessID: s.googleServiceAccountEmail,
|
||||
Method: "GET",
|
||||
Expires: time.Now().Add(s.downloadURLExpiry),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("failed to generate signed download URL", "error", err, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return "", err
|
||||
}
|
||||
|
||||
return downloadURL, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Delete(ctx context.Context, objectID string) error {
|
||||
obj, err := s.repo.getByID(ctx, objectID)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete from GCS (ignore not found errors)
|
||||
gcsErr := s.storageClient.Bucket(obj.BucketName).Object(obj.ObjectKey).Delete(ctx)
|
||||
if gcsErr != nil && !errors.Is(gcsErr, storage.ErrObjectNotExist) {
|
||||
slog.Error("failed to delete object from GCS", "error", gcsErr, "bucket", obj.BucketName, "object_key", obj.ObjectKey)
|
||||
return gcsErr
|
||||
}
|
||||
|
||||
// Delete from database
|
||||
if err := s.repo.delete(ctx, objectID); err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Exists(ctx context.Context, objectID string) (bool, error) {
|
||||
return s.repo.exists(ctx, objectID)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package depot_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/depot"
|
||||
"github.com/flowy-live/llink/internal/testhelper"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var dbPool *pgxpool.Pool
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbPool = testhelper.SetupTestDB()
|
||||
defer testhelper.TeardownTestDB()
|
||||
|
||||
ret := m.Run()
|
||||
os.Exit(ret)
|
||||
}
|
||||
|
||||
// TestDepotRepository tests the repository layer directly
|
||||
// These tests can run without GCS since they only test database operations
|
||||
func TestDepotRepository_CreateAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a depot object directly in the database for testing
|
||||
var id string
|
||||
err := dbPool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
"dpo_test123", "test-file.txt", "text/plain", int64(1024), "test-bucket", "test-key-123", false,
|
||||
).Scan(&id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "dpo_test123", id)
|
||||
|
||||
// Query the object back
|
||||
var obj struct {
|
||||
ID string
|
||||
Name string
|
||||
ContentType string
|
||||
ContentLength int64
|
||||
BucketName string
|
||||
ObjectKey string
|
||||
ContainsContent bool
|
||||
CreatedAt time.Time
|
||||
}
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`SELECT id, name, content_type, content_length, bucket_name, object_key, contains_content, created_at
|
||||
FROM depot_objects WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&obj.ID, &obj.Name, &obj.ContentType, &obj.ContentLength,
|
||||
&obj.BucketName, &obj.ObjectKey, &obj.ContainsContent, &obj.CreatedAt)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "test-file.txt", obj.Name)
|
||||
assert.Equal(t, "text/plain", obj.ContentType)
|
||||
assert.Equal(t, int64(1024), obj.ContentLength)
|
||||
assert.Equal(t, "test-bucket", obj.BucketName)
|
||||
assert.Equal(t, "test-key-123", obj.ObjectKey)
|
||||
assert.False(t, obj.ContainsContent)
|
||||
assert.False(t, obj.CreatedAt.IsZero())
|
||||
|
||||
// Clean up
|
||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDepotRepository_ConfirmUpload(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a depot object
|
||||
var id string
|
||||
err := dbPool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
"dpo_confirm123", "confirm-file.txt", "text/plain", int64(2048), "test-bucket", "confirm-key", false,
|
||||
).Scan(&id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify it starts with contains_content = false
|
||||
var containsContent bool
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`SELECT contains_content FROM depot_objects WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&containsContent)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, containsContent)
|
||||
|
||||
// Confirm upload
|
||||
_, err = dbPool.Exec(ctx,
|
||||
`UPDATE depot_objects SET contains_content = TRUE WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify it's now true
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`SELECT contains_content FROM depot_objects WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&containsContent)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, containsContent)
|
||||
|
||||
// Clean up
|
||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDepotRepository_Delete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a depot object
|
||||
var id string
|
||||
err := dbPool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
"dpo_delete123", "delete-file.txt", "text/plain", int64(512), "test-bucket", "delete-key", false,
|
||||
).Scan(&id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Delete it
|
||||
result, err := dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), result.RowsAffected())
|
||||
|
||||
// Verify it's gone
|
||||
var count int
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`SELECT COUNT(*) FROM depot_objects WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&count)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, count)
|
||||
}
|
||||
|
||||
func TestDepotRepository_Exists(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create a depot object
|
||||
var id string
|
||||
err := dbPool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
"dpo_exists123", "exists-file.txt", "text/plain", int64(256), "test-bucket", "exists-key", true,
|
||||
).Scan(&id)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check exists
|
||||
var exists bool
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
|
||||
id,
|
||||
).Scan(&exists)
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Check non-existent
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM depot_objects WHERE id = $1)`,
|
||||
"dpo_nonexistent",
|
||||
).Scan(&exists)
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
|
||||
// Clean up
|
||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id = $1`, id)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestDepotRepository_OrphanedIndex(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Create an orphaned object (contains_content = false)
|
||||
var orphanID string
|
||||
err := dbPool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
"dpo_orphan123", "orphan-file.txt", "text/plain", int64(128), "test-bucket", "orphan-key", false,
|
||||
).Scan(&orphanID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a confirmed object (contains_content = true)
|
||||
var confirmedID string
|
||||
err = dbPool.QueryRow(ctx,
|
||||
`INSERT INTO depot_objects (id, name, content_type, content_length, bucket_name, object_key, contains_content)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id`,
|
||||
"dpo_confirmed123", "confirmed-file.txt", "text/plain", int64(128), "test-bucket", "confirmed-key", true,
|
||||
).Scan(&confirmedID)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Query orphaned objects using the index
|
||||
rows, err := dbPool.Query(ctx,
|
||||
`SELECT id FROM depot_objects WHERE contains_content = FALSE`)
|
||||
assert.NoError(t, err)
|
||||
defer rows.Close()
|
||||
|
||||
var orphanedIDs []string
|
||||
for rows.Next() {
|
||||
var id string
|
||||
err := rows.Scan(&id)
|
||||
assert.NoError(t, err)
|
||||
orphanedIDs = append(orphanedIDs, id)
|
||||
}
|
||||
|
||||
// Our orphan should be in the list
|
||||
assert.Contains(t, orphanedIDs, orphanID)
|
||||
assert.NotContains(t, orphanedIDs, confirmedID)
|
||||
|
||||
// Clean up
|
||||
_, err = dbPool.Exec(ctx, `DELETE FROM depot_objects WHERE id IN ($1, $2)`, orphanID, confirmedID)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestDepotModels tests the model structures
|
||||
func TestDepotModels(t *testing.T) {
|
||||
// Test Config defaults
|
||||
config := depot.Config{
|
||||
BucketName: "test-bucket",
|
||||
}
|
||||
assert.Equal(t, "test-bucket", config.BucketName)
|
||||
assert.Equal(t, time.Duration(0), config.UploadURLExpiry)
|
||||
assert.Equal(t, time.Duration(0), config.DownloadURLExpiry)
|
||||
|
||||
// Test with explicit values
|
||||
config = depot.Config{
|
||||
BucketName: "custom-bucket",
|
||||
UploadURLExpiry: 10 * time.Minute,
|
||||
DownloadURLExpiry: 12 * time.Hour,
|
||||
}
|
||||
assert.Equal(t, "custom-bucket", config.BucketName)
|
||||
assert.Equal(t, 10*time.Minute, config.UploadURLExpiry)
|
||||
assert.Equal(t, 12*time.Hour, config.DownloadURLExpiry)
|
||||
}
|
||||
|
||||
// TestDepotErrors tests the error definitions
|
||||
func TestDepotErrors(t *testing.T) {
|
||||
assert.Error(t, depot.ErrNotFound)
|
||||
assert.Error(t, depot.ErrInvalidInput)
|
||||
assert.Equal(t, "object not found", depot.ErrNotFound.Error())
|
||||
assert.Equal(t, "invalid input", depot.ErrInvalidInput.Error())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,10 @@
|
||||
package human
|
||||
|
||||
import "time"
|
||||
|
||||
type Human struct {
|
||||
ID string
|
||||
Email string
|
||||
EmailPrefix string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package human
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.jetify.com/typeid"
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
type humanIDPrefix struct{}
|
||||
|
||||
func (humanIDPrefix) Prefix() string { return "human" }
|
||||
|
||||
type humanID struct {
|
||||
typeid.TypeID[humanIDPrefix]
|
||||
}
|
||||
|
||||
func newHumanID() (humanID, error) {
|
||||
return typeid.New[humanID]()
|
||||
}
|
||||
|
||||
func emailPrefix(email string) string {
|
||||
return strings.Split(email, "@")[0]
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
getByEmail(ctx context.Context, email string) (*Human, error)
|
||||
getByID(ctx context.Context, id string) (*Human, error)
|
||||
create(ctx context.Context, email string) (*Human, error)
|
||||
exists(ctx context.Context, email string) (bool, error)
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getByEmail(ctx context.Context, email string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, created_at FROM humans WHERE email = $1`,
|
||||
email,
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
h.EmailPrefix = emailPrefix(h.Email)
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Human, error) {
|
||||
var h Human
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, email, created_at FROM humans WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
h.EmailPrefix = emailPrefix(h.Email)
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) create(ctx context.Context, email string) (*Human, error) {
|
||||
id, err := newHumanID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var h Human
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO humans (id, email) VALUES ($1, $2)
|
||||
RETURNING id, email, created_at`,
|
||||
id.String(), email,
|
||||
).Scan(&h.ID, &h.Email, &h.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h.EmailPrefix = emailPrefix(h.Email)
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) exists(ctx context.Context, email string) (bool, error) {
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT EXISTS(SELECT 1 FROM humans WHERE email = $1)`,
|
||||
email,
|
||||
).Scan(&exists)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return exists, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package human
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("human not found")
|
||||
|
||||
type Service interface {
|
||||
GetOrCreateByEmail(ctx context.Context, email string) (*Human, error)
|
||||
// GetByEmail returns ErrNotFound if no human found
|
||||
GetByEmail(ctx context.Context, email string) (*Human, error)
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool) Service {
|
||||
return &serviceImpl{repo: newRepository(pool)}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetOrCreateByEmail(ctx context.Context, email string) (*Human, error) {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := s.repo.getByEmail(ctx, email)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return s.repo.create(ctx, email)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetByEmail(ctx context.Context, email string) (*Human, error) {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
h, err := s.repo.getByEmail(ctx, email)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return h, err
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package human_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/flowy-live/llink/internal/human"
|
||||
"github.com/flowy-live/llink/internal/testhelper"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var dbPool *pgxpool.Pool
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbPool = testhelper.SetupTestDB()
|
||||
defer testhelper.TeardownTestDB()
|
||||
|
||||
ret := m.Run()
|
||||
os.Exit(ret)
|
||||
}
|
||||
|
||||
func TestHumanService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := human.NewService(dbPool)
|
||||
|
||||
// Test GetByEmail with non-existent email
|
||||
_, err := svc.GetByEmail(ctx, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, human.ErrNotFound)
|
||||
|
||||
// Test GetOrCreateByEmail creates new human
|
||||
createdHuman, err := svc.GetOrCreateByEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, createdHuman.ID)
|
||||
assert.Equal(t, "[email protected]", createdHuman.Email)
|
||||
assert.Equal(t, "newuser", createdHuman.EmailPrefix)
|
||||
assert.NotZero(t, createdHuman.CreatedAt)
|
||||
|
||||
// Test GetOrCreateByEmail returns existing human
|
||||
existingHuman, err := svc.GetOrCreateByEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, createdHuman.ID, existingHuman.ID)
|
||||
assert.Equal(t, createdHuman.Email, existingHuman.Email)
|
||||
|
||||
// Test GetByEmail with existing email
|
||||
foundHuman, err := svc.GetByEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, createdHuman.ID, foundHuman.ID)
|
||||
assert.Equal(t, createdHuman.Email, foundHuman.Email)
|
||||
|
||||
// Test with another email
|
||||
anotherHuman, err := svc.GetOrCreateByEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEqual(t, createdHuman.ID, anotherHuman.ID)
|
||||
assert.Equal(t, "[email protected]", anotherHuman.Email)
|
||||
assert.Equal(t, "another", anotherHuman.EmailPrefix)
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/flowy-live/llink/internal/auth"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const emailContextKey contextKey = "email"
|
||||
|
||||
// WithEmail adds the email to the context
|
||||
func WithEmail(ctx context.Context, email string) context.Context {
|
||||
return context.WithValue(ctx, emailContextKey, email)
|
||||
}
|
||||
|
||||
// EmailFromContext extracts the email from the context
|
||||
func EmailFromContext(ctx context.Context) (string, bool) {
|
||||
email, ok := ctx.Value(emailContextKey).(string)
|
||||
return email, ok
|
||||
}
|
||||
|
||||
// Auth returns a middleware that validates the session token and adds the email to the context
|
||||
func Auth(authSvc auth.AuthService) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
token := extractBearerToken(r)
|
||||
if token == "" {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
email, err := authSvc.GetSession(r.Context(), token)
|
||||
if err != nil {
|
||||
http.Error(w, "unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Auto-extend session
|
||||
if err := authSvc.ExtendSession(r.Context(), token); err != nil {
|
||||
slog.Warn("failed to extend session", "error", err)
|
||||
}
|
||||
|
||||
ctx := WithEmail(r.Context(), email)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// extractBearerToken extracts the token from the Authorization header
|
||||
func extractBearerToken(r *http.Request) string {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
|
||||
return ""
|
||||
}
|
||||
|
||||
return parts[1]
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package network
|
||||
|
||||
import "time"
|
||||
|
||||
type Network struct {
|
||||
ID string
|
||||
Name string
|
||||
AdminEmail string
|
||||
MemberEmails []string
|
||||
OpenStreamCapacity int
|
||||
OpenStreamCount int
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.jetify.com/typeid"
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
|
||||
type networkIDPrefix struct{}
|
||||
|
||||
func (networkIDPrefix) Prefix() string { return "net" }
|
||||
|
||||
type networkID struct {
|
||||
typeid.TypeID[networkIDPrefix]
|
||||
}
|
||||
|
||||
func newNetworkID() (networkID, error) {
|
||||
return typeid.New[networkID]()
|
||||
}
|
||||
|
||||
var errCapacityExceeded = errors.New("capacity exceeded")
|
||||
|
||||
type repository interface {
|
||||
create(ctx context.Context, name, adminEmail string) (*Network, error)
|
||||
getByID(ctx context.Context, id string) (*Network, error)
|
||||
updateName(ctx context.Context, id, name string) error
|
||||
delete(ctx context.Context, id string) error
|
||||
addMember(ctx context.Context, networkID, email string) error
|
||||
removeMember(ctx context.Context, networkID, email string) error
|
||||
getMemberEmails(ctx context.Context, networkID string) ([]string, error)
|
||||
getNetworksForEmail(ctx context.Context, email string) ([]*Network, error)
|
||||
isMember(ctx context.Context, networkID, email string) (bool, error)
|
||||
setOpenStreamCapacity(ctx context.Context, id string, capacity int) error
|
||||
incrementOpenStreamCount(ctx context.Context, id string) error
|
||||
decrementOpenStreamCount(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) create(ctx context.Context, name, adminEmail string) (*Network, error) {
|
||||
id, err := newNetworkID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var n Network
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO networks (id, name, admin_email) VALUES ($1, $2, $3)
|
||||
RETURNING id, name, admin_email, open_stream_capacity, open_stream_count, created_at`,
|
||||
id.String(), name, adminEmail,
|
||||
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.MemberEmails = []string{}
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Network, error) {
|
||||
var n Network
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, name, admin_email, open_stream_capacity, open_stream_count, created_at FROM networks WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
n.MemberEmails, err = r.getMemberEmails(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &n, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) updateName(ctx context.Context, id, name string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET name = $1 WHERE id = $2`,
|
||||
name, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) delete(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx, `DELETE FROM networks WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) addMember(ctx context.Context, networkID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO network_members (network_id, email) VALUES ($1, $2)
|
||||
ON CONFLICT (network_id, email) DO NOTHING`,
|
||||
networkID, email,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) removeMember(ctx context.Context, networkID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM network_members WHERE network_id = $1 AND email = $2`,
|
||||
networkID, email,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getMemberEmails(ctx context.Context, networkID string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT email FROM network_members WHERE network_id = $1`,
|
||||
networkID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var emails []string
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
return emails, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getNetworksForEmail(ctx context.Context, email string) ([]*Network, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT n.id, n.name, n.admin_email, n.open_stream_capacity, n.open_stream_count, n.created_at
|
||||
FROM networks n
|
||||
WHERE n.admin_email = $1
|
||||
OR EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = n.id AND nm.email = $1)`,
|
||||
email,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var networks []*Network
|
||||
for rows.Next() {
|
||||
var n Network
|
||||
if err := rows.Scan(&n.ID, &n.Name, &n.AdminEmail, &n.OpenStreamCapacity, &n.OpenStreamCount, &n.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
networks = append(networks, &n)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, n := range networks {
|
||||
n.MemberEmails, err = r.getMemberEmails(ctx, n.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return networks, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) isMember(ctx context.Context, networkID, email string) (bool, error) {
|
||||
var isMember bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM networks n
|
||||
LEFT JOIN network_members nm ON nm.network_id = n.id AND nm.email = $2
|
||||
WHERE n.id = $1 AND (n.admin_email = $2 OR nm.email IS NOT NULL)
|
||||
)
|
||||
`, networkID, email).Scan(&isMember)
|
||||
return isMember, err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) setOpenStreamCapacity(ctx context.Context, id string, capacity int) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET open_stream_capacity = $1 WHERE id = $2`,
|
||||
capacity, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) incrementOpenStreamCount(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET open_stream_count = open_stream_count + 1
|
||||
WHERE id = $1 AND open_stream_count < open_stream_capacity`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
// Check if network exists vs capacity exceeded
|
||||
var exists bool
|
||||
err := r.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM networks WHERE id = $1)`, id).Scan(&exists)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !exists {
|
||||
return errNotFound
|
||||
}
|
||||
return errCapacityExceeded
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) decrementOpenStreamCount(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE networks SET open_stream_count = GREATEST(0, open_stream_count - 1) WHERE id = $1`,
|
||||
id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package network
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("network not found")
|
||||
var ErrInvalidName = errors.New("name cannot be empty")
|
||||
var ErrCapacityExceeded = errors.New("active stream capacity exceeded")
|
||||
|
||||
type Service interface {
|
||||
// Create creates a network and adds adminEmail as the first member. Returns ErrInvalidName if name is empty.
|
||||
Create(ctx context.Context, name, adminEmail string) (*Network, error)
|
||||
// GetByID returns ErrNotFound if network doesn't exist.
|
||||
GetByID(ctx context.Context, id string) (*Network, error)
|
||||
// SetName returns ErrNotFound or ErrInvalidName.
|
||||
SetName(ctx context.Context, id, name string) error
|
||||
AddMembers(ctx context.Context, networkID string, emails []string) error
|
||||
RemoveMember(ctx context.Context, networkID, email string) error
|
||||
ListForEmail(ctx context.Context, email string) ([]*Network, error)
|
||||
IsMember(ctx context.Context, networkID, email string) (bool, error)
|
||||
|
||||
// SetOpenStreamCapacity sets the max open streams for a network. Returns ErrNotFound.
|
||||
SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error
|
||||
// IncrementOpenStreamCount returns ErrNotFound or ErrCapacityExceeded.
|
||||
IncrementOpenStreamCount(ctx context.Context, networkID string) error
|
||||
// DecrementOpenStreamCount returns ErrNotFound.
|
||||
DecrementOpenStreamCount(ctx context.Context, networkID string) error
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool) Service {
|
||||
return &serviceImpl{repo: newRepository(pool)}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Create(ctx context.Context, name, adminEmail string) (*Network, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, ErrInvalidName
|
||||
}
|
||||
|
||||
adminEmail, err := utils.NormalizeEmail(adminEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
network, err := s.repo.create(ctx, name, adminEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.AddMembers(ctx, network.ID, []string{adminEmail})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetByID(ctx context.Context, id string) (*Network, error) {
|
||||
n, err := s.repo.getByID(ctx, id)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return n, err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) SetName(ctx context.Context, id, name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return ErrInvalidName
|
||||
}
|
||||
|
||||
err := s.repo.updateName(ctx, id, name)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) AddMembers(ctx context.Context, networkID string, emails []string) error {
|
||||
for _, email := range emails {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
if err := s.repo.addMember(ctx, networkID, normalized); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) RemoveMember(ctx context.Context, networkID, email string) error {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
return s.repo.removeMember(ctx, networkID, email)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) ListForEmail(ctx context.Context, email string) ([]*Network, error) {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid email: %w", err)
|
||||
}
|
||||
return s.repo.getNetworksForEmail(ctx, email)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) IsMember(ctx context.Context, networkID, email string) (bool, error) {
|
||||
email, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.repo.isMember(ctx, networkID, email)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) SetOpenStreamCapacity(ctx context.Context, networkID string, capacity int) error {
|
||||
if capacity < 0 {
|
||||
capacity = 0
|
||||
}
|
||||
err := s.repo.setOpenStreamCapacity(ctx, networkID, capacity)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) IncrementOpenStreamCount(ctx context.Context, networkID string) error {
|
||||
err := s.repo.incrementOpenStreamCount(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
if errors.Is(err, errCapacityExceeded) {
|
||||
return ErrCapacityExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) DecrementOpenStreamCount(ctx context.Context, networkID string) error {
|
||||
err := s.repo.decrementOpenStreamCount(ctx, networkID)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package network_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/testhelper"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var dbPool *pgxpool.Pool
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbPool = testhelper.SetupTestDB()
|
||||
defer testhelper.TeardownTestDB()
|
||||
|
||||
ret := m.Run()
|
||||
os.Exit(ret)
|
||||
}
|
||||
|
||||
func TestNetworkService(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
svc := network.NewService(dbPool)
|
||||
|
||||
// Test Create
|
||||
createdNetwork, err := svc.Create(ctx, "Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, createdNetwork.ID)
|
||||
assert.Equal(t, "Test Network", createdNetwork.Name)
|
||||
assert.Equal(t, "[email protected]", createdNetwork.AdminEmail)
|
||||
assert.NotZero(t, createdNetwork.CreatedAt)
|
||||
|
||||
// Test GetByID
|
||||
foundNetwork, err := svc.GetByID(ctx, createdNetwork.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, createdNetwork.ID, foundNetwork.ID)
|
||||
assert.Equal(t, createdNetwork.Name, foundNetwork.Name)
|
||||
assert.Equal(t, createdNetwork.AdminEmail, foundNetwork.AdminEmail)
|
||||
|
||||
// Test GetByID with non-existent id
|
||||
_, err = svc.GetByID(ctx, "network_nonexistent")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, network.ErrNotFound)
|
||||
|
||||
// Test SetName
|
||||
err = svc.SetName(ctx, createdNetwork.ID, "Updated Network Name")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify name was updated
|
||||
updatedNetwork, err := svc.GetByID(ctx, createdNetwork.ID)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "Updated Network Name", updatedNetwork.Name)
|
||||
|
||||
// Test SetName with non-existent id
|
||||
err = svc.SetName(ctx, "network_nonexistent", "New Name")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, network.ErrNotFound)
|
||||
|
||||
// Test AddMembers
|
||||
err = svc.AddMembers(ctx, createdNetwork.ID, []string{"[email protected]", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test ListForEmail - should find network for admin
|
||||
networks, err := svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 1)
|
||||
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
||||
|
||||
// Test ListForEmail - should find network for member
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 1)
|
||||
assert.Equal(t, createdNetwork.ID, networks[0].ID)
|
||||
|
||||
// Test ListForEmail - should return empty for non-member
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 0)
|
||||
|
||||
// Test RemoveMember
|
||||
err = svc.RemoveMember(ctx, createdNetwork.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify member was removed
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 0)
|
||||
|
||||
// member2 should still have access
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 1)
|
||||
|
||||
// Create another network and verify ListForEmail returns multiple
|
||||
network2, err := svc.Create(ctx, "Second Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
networks, err = svc.ListForEmail(ctx, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, networks, 2)
|
||||
|
||||
// Verify both networks are returned
|
||||
networkIDs := []string{networks[0].ID, networks[1].ID}
|
||||
assert.Contains(t, networkIDs, createdNetwork.ID)
|
||||
assert.Contains(t, networkIDs, network2.ID)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package particle
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("particle not found")
|
||||
ErrAccessDenied = errors.New("access denied")
|
||||
ErrCapacityExceeded = errors.New("open stream capacity exceeded")
|
||||
ErrInvalidParent = errors.New("invalid parent particle")
|
||||
ErrInvalidType = errors.New("invalid particle type")
|
||||
ErrInvalidData = errors.New("invalid particle data")
|
||||
ErrNotAStream = errors.New("particle is not a stream")
|
||||
ErrStreamAlreadyOpen = errors.New("stream is already open")
|
||||
ErrStreamAlreadyClosed = errors.New("stream is already closed")
|
||||
ErrAccessExpansion = errors.New("cannot expand access beyond parent")
|
||||
ErrMembersRequired = errors.New("custom visibility requires at least one member")
|
||||
ErrInheritedAtRoot = errors.New("root particles cannot use inherited visibility")
|
||||
ErrNotAContainer = errors.New("only streams can have members")
|
||||
)
|
||||
@@ -0,0 +1,174 @@
|
||||
package particle
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ParticleType represents the type of particle
|
||||
type ParticleType string
|
||||
|
||||
const (
|
||||
TypeStream ParticleType = "stream"
|
||||
TypeFolder ParticleType = "folder"
|
||||
TypeMedia ParticleType = "media"
|
||||
TypeFile ParticleType = "file"
|
||||
TypeText ParticleType = "text"
|
||||
TypeQuest ParticleType = "quest"
|
||||
TypePaper ParticleType = "paper"
|
||||
// TypeThink ParticleType = "think"
|
||||
)
|
||||
|
||||
// VisibilityMode represents how access to a particle is determined
|
||||
type VisibilityMode string
|
||||
|
||||
const (
|
||||
VisibilityNetworkAll VisibilityMode = "network_all"
|
||||
VisibilityCustom VisibilityMode = "custom"
|
||||
VisibilityInherited VisibilityMode = "inherited"
|
||||
)
|
||||
|
||||
var ErrInvalidParticleType = errors.New("invalid particle type")
|
||||
var ErrInvalidVisibilityMode = errors.New("invalid visibility mode")
|
||||
|
||||
// ParseParticleType parses a string into a ParticleType
|
||||
func ParseParticleType(s string) (ParticleType, error) {
|
||||
switch s {
|
||||
case string(TypeStream):
|
||||
return TypeStream, nil
|
||||
case string(TypeFolder):
|
||||
return TypeFolder, nil
|
||||
case string(TypeMedia):
|
||||
return TypeMedia, nil
|
||||
case string(TypeFile):
|
||||
return TypeFile, nil
|
||||
case string(TypeText):
|
||||
return TypeText, nil
|
||||
case string(TypeQuest):
|
||||
return TypeQuest, nil
|
||||
case string(TypePaper):
|
||||
return TypePaper, nil
|
||||
default:
|
||||
return "", ErrInvalidParticleType
|
||||
}
|
||||
}
|
||||
|
||||
// ParseVisibilityMode parses a string into a VisibilityMode
|
||||
func ParseVisibilityMode(s string) (VisibilityMode, error) {
|
||||
switch s {
|
||||
case "", string(VisibilityNetworkAll):
|
||||
return VisibilityNetworkAll, nil
|
||||
case string(VisibilityCustom):
|
||||
return VisibilityCustom, nil
|
||||
case string(VisibilityInherited):
|
||||
return VisibilityInherited, nil
|
||||
default:
|
||||
return "", ErrInvalidVisibilityMode
|
||||
}
|
||||
}
|
||||
|
||||
// Stream status values
|
||||
type StreamStatus string
|
||||
|
||||
const (
|
||||
StreamStatusOpen StreamStatus = "open"
|
||||
StreamStatusClosed StreamStatus = "closed"
|
||||
)
|
||||
|
||||
// Particle represents a content particle in the system
|
||||
type Particle struct {
|
||||
ID string
|
||||
Type ParticleType
|
||||
NetworkID string
|
||||
ParentID *string
|
||||
CreatedByEmail string
|
||||
Visibility VisibilityMode
|
||||
Data json.RawMessage
|
||||
UpdatedAt time.Time
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// CreateInput represents the input for creating a new particle
|
||||
type CreateInput struct {
|
||||
Type ParticleType
|
||||
NetworkID string
|
||||
ParentID *string
|
||||
Data json.RawMessage
|
||||
Members []string // Only used when visibility is custom
|
||||
Visibility VisibilityMode
|
||||
}
|
||||
|
||||
// ListFilter represents filtering options for listing particles
|
||||
type ListFilter struct {
|
||||
Types []ParticleType
|
||||
}
|
||||
|
||||
// Cursor represents a pagination cursor for bidirectional pagination
|
||||
type Cursor struct {
|
||||
Position string // particle ID or timestamp
|
||||
Direction string // "before" or "after"
|
||||
}
|
||||
|
||||
// ParticleList represents a paginated list of particles
|
||||
type ParticleList struct {
|
||||
Particles []*Particle
|
||||
HasMore bool
|
||||
NextCursor *Cursor
|
||||
PrevCursor *Cursor
|
||||
}
|
||||
|
||||
// StreamData represents the data stored for stream particles
|
||||
type StreamData struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"` // "open" or "closed"
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// FolderData represents the data stored for folder particles
|
||||
type FolderData struct {
|
||||
Name string `json:"name"`
|
||||
Color *string `json:"color"`
|
||||
}
|
||||
|
||||
// MediaData represents the data stored for media particles
|
||||
type MediaData struct {
|
||||
ObjectID string `json:"object_id"` // reference to storage object
|
||||
MimeType string `json:"mime_type"`
|
||||
DurationMs int `json:"duration_ms"`
|
||||
// Caption *string `json:"caption"`
|
||||
}
|
||||
|
||||
// FileData represents the data stored for file particles
|
||||
type FileData struct {
|
||||
ObjectID string `json:"object_id"` // reference to storage object
|
||||
Filename string `json:"filename"`
|
||||
MimeType string `json:"mime_type"`
|
||||
Size int64 `json:"size"` // in bytes
|
||||
}
|
||||
|
||||
// TextData represents the data stored for text particles
|
||||
type TextData struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
|
||||
// QuestData represents the data stored for quest particles
|
||||
type QuestData struct {
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
AssignedTo *string `json:"assigned_to,omitempty"` // email
|
||||
DueDate *string `json:"due_date,omitempty"` // ISO date string
|
||||
}
|
||||
|
||||
// PaperData represents the data stored for paper particles
|
||||
type PaperData struct {
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"` // markdown
|
||||
}
|
||||
|
||||
// AckInfo represents an acknowledgment record
|
||||
type AckInfo struct {
|
||||
Email string
|
||||
AckedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,413 @@
|
||||
package particle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"go.jetify.com/typeid"
|
||||
)
|
||||
|
||||
var errNotFound = errors.New("not found")
|
||||
var errAccessDenied = errors.New("access denied")
|
||||
|
||||
type particleIDPrefix struct{}
|
||||
|
||||
func (particleIDPrefix) Prefix() string { return "particle" }
|
||||
|
||||
type particleID struct {
|
||||
typeid.TypeID[particleIDPrefix]
|
||||
}
|
||||
|
||||
func newParticleID() (particleID, error) {
|
||||
return typeid.New[particleID]()
|
||||
}
|
||||
|
||||
type repository interface {
|
||||
create(ctx context.Context, p *Particle) (*Particle, error)
|
||||
getByID(ctx context.Context, id string) (*Particle, error)
|
||||
update(ctx context.Context, id string, data json.RawMessage, updatedAt time.Time) error
|
||||
delete(ctx context.Context, id string) error
|
||||
|
||||
list(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, limit int, cursor *Cursor) ([]*Particle, error)
|
||||
|
||||
setVisibility(ctx context.Context, id string, mode VisibilityMode) error
|
||||
addMembers(ctx context.Context, particleID string, emails []string) error
|
||||
removeMembers(ctx context.Context, particleID string, emails []string) error
|
||||
getMembers(ctx context.Context, particleID string) ([]string, error)
|
||||
getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||
|
||||
// getAncestorChain returns the particle and all its ancestors (for access checks)
|
||||
getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error)
|
||||
isMemberOf(ctx context.Context, particleID, email string) (bool, error)
|
||||
|
||||
// Seen tracking
|
||||
markSeen(ctx context.Context, particleID, email string) error
|
||||
getSeenMap(ctx context.Context, particleIDs []string, email string) (map[string]bool, error)
|
||||
getUnseenCounts(ctx context.Context, streamIDs []string, email string) (map[string]int, error)
|
||||
|
||||
// Ack tracking
|
||||
ack(ctx context.Context, particleID, email string) error
|
||||
getAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
||||
}
|
||||
|
||||
type repositoryImpl struct {
|
||||
pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func newRepository(pool *pgxpool.Pool) repository {
|
||||
return &repositoryImpl{pool: pool}
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) create(ctx context.Context, p *Particle) (*Particle, error) {
|
||||
id, err := newParticleID()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var result Particle
|
||||
err = r.pool.QueryRow(ctx,
|
||||
`INSERT INTO particles (id, type, network_id, parent_id, created_by_email, visibility, data)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at`,
|
||||
id.String(), p.Type, p.NetworkID, p.ParentID, p.CreatedByEmail, p.Visibility, p.Data,
|
||||
).Scan(&result.ID, &result.Type, &result.NetworkID, &result.ParentID, &result.CreatedByEmail,
|
||||
&result.Visibility, &result.Data, &result.UpdatedAt, &result.CreatedAt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getByID(ctx context.Context, id string) (*Particle, error) {
|
||||
var p Particle
|
||||
err := r.pool.QueryRow(ctx,
|
||||
`SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
||||
FROM particles WHERE id = $1`,
|
||||
id,
|
||||
).Scan(&p.ID, &p.Type, &p.NetworkID, &p.ParentID, &p.CreatedByEmail,
|
||||
&p.Visibility, &p.Data, &p.UpdatedAt, &p.CreatedAt)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) update(ctx context.Context, id string, data json.RawMessage, updatedAt time.Time) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE particles SET data = $1, updated_at = $2 WHERE id = $3`,
|
||||
data, updatedAt, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) delete(ctx context.Context, id string) error {
|
||||
result, err := r.pool.Exec(ctx, `DELETE FROM particles WHERE id = $1`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) list(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, limit int, cursor *Cursor) ([]*Particle, error) {
|
||||
query := `SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
||||
FROM particles p WHERE p.network_id = $1`
|
||||
|
||||
args := []any{networkID}
|
||||
argIdx := 2
|
||||
|
||||
if parentID != nil {
|
||||
query += ` AND p.parent_id = $` + string(rune('0'+argIdx))
|
||||
args = append(args, *parentID)
|
||||
argIdx++
|
||||
} else {
|
||||
query += ` AND p.parent_id IS NULL`
|
||||
}
|
||||
|
||||
// Filter by visibility: include if network_all, inherited, OR user is a member
|
||||
query += ` AND (p.visibility = 'network_all' OR p.visibility = 'inherited' OR EXISTS (SELECT 1 FROM particle_members pm WHERE pm.particle_id = p.id AND pm.email = $` + string(rune('0'+argIdx)) + `))`
|
||||
args = append(args, requesterEmail)
|
||||
argIdx++
|
||||
|
||||
if len(filter.Types) > 0 {
|
||||
query += ` AND p.type = ANY($` + string(rune('0'+argIdx)) + `)`
|
||||
typeStrings := make([]string, len(filter.Types))
|
||||
for i, t := range filter.Types {
|
||||
typeStrings[i] = string(t)
|
||||
}
|
||||
args = append(args, typeStrings)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
if cursor != nil {
|
||||
if cursor.Direction == "before" {
|
||||
query += ` AND p.updated_at > $` + string(rune('0'+argIdx))
|
||||
} else {
|
||||
query += ` AND p.updated_at < $` + string(rune('0'+argIdx))
|
||||
}
|
||||
args = append(args, cursor.Position)
|
||||
argIdx++
|
||||
}
|
||||
|
||||
query += ` ORDER BY p.updated_at DESC LIMIT $` + string(rune('0'+argIdx))
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := r.pool.Query(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
return scanParticles(rows)
|
||||
}
|
||||
|
||||
func scanParticles(rows pgx.Rows) ([]*Particle, error) {
|
||||
var particles []*Particle
|
||||
for rows.Next() {
|
||||
var p Particle
|
||||
if err := rows.Scan(&p.ID, &p.Type, &p.NetworkID, &p.ParentID, &p.CreatedByEmail,
|
||||
&p.Visibility, &p.Data, &p.UpdatedAt, &p.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
particles = append(particles, &p)
|
||||
}
|
||||
return particles, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) setVisibility(ctx context.Context, id string, mode VisibilityMode) error {
|
||||
result, err := r.pool.Exec(ctx,
|
||||
`UPDATE particles SET visibility = $1, updated_at = NOW() WHERE id = $2`,
|
||||
mode, id,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.RowsAffected() == 0 {
|
||||
return errNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) addMembers(ctx context.Context, particleID string, emails []string) error {
|
||||
for _, email := range emails {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO particle_members (particle_id, email) VALUES ($1, $2)
|
||||
ON CONFLICT (particle_id, email) DO NOTHING`,
|
||||
particleID, email,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) removeMembers(ctx context.Context, particleID string, emails []string) error {
|
||||
for _, email := range emails {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`DELETE FROM particle_members WHERE particle_id = $1 AND email = $2`,
|
||||
particleID, email,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getMembers(ctx context.Context, particleID string) ([]string, error) {
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT email FROM particle_members WHERE particle_id = $1`,
|
||||
particleID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var emails []string
|
||||
for rows.Next() {
|
||||
var email string
|
||||
if err := rows.Scan(&email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
emails = append(emails, email)
|
||||
}
|
||||
return emails, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) {
|
||||
if len(particleIDs) == 0 {
|
||||
return map[string][]string{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT particle_id, email FROM particle_members WHERE particle_id = ANY($1)`,
|
||||
particleIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]string)
|
||||
for rows.Next() {
|
||||
var particleID, email string
|
||||
if err := rows.Scan(&particleID, &email); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[particleID] = append(result[particleID], email)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getAncestorChain(ctx context.Context, particleID string) ([]*Particle, error) {
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
WITH RECURSIVE ancestors AS (
|
||||
SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
||||
FROM particles WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT p.id, p.type, p.network_id, p.parent_id, p.created_by_email, p.visibility, p.data, p.updated_at, p.created_at
|
||||
FROM particles p JOIN ancestors a ON p.id = a.parent_id
|
||||
)
|
||||
SELECT id, type, network_id, parent_id, created_by_email, visibility, data, updated_at, created_at
|
||||
FROM ancestors;
|
||||
`, particleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
particles, err := scanParticles(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(particles) == 0 {
|
||||
return nil, errNotFound
|
||||
}
|
||||
return particles, nil
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) isMemberOf(ctx context.Context, particleID, email string) (bool, error) {
|
||||
var isMember bool
|
||||
err := r.pool.QueryRow(ctx, `
|
||||
SELECT EXISTS(SELECT 1 FROM particle_members WHERE particle_id = $1 AND email = $2)
|
||||
`, particleID, email).Scan(&isMember)
|
||||
return isMember, err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) markSeen(ctx context.Context, particleID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO particle_seen (particle_id, email) VALUES ($1, $2)
|
||||
ON CONFLICT (particle_id, email) DO NOTHING`,
|
||||
particleID, email,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getSeenMap(ctx context.Context, particleIDs []string, email string) (map[string]bool, error) {
|
||||
if len(particleIDs) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT particle_id FROM particle_seen WHERE particle_id = ANY($1) AND email = $2`,
|
||||
particleIDs, email,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]bool)
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = true
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getUnseenCounts(ctx context.Context, streamIDs []string, email string) (map[string]int, error) {
|
||||
if len(streamIDs) == 0 {
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx, `
|
||||
SELECT p.parent_id, COUNT(*)
|
||||
FROM particles p
|
||||
WHERE p.parent_id = ANY($1)
|
||||
AND NOT EXISTS (SELECT 1 FROM particle_seen ps WHERE ps.particle_id = p.id AND ps.email = $2)
|
||||
AND (p.visibility = 'network_all' OR p.visibility = 'inherited'
|
||||
OR EXISTS (SELECT 1 FROM particle_members pm WHERE pm.particle_id = p.id AND pm.email = $2))
|
||||
GROUP BY p.parent_id
|
||||
`, streamIDs, email)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var parentID string
|
||||
var count int
|
||||
if err := rows.Scan(&parentID, &count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[parentID] = count
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) ack(ctx context.Context, particleID, email string) error {
|
||||
_, err := r.pool.Exec(ctx,
|
||||
`INSERT INTO particle_acks (particle_id, email) VALUES ($1, $2)
|
||||
ON CONFLICT (particle_id, email) DO NOTHING`,
|
||||
particleID, email,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func (r *repositoryImpl) getAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) {
|
||||
if len(particleIDs) == 0 {
|
||||
return map[string][]AckInfo{}, nil
|
||||
}
|
||||
|
||||
rows, err := r.pool.Query(ctx,
|
||||
`SELECT particle_id, email, acked_at FROM particle_acks WHERE particle_id = ANY($1) ORDER BY acked_at`,
|
||||
particleIDs,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
result := make(map[string][]AckInfo)
|
||||
for rows.Next() {
|
||||
var particleID string
|
||||
var info AckInfo
|
||||
if err := rows.Scan(&particleID, &info.Email, &info.AckedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[particleID] = append(result[particleID], info)
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,954 @@
|
||||
package particle
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const defaultPageSize = 50
|
||||
|
||||
type Service interface {
|
||||
// Create creates a new particle. Caller must be a network member (verified by handler).
|
||||
// Returns ErrInvalidType, ErrInvalidData, ErrMembersRequired, ErrInvalidParent, ErrAccessDenied, or ErrCapacityExceeded.
|
||||
Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error)
|
||||
// GetByID returns ErrNotFound or ErrAccessDenied.
|
||||
GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error)
|
||||
// Update updates the particle's data. Returns ErrNotFound, ErrAccessDenied, or ErrInvalidData.
|
||||
Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error)
|
||||
// Delete returns ErrNotFound or ErrAccessDenied.
|
||||
Delete(ctx context.Context, id, requesterEmail string) error
|
||||
|
||||
// List returns particles in a network. Use parentID=nil for root particles.
|
||||
// Returns ErrNotFound or ErrAccessDenied if parentID is specified and inaccessible.
|
||||
List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error)
|
||||
|
||||
// OpenStream opens a closed stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, ErrStreamAlreadyOpen, or ErrCapacityExceeded.
|
||||
OpenStream(ctx context.Context, id, requesterEmail string) error
|
||||
// CloseStream closes an open stream. Returns ErrNotFound, ErrAccessDenied, ErrNotAStream, or ErrStreamAlreadyClosed.
|
||||
CloseStream(ctx context.Context, id, requesterEmail string) error
|
||||
|
||||
// SetVisibility changes the particle's visibility mode. Returns ErrNotFound, ErrAccessDenied, or ErrAccessExpansion.
|
||||
SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error
|
||||
// AddMembers adds members to a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
||||
AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
||||
// RemoveMembers removes members from a custom visibility particle. Returns ErrNotFound or ErrAccessDenied.
|
||||
RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error
|
||||
|
||||
// Seen tracking (private)
|
||||
MarkSeen(ctx context.Context, id, requesterEmail string) error
|
||||
MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error
|
||||
|
||||
// Ack tracking (public, permanent)
|
||||
Ack(ctx context.Context, id, requesterEmail string) error
|
||||
|
||||
// Unseen counts for stream list view
|
||||
GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error)
|
||||
|
||||
// Bulk lookups for handler enrichment
|
||||
GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error)
|
||||
GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error)
|
||||
GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error)
|
||||
}
|
||||
|
||||
type serviceImpl struct {
|
||||
repo repository
|
||||
networkSvc network.Service
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, networkSvc network.Service) Service {
|
||||
return &serviceImpl{
|
||||
repo: newRepository(pool),
|
||||
networkSvc: networkSvc,
|
||||
}
|
||||
}
|
||||
|
||||
// checkAccess verifies that the email has access to the particle based on visibility.
|
||||
// Assumes the caller is already verified as a network member (handler responsibility).
|
||||
// Walks up the ancestor chain only when visibility is inherited, stopping at the first
|
||||
// network_all or custom node.
|
||||
func (s *serviceImpl) checkAccess(ctx context.Context, particleID, email string) (bool, error) {
|
||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if len(ancestors) == 0 {
|
||||
return false, errNotFound
|
||||
}
|
||||
|
||||
// Build lookup map by ID
|
||||
byID := make(map[string]*Particle, len(ancestors))
|
||||
for _, p := range ancestors {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
|
||||
// Start from the target particle (first in chain) and walk up on inherited
|
||||
current := ancestors[0]
|
||||
for {
|
||||
switch current.Visibility {
|
||||
case VisibilityNetworkAll:
|
||||
return true, nil
|
||||
case VisibilityCustom:
|
||||
return s.repo.isMemberOf(ctx, current.ID, email)
|
||||
case VisibilityInherited:
|
||||
if current.ParentID == nil {
|
||||
// inherited at root is invalid state, deny access
|
||||
return false, nil
|
||||
}
|
||||
parent, ok := byID[*current.ParentID]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
current = parent
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Create(ctx context.Context, input CreateInput, requesterEmail string) (*Particle, error) {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate particle type
|
||||
if !isValidParticleType(input.Type) {
|
||||
return nil, ErrInvalidType
|
||||
}
|
||||
|
||||
// Validate data matches type requirements
|
||||
if err := validateParticleData(input.Type, input.Data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// MVP visibility rules:
|
||||
// - Child particles (have parent) → always inherited
|
||||
// - Root particles (no parent) → cannot be inherited, default network_all
|
||||
if input.ParentID != nil {
|
||||
// Children always inherit from parent
|
||||
input.Visibility = VisibilityInherited
|
||||
input.Members = nil // no members on inherited particles
|
||||
|
||||
// Reject streams and folders as children (MVP: streams are root-level only)
|
||||
if input.Type == TypeStream || input.Type == TypeFolder {
|
||||
return nil, ErrInvalidParent
|
||||
}
|
||||
} else {
|
||||
// Root particles cannot be inherited
|
||||
if input.Visibility == VisibilityInherited {
|
||||
return nil, ErrInheritedAtRoot
|
||||
}
|
||||
if input.Visibility == "" {
|
||||
input.Visibility = VisibilityNetworkAll
|
||||
}
|
||||
}
|
||||
|
||||
// Custom visibility requires at least one member and must be a stream
|
||||
if input.Visibility == VisibilityCustom {
|
||||
if input.Type != TypeStream {
|
||||
return nil, ErrNotAContainer
|
||||
}
|
||||
if len(input.Members) == 0 {
|
||||
return nil, ErrMembersRequired
|
||||
}
|
||||
}
|
||||
|
||||
// Network membership is verified by handler - we only check particle visibility
|
||||
// If parent specified, check parent access (visibility-based)
|
||||
if input.ParentID != nil {
|
||||
hasAccess, err := s.checkAccess(ctx, *input.ParentID, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrInvalidParent
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
// Build the particle
|
||||
p := &Particle{
|
||||
Type: input.Type,
|
||||
NetworkID: input.NetworkID,
|
||||
ParentID: input.ParentID,
|
||||
CreatedByEmail: requesterEmail,
|
||||
Visibility: input.Visibility,
|
||||
Data: input.Data,
|
||||
}
|
||||
|
||||
if p.Data == nil {
|
||||
p.Data = json.RawMessage("{}")
|
||||
}
|
||||
|
||||
// For streams, set initial status to open and check capacity
|
||||
if input.Type == TypeStream {
|
||||
// Set status to open in the data JSON
|
||||
data, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Data = data
|
||||
|
||||
// Check and increment capacity
|
||||
err = s.networkSvc.IncrementOpenStreamCount(ctx, input.NetworkID)
|
||||
if err != nil {
|
||||
if errors.Is(err, network.ErrCapacityExceeded) {
|
||||
return nil, ErrCapacityExceeded
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the particle
|
||||
created, err := s.repo.create(ctx, p)
|
||||
if err != nil {
|
||||
// If we incremented the stream count but creation failed, decrement it
|
||||
if input.Type == TypeStream {
|
||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, input.NetworkID); decErr != nil {
|
||||
slog.Warn("failed to rollback stream count after particle creation failure", "error", decErr, "network_id", input.NetworkID)
|
||||
}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add members if custom visibility (only streams for MVP)
|
||||
if input.Visibility == VisibilityCustom && len(input.Members) > 0 {
|
||||
normalizedEmails := make([]string, 0, len(input.Members)+1)
|
||||
// Always include the creator
|
||||
normalizedEmails = append(normalizedEmails, requesterEmail)
|
||||
for _, email := range input.Members {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
continue // Skip invalid emails
|
||||
}
|
||||
if normalized == requesterEmail {
|
||||
continue // Already added
|
||||
}
|
||||
normalizedEmails = append(normalizedEmails, normalized)
|
||||
}
|
||||
if err := s.repo.addMembers(ctx, created.ID, normalizedEmails); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return created, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetByID(ctx context.Context, id, requesterEmail string) (*Particle, error) {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Update(ctx context.Context, id string, data json.RawMessage, requesterEmail string) (*Particle, error) {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to validate data against its type
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Validate data matches type requirements
|
||||
if err := validateParticleData(p.Type, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.repo.update(ctx, id, data, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.getByID(ctx, id)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Delete(ctx context.Context, id, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check if it's an open stream
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// If it's an open stream, decrement the count
|
||||
if p.Type == TypeStream && getStreamStatus(p.Data) == string(StreamStatusOpen) {
|
||||
if err := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = s.repo.delete(ctx, id)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *serviceImpl) List(ctx context.Context, networkID string, parentID *string, requesterEmail string, filter ListFilter, cursor *Cursor, limit int) (*ParticleList, error) {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Network membership is verified by handler - we only check particle visibility
|
||||
// If parentID specified, check access to parent (visibility-based)
|
||||
if parentID != nil {
|
||||
hasAccess, err := s.checkAccess(ctx, *parentID, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !hasAccess {
|
||||
return nil, ErrAccessDenied
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch one extra to determine if there are more
|
||||
// Access filtering is done in the query itself (network_all OR user is member)
|
||||
if limit == 0 {
|
||||
limit = defaultPageSize
|
||||
}
|
||||
extraLimit := limit + 1
|
||||
particles, err := s.repo.list(ctx, networkID, parentID, requesterEmail, filter, extraLimit, cursor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hasMore := len(particles) > limit
|
||||
result := &ParticleList{
|
||||
HasMore: hasMore,
|
||||
}
|
||||
|
||||
if hasMore {
|
||||
particles = particles[:limit]
|
||||
}
|
||||
result.Particles = particles
|
||||
|
||||
// Bidirectional cursors
|
||||
if len(particles) > 0 {
|
||||
firstParticle := particles[0]
|
||||
lastParticle := particles[len(particles)-1]
|
||||
|
||||
result.PrevCursor = &Cursor{
|
||||
Position: firstParticle.UpdatedAt.Format(time.RFC3339Nano),
|
||||
Direction: "before",
|
||||
}
|
||||
|
||||
if result.HasMore {
|
||||
result.NextCursor = &Cursor{
|
||||
Position: lastParticle.UpdatedAt.Format(time.RFC3339Nano),
|
||||
Direction: "after",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) OpenStream(ctx context.Context, id, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAStream
|
||||
}
|
||||
|
||||
if getStreamStatus(p.Data) == string(StreamStatusOpen) {
|
||||
return ErrStreamAlreadyOpen
|
||||
}
|
||||
|
||||
// Check and increment capacity
|
||||
err = s.networkSvc.IncrementOpenStreamCount(ctx, p.NetworkID)
|
||||
if err != nil {
|
||||
if errors.Is(err, network.ErrCapacityExceeded) {
|
||||
return ErrCapacityExceeded
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Update stream status in data
|
||||
newData, err := setStreamStatus(p.Data, string(StreamStatusOpen))
|
||||
if err != nil {
|
||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
|
||||
slog.Warn("failed to rollback stream count after status update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.repo.update(ctx, id, newData, time.Now())
|
||||
if err != nil {
|
||||
// Rollback the capacity increment
|
||||
if decErr := s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID); decErr != nil {
|
||||
slog.Warn("failed to rollback stream count after particle update failure", "error", decErr, "network_id", p.NetworkID, "particle_id", id)
|
||||
}
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) CloseStream(ctx context.Context, id, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAStream
|
||||
}
|
||||
|
||||
if getStreamStatus(p.Data) == string(StreamStatusClosed) {
|
||||
return ErrStreamAlreadyClosed
|
||||
}
|
||||
|
||||
// Update stream status in data
|
||||
newData, err := setStreamStatus(p.Data, string(StreamStatusClosed))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.repo.update(ctx, id, newData, time.Now())
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Decrement capacity
|
||||
return s.networkSvc.DecrementOpenStreamCount(ctx, p.NetworkID)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) SetVisibility(ctx context.Context, id string, mode VisibilityMode, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check constraints
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Root particles cannot be inherited
|
||||
if mode == VisibilityInherited && p.ParentID == nil {
|
||||
return ErrInheritedAtRoot
|
||||
}
|
||||
|
||||
// If expanding to network_all, check that parent's effective visibility allows it
|
||||
if mode == VisibilityNetworkAll && p.ParentID != nil {
|
||||
parentVis, err := s.getEffectiveVisibility(ctx, *p.ParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if parentVis == VisibilityCustom {
|
||||
return ErrAccessExpansion
|
||||
}
|
||||
}
|
||||
|
||||
err = s.repo.setVisibility(ctx, id, mode)
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// getEffectiveVisibility walks up the inherited chain to find the concrete visibility mode.
|
||||
func (s *serviceImpl) getEffectiveVisibility(ctx context.Context, particleID string) (VisibilityMode, error) {
|
||||
ancestors, err := s.repo.getAncestorChain(ctx, particleID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(ancestors) == 0 {
|
||||
return "", errNotFound
|
||||
}
|
||||
|
||||
byID := make(map[string]*Particle, len(ancestors))
|
||||
for _, p := range ancestors {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
|
||||
current := ancestors[0]
|
||||
for {
|
||||
if current.Visibility != VisibilityInherited {
|
||||
return current.Visibility, nil
|
||||
}
|
||||
if current.ParentID == nil {
|
||||
return VisibilityNetworkAll, nil
|
||||
}
|
||||
parent, ok := byID[*current.ParentID]
|
||||
if !ok {
|
||||
return VisibilityNetworkAll, nil
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
func (s *serviceImpl) AddMembers(ctx context.Context, id string, emails []string, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check type and parent access
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Only streams can have members
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAContainer
|
||||
}
|
||||
|
||||
// Validate and normalize emails, check network membership
|
||||
normalizedEmails := make([]string, 0, len(emails))
|
||||
for _, email := range emails {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Root stream - check network membership
|
||||
isMember, err := s.networkSvc.IsMember(ctx, p.NetworkID, normalized)
|
||||
if err != nil || !isMember {
|
||||
continue
|
||||
}
|
||||
|
||||
normalizedEmails = append(normalizedEmails, normalized)
|
||||
}
|
||||
|
||||
if len(normalizedEmails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.repo.addMembers(ctx, id, normalizedEmails)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) RemoveMembers(ctx context.Context, id string, emails []string, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Get the particle to check type
|
||||
p, err := s.repo.getByID(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Only streams can have members
|
||||
if p.Type != TypeStream {
|
||||
return ErrNotAContainer
|
||||
}
|
||||
|
||||
normalizedEmails := make([]string, 0, len(emails))
|
||||
for _, email := range emails {
|
||||
normalized, err := utils.NormalizeEmail(email)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
normalizedEmails = append(normalizedEmails, normalized)
|
||||
}
|
||||
|
||||
if len(normalizedEmails) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return s.repo.removeMembers(ctx, id, normalizedEmails)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) MarkSeen(ctx context.Context, id, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
return s.repo.markSeen(ctx, id, requesterEmail)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) MarkSeenBatch(ctx context.Context, ids []string, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access for each particle and mark seen
|
||||
for _, id := range ids {
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
continue // Skip non-existent particles
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
continue // Skip inaccessible particles
|
||||
}
|
||||
|
||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serviceImpl) Ack(ctx context.Context, id, requesterEmail string) error {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Check access
|
||||
hasAccess, err := s.checkAccess(ctx, id, requesterEmail)
|
||||
if err != nil {
|
||||
if errors.Is(err, errNotFound) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if !hasAccess {
|
||||
return ErrAccessDenied
|
||||
}
|
||||
|
||||
// Ack also marks as seen
|
||||
if err := s.repo.markSeen(ctx, id, requesterEmail); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.repo.ack(ctx, id, requesterEmail)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetUnseenCounts(ctx context.Context, networkID string, streamIDs []string, requesterEmail string) (map[string]int, error) {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.getUnseenCounts(ctx, streamIDs, requesterEmail)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetSeenMap(ctx context.Context, particleIDs []string, requesterEmail string) (map[string]bool, error) {
|
||||
requesterEmail, err := utils.NormalizeEmail(requesterEmail)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.repo.getSeenMap(ctx, particleIDs, requesterEmail)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetAcksMap(ctx context.Context, particleIDs []string) (map[string][]AckInfo, error) {
|
||||
return s.repo.getAcksMap(ctx, particleIDs)
|
||||
}
|
||||
|
||||
func (s *serviceImpl) GetMembersMap(ctx context.Context, particleIDs []string) (map[string][]string, error) {
|
||||
return s.repo.getMembersMap(ctx, particleIDs)
|
||||
}
|
||||
|
||||
func isValidParticleType(t ParticleType) bool {
|
||||
switch t {
|
||||
case TypeStream, TypeFolder, TypeMedia, TypeFile, TypeText, TypeQuest, TypePaper:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// getStreamStatus extracts the status from a stream particle's data
|
||||
func getStreamStatus(data json.RawMessage) string {
|
||||
var d StreamData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return ""
|
||||
}
|
||||
return d.Status
|
||||
}
|
||||
|
||||
// setStreamStatus updates the status in a stream particle's data
|
||||
func setStreamStatus(data json.RawMessage, status string) (json.RawMessage, error) {
|
||||
var d StreamData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
d = StreamData{}
|
||||
}
|
||||
d.Status = status
|
||||
return json.Marshal(d)
|
||||
}
|
||||
|
||||
// validateParticleData validates that the data field contains valid JSON
|
||||
// and has required fields for the given particle type.
|
||||
func validateParticleData(pType ParticleType, data json.RawMessage) error {
|
||||
// Empty or null data is allowed - will default to {}
|
||||
if len(data) == 0 || string(data) == "null" || string(data) == "{}" {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch pType {
|
||||
case TypeStream:
|
||||
var d StreamData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.Name == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("stream requires name"))
|
||||
}
|
||||
if d.Status != string(StreamStatusOpen) && d.Status != string(StreamStatusClosed) {
|
||||
return errors.Join(ErrInvalidData, errors.New("stream requires valid status"))
|
||||
}
|
||||
|
||||
case TypeFolder:
|
||||
var d FolderData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.Name == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("folder requires name"))
|
||||
}
|
||||
|
||||
case TypeMedia:
|
||||
var d MediaData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.ObjectID == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("media requires object_id"))
|
||||
}
|
||||
if d.MimeType == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("media requires mime_type"))
|
||||
}
|
||||
if d.DurationMs <= 0 {
|
||||
return errors.Join(ErrInvalidData, errors.New("media requires positive duration_ms"))
|
||||
}
|
||||
|
||||
case TypeFile:
|
||||
var d FileData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.ObjectID == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("file requires object_id"))
|
||||
}
|
||||
if d.Filename == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("file requires filename"))
|
||||
}
|
||||
if d.MimeType == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("file requires mime_type"))
|
||||
}
|
||||
if d.Size <= 0 {
|
||||
return errors.Join(ErrInvalidData, errors.New("file requires non-negative size"))
|
||||
}
|
||||
|
||||
case TypeText:
|
||||
var d TextData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.Content == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("text requires content"))
|
||||
}
|
||||
|
||||
case TypeQuest:
|
||||
var d QuestData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.Title == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("quest requires title"))
|
||||
}
|
||||
if d.Description == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("quest requires description"))
|
||||
}
|
||||
|
||||
case TypePaper:
|
||||
var d PaperData
|
||||
if err := json.Unmarshal(data, &d); err != nil {
|
||||
return errors.Join(ErrInvalidData, err)
|
||||
}
|
||||
if d.Title == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("paper requires title"))
|
||||
}
|
||||
if d.Content == "" {
|
||||
return errors.Join(ErrInvalidData, errors.New("paper requires content"))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
package particle_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/flowy-live/llink/internal/network"
|
||||
"github.com/flowy-live/llink/internal/particle"
|
||||
"github.com/flowy-live/llink/internal/testhelper"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
var dbPool *pgxpool.Pool
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
dbPool = testhelper.SetupTestDB()
|
||||
defer testhelper.TeardownTestDB()
|
||||
|
||||
ret := m.Run()
|
||||
os.Exit(ret)
|
||||
}
|
||||
|
||||
func getStreamStatus(data json.RawMessage) string {
|
||||
var d struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
json.Unmarshal(data, &d)
|
||||
return d.Status
|
||||
}
|
||||
|
||||
func TestParticleService_CreateAndGet(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network first
|
||||
net, err := networkSvc.Create(ctx, "Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test Create stream particle
|
||||
data := json.RawMessage(`{"name":"My Stream","status":"open","description":"A test stream"}`)
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Data: data,
|
||||
}
|
||||
created, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
assert.Equal(t, particle.TypeStream, created.Type)
|
||||
assert.Equal(t, net.ID, created.NetworkID)
|
||||
assert.Nil(t, created.ParentID)
|
||||
assert.Equal(t, particle.VisibilityNetworkAll, created.Visibility)
|
||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(created.Data))
|
||||
|
||||
// Test GetByID
|
||||
found, err := svc.GetByID(ctx, created.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, created.ID, found.ID)
|
||||
|
||||
// Test GetByID with non-existent id
|
||||
_, err = svc.GetByID(ctx, "particle_nonexistent", "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrNotFound)
|
||||
|
||||
// Note: Network membership check is handler's responsibility
|
||||
// Service assumes caller is already verified as network member
|
||||
}
|
||||
|
||||
func TestParticleService_StreamCapacity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network with capacity 2
|
||||
net, err := networkSvc.Create(ctx, "Capacity Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = networkSvc.SetOpenStreamCapacity(ctx, net.ID, 2)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create first stream - should succeed
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Stream 1","status":"open"}`),
|
||||
}
|
||||
stream1, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create second stream - should succeed
|
||||
input.Data = json.RawMessage(`{"name":"Stream 2","status":"open"}`)
|
||||
stream2, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create third stream - should fail with capacity exceeded
|
||||
input.Data = json.RawMessage(`{"name":"Stream 3","status":"open"}`)
|
||||
_, err = svc.Create(ctx, input, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrCapacityExceeded)
|
||||
|
||||
// Close a stream
|
||||
err = svc.CloseStream(ctx, stream1.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Now we can create another stream
|
||||
stream3, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, stream3.ID)
|
||||
|
||||
// Verify stream2 is still open
|
||||
found, err := svc.GetByID(ctx, stream2.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
|
||||
|
||||
// Verify stream1 is closed
|
||||
found, err = svc.GetByID(ctx, stream1.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
|
||||
}
|
||||
|
||||
func TestParticleService_NestedParticles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Nested Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a parent stream
|
||||
streamInput := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Parent Stream","status":"open"}`),
|
||||
}
|
||||
stream, err := svc.Create(ctx, streamInput, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a text particle as child
|
||||
textInput := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: net.ID,
|
||||
ParentID: &stream.ID,
|
||||
Data: json.RawMessage(`{"content":"Hello world"}`),
|
||||
}
|
||||
text, err := svc.Create(ctx, textInput, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, stream.ID, *text.ParentID)
|
||||
|
||||
// Create a file as child of stream
|
||||
fileInput := particle.CreateInput{
|
||||
Type: particle.TypeFile,
|
||||
NetworkID: net.ID,
|
||||
ParentID: &stream.ID,
|
||||
Data: json.RawMessage(`{"object_id":"obj_abc123","filename":"test.pdf","mime_type":"application/pdf","size":1024}`),
|
||||
}
|
||||
file, err := svc.Create(ctx, fileInput, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, stream.ID, *file.ParentID)
|
||||
|
||||
// List children of stream
|
||||
children, err := svc.List(ctx, net.ID, &stream.ID, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, children.Particles, 2)
|
||||
}
|
||||
|
||||
func TestParticleService_CustomVisibility(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network with a member
|
||||
net, err := networkSvc.Create(ctx, "Visibility Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"[email protected]", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a stream with custom visibility including only admin and member
|
||||
streamInput := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]", "[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
||||
}
|
||||
stream, err := svc.Create(ctx, streamInput, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Admin can access
|
||||
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Member can access
|
||||
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Other network member cannot access
|
||||
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrAccessDenied)
|
||||
|
||||
// Non-network member cannot access
|
||||
_, err = svc.GetByID(ctx, stream.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrAccessDenied)
|
||||
}
|
||||
|
||||
func TestParticleService_UpdateAndDelete(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Update Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a text particle
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"content":"Original content"}`),
|
||||
}
|
||||
created, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Update the particle
|
||||
newData := json.RawMessage(`{"content":"Updated content"}`)
|
||||
updated, err := svc.Update(ctx, created.ID, newData, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
// PostgreSQL normalizes JSON, so compare unmarshaled values
|
||||
var expected, actual map[string]interface{}
|
||||
json.Unmarshal(newData, &expected)
|
||||
json.Unmarshal(updated.Data, &actual)
|
||||
assert.Equal(t, expected, actual)
|
||||
|
||||
// Delete the particle
|
||||
err = svc.Delete(ctx, created.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify it's gone
|
||||
_, err = svc.GetByID(ctx, created.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrNotFound)
|
||||
}
|
||||
|
||||
func TestParticleService_ListRootParticles(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "List Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create multiple root particles
|
||||
for i := 0; i < 3; i++ {
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Stream","status":"open"}`),
|
||||
}
|
||||
_, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
// List root particles (parentID = nil)
|
||||
list, err := svc.List(ctx, net.ID, nil, "[email protected]", particle.ListFilter{}, nil, 50)
|
||||
assert.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(list.Particles), 3)
|
||||
}
|
||||
|
||||
func TestParticleService_OpenCloseStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Open Close Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a stream
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"name":"Test Stream","status":"open"}`),
|
||||
}
|
||||
stream, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(stream.Data))
|
||||
|
||||
// Close the stream
|
||||
err = svc.CloseStream(ctx, stream.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify it's closed
|
||||
found, err := svc.GetByID(ctx, stream.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusClosed), getStreamStatus(found.Data))
|
||||
|
||||
// Try to close again - should error
|
||||
err = svc.CloseStream(ctx, stream.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrStreamAlreadyClosed)
|
||||
|
||||
// Reopen the stream
|
||||
err = svc.OpenStream(ctx, stream.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify it's open
|
||||
found, err = svc.GetByID(ctx, stream.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, string(particle.StreamStatusOpen), getStreamStatus(found.Data))
|
||||
|
||||
// Try to open again - should error
|
||||
err = svc.OpenStream(ctx, stream.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrStreamAlreadyOpen)
|
||||
}
|
||||
|
||||
func TestParticleService_NotAStream(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network
|
||||
net, err := networkSvc.Create(ctx, "Not Stream Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a text particle
|
||||
input := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: net.ID,
|
||||
Data: json.RawMessage(`{"content":"Hello"}`),
|
||||
}
|
||||
text, err := svc.Create(ctx, input, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Try to open it as a stream
|
||||
err = svc.OpenStream(ctx, text.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrNotAStream)
|
||||
|
||||
// Try to close it as a stream
|
||||
err = svc.CloseStream(ctx, text.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrNotAStream)
|
||||
}
|
||||
|
||||
func TestParticleService_AccessInheritance(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
networkSvc := network.NewService(dbPool)
|
||||
svc := particle.NewService(dbPool, networkSvc)
|
||||
|
||||
// Create a network with members
|
||||
net, err := networkSvc.Create(ctx, "Access Inheritance Test Network", "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = networkSvc.AddMembers(ctx, net.ID, []string{"[email protected]", "[email protected]"})
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a stream with custom visibility (admin and member only)
|
||||
streamInput := particle.CreateInput{
|
||||
Type: particle.TypeStream,
|
||||
NetworkID: net.ID,
|
||||
Visibility: particle.VisibilityCustom,
|
||||
Members: []string{"[email protected]", "[email protected]"},
|
||||
Data: json.RawMessage(`{"name":"Private Stream","status":"open"}`),
|
||||
}
|
||||
stream, err := svc.Create(ctx, streamInput, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Create a child text (network_all visibility)
|
||||
textInput := particle.CreateInput{
|
||||
Type: particle.TypeText,
|
||||
NetworkID: net.ID,
|
||||
ParentID: &stream.ID,
|
||||
Data: json.RawMessage(`{"content":"Child text"}`),
|
||||
}
|
||||
text, err := svc.Create(ctx, textInput, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Admin can access child
|
||||
_, err = svc.GetByID(ctx, text.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Member can access child
|
||||
_, err = svc.GetByID(ctx, text.ID, "[email protected]")
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Other cannot access child (even though child is network_all, parent restricts)
|
||||
_, err = svc.GetByID(ctx, text.ID, "[email protected]")
|
||||
assert.Error(t, err)
|
||||
assert.ErrorIs(t, err, particle.ErrAccessDenied)
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/flowy-live/llink/internal/utils"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func ConnectAndTestRedis(db int) *redis.Client {
|
||||
redisHost := utils.MustGetEnv("REDIS_HOST")
|
||||
if redisHost == "" {
|
||||
slog.Error("must provide REDIS_HOST")
|
||||
os.Exit(1)
|
||||
}
|
||||
redisAddr := fmt.Sprintf("%s:%s", redisHost, "6379")
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: redisAddr,
|
||||
Password: "", // no password set
|
||||
DB: db,
|
||||
})
|
||||
|
||||
err := rdb.Set(context.Background(), "key", "value", 0).Err()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
val, err := rdb.Get(context.Background(), "key").Result()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
slog.Debug("redis test", "key", val)
|
||||
if val != "value" {
|
||||
panic("unexpected value")
|
||||
}
|
||||
|
||||
err = rdb.Del(context.Background(), "key").Err()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return rdb
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package testhelper
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
_ "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/testcontainers/testcontainers-go"
|
||||
"github.com/testcontainers/testcontainers-go/modules/postgres"
|
||||
"github.com/testcontainers/testcontainers-go/wait"
|
||||
)
|
||||
|
||||
var (
|
||||
container *postgres.PostgresContainer
|
||||
dbPool *pgxpool.Pool
|
||||
ctx = context.Background()
|
||||
)
|
||||
|
||||
// SetupTestDB starts a PostgreSQL container and returns a connection pool
|
||||
func SetupTestDB() *pgxpool.Pool {
|
||||
var err error
|
||||
|
||||
// Start PostgreSQL container
|
||||
container, err = postgres.Run(ctx,
|
||||
"postgres:16-alpine",
|
||||
postgres.WithDatabase("testdb"),
|
||||
postgres.WithUsername("postgres"),
|
||||
postgres.WithPassword("testpassword"),
|
||||
testcontainers.WithWaitStrategy(
|
||||
wait.ForLog("database system is ready to accept connections").
|
||||
WithOccurrence(2).
|
||||
WithStartupTimeout(60*time.Second),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
slog.Error("failed to start postgres container", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Get connection URL
|
||||
host, err := container.Host(ctx)
|
||||
if err != nil {
|
||||
slog.Error("failed to get container host", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
port, err := container.MappedPort(ctx, "5432")
|
||||
if err != nil {
|
||||
slog.Error("failed to get container port", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
connectionURL := fmt.Sprintf("postgres://postgres:testpassword@%s:%s/testdb?sslmode=disable",
|
||||
host, port.Port())
|
||||
|
||||
// Run migrations
|
||||
m, err := migrate.New("file://../../migrations", connectionURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to create migrate instance", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
slog.Error("failed to run migrations", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Create connection pool
|
||||
dbPool, err = pgxpool.New(ctx, connectionURL)
|
||||
if err != nil {
|
||||
slog.Error("failed to create connection pool", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
return dbPool
|
||||
}
|
||||
|
||||
// TeardownTestDB cleans up the test database
|
||||
func TeardownTestDB() {
|
||||
if dbPool != nil {
|
||||
dbPool.Close()
|
||||
}
|
||||
if container != nil {
|
||||
if err := container.Terminate(ctx); err != nil {
|
||||
slog.Error("failed to terminate container", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/mail"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func NormalizeEmail(email string) (string, error) {
|
||||
if email == "" {
|
||||
return "", errors.New("email empty")
|
||||
}
|
||||
|
||||
lower := strings.ToLower(email)
|
||||
lower = strings.TrimSpace(lower)
|
||||
|
||||
parsed, err := mail.ParseAddress(lower)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parsed.Address, nil
|
||||
}
|
||||
|
||||
func IsValidEmail(email string) bool {
|
||||
_, err := mail.ParseAddress(email)
|
||||
return err == nil
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// enum of environment variables
|
||||
type EnvVar string
|
||||
|
||||
const ()
|
||||
|
||||
// MustGetEnv returns the value of the environment variable with the given key.
|
||||
// panics if the variable is not set.
|
||||
func MustGetEnv[T string | EnvVar](key T) string {
|
||||
keyString := string(key)
|
||||
value := os.Getenv(keyString)
|
||||
if value == "" {
|
||||
logrus.Errorf("Missing required environment variable %s", key)
|
||||
panic("Missing required environment variable")
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
// GetEnv returns the value of the environment variable with the given key.
|
||||
// returns an empty string if the variable is not set.
|
||||
func GetEnv(key string) string {
|
||||
value := os.Getenv(key)
|
||||
if value == "" {
|
||||
logrus.Warnf("Missing optional environment variable %s", key)
|
||||
}
|
||||
return value
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func OptionalBool(input *bool) bool {
|
||||
if input == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return *input
|
||||
}
|
||||
|
||||
func CreateOptionalBool(input bool) *bool {
|
||||
if input == false {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &input
|
||||
}
|
||||
|
||||
// OptionalString converts a non-nil *string to the respective string or returns "".
|
||||
func OptionalString(input *string) string {
|
||||
if input == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
return *input
|
||||
}
|
||||
|
||||
// OptionalInt converts a non-nil *int to the respective int, otherwise returns 0.
|
||||
func OptionalInt(input *int) int {
|
||||
if input == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *input
|
||||
}
|
||||
|
||||
// CreateOptionalInt when given a zero value int (0), it returns a nil *int.
|
||||
// Otherwise, it gives a proper *int with valid value.
|
||||
func CreateOptionalInt(input int) *int {
|
||||
if input == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &input
|
||||
}
|
||||
|
||||
// CreateOptionalString when given an empty string, it returns a nil *string.
|
||||
// Otherwise, it gives a proper *string with valid value.
|
||||
func CreateOptionalString(input string) *string {
|
||||
if input == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &input
|
||||
}
|
||||
|
||||
// GetNumberFromString converts a string to a number.
|
||||
// Returns error if the query is not a number.
|
||||
func GetNumberFromString(input string) (int, error) {
|
||||
for _, c := range input {
|
||||
if c < '0' || c > '9' {
|
||||
return 0, errors.New("invalid input")
|
||||
}
|
||||
}
|
||||
|
||||
idAsInt, err := strconv.Atoi(input)
|
||||
if err != nil || idAsInt <= 0 {
|
||||
return 0, errors.New("invalid input")
|
||||
}
|
||||
|
||||
return idAsInt, nil
|
||||
}
|
||||
|
||||
type Number interface {
|
||||
int | int8 | int16 | int32 | int64 | uint | uint8 | uint16 | uint32 | uint64 | float32 | float64
|
||||
}
|
||||
|
||||
// OptionalNumber converts a non-nil *NUMBER to the respective number value or returns 0.
|
||||
func OptionalNumber[T Number](input *T) T {
|
||||
if input == nil {
|
||||
return 0
|
||||
}
|
||||
|
||||
return *input
|
||||
}
|
||||
|
||||
// CreateOptionalNumber when given an zero value NUMBER (0), it returns a nil *NUMBER, otherwise, it gives a proper *NUMBER with valid value.
|
||||
func CreateOptionalNumber[T Number](input T) *T {
|
||||
if input == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &input
|
||||
}
|
||||
|
||||
func IntToInt64Pointer(input *int) *int64 {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
val := int64(*input)
|
||||
return &val
|
||||
}
|
||||
|
||||
func NumberToNumberPointer[T Number, Y Number](input *T) *Y {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
val := Y(*input)
|
||||
return &val
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
charset = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
charsetNumbers = "0123456789"
|
||||
)
|
||||
|
||||
// RandomString generates a random string of length n based on self defined charset
|
||||
func RandomString(length int) string {
|
||||
sb := strings.Builder{}
|
||||
sb.Grow(length)
|
||||
for i := 0; i < length; i++ {
|
||||
sb.WriteByte(charset[rand.Intn(len(charset))])
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// RandomStringNumbers
|
||||
func RandomStringNumbers(length int) string {
|
||||
sb := strings.Builder{}
|
||||
sb.Grow(length)
|
||||
for range length {
|
||||
sb.WriteByte(charsetNumbers[rand.Intn(len(charsetNumbers))])
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
func Unique(slice []string) []string {
|
||||
keys := make(map[string]bool)
|
||||
list := []string{}
|
||||
|
||||
for _, entry := range slice {
|
||||
if _, value := keys[entry]; !value {
|
||||
keys[entry] = true
|
||||
list = append(list, entry)
|
||||
}
|
||||
}
|
||||
|
||||
return list
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package utils
|
||||
|
||||
func HoursToMicroseconds[K uint32 | uint64](hours K) int64 {
|
||||
return int64(hours) * 60 * 60 * 1000000
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package utils
|
||||
|
||||
import "net/url"
|
||||
|
||||
func IsValidURL(urlString string) bool {
|
||||
_, err := url.ParseRequestURI(urlString)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
u, err := url.Parse(urlString)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeEmail(t *testing.T) {
|
||||
email := "[email protected]"
|
||||
normalizedEmail, err := NormalizeEmail(email)
|
||||
if err != nil {
|
||||
t.Error("invalid email parse", err)
|
||||
}
|
||||
|
||||
email = " [email protected]"
|
||||
normalizedEmail, err = NormalizeEmail(email)
|
||||
if err != nil {
|
||||
t.Error("invalid email parse", err)
|
||||
}
|
||||
if normalizedEmail != "[email protected]" {
|
||||
t.Errorf("invalid email parse: %s", normalizedEmail)
|
||||
}
|
||||
|
||||
email = "[email protected]"
|
||||
normalizedEmail, err = NormalizeEmail(email)
|
||||
if err != nil {
|
||||
t.Error("invalid email parse", err)
|
||||
}
|
||||
if normalizedEmail != "[email protected]" {
|
||||
t.Errorf("invalid email parse: %s", normalizedEmail)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user