mirror of
https://github.com/Dvorinka/Primora.git
synced 2026-06-04 04:23:00 +00:00
1826 lines
62 KiB
Go
1826 lines
62 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
|
|
db "github.com/tdvorak/primora/apps/backend/internal/database/db"
|
|
"github.com/tdvorak/primora/apps/backend/internal/models"
|
|
"github.com/tdvorak/primora/apps/backend/internal/repositories"
|
|
"github.com/tdvorak/primora/apps/backend/internal/storage"
|
|
)
|
|
|
|
type PlatformService struct {
|
|
repo *repositories.CoreRepository
|
|
store *storage.LocalStore
|
|
mailer *Mailer
|
|
publicURL string
|
|
}
|
|
|
|
type BootstrapInput struct {
|
|
OrganizationName string `json:"organizationName" validate:"required,min=2"`
|
|
OrganizationSlug string `json:"organizationSlug" validate:"required,min=2"`
|
|
ProjectName string `json:"projectName" validate:"required,min=2"`
|
|
ProjectSlug string `json:"projectSlug" validate:"required,min=2"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
type CreateProjectInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
type CreateOrganizationInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
}
|
|
|
|
type UpdateOrganizationInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
}
|
|
|
|
type UpdateProjectInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
Description *string `json:"description"`
|
|
}
|
|
|
|
type CreateInvitationInput struct {
|
|
Email string `json:"email" validate:"required,email"`
|
|
OrgRole string `json:"orgRole" validate:"required,oneof=owner admin member"`
|
|
ProjectID *string `json:"projectId"`
|
|
ProjectRole *string `json:"projectRole" validate:"omitempty,oneof=admin developer viewer"`
|
|
RedirectURL *string `json:"redirectUrl"`
|
|
}
|
|
|
|
type CreateAPIKeyInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
}
|
|
|
|
type CreateBucketInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
Visibility string `json:"visibility" validate:"required,oneof=private public"`
|
|
}
|
|
|
|
type UpdateBucketInput struct {
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
Visibility string `json:"visibility" validate:"required,oneof=private public"`
|
|
}
|
|
|
|
type UpdateOrganizationMemberRoleInput struct {
|
|
Role string `json:"role" validate:"required,oneof=owner admin member"`
|
|
}
|
|
|
|
type UpdateProjectMemberRoleInput struct {
|
|
Role string `json:"role" validate:"required,oneof=admin developer viewer"`
|
|
}
|
|
|
|
type AcceptInvitationInput struct {
|
|
Token string `json:"token" validate:"required"`
|
|
}
|
|
|
|
type UpdateObjectInput struct {
|
|
NewObjectKey string `json:"newObjectKey" validate:"required,min=1"`
|
|
DestinationBucketID *string `json:"destinationBucketId"`
|
|
}
|
|
|
|
type CopyObjectInput struct {
|
|
ObjectKey string `json:"objectKey" validate:"required,min=1"`
|
|
NewObjectKey string `json:"newObjectKey" validate:"required,min=1"`
|
|
DestinationBucketID *string `json:"destinationBucketId"`
|
|
}
|
|
|
|
type CreateCollectionInput struct {
|
|
Slug string `json:"slug" validate:"required,min=2"`
|
|
Name string `json:"name" validate:"required,min=2"`
|
|
Description *string `json:"description"`
|
|
Schema map[string]any `json:"schema"`
|
|
}
|
|
|
|
type UpdateCollectionInput struct {
|
|
Name *string `json:"name" validate:"omitempty,min=2"`
|
|
Description *string `json:"description"`
|
|
Schema map[string]any `json:"schema"`
|
|
}
|
|
|
|
type CreateDocumentInput struct {
|
|
Data map[string]any `json:"data" validate:"required"`
|
|
}
|
|
|
|
type UpdateDocumentInput struct {
|
|
Data map[string]any `json:"data" validate:"required"`
|
|
}
|
|
|
|
type PlatformSummary struct {
|
|
User CoreUserSummary `json:"user"`
|
|
Organizations []OrganizationWithProjects `json:"organizations"`
|
|
}
|
|
|
|
type CoreUserSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
AuthSubject string `json:"authSubject"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
EmailVerified bool `json:"emailVerified"`
|
|
}
|
|
|
|
type OrganizationWithProjects struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
MembershipRole string `json:"membershipRole"`
|
|
Projects []ProjectSummary `json:"projects"`
|
|
}
|
|
|
|
type ProjectSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug string `json:"slug"`
|
|
Description *string `json:"description,omitempty"`
|
|
MembershipRole *string `json:"membershipRole,omitempty"`
|
|
}
|
|
|
|
type ProjectOverview struct {
|
|
ProjectID uuid.UUID `json:"project_id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
ProjectSlug string `json:"project_slug"`
|
|
ProjectName string `json:"project_name"`
|
|
MemberCount int64 `json:"member_count"`
|
|
ActiveAPIKeyCount int64 `json:"active_api_key_count"`
|
|
BucketCount int64 `json:"bucket_count"`
|
|
ObjectCount int64 `json:"object_count"`
|
|
ObjectBytesTotal int64 `json:"object_bytes_total"`
|
|
PendingInvitationCount int64 `json:"pending_invitation_count"`
|
|
AuditEvents24h int64 `json:"audit_events_24h"`
|
|
LastAuditAt *time.Time `json:"last_audit_at,omitempty"`
|
|
}
|
|
|
|
type OrganizationMemberSummary struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
Role string `json:"role"`
|
|
JoinedAt time.Time `json:"joined_at"`
|
|
}
|
|
|
|
type ProjectMemberSummary struct {
|
|
UserID uuid.UUID `json:"user_id"`
|
|
Email string `json:"email"`
|
|
Name string `json:"name"`
|
|
EmailVerified bool `json:"email_verified"`
|
|
Role string `json:"role"`
|
|
JoinedAt time.Time `json:"joined_at"`
|
|
}
|
|
|
|
type InvitationSummary struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
ProjectID *uuid.UUID `json:"project_id"`
|
|
ProjectName *string `json:"project_name"`
|
|
Email string `json:"email"`
|
|
OrgRole string `json:"org_role"`
|
|
ProjectRole *string `json:"project_role"`
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
AcceptedAt *time.Time `json:"accepted_at"`
|
|
InvitedByUserID *uuid.UUID `json:"invited_by_user_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
func NewPlatformService(repo *repositories.CoreRepository, store *storage.LocalStore, mailer *Mailer, publicURL string) *PlatformService {
|
|
return &PlatformService{repo: repo, store: store, mailer: mailer, publicURL: publicURL}
|
|
}
|
|
|
|
func (s *PlatformService) Me(ctx context.Context, actor *models.Actor) (PlatformSummary, error) {
|
|
if actor.UserID == nil {
|
|
return PlatformSummary{}, errors.New("user actor required")
|
|
}
|
|
user, err := s.repo.Queries().GetUserByAuthSubject(ctx, actor.AuthSubject)
|
|
if err != nil {
|
|
return PlatformSummary{}, err
|
|
}
|
|
orgs, err := s.repo.Queries().ListOrganizationsForUser(ctx, *actor.UserID)
|
|
if err != nil {
|
|
return PlatformSummary{}, err
|
|
}
|
|
response := PlatformSummary{
|
|
User: CoreUserSummary{
|
|
ID: user.ID,
|
|
AuthSubject: user.AuthSubject,
|
|
Email: user.Email,
|
|
Name: user.Name,
|
|
EmailVerified: user.EmailVerified,
|
|
},
|
|
Organizations: make([]OrganizationWithProjects, 0, len(orgs)),
|
|
}
|
|
for _, org := range orgs {
|
|
projects, err := s.repo.Queries().ListProjectsForOrganization(ctx, db.ListProjectsForOrganizationParams{
|
|
OrganizationID: org.ID,
|
|
UserID: *actor.UserID,
|
|
Btrim: "",
|
|
})
|
|
if err != nil {
|
|
return PlatformSummary{}, err
|
|
}
|
|
item := OrganizationWithProjects{
|
|
ID: org.ID,
|
|
Name: org.Name,
|
|
Slug: org.Slug,
|
|
MembershipRole: org.MembershipRole,
|
|
Projects: make([]ProjectSummary, 0, len(projects)),
|
|
}
|
|
for _, project := range projects {
|
|
var membershipRole *string
|
|
if project.MembershipRole.Valid {
|
|
role := string(project.MembershipRole.CoreProjectRole)
|
|
membershipRole = &role
|
|
}
|
|
item.Projects = append(item.Projects, ProjectSummary{
|
|
ID: project.ID,
|
|
Name: project.Name,
|
|
Slug: project.Slug,
|
|
Description: project.Description,
|
|
MembershipRole: membershipRole,
|
|
})
|
|
}
|
|
response.Organizations = append(response.Organizations, item)
|
|
}
|
|
return response, nil
|
|
}
|
|
|
|
func (s *PlatformService) Bootstrap(ctx context.Context, actor *models.Actor, input BootstrapInput, requestID string) (db.BootstrapOrganizationRow, error) {
|
|
if actor.UserID == nil {
|
|
return db.BootstrapOrganizationRow{}, errors.New("user actor required")
|
|
}
|
|
count, err := s.repo.CountOrganizations(ctx)
|
|
if err != nil {
|
|
return db.BootstrapOrganizationRow{}, err
|
|
}
|
|
if count > 0 {
|
|
return db.BootstrapOrganizationRow{}, fmt.Errorf("bootstrap already completed")
|
|
}
|
|
var result db.BootstrapOrganizationRow
|
|
err = s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
row, err := q.BootstrapOrganization(ctx, db.BootstrapOrganizationParams{
|
|
Slug: normalizeSlug(input.OrganizationSlug),
|
|
Name: strings.TrimSpace(input.OrganizationName),
|
|
UserID: *actor.UserID,
|
|
Slug_2: normalizeSlug(input.ProjectSlug),
|
|
Name_2: strings.TrimSpace(input.ProjectName),
|
|
Description: input.Description,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(row.OrganizationID, row.ProjectID, actor, requestID, "platform.bootstrap", "platform", "bootstrap", map[string]any{
|
|
"organizationSlug": row.OrganizationSlug,
|
|
"projectSlug": row.ProjectSlug,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
result = row
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func (s *PlatformService) ListOrganizations(ctx context.Context, actor *models.Actor) ([]db.ListOrganizationsForUserRow, error) {
|
|
if actor.UserID == nil {
|
|
return nil, errors.New("user actor required")
|
|
}
|
|
return s.repo.Queries().ListOrganizationsForUser(ctx, *actor.UserID)
|
|
}
|
|
|
|
func (s *PlatformService) CreateOrganization(ctx context.Context, actor *models.Actor, input CreateOrganizationInput, requestID string) (db.CoreOrganization, error) {
|
|
if actor.UserID == nil {
|
|
return db.CoreOrganization{}, errors.New("user actor required")
|
|
}
|
|
var organization db.CoreOrganization
|
|
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
row, err := q.CreateOrganization(ctx, db.CreateOrganizationParams{
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.AddOrganizationMember(ctx, db.AddOrganizationMemberParams{
|
|
OrganizationID: row.ID,
|
|
UserID: *actor.UserID,
|
|
Role: "owner",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(row.ID, uuid.Nil, actor, requestID, "organization.created", "organization", row.ID.String(), map[string]any{
|
|
"slug": row.Slug,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
organization = row
|
|
return nil
|
|
})
|
|
return organization, err
|
|
}
|
|
|
|
func (s *PlatformService) UpdateOrganization(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, input UpdateOrganizationInput, requestID string) (db.CoreOrganization, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
|
|
return db.CoreOrganization{}, err
|
|
}
|
|
updated, err := s.repo.Queries().UpdateOrganizationByID(ctx, db.UpdateOrganizationByIDParams{
|
|
ID: organizationID,
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
})
|
|
if err != nil {
|
|
return db.CoreOrganization{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(organizationID, uuid.Nil, actor, requestID, "organization.updated", "organization", organizationID.String(), map[string]any{
|
|
"slug": updated.Slug,
|
|
"name": updated.Name,
|
|
}))
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteOrganization(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, requestID string) error {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner"); err != nil {
|
|
return err
|
|
}
|
|
bucketIDs, err := s.repo.Queries().ListBucketsForOrganization(ctx, organizationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
deleted, err := q.DeleteOrganizationByID(ctx, organizationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(uuid.Nil, uuid.Nil, actor, requestID, "organization.deleted", "organization", deleted.ID.String(), map[string]any{
|
|
"slug": deleted.Slug,
|
|
"name": deleted.Name,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
for _, bucketID := range bucketIDs {
|
|
_ = s.store.DeleteBucket(bucketID.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) ListProjects(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, query string) ([]db.ListProjectsForOrganizationRow, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin", "member"); err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return nil, errors.New("user actor required")
|
|
}
|
|
return s.repo.Queries().ListProjectsForOrganization(ctx, db.ListProjectsForOrganizationParams{
|
|
OrganizationID: organizationID,
|
|
UserID: *actor.UserID,
|
|
Btrim: query,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) ListOrganizationMembers(ctx context.Context, actor *models.Actor, organizationID uuid.UUID) ([]OrganizationMemberSummary, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin", "member"); err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return nil, errors.New("user actor required")
|
|
}
|
|
rows, err := s.repo.Queries().ListOrganizationMembers(ctx, organizationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]OrganizationMemberSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, OrganizationMemberSummary{
|
|
UserID: row.UserID,
|
|
Email: row.Email,
|
|
Name: row.Name,
|
|
EmailVerified: row.EmailVerified,
|
|
Role: row.Role,
|
|
JoinedAt: row.CreatedAt.Time,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *PlatformService) UpdateOrganizationMemberRole(ctx context.Context, actor *models.Actor, organizationID, userID uuid.UUID, input UpdateOrganizationMemberRoleInput, requestID string) (OrganizationMemberSummary, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner"); err != nil {
|
|
return OrganizationMemberSummary{}, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return OrganizationMemberSummary{}, errors.New("user actor required")
|
|
}
|
|
current, err := s.repo.Queries().GetOrganizationMembership(ctx, db.GetOrganizationMembershipParams{
|
|
OrganizationID: organizationID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return OrganizationMemberSummary{}, err
|
|
}
|
|
if current.Role == "owner" && input.Role != "owner" {
|
|
owners, err := s.repo.Queries().CountOrganizationOwners(ctx, organizationID)
|
|
if err != nil {
|
|
return OrganizationMemberSummary{}, err
|
|
}
|
|
if owners <= 1 {
|
|
return OrganizationMemberSummary{}, fmt.Errorf("organization must retain at least one owner")
|
|
}
|
|
}
|
|
updated, err := s.repo.Queries().UpdateOrganizationMemberRole(ctx, db.UpdateOrganizationMemberRoleParams{
|
|
OrganizationID: organizationID,
|
|
UserID: userID,
|
|
Role: input.Role,
|
|
})
|
|
if err != nil {
|
|
return OrganizationMemberSummary{}, err
|
|
}
|
|
user, err := s.repo.Queries().GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return OrganizationMemberSummary{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(organizationID, uuid.Nil, actor, requestID, "organization.member.role_updated", "organization_member", userID.String(), map[string]any{
|
|
"oldRole": current.Role,
|
|
"newRole": updated.Role,
|
|
}))
|
|
return OrganizationMemberSummary{
|
|
UserID: user.ID,
|
|
Email: user.Email,
|
|
Name: user.Name,
|
|
EmailVerified: user.EmailVerified,
|
|
Role: updated.Role,
|
|
JoinedAt: updated.CreatedAt.Time,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PlatformService) RemoveOrganizationMember(ctx context.Context, actor *models.Actor, organizationID, userID uuid.UUID, requestID string) error {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner"); err != nil {
|
|
return err
|
|
}
|
|
if actor.UserID == nil {
|
|
return errors.New("user actor required")
|
|
}
|
|
current, err := s.repo.Queries().GetOrganizationMembership(ctx, db.GetOrganizationMembershipParams{
|
|
OrganizationID: organizationID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Role == "owner" {
|
|
owners, err := s.repo.Queries().CountOrganizationOwners(ctx, organizationID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if owners <= 1 {
|
|
return fmt.Errorf("organization must retain at least one owner")
|
|
}
|
|
}
|
|
return s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
if _, err := q.RemoveOrganizationMember(ctx, db.RemoveOrganizationMemberParams{
|
|
OrganizationID: organizationID,
|
|
UserID: userID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if err := q.RemoveProjectMembershipsForOrganizationUser(ctx, db.RemoveProjectMembershipsForOrganizationUserParams{
|
|
OrganizationID: organizationID,
|
|
UserID: userID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_, err := q.CreateAuditLog(ctx, newAuditParams(organizationID, uuid.Nil, actor, requestID, "organization.member.removed", "organization_member", userID.String(), map[string]any{
|
|
"oldRole": current.Role,
|
|
}))
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) ListProjectMembers(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]ProjectMemberSummary, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return nil, errors.New("user actor required")
|
|
}
|
|
rows, err := s.repo.Queries().ListProjectMembers(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
items := make([]ProjectMemberSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
items = append(items, ProjectMemberSummary{
|
|
UserID: row.UserID,
|
|
Email: row.Email,
|
|
Name: row.Name,
|
|
EmailVerified: row.EmailVerified,
|
|
Role: row.Role,
|
|
JoinedAt: row.CreatedAt.Time,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *PlatformService) GetProjectOverview(ctx context.Context, actor *models.Actor, projectID uuid.UUID) (ProjectOverview, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return ProjectOverview{}, err
|
|
}
|
|
row, err := s.repo.Queries().GetProjectOverview(ctx, projectID)
|
|
if err != nil {
|
|
return ProjectOverview{}, err
|
|
}
|
|
return ProjectOverview{
|
|
ProjectID: row.ProjectID,
|
|
OrganizationID: row.OrganizationID,
|
|
ProjectSlug: row.ProjectSlug,
|
|
ProjectName: row.ProjectName,
|
|
MemberCount: row.MemberCount,
|
|
ActiveAPIKeyCount: row.ActiveApiKeyCount,
|
|
BucketCount: row.BucketCount,
|
|
ObjectCount: row.ObjectCount,
|
|
ObjectBytesTotal: row.ObjectBytesTotal,
|
|
PendingInvitationCount: row.PendingInvitationCount,
|
|
AuditEvents24h: row.AuditEvents24h,
|
|
LastAuditAt: projectOverviewLastAuditAt(row.LastAuditAt),
|
|
}, nil
|
|
}
|
|
|
|
func projectOverviewLastAuditAt(value pgtype.Timestamptz) *time.Time {
|
|
if !value.Valid {
|
|
return nil
|
|
}
|
|
timestamp := value.Time
|
|
return ×tamp
|
|
}
|
|
|
|
func (s *PlatformService) UpdateProjectMemberRole(ctx context.Context, actor *models.Actor, projectID, userID uuid.UUID, input UpdateProjectMemberRoleInput, requestID string) (ProjectMemberSummary, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return ProjectMemberSummary{}, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return ProjectMemberSummary{}, errors.New("user actor required")
|
|
}
|
|
current, err := s.repo.Queries().GetProjectMembership(ctx, db.GetProjectMembershipParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return ProjectMemberSummary{}, err
|
|
}
|
|
if current.Role == "admin" && input.Role != "admin" {
|
|
adminCount, err := s.repo.Queries().CountProjectAdmins(ctx, projectID)
|
|
if err != nil {
|
|
return ProjectMemberSummary{}, err
|
|
}
|
|
if adminCount <= 1 {
|
|
return ProjectMemberSummary{}, fmt.Errorf("project must retain at least one admin")
|
|
}
|
|
}
|
|
updated, err := s.repo.Queries().UpdateProjectMemberRole(ctx, db.UpdateProjectMemberRoleParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
Role: input.Role,
|
|
})
|
|
if err != nil {
|
|
return ProjectMemberSummary{}, err
|
|
}
|
|
user, err := s.repo.Queries().GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return ProjectMemberSummary{}, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "project.member.role_updated", "project_member", userID.String(), map[string]any{
|
|
"oldRole": current.Role,
|
|
"newRole": updated.Role,
|
|
}))
|
|
}
|
|
return ProjectMemberSummary{
|
|
UserID: user.ID,
|
|
Email: user.Email,
|
|
Name: user.Name,
|
|
EmailVerified: user.EmailVerified,
|
|
Role: updated.Role,
|
|
JoinedAt: updated.CreatedAt.Time,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PlatformService) RemoveProjectMember(ctx context.Context, actor *models.Actor, projectID, userID uuid.UUID, requestID string) error {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return err
|
|
}
|
|
if actor.UserID == nil {
|
|
return errors.New("user actor required")
|
|
}
|
|
current, err := s.repo.Queries().GetProjectMembership(ctx, db.GetProjectMembershipParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.Role == "admin" {
|
|
adminCount, err := s.repo.Queries().CountProjectAdmins(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if adminCount <= 1 {
|
|
return fmt.Errorf("project must retain at least one admin")
|
|
}
|
|
}
|
|
if _, err := s.repo.Queries().RemoveProjectMember(ctx, db.RemoveProjectMemberParams{
|
|
ProjectID: projectID,
|
|
UserID: userID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "project.member.removed", "project_member", userID.String(), map[string]any{
|
|
"oldRole": current.Role,
|
|
}))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) CreateProject(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, input CreateProjectInput, requestID string) (db.CoreProject, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
|
|
return db.CoreProject{}, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return db.CoreProject{}, errors.New("user actor required")
|
|
}
|
|
var project db.CoreProject
|
|
err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
row, err := q.CreateProject(ctx, db.CreateProjectParams{
|
|
OrganizationID: organizationID,
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
Description: input.Description,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.AddProjectMember(ctx, db.AddProjectMemberParams{
|
|
ProjectID: row.ID,
|
|
UserID: *actor.UserID,
|
|
Role: "admin",
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(organizationID, row.ID, actor, requestID, "project.created", "project", row.ID.String(), map[string]any{
|
|
"slug": row.Slug,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
project = row
|
|
return nil
|
|
})
|
|
return project, err
|
|
}
|
|
|
|
func (s *PlatformService) UpdateProject(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input UpdateProjectInput, requestID string) (db.CoreProject, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return db.CoreProject{}, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err != nil {
|
|
return db.CoreProject{}, err
|
|
}
|
|
updated, err := s.repo.Queries().UpdateProjectByID(ctx, db.UpdateProjectByIDParams{
|
|
ID: projectID,
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
Description: input.Description,
|
|
})
|
|
if err != nil {
|
|
return db.CoreProject{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "project.updated", "project", projectID.String(), map[string]any{
|
|
"slug": updated.Slug,
|
|
"name": updated.Name,
|
|
}))
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteProject(ctx context.Context, actor *models.Actor, projectID uuid.UUID, requestID string) error {
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.requireOrganizationRole(ctx, actor, project.OrganizationID, "owner", "admin"); err != nil {
|
|
return err
|
|
}
|
|
buckets, err := s.repo.Queries().ListBucketsForProject(ctx, db.ListBucketsForProjectParams{
|
|
ProjectID: projectID,
|
|
Btrim: "",
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
if _, err := q.DeleteProjectByID(ctx, projectID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(project.OrganizationID, uuid.Nil, actor, requestID, "project.deleted", "project", project.ID.String(), map[string]any{
|
|
"slug": project.Slug,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
for _, bucket := range buckets {
|
|
_ = s.store.DeleteBucket(bucket.ID.String())
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) CreateInvitation(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, input CreateInvitationInput, requestID string) (map[string]any, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return nil, errors.New("user actor required")
|
|
}
|
|
var projectUUID pgtype.UUID
|
|
var projectRole db.NullCoreProjectRole
|
|
projectIDRaw := ""
|
|
if input.ProjectID != nil {
|
|
projectIDRaw = strings.TrimSpace(*input.ProjectID)
|
|
}
|
|
projectRoleRaw := ""
|
|
if input.ProjectRole != nil {
|
|
projectRoleRaw = strings.TrimSpace(*input.ProjectRole)
|
|
}
|
|
if projectIDRaw == "" && projectRoleRaw != "" {
|
|
return nil, fmt.Errorf("invalid invitation project scope")
|
|
}
|
|
if projectIDRaw != "" && projectRoleRaw == "" {
|
|
return nil, fmt.Errorf("project role is required when project id is provided")
|
|
}
|
|
if projectIDRaw != "" {
|
|
projectID, err := uuid.Parse(projectIDRaw)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("parse project id: %w", err)
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if project.OrganizationID != organizationID {
|
|
return nil, fmt.Errorf("invalid invitation project scope")
|
|
}
|
|
projectUUID = pgtype.UUID{Bytes: projectID, Valid: true}
|
|
projectRole = db.NullCoreProjectRole{
|
|
CoreProjectRole: db.CoreProjectRole(projectRoleRaw),
|
|
Valid: true,
|
|
}
|
|
}
|
|
token, err := randomToken(32)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate invitation token: %w", err)
|
|
}
|
|
tokenHash := digestString(token)
|
|
var invitation db.CoreProjectInvitation
|
|
err = s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
row, err := q.CreateInvitation(ctx, db.CreateInvitationParams{
|
|
OrganizationID: organizationID,
|
|
ProjectID: projectUUID,
|
|
Lower: strings.ToLower(strings.TrimSpace(input.Email)),
|
|
OrgRole: input.OrgRole,
|
|
ProjectRole: projectRole,
|
|
TokenHash: tokenHash,
|
|
ExpiresAt: pgtype.Timestamptz{Time: time.Now().Add(72 * time.Hour), Valid: true},
|
|
InvitedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(organizationID, pgUUIDOrNil(projectUUID), actor, requestID, "invitation.created", "invitation", row.ID.String(), map[string]any{
|
|
"email": row.Email,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
invitation = row
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
baseInviteURL := s.publicURL + "/invite"
|
|
if input.RedirectURL != nil && *input.RedirectURL != "" {
|
|
baseInviteURL = *input.RedirectURL
|
|
}
|
|
inviteURL, err := url.JoinPath(strings.TrimRight(baseInviteURL, "/"), token)
|
|
if err != nil {
|
|
inviteURL = strings.TrimRight(baseInviteURL, "/") + "/" + token
|
|
}
|
|
if err := s.mailer.SendInvitation(ctx, invitation.Email, "Primora", inviteURL); err != nil {
|
|
return nil, err
|
|
}
|
|
return map[string]any{
|
|
"id": invitation.ID,
|
|
"email": invitation.Email,
|
|
"expiresAt": invitation.ExpiresAt.Time,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PlatformService) ListInvitations(ctx context.Context, actor *models.Actor, organizationID uuid.UUID) ([]InvitationSummary, error) {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
|
|
return nil, err
|
|
}
|
|
rows, err := s.repo.Queries().ListInvitationsForOrganization(ctx, organizationID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
now := time.Now()
|
|
items := make([]InvitationSummary, 0, len(rows))
|
|
for _, row := range rows {
|
|
var projectID *uuid.UUID
|
|
if row.ProjectID.Valid {
|
|
id := uuid.UUID(row.ProjectID.Bytes)
|
|
projectID = &id
|
|
}
|
|
var projectRole *string
|
|
if row.ProjectRole.Valid {
|
|
role := string(row.ProjectRole.CoreProjectRole)
|
|
projectRole = &role
|
|
}
|
|
var acceptedAt *time.Time
|
|
if row.AcceptedAt.Valid {
|
|
accepted := row.AcceptedAt.Time
|
|
acceptedAt = &accepted
|
|
}
|
|
var invitedByUserID *uuid.UUID
|
|
if row.InvitedByUserID.Valid {
|
|
userID := uuid.UUID(row.InvitedByUserID.Bytes)
|
|
invitedByUserID = &userID
|
|
}
|
|
|
|
status := "pending"
|
|
if acceptedAt != nil {
|
|
status = "accepted"
|
|
} else if row.ExpiresAt.Valid && row.ExpiresAt.Time.Before(now) {
|
|
status = "expired"
|
|
}
|
|
|
|
items = append(items, InvitationSummary{
|
|
ID: row.ID,
|
|
OrganizationID: row.OrganizationID,
|
|
ProjectID: projectID,
|
|
ProjectName: row.ProjectName,
|
|
Email: row.Email,
|
|
OrgRole: row.OrgRole,
|
|
ProjectRole: projectRole,
|
|
ExpiresAt: row.ExpiresAt.Time,
|
|
AcceptedAt: acceptedAt,
|
|
InvitedByUserID: invitedByUserID,
|
|
CreatedAt: row.CreatedAt.Time,
|
|
Status: status,
|
|
})
|
|
}
|
|
return items, nil
|
|
}
|
|
|
|
func (s *PlatformService) RevokeInvitation(ctx context.Context, actor *models.Actor, organizationID, invitationID uuid.UUID, requestID string) error {
|
|
if err := s.requireOrganizationRole(ctx, actor, organizationID, "owner", "admin"); err != nil {
|
|
return err
|
|
}
|
|
invitation, err := s.repo.Queries().GetInvitationByIDForOrganization(ctx, db.GetInvitationByIDForOrganizationParams{
|
|
OrganizationID: organizationID,
|
|
ID: invitationID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if invitation.AcceptedAt.Valid {
|
|
return fmt.Errorf("invitation already accepted")
|
|
}
|
|
deleted, err := s.repo.Queries().DeletePendingInvitationByIDForOrganization(ctx, db.DeletePendingInvitationByIDForOrganizationParams{
|
|
OrganizationID: organizationID,
|
|
ID: invitationID,
|
|
})
|
|
if err != nil {
|
|
if errors.Is(err, pgx.ErrNoRows) {
|
|
return fmt.Errorf("invitation already accepted")
|
|
}
|
|
return err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(organizationID, pgUUIDOrNil(deleted.ProjectID), actor, requestID, "invitation.revoked", "invitation", invitationID.String(), map[string]any{
|
|
"email": deleted.Email,
|
|
}))
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) AcceptInvitation(ctx context.Context, actor *models.Actor, input AcceptInvitationInput, requestID string) error {
|
|
if actor.UserID == nil {
|
|
return errors.New("user actor required")
|
|
}
|
|
tokenHash := digestString(input.Token)
|
|
return s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
invite, err := q.GetInvitationByTokenHash(ctx, tokenHash)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if invite.AcceptedAt.Valid {
|
|
return fmt.Errorf("invitation already accepted")
|
|
}
|
|
if invite.ExpiresAt.Time.Before(time.Now()) {
|
|
return fmt.Errorf("invitation expired")
|
|
}
|
|
if !strings.EqualFold(invite.Email, actor.Email) {
|
|
return fmt.Errorf("invitation email does not match authenticated user")
|
|
}
|
|
if _, err := q.AddOrganizationMember(ctx, db.AddOrganizationMemberParams{
|
|
OrganizationID: invite.OrganizationID,
|
|
UserID: *actor.UserID,
|
|
Role: invite.OrgRole,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
if invite.ProjectID.Valid && invite.ProjectRole.Valid {
|
|
project, err := q.GetProjectByID(ctx, invite.ProjectID.Bytes)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if project.OrganizationID != invite.OrganizationID {
|
|
return fmt.Errorf("invalid invitation project scope")
|
|
}
|
|
if _, err := q.AddProjectMember(ctx, db.AddProjectMemberParams{
|
|
ProjectID: invite.ProjectID.Bytes,
|
|
UserID: *actor.UserID,
|
|
Role: string(invite.ProjectRole.CoreProjectRole),
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := q.MarkInvitationAccepted(ctx, invite.ID); err != nil {
|
|
return err
|
|
}
|
|
_, err = q.CreateAuditLog(ctx, newAuditParams(invite.OrganizationID, pgUUIDOrNil(invite.ProjectID), actor, requestID, "invitation.accepted", "invitation", invite.ID.String(), map[string]any{
|
|
"email": invite.Email,
|
|
}))
|
|
return err
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) ListAPIKeys(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]db.CoreApiKey, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Queries().ListAPIKeysForProject(ctx, projectID)
|
|
}
|
|
|
|
func (s *PlatformService) CreateAPIKey(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateAPIKeyInput, requestID string) (map[string]any, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return nil, err
|
|
}
|
|
if actor.UserID == nil {
|
|
return nil, errors.New("user actor required")
|
|
}
|
|
prefixSuffix, err := randomString(8)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate api key prefix: %w", err)
|
|
}
|
|
keySuffix, err := randomString(24)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("generate api key secret: %w", err)
|
|
}
|
|
prefix := "prm_" + prefixSuffix
|
|
rawKey := prefix + "_" + keySuffix
|
|
secretHash := sha256.Sum256([]byte(rawKey))
|
|
row, err := s.repo.Queries().CreateAPIKey(ctx, db.CreateAPIKeyParams{
|
|
ProjectID: projectID,
|
|
Name: strings.TrimSpace(input.Name),
|
|
Prefix: prefix,
|
|
SecretHash: secretHash[:],
|
|
CreatedByUserID: pgtype.UUID{Bytes: *actor.UserID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "api_key.created", "api_key", row.ID.String(), map[string]any{
|
|
"name": row.Name,
|
|
}))
|
|
}
|
|
return map[string]any{
|
|
"id": row.ID,
|
|
"prefix": row.Prefix,
|
|
"secret": rawKey,
|
|
"name": row.Name,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PlatformService) RevokeAPIKey(ctx context.Context, actor *models.Actor, projectID, apiKeyID uuid.UUID, requestID string) error {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return err
|
|
}
|
|
if actor.UserID == nil {
|
|
return errors.New("user actor required")
|
|
}
|
|
key, err := s.repo.Queries().GetAPIKeyByIDForProject(ctx, db.GetAPIKeyByIDForProjectParams{
|
|
ProjectID: projectID,
|
|
ID: apiKeyID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if key.RevokedAt.Valid {
|
|
return fmt.Errorf("api key already revoked")
|
|
}
|
|
if _, err := s.repo.Queries().RevokeAPIKey(ctx, db.RevokeAPIKeyParams{
|
|
ProjectID: projectID,
|
|
ID: apiKeyID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "api_key.revoked", "api_key", apiKeyID.String(), map[string]any{}))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) ListBuckets(ctx context.Context, actor *models.Actor, projectID uuid.UUID, query string) ([]db.CoreBucket, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Queries().ListBucketsForProject(ctx, db.ListBucketsForProjectParams{
|
|
ProjectID: projectID,
|
|
Btrim: query,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) CreateBucket(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateBucketInput, requestID string) (db.CoreBucket, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return db.CoreBucket{}, err
|
|
}
|
|
var createdBy pgtype.UUID
|
|
if actor.UserID != nil {
|
|
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
row, err := s.repo.Queries().CreateBucket(ctx, db.CreateBucketParams{
|
|
ProjectID: projectID,
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
Visibility: input.Visibility,
|
|
CreatedByUserID: createdBy,
|
|
})
|
|
if err != nil {
|
|
return db.CoreBucket{}, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "bucket.created", "bucket", row.ID.String(), map[string]any{
|
|
"slug": row.Slug,
|
|
}))
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func (s *PlatformService) UpdateBucket(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, input UpdateBucketInput, requestID string) (db.CoreBucket, error) {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return db.CoreBucket{}, err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer"); err != nil {
|
|
return db.CoreBucket{}, err
|
|
}
|
|
updated, err := s.repo.Queries().UpdateBucketByID(ctx, db.UpdateBucketByIDParams{
|
|
ID: bucketID,
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
Visibility: input.Visibility,
|
|
})
|
|
if err != nil {
|
|
return db.CoreBucket{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "bucket.updated", "bucket", bucketID.String(), map[string]any{
|
|
"slug": updated.Slug,
|
|
"name": updated.Name,
|
|
"visibility": updated.Visibility,
|
|
}))
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteBucket(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, requestID string) error {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.WithTx(ctx, func(q *db.Queries) error {
|
|
if _, err := q.DeleteBucketByID(ctx, bucketID); err != nil {
|
|
return err
|
|
}
|
|
if _, err := q.CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "bucket.deleted", "bucket", bucketID.String(), map[string]any{
|
|
"slug": bucket.Slug,
|
|
})); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
_ = s.store.DeleteBucket(bucketID.String())
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) UploadObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, contentType string, reader io.Reader, requestID string) (db.CoreBucketObject, error) {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
uploader := pgtype.UUID{}
|
|
if actor != nil && actor.UserID != nil {
|
|
uploader = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
stored, err := s.store.Put(ctx, bucket.ID.String(), objectKey, reader)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
row, err := s.repo.Queries().CreateBucketObject(ctx, db.CreateBucketObjectParams{
|
|
BucketID: bucket.ID,
|
|
ObjectKey: objectKey,
|
|
ContentType: contentType,
|
|
SizeBytes: stored.SizeBytes,
|
|
ChecksumSha256: stored.SHA256Digest,
|
|
StoragePath: filepath.Clean(stored.Path),
|
|
UploadedByUserID: uploader,
|
|
})
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "object.uploaded", "bucket_object", row.ID.String(), map[string]any{
|
|
"bucketId": bucket.ID.String(),
|
|
"key": objectKey,
|
|
}))
|
|
return row, nil
|
|
}
|
|
|
|
func (s *PlatformService) ListObjects(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, query string, limit, offset int32) ([]db.CoreBucketObject, error) {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Queries().ListBucketObjects(ctx, db.ListBucketObjectsParams{
|
|
BucketID: bucketID,
|
|
Btrim: query,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) CountObjects(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, query string) (int64, error) {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.repo.Queries().CountBucketObjects(ctx, db.CountBucketObjectsParams{
|
|
BucketID: bucketID,
|
|
Btrim: query,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) GetObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string) (db.CoreBucketObject, io.ReadCloser, error) {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, nil, err
|
|
}
|
|
if err := s.requireBucketRead(ctx, actor, bucket); err != nil {
|
|
return db.CoreBucketObject{}, nil, err
|
|
}
|
|
object, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
|
BucketID: bucketID,
|
|
ObjectKey: objectKey,
|
|
})
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, nil, err
|
|
}
|
|
file, _, err := s.store.Open(bucketID.String(), objectKey)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, nil, err
|
|
}
|
|
return object, file, nil
|
|
}
|
|
|
|
func (s *PlatformService) UpdateObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey string, input UpdateObjectInput, requestID string) (db.CoreBucketObject, error) {
|
|
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
if err := s.requireBucketWrite(ctx, actor, sourceBucket); err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
destinationBucket := sourceBucket
|
|
destinationBucketID := sourceBucket.ID
|
|
if input.DestinationBucketID != nil {
|
|
destinationID := strings.TrimSpace(*input.DestinationBucketID)
|
|
if destinationID != "" {
|
|
parsedDestinationID, err := uuid.Parse(destinationID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, fmt.Errorf("invalid destination bucket id")
|
|
}
|
|
targetBucket, err := s.repo.Queries().GetBucketByID(ctx, parsedDestinationID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
if targetBucket.ProjectID != sourceBucket.ProjectID {
|
|
return db.CoreBucketObject{}, fmt.Errorf("invalid move scope: destination bucket must belong to the same project")
|
|
}
|
|
if err := s.requireBucketWrite(ctx, actor, targetBucket); err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
destinationBucket = targetBucket
|
|
destinationBucketID = parsedDestinationID
|
|
}
|
|
}
|
|
newObjectKey := strings.TrimSpace(input.NewObjectKey)
|
|
if newObjectKey == "" {
|
|
return db.CoreBucketObject{}, fmt.Errorf("new object key is required")
|
|
}
|
|
oldObject, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
|
BucketID: bucketID,
|
|
ObjectKey: objectKey,
|
|
})
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
if destinationBucketID == sourceBucket.ID && newObjectKey == oldObject.ObjectKey {
|
|
return oldObject, nil
|
|
}
|
|
newStoragePath, err := s.store.MoveBetweenBuckets(sourceBucket.ID.String(), destinationBucketID.String(), objectKey, newObjectKey)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
updated, err := s.repo.Queries().MoveBucketObject(ctx, db.MoveBucketObjectParams{
|
|
BucketID: bucketID,
|
|
ObjectKey: objectKey,
|
|
BucketID_2: destinationBucketID,
|
|
ObjectKey_2: newObjectKey,
|
|
StoragePath: filepath.Clean(newStoragePath),
|
|
})
|
|
if err != nil {
|
|
_, _ = s.store.MoveBetweenBuckets(destinationBucketID.String(), sourceBucket.ID.String(), newObjectKey, objectKey)
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(sourceBucket.OrganizationID, sourceBucket.ProjectID, actor, requestID, "object.moved", "bucket_object", updated.ID.String(), map[string]any{
|
|
"sourceBucketId": sourceBucket.ID.String(),
|
|
"destinationBucketId": destinationBucket.ID.String(),
|
|
"oldObjectKey": oldObject.ObjectKey,
|
|
"newObjectKey": updated.ObjectKey,
|
|
}))
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *PlatformService) CopyObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, input CopyObjectInput, requestID string) (db.CoreBucketObject, error) {
|
|
sourceBucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
if err := s.requireBucketRead(ctx, actor, sourceBucket); err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
sourceObjectKey := strings.TrimSpace(input.ObjectKey)
|
|
if sourceObjectKey == "" {
|
|
return db.CoreBucketObject{}, fmt.Errorf("object key is required")
|
|
}
|
|
newObjectKey := strings.TrimSpace(input.NewObjectKey)
|
|
if newObjectKey == "" {
|
|
return db.CoreBucketObject{}, fmt.Errorf("new object key is required")
|
|
}
|
|
sourceObject, err := s.repo.Queries().GetBucketObjectByKey(ctx, db.GetBucketObjectByKeyParams{
|
|
BucketID: sourceBucket.ID,
|
|
ObjectKey: sourceObjectKey,
|
|
})
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
destinationBucket := sourceBucket
|
|
destinationBucketID := sourceBucket.ID
|
|
if input.DestinationBucketID != nil {
|
|
destinationID := strings.TrimSpace(*input.DestinationBucketID)
|
|
if destinationID != "" {
|
|
parsedDestinationID, err := uuid.Parse(destinationID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, fmt.Errorf("invalid destination bucket id")
|
|
}
|
|
targetBucket, err := s.repo.Queries().GetBucketByID(ctx, parsedDestinationID)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
if targetBucket.ProjectID != sourceBucket.ProjectID {
|
|
return db.CoreBucketObject{}, fmt.Errorf("invalid copy scope: destination bucket must belong to the same project")
|
|
}
|
|
destinationBucket = targetBucket
|
|
destinationBucketID = parsedDestinationID
|
|
}
|
|
}
|
|
if err := s.requireBucketWrite(ctx, actor, destinationBucket); err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
sourceFile, _, err := s.store.Open(sourceBucket.ID.String(), sourceObjectKey)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
defer sourceFile.Close()
|
|
stored, err := s.store.Put(ctx, destinationBucketID.String(), newObjectKey, sourceFile)
|
|
if err != nil {
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
uploader := pgtype.UUID{}
|
|
if actor != nil && actor.UserID != nil {
|
|
uploader = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
created, err := s.repo.Queries().CreateBucketObject(ctx, db.CreateBucketObjectParams{
|
|
BucketID: destinationBucketID,
|
|
ObjectKey: newObjectKey,
|
|
ContentType: sourceObject.ContentType,
|
|
SizeBytes: stored.SizeBytes,
|
|
ChecksumSha256: stored.SHA256Digest,
|
|
StoragePath: filepath.Clean(stored.Path),
|
|
UploadedByUserID: uploader,
|
|
})
|
|
if err != nil {
|
|
_ = s.store.Delete(destinationBucketID.String(), newObjectKey)
|
|
return db.CoreBucketObject{}, err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(sourceBucket.OrganizationID, sourceBucket.ProjectID, actor, requestID, "object.copied", "bucket_object", created.ID.String(), map[string]any{
|
|
"sourceBucketId": sourceBucket.ID.String(),
|
|
"destinationBucketId": destinationBucket.ID.String(),
|
|
"sourceObjectKey": sourceObjectKey,
|
|
"newObjectKey": created.ObjectKey,
|
|
}))
|
|
return created, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteObject(ctx context.Context, actor *models.Actor, bucketID uuid.UUID, objectKey, requestID string) error {
|
|
bucket, err := s.repo.Queries().GetBucketByID(ctx, bucketID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.requireBucketWrite(ctx, actor, bucket); err != nil {
|
|
return err
|
|
}
|
|
object, err := s.repo.Queries().DeleteBucketObjectByKey(ctx, db.DeleteBucketObjectByKeyParams{
|
|
BucketID: bucketID,
|
|
ObjectKey: objectKey,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.store.Delete(bucketID.String(), objectKey); err != nil {
|
|
return err
|
|
}
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(bucket.OrganizationID, bucket.ProjectID, actor, requestID, "object.deleted", "bucket_object", object.ID.String(), map[string]any{
|
|
"bucketId": bucketID.String(),
|
|
"key": objectKey,
|
|
}))
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) ListAuditLogs(
|
|
ctx context.Context,
|
|
actor *models.Actor,
|
|
projectID uuid.UUID,
|
|
query string,
|
|
action string,
|
|
limit,
|
|
offset int32,
|
|
) ([]db.CoreAuditLog, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Queries().ListAuditLogsForProject(ctx, db.ListAuditLogsForProjectParams{
|
|
ProjectID: pgtype.UUID{Bytes: projectID, Valid: true},
|
|
Btrim: query,
|
|
Btrim_2: action,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) CountAuditLogs(
|
|
ctx context.Context,
|
|
actor *models.Actor,
|
|
projectID uuid.UUID,
|
|
query string,
|
|
action string,
|
|
) (int64, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin"); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.repo.Queries().CountAuditLogsForProject(ctx, db.CountAuditLogsForProjectParams{
|
|
ProjectID: pgtype.UUID{Bytes: projectID, Valid: true},
|
|
Btrim: query,
|
|
Btrim_2: action,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) ListCollections(ctx context.Context, actor *models.Actor, projectID uuid.UUID) ([]db.CoreCollection, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Queries().ListCollections(ctx, projectID)
|
|
}
|
|
|
|
func (s *PlatformService) GetCollection(ctx context.Context, actor *models.Actor, projectID, collectionID uuid.UUID) (db.CoreCollection, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer", "viewer"); err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
if collection.ProjectID != projectID {
|
|
return db.CoreCollection{}, fmt.Errorf("collection access denied")
|
|
}
|
|
return collection, nil
|
|
}
|
|
|
|
func (s *PlatformService) CreateCollection(ctx context.Context, actor *models.Actor, projectID uuid.UUID, input CreateCollectionInput, requestID string) (db.CoreCollection, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
var createdBy pgtype.UUID
|
|
if actor.UserID != nil {
|
|
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
schemaBytes, _ := json.Marshal(input.Schema)
|
|
row, err := s.repo.Queries().CreateCollection(ctx, db.CreateCollectionParams{
|
|
ProjectID: projectID,
|
|
Slug: normalizeSlug(input.Slug),
|
|
Name: strings.TrimSpace(input.Name),
|
|
Description: input.Description,
|
|
Schema: schemaBytes,
|
|
CreatedByUserID: createdBy,
|
|
})
|
|
if err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
project, err := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
if err == nil {
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "collection.created", "collection", row.ID.String(), map[string]any{
|
|
"slug": row.Slug,
|
|
}))
|
|
}
|
|
return row, nil
|
|
}
|
|
|
|
func (s *PlatformService) UpdateCollection(ctx context.Context, actor *models.Actor, projectID, collectionID uuid.UUID, input UpdateCollectionInput, requestID string) (db.CoreCollection, error) {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
current, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
if current.ProjectID != projectID {
|
|
return db.CoreCollection{}, fmt.Errorf("collection access denied")
|
|
}
|
|
|
|
name := current.Name
|
|
if input.Name != nil {
|
|
name = strings.TrimSpace(*input.Name)
|
|
}
|
|
description := current.Description
|
|
if input.Description != nil {
|
|
description = input.Description
|
|
}
|
|
schemaBytes := current.Schema
|
|
if input.Schema != nil {
|
|
schemaBytes, _ = json.Marshal(input.Schema)
|
|
}
|
|
|
|
updated, err := s.repo.Queries().UpdateCollection(ctx, db.UpdateCollectionParams{
|
|
ID: collectionID,
|
|
ProjectID: projectID,
|
|
Name: name,
|
|
Description: description,
|
|
Schema: schemaBytes,
|
|
})
|
|
if err != nil {
|
|
return db.CoreCollection{}, err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "collection.updated", "collection", collectionID.String(), map[string]any{
|
|
"name": updated.Name,
|
|
}))
|
|
return updated, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteCollection(ctx context.Context, actor *models.Actor, projectID, collectionID uuid.UUID, requestID string) error {
|
|
if err := s.requireProjectRole(ctx, actor, projectID, "admin", "developer"); err != nil {
|
|
return err
|
|
}
|
|
current, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if current.ProjectID != projectID {
|
|
return fmt.Errorf("collection access denied")
|
|
}
|
|
|
|
if err := s.repo.Queries().DeleteCollection(ctx, db.DeleteCollectionParams{
|
|
ID: collectionID,
|
|
ProjectID: projectID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, projectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, projectID, actor, requestID, "collection.deleted", "collection", collectionID.String(), map[string]any{
|
|
"slug": current.Slug,
|
|
}))
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) ListDocuments(ctx context.Context, actor *models.Actor, collectionID uuid.UUID, limit, offset int32) ([]db.CoreDocument, error) {
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.repo.Queries().ListDocuments(ctx, db.ListDocumentsParams{
|
|
CollectionID: collectionID,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) CountDocuments(ctx context.Context, actor *models.Actor, collectionID uuid.UUID) (int64, error) {
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
|
|
return 0, err
|
|
}
|
|
return s.repo.Queries().CountDocuments(ctx, collectionID)
|
|
}
|
|
|
|
func (s *PlatformService) GetDocument(ctx context.Context, actor *models.Actor, collectionID, documentID uuid.UUID) (db.CoreDocument, error) {
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer", "viewer"); err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
return s.repo.Queries().GetDocumentByID(ctx, db.GetDocumentByIDParams{
|
|
ID: documentID,
|
|
CollectionID: collectionID,
|
|
})
|
|
}
|
|
|
|
func (s *PlatformService) CreateDocument(ctx context.Context, actor *models.Actor, collectionID uuid.UUID, input CreateDocumentInput, requestID string) (db.CoreDocument, error) {
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer"); err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
var createdBy pgtype.UUID
|
|
if actor.UserID != nil {
|
|
createdBy = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
dataBytes, _ := json.Marshal(input.Data)
|
|
row, err := s.repo.Queries().CreateDocument(ctx, db.CreateDocumentParams{
|
|
CollectionID: collectionID,
|
|
Data: dataBytes,
|
|
CreatedByUserID: createdBy,
|
|
})
|
|
if err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, collection.ProjectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.created", "document", row.ID.String(), map[string]any{
|
|
"collectionId": collectionID.String(),
|
|
}))
|
|
return row, nil
|
|
}
|
|
|
|
func (s *PlatformService) UpdateDocument(ctx context.Context, actor *models.Actor, collectionID, documentID uuid.UUID, input UpdateDocumentInput, requestID string) (db.CoreDocument, error) {
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer"); err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
dataBytes, _ := json.Marshal(input.Data)
|
|
row, err := s.repo.Queries().UpdateDocument(ctx, db.UpdateDocumentParams{
|
|
ID: documentID,
|
|
CollectionID: collectionID,
|
|
Data: dataBytes,
|
|
})
|
|
if err != nil {
|
|
return db.CoreDocument{}, err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, collection.ProjectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.updated", "document", documentID.String(), map[string]any{
|
|
"collectionId": collectionID.String(),
|
|
}))
|
|
return row, nil
|
|
}
|
|
|
|
func (s *PlatformService) DeleteDocument(ctx context.Context, actor *models.Actor, collectionID, documentID uuid.UUID, requestID string) error {
|
|
collection, err := s.repo.Queries().GetCollectionByID(ctx, collectionID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := s.requireProjectRole(ctx, actor, collection.ProjectID, "admin", "developer"); err != nil {
|
|
return err
|
|
}
|
|
if err := s.repo.Queries().DeleteDocument(ctx, db.DeleteDocumentParams{
|
|
ID: documentID,
|
|
CollectionID: collectionID,
|
|
}); err != nil {
|
|
return err
|
|
}
|
|
project, _ := s.repo.Queries().GetProjectByID(ctx, collection.ProjectID)
|
|
_, _ = s.repo.Queries().CreateAuditLog(ctx, newAuditParams(project.OrganizationID, collection.ProjectID, actor, requestID, "document.deleted", "document", documentID.String(), map[string]any{
|
|
"collectionId": collectionID.String(),
|
|
}))
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) requireOrganizationRole(ctx context.Context, actor *models.Actor, organizationID uuid.UUID, allowedRoles ...string) error {
|
|
if actor == nil {
|
|
return errors.New("authentication required")
|
|
}
|
|
if actor.IsAPIKey() {
|
|
if actor.OrganizationID != nil && *actor.OrganizationID == organizationID {
|
|
return nil
|
|
}
|
|
return errors.New("organization access denied")
|
|
}
|
|
if actor.UserID == nil {
|
|
return errors.New("user actor required")
|
|
}
|
|
membership, err := s.repo.Queries().GetOrganizationMembership(ctx, db.GetOrganizationMembershipParams{
|
|
OrganizationID: organizationID,
|
|
UserID: *actor.UserID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !containsRole(membership.Role, allowedRoles...) {
|
|
return fmt.Errorf("organization role %s is insufficient", membership.Role)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) requireProjectRole(ctx context.Context, actor *models.Actor, projectID uuid.UUID, allowedRoles ...string) error {
|
|
if actor == nil {
|
|
return errors.New("authentication required")
|
|
}
|
|
if actor.IsAPIKey() {
|
|
if actor.ProjectID != nil && *actor.ProjectID == projectID {
|
|
return nil
|
|
}
|
|
return errors.New("project access denied")
|
|
}
|
|
if actor.UserID == nil {
|
|
return errors.New("user actor required")
|
|
}
|
|
membership, err := s.repo.Queries().GetProjectMembership(ctx, db.GetProjectMembershipParams{
|
|
ProjectID: projectID,
|
|
UserID: *actor.UserID,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !containsRole(membership.Role, allowedRoles...) {
|
|
return fmt.Errorf("project role %s is insufficient", membership.Role)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *PlatformService) requireBucketRead(ctx context.Context, actor *models.Actor, bucket db.GetBucketByIDRow) error {
|
|
if bucket.Visibility == "public" {
|
|
return nil
|
|
}
|
|
return s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer", "viewer")
|
|
}
|
|
|
|
func (s *PlatformService) requireBucketWrite(ctx context.Context, actor *models.Actor, bucket db.GetBucketByIDRow) error {
|
|
return s.requireProjectRole(ctx, actor, bucket.ProjectID, "admin", "developer")
|
|
}
|
|
|
|
func containsRole(current string, allowed ...string) bool {
|
|
for _, role := range allowed {
|
|
if role == current {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeSlug(input string) string {
|
|
slug := strings.ToLower(strings.TrimSpace(input))
|
|
slug = strings.ReplaceAll(slug, " ", "-")
|
|
return slug
|
|
}
|
|
|
|
func randomString(length int) (string, error) {
|
|
const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"
|
|
buffer := make([]byte, length)
|
|
if _, err := rand.Read(buffer); err != nil {
|
|
return "", err
|
|
}
|
|
for i := range buffer {
|
|
buffer[i] = alphabet[int(buffer[i])%len(alphabet)]
|
|
}
|
|
return string(buffer), nil
|
|
}
|
|
|
|
func randomToken(length int) (string, error) {
|
|
bytes := make([]byte, length)
|
|
if _, err := rand.Read(bytes); err != nil {
|
|
return "", err
|
|
}
|
|
return hex.EncodeToString(bytes), nil
|
|
}
|
|
|
|
func digestString(input string) string {
|
|
sum := sha256.Sum256([]byte(input))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func newAuditParams(orgID, projectID uuid.UUID, actor *models.Actor, requestID, action, resourceType, resourceID string, metadata map[string]any) db.CreateAuditLogParams {
|
|
payload, _ := json.Marshal(metadata)
|
|
params := db.CreateAuditLogParams{
|
|
Action: action,
|
|
ResourceType: resourceType,
|
|
ResourceID: resourceID,
|
|
Metadata: payload,
|
|
RequestID: requestID,
|
|
}
|
|
if orgID != uuid.Nil {
|
|
params.OrganizationID = pgtype.UUID{Bytes: orgID, Valid: true}
|
|
}
|
|
if projectID != uuid.Nil {
|
|
params.ProjectID = pgtype.UUID{Bytes: projectID, Valid: true}
|
|
}
|
|
if actor != nil {
|
|
if actor.UserID != nil {
|
|
params.ActorUserID = pgtype.UUID{Bytes: *actor.UserID, Valid: true}
|
|
}
|
|
if actor.APIKeyID != nil {
|
|
params.ActorApiKeyID = pgtype.UUID{Bytes: *actor.APIKeyID, Valid: true}
|
|
}
|
|
}
|
|
return params
|
|
}
|
|
|
|
func pgUUIDOrNil(value pgtype.UUID) uuid.UUID {
|
|
if value.Valid {
|
|
return value.Bytes
|
|
}
|
|
return uuid.Nil
|
|
}
|