opcotech/elemo

View on GitHub
internal/repository/neo4j/organization.go

Summary

Maintainability
D
2 days
Test Coverage
package neo4j

import (
    "context"
    "errors"
    "time"

    "github.com/neo4j/neo4j-go-driver/v5/neo4j"

    "github.com/opcotech/elemo/internal/model"
    "github.com/opcotech/elemo/internal/repository"
)

// OrganizationRepository is a repository for managing organizations.
type OrganizationRepository struct {
    *baseRepository
}

func (r *OrganizationRepository) scan(op, np, tp, mp string) func(rec *neo4j.Record) (*model.Organization, error) {
    return func(rec *neo4j.Record) (*model.Organization, error) {
        org := new(model.Organization)

        val, _, err := neo4j.GetRecordValue[neo4j.Node](rec, op)
        if err != nil {
            return nil, err
        }

        if err := ScanIntoStruct(&val, &org, []string{"id"}); err != nil {
            return nil, err
        }

        org.ID, _ = model.NewIDFromString(val.GetProperties()["id"].(string), model.ResourceTypeOrganization.String())

        if org.Namespaces, err = ParseIDsFromRecord(rec, np, model.ResourceTypeNamespace.String()); err != nil {
            return nil, err
        }

        if org.Teams, err = ParseIDsFromRecord(rec, tp, model.ResourceTypeRole.String()); err != nil {
            return nil, err
        }

        if org.Members, err = ParseIDsFromRecord(rec, mp, model.ResourceTypeUser.String()); err != nil {
            return nil, err
        }

        if err := org.Validate(); err != nil {
            return nil, err
        }

        return org, nil
    }
}

func (r *OrganizationRepository) Create(ctx context.Context, owner model.ID, organization *model.Organization) error {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/Create")
    defer span.End()

    if err := owner.Validate(); err != nil {
        return errors.Join(repository.ErrOrganizationCreate, err)
    }

    if err := organization.Validate(); err != nil {
        return errors.Join(repository.ErrOrganizationCreate, err)
    }

    createdAt := time.Now().UTC()

    organization.ID = model.MustNewID(model.ResourceTypeOrganization)
    organization.CreatedAt = &createdAt
    organization.UpdatedAt = nil

    cypher := `
    MATCH (u:` + owner.Label() + ` {id: $owner_id})
    CREATE (o:` + organization.ID.Label() + ` { id: $id, name: $name, email: $email, logo: $logo, website: $website,
        status: $status, created_at: datetime($created_at)
    }),
    (u)-[:` + EdgeKindMemberOf.String() + ` {id: $membership_id, created_at: datetime($created_at)}]->(o),
    (u)-[:` + EdgeKindHasPermission.String() + `{id: $permission_id, created_at: datetime($created_at), kind: $permission_kind}]->(o)`

    params := map[string]any{
        "id":              organization.ID.String(),
        "name":            organization.Name,
        "email":           organization.Email,
        "logo":            organization.Logo,
        "website":         organization.Website,
        "status":          organization.Status.String(),
        "created_at":      createdAt.Format(time.RFC3339Nano),
        "owner_id":        owner.String(),
        "membership_id":   model.NewRawID(),
        "permission_id":   model.NewRawID(),
        "permission_kind": model.PermissionKindAll.String(),
    }

    if err := ExecuteWriteAndConsume(ctx, r.db, cypher, params); err != nil {
        return errors.Join(repository.ErrOrganizationCreate, err)
    }

    return nil
}

func (r *OrganizationRepository) Get(ctx context.Context, id model.ID) (*model.Organization, error) {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/Get")
    defer span.End()

    cypher := `
    MATCH (o:` + id.Label() + ` {id: $id})
    OPTIONAL MATCH (u:` + model.ResourceTypeUser.String() + `)-[:` + EdgeKindMemberOf.String() + `]->(o)
    OPTIONAL MATCH (o)-[:` + EdgeKindHasNamespace.String() + `]->(n:` + model.ResourceTypeNamespace.String() + `)
    OPTIONAL MATCH (o)-[:` + EdgeKindHasTeam.String() + `]->(t:` + model.ResourceTypeRole.String() + `)
    RETURN o, collect(DISTINCT u.id) AS m, collect(DISTINCT n.id) AS n, collect(DISTINCT t.id) AS t
    `

    params := map[string]any{
        "id": id.String(),
    }

    org, err := ExecuteReadAndReadSingle(ctx, r.db, cypher, params, r.scan("o", "n", "t", "m"))
    if err != nil {
        return nil, errors.Join(repository.ErrOrganizationRead, err)
    }

    return org, nil
}

func (r *OrganizationRepository) GetAll(ctx context.Context, offset, limit int) ([]*model.Organization, error) {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/GetAllBelongsTo")
    defer span.End()

    cypher := `
    MATCH (o:` + model.ResourceTypeOrganization.String() + `)
    OPTIONAL MATCH (u:` + model.ResourceTypeUser.String() + `)-[:` + EdgeKindMemberOf.String() + `]->(o)
    OPTIONAL MATCH (o)-[:` + EdgeKindHasNamespace.String() + `]->(n:` + model.ResourceTypeNamespace.String() + `)
    OPTIONAL MATCH (o)-[:` + EdgeKindHasTeam.String() + `]->(t:` + model.ResourceTypeRole.String() + `)
    RETURN o, collect(DISTINCT u.id) AS m, collect(DISTINCT n.id) AS n, collect(DISTINCT t.id) AS t
    ORDER BY o.created_at DESC
    SKIP $offset LIMIT $limit`

    params := map[string]any{
        "offset": offset,
        "limit":  limit,
    }

    orgs, err := ExecuteReadAndReadAll(ctx, r.db, cypher, params, r.scan("o", "n", "t", "m"))
    if err != nil {
        return nil, errors.Join(repository.ErrOrganizationRead, err)
    }

    return orgs, nil
}

func (r *OrganizationRepository) Update(ctx context.Context, id model.ID, patch map[string]any) (*model.Organization, error) {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/Update")
    defer span.End()

    cypher := `
    MATCH (o:` + id.Label() + ` {id: $id}) SET o += $patch, o.updated_at = datetime()
    WITH o
    OPTIONAL MATCH (u:` + model.ResourceTypeUser.String() + `)-[:` + EdgeKindMemberOf.String() + `]->(o)
    OPTIONAL MATCH (o)-[:` + EdgeKindHasNamespace.String() + `]->(n:` + model.ResourceTypeNamespace.String() + `)
    OPTIONAL MATCH (o)-[:` + EdgeKindHasTeam.String() + `]->(t:` + model.ResourceTypeRole.String() + `)
    RETURN o, collect(DISTINCT u.id) AS m, collect(DISTINCT n.id) AS n, collect(DISTINCT t.id) AS t`

    params := map[string]any{
        "id":    id.String(),
        "patch": patch,
    }

    org, err := ExecuteWriteAndReadSingle(ctx, r.db, cypher, params, r.scan("o", "n", "t", "m"))
    if err != nil {
        return nil, errors.Join(repository.ErrOrganizationUpdate, err)
    }

    return org, nil
}

func (r *OrganizationRepository) AddMember(ctx context.Context, orgID, memberID model.ID) error {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/AddMember")
    defer span.End()

    if err := orgID.Validate(); err != nil {
        return errors.Join(repository.ErrOrganizationAddMember, err)
    }

    if err := memberID.Validate(); err != nil {
        return errors.Join(repository.ErrOrganizationAddMember, err)
    }

    cypher := `
    MATCH (o:` + orgID.Label() + ` {id: $org_id}), (u:` + memberID.Label() + ` {id: $member_id})
    MERGE (u)-[m:` + EdgeKindMemberOf.String() + `]->(o)
    ON CREATE SET m.created_at = datetime($now), m.id = $membership_id
    ON MATCH SET m.updated_at = datetime($now)`

    params := map[string]any{
        "org_id":        orgID.String(),
        "member_id":     memberID.String(),
        "membership_id": model.NewRawID(),
        "now":           time.Now().UTC().Format(time.RFC3339Nano),
    }

    if err := ExecuteWriteAndConsume(ctx, r.db, cypher, params); err != nil {
        return errors.Join(repository.ErrOrganizationAddMember, err)
    }

    return nil
}

func (r *OrganizationRepository) RemoveMember(ctx context.Context, orgID, memberID model.ID) error {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/RemoveMember")
    defer span.End()

    if err := orgID.Validate(); err != nil {
        return errors.Join(repository.ErrOrganizationRemoveMember, err)
    }

    if err := memberID.Validate(); err != nil {
        return errors.Join(repository.ErrOrganizationRemoveMember, err)
    }

    cypher := `
    MATCH (:` + memberID.Label() + ` {id: $member_id})-[r:` + EdgeKindMemberOf.String() + `]->(:` + orgID.Label() + ` {id: $org_id})
    DELETE r`

    params := map[string]any{
        "org_id":    orgID.String(),
        "member_id": memberID.String(),
    }

    if err := ExecuteWriteAndConsume(ctx, r.db, cypher, params); err != nil {
        return errors.Join(repository.ErrOrganizationRemoveMember, err)
    }

    return nil
}

func (r *OrganizationRepository) Delete(ctx context.Context, id model.ID) error {
    ctx, span := r.tracer.Start(ctx, "repository.neo4j.OrganizationRepository/Delete")
    defer span.End()

    cypher := `MATCH (o:` + id.Label() + ` {id: $id}), (o)-[r]-() DETACH DELETE o, r`
    params := map[string]any{
        "id": id.String(),
    }

    if err := ExecuteWriteAndConsume(ctx, r.db, cypher, params); err != nil {
        return errors.Join(repository.ErrOrganizationDelete, err)
    }

    return nil
}

// NewOrganizationRepository creates a new organization baseRepository.
func NewOrganizationRepository(opts ...RepositoryOption) (*OrganizationRepository, error) {
    baseRepo, err := newRepository(opts...)
    if err != nil {
        return nil, err
    }

    return &OrganizationRepository{
        baseRepository: baseRepo,
    }, nil
}