Function bodies 1,000 total
proofing.Service.CreateComment method · go · L160-L205 (46 LOC)backend/internal/domain/proofing/service.go
func (s *Service) CreateComment(ctx context.Context, req CreateCommentRequest) (*repository.PhotoComment, error) {
// Verify proofing is enabled
enabled, err := s.IsProofingEnabled(ctx, req.GalleryID)
if err != nil {
return nil, err
}
if !enabled {
return nil, errors.NewForbidden("Client proofing is not enabled for this gallery")
}
// Verify photo exists and belongs to this gallery (prevents IDOR)
photo, err := s.photoRepo.GetByID(ctx, req.PhotoID)
if err != nil {
return nil, fmt.Errorf("failed to verify photo: %w", err)
}
if photo == nil {
return nil, errors.NewNotFound("Photo")
}
if photo.GalleryID != req.GalleryID {
return nil, errors.NewBadRequest("Photo does not belong to this gallery")
}
// Validate comment text
if req.Text == "" {
return nil, errors.NewBadRequest("Comment text is required")
}
if len(req.Text) > 1000 {
return nil, errors.NewBadRequest("Comment text must be 1000 characters or less")
}
comment := &repository.PhotoComment{
Commproofing.Service.ListCommentsForGallery method · go · L208-L224 (17 LOC)backend/internal/domain/proofing/service.go
func (s *Service) ListCommentsForGallery(ctx context.Context, galleryID, photographerID string) ([]*repository.PhotoComment, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByIDForPhotographer(ctx, galleryID, photographerID)
if err != nil {
return nil, fmt.Errorf("failed to get gallery: %w", err)
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
comments, err := s.commentRepo.ListByGallery(ctx, galleryID)
if err != nil {
return nil, fmt.Errorf("failed to list comments: %w", err)
}
return comments, nil
}proofing.Service.ListCommentsForSession method · go · L227-L243 (17 LOC)backend/internal/domain/proofing/service.go
func (s *Service) ListCommentsForSession(ctx context.Context, galleryID, sessionID string) ([]*repository.PhotoComment, error) {
// Verify proofing is enabled
enabled, err := s.IsProofingEnabled(ctx, galleryID)
if err != nil {
return nil, err
}
if !enabled {
return nil, errors.NewForbidden("Client proofing is not enabled for this gallery")
}
comments, err := s.commentRepo.ListBySession(ctx, galleryID, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to list comments: %w", err)
}
return comments, nil
}proofing.Service.DeleteComment method · go · L247-L280 (34 LOC)backend/internal/domain/proofing/service.go
func (s *Service) DeleteComment(ctx context.Context, galleryID, photoID, commentID, sessionID string) error {
comment, err := s.commentRepo.GetByID(ctx, photoID, commentID)
if err != nil {
return fmt.Errorf("failed to get comment: %w", err)
}
if comment == nil {
return errors.NewNotFound("Comment")
}
// Verify the comment belongs to the specified gallery (prevents cross-gallery IDOR)
if comment.GalleryID != galleryID {
return errors.NewForbidden("Comment does not belong to this gallery")
}
// Verify proofing is enabled for the gallery
enabled, err := s.IsProofingEnabled(ctx, galleryID)
if err != nil {
return err
}
if !enabled {
return errors.NewForbidden("Client proofing is not enabled for this gallery")
}
// Only the session owner can delete their own comment
if comment.SessionID != sessionID {
return errors.NewForbidden("You can only delete your own comments")
}
if err := s.commentRepo.Delete(ctx, photoID, commentID); err != nil {
return fmt.Errorfproofing.Service.CreateEditRequest method · go · L292-L350 (59 LOC)backend/internal/domain/proofing/service.go
func (s *Service) CreateEditRequest(ctx context.Context, req CreateEditRequestInput) (*repository.EditRequest, error) {
// Verify proofing is enabled
enabled, err := s.IsProofingEnabled(ctx, req.GalleryID)
if err != nil {
return nil, err
}
if !enabled {
return nil, errors.NewForbidden("Client proofing is not enabled for this gallery")
}
// Verify photo exists and belongs to this gallery (prevents IDOR)
photo, err := s.photoRepo.GetByID(ctx, req.PhotoID)
if err != nil {
return nil, fmt.Errorf("failed to verify photo: %w", err)
}
if photo == nil {
return nil, errors.NewNotFound("Photo")
}
if photo.GalleryID != req.GalleryID {
return nil, errors.NewBadRequest("Photo does not belong to this gallery")
}
// Validate request type
validTypes := map[string]bool{
repository.EditRequestTypeCrop: true,
repository.EditRequestTypeRetouch: true,
repository.EditRequestTypeColor: true,
repository.EditRequestTypeOther: true,
}
if !validTypes[req.RequestType] proofing.Service.ListEditRequestsForGallery method · go · L353-L369 (17 LOC)backend/internal/domain/proofing/service.go
func (s *Service) ListEditRequestsForGallery(ctx context.Context, galleryID, photographerID, status string) ([]*repository.EditRequest, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByIDForPhotographer(ctx, galleryID, photographerID)
if err != nil {
return nil, fmt.Errorf("failed to get gallery: %w", err)
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
requests, err := s.editRequestRepo.ListByGallery(ctx, galleryID, status)
if err != nil {
return nil, fmt.Errorf("failed to list edit requests: %w", err)
}
return requests, nil
}proofing.Service.ListEditRequestsForSession method · go · L372-L388 (17 LOC)backend/internal/domain/proofing/service.go
func (s *Service) ListEditRequestsForSession(ctx context.Context, galleryID, sessionID string) ([]*repository.EditRequest, error) {
// Verify proofing is enabled
enabled, err := s.IsProofingEnabled(ctx, galleryID)
if err != nil {
return nil, err
}
if !enabled {
return nil, errors.NewForbidden("Client proofing is not enabled for this gallery")
}
requests, err := s.editRequestRepo.ListBySession(ctx, galleryID, sessionID)
if err != nil {
return nil, fmt.Errorf("failed to list edit requests: %w", err)
}
return requests, nil
}Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
proofing.Service.UpdateEditRequestStatus method · go · L392-L432 (41 LOC)backend/internal/domain/proofing/service.go
func (s *Service) UpdateEditRequestStatus(ctx context.Context, photoID, requestID, photographerID, status string) (*repository.EditRequest, error) {
// Validate status
validStatuses := map[string]bool{
repository.EditRequestStatusApproved: true,
repository.EditRequestStatusRejected: true,
repository.EditRequestStatusCompleted: true,
}
if !validStatuses[status] {
return nil, errors.NewBadRequest("Invalid status. Valid statuses: approved, rejected, completed")
}
// Get the request to verify ownership
request, err := s.editRequestRepo.GetByID(ctx, photoID, requestID)
if err != nil {
return nil, fmt.Errorf("failed to get edit request: %w", err)
}
if request == nil {
return nil, errors.NewNotFound("Edit request")
}
// Verify photographer owns the gallery
gallery, err := s.galleryRepo.GetByIDForPhotographer(ctx, request.GalleryID, photographerID)
if err != nil {
return nil, fmt.Errorf("failed to verify gallery ownership: %w", err)
}
if gallery == nil {
retproofing.Service.GetProofingSummary method · go · L445-L491 (47 LOC)backend/internal/domain/proofing/service.go
func (s *Service) GetProofingSummary(ctx context.Context, galleryID, photographerID string) (*ProofingSummary, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByIDForPhotographer(ctx, galleryID, photographerID)
if err != nil {
return nil, fmt.Errorf("failed to get gallery: %w", err)
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
summary := &ProofingSummary{
GalleryID: galleryID,
ProofingEnabled: isProofingPhase(gallery.Phase),
}
// Get selections (favorites in proofing context)
if isProofingPhase(gallery.Phase) {
favorites, err := s.favoriteRepo.ListByGallery(ctx, galleryID)
if err != nil {
return nil, fmt.Errorf("failed to get selections: %w", err)
}
summary.TotalSelections = len(favorites)
// Get comment count
commentCount, err := s.commentRepo.CountByGallery(ctx, galleryID)
if err != nil {
return nil, fmt.Errorf("failed to count comments: %w", err)
}
summary.TotalComments = commentCount
// Get penproofing.Service.GetSelectionsForGallery method · go · L494-L514 (21 LOC)backend/internal/domain/proofing/service.go
func (s *Service) GetSelectionsForGallery(ctx context.Context, galleryID, photographerID string) ([]*repository.Favorite, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByIDForPhotographer(ctx, galleryID, photographerID)
if err != nil {
return nil, fmt.Errorf("failed to get gallery: %w", err)
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
if !isProofingPhase(gallery.Phase) {
return nil, errors.NewForbidden("Client proofing is not enabled for this gallery")
}
favorites, err := s.favoriteRepo.ListByGallery(ctx, galleryID)
if err != nil {
return nil, fmt.Errorf("failed to get selections: %w", err)
}
return favorites, nil
}selection.NewService function · go · L28-L42 (15 LOC)backend/internal/domain/selection/service.go
func NewService(
submissionRepo repository.SelectionSubmissionRepository,
galleryRepo repository.GalleryRepository,
photoRepo repository.PhotoRepository,
favoriteRepo repository.FavoriteRepository,
eventBus events.EventBus,
) *Service {
return &Service{
submissionRepo: submissionRepo,
galleryRepo: galleryRepo,
photoRepo: photoRepo,
favoriteRepo: favoriteRepo,
eventBus: eventBus,
}
}selection.Service.SubmitSelections method · go · L48-L182 (135 LOC)backend/internal/domain/selection/service.go
func (s *Service) SubmitSelections(ctx context.Context, galleryID, sessionID string, photoIDs []string, notes string) (*repository.SelectionSubmission, error) {
// Validate minimum photo count
if len(photoIDs) == 0 {
return nil, errors.NewBadRequest("Must select at least one photo")
}
// Validate photoIDs array length to prevent DoS
if len(photoIDs) > MaxSelectionPhotos {
return nil, errors.NewBadRequest(fmt.Sprintf("Cannot submit more than %d photos at once", MaxSelectionPhotos))
}
// Validate for duplicate photo IDs
uniquePhotoIDs := make(map[string]bool)
for _, photoID := range photoIDs {
if uniquePhotoIDs[photoID] {
return nil, errors.NewBadRequest(fmt.Sprintf("Duplicate photo ID: %s", photoID))
}
uniquePhotoIDs[photoID] = true
}
// Get gallery to verify it exists and is in proofing phase
gallery, err := s.galleryRepo.GetByID(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get gallery")
}
if gallery == nil {
return nilselection.Service.GetSubmissionForSession method · go · L185-L191 (7 LOC)backend/internal/domain/selection/service.go
func (s *Service) GetSubmissionForSession(ctx context.Context, galleryID, sessionID string) (*repository.SelectionSubmission, error) {
submission, err := s.submissionRepo.GetByGalleryAndSession(ctx, galleryID, sessionID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get submission")
}
return submission, nil
}selection.Service.GetSubmissionByGallery method · go · L194-L212 (19 LOC)backend/internal/domain/selection/service.go
func (s *Service) GetSubmissionByGallery(ctx context.Context, galleryID, photographerID string) (*repository.SelectionSubmission, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByID(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get gallery")
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
if gallery.PhotographerID != photographerID {
return nil, errors.NewForbidden("You do not have permission to view this gallery")
}
submission, err := s.submissionRepo.GetByGallery(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get submission")
}
return submission, nil
}selection.Service.ListSubmissionsByGallery method · go · L215-L233 (19 LOC)backend/internal/domain/selection/service.go
func (s *Service) ListSubmissionsByGallery(ctx context.Context, galleryID, photographerID string) ([]*repository.SelectionSubmission, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByID(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get gallery")
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
if gallery.PhotographerID != photographerID {
return nil, errors.NewForbidden("You do not have permission to view this gallery")
}
submissions, err := s.submissionRepo.ListByGallery(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to list submissions")
}
return submissions, nil
}Provenance: Repobility (https://repobility.com) — every score reproducible from /scan/
selection.Service.UnlockSubmission method · go · L236-L312 (77 LOC)backend/internal/domain/selection/service.go
func (s *Service) UnlockSubmission(ctx context.Context, galleryID, submissionID, photographerID string) (*repository.SelectionSubmission, error) {
// Verify ownership
gallery, err := s.galleryRepo.GetByID(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get gallery")
}
if gallery == nil {
return nil, errors.NewNotFound("Gallery")
}
if gallery.PhotographerID != photographerID {
return nil, errors.NewForbidden("You do not have permission to modify this gallery")
}
// Get the submission
submissions, err := s.submissionRepo.ListByGallery(ctx, galleryID)
if err != nil {
return nil, errors.Wrap(err, 500, "Failed to get submissions")
}
var submission *repository.SelectionSubmission
for _, sub := range submissions {
if sub.SubmissionID == submissionID {
submission = sub
break
}
}
if submission == nil {
return nil, errors.NewNotFound("Submission")
}
// Preserve original lock state for potential rollback
originalLockedAt := gselection.Service.ExportSelections method · go · L315-L390 (76 LOC)backend/internal/domain/selection/service.go
func (s *Service) ExportSelections(ctx context.Context, galleryID, photographerID string, format string) ([]byte, string, error) {
logger.Info("ExportSelections called", map[string]interface{}{
"galleryId": galleryID,
"photographerId": photographerID,
"format": format,
})
// Verify ownership
gallery, err := s.galleryRepo.GetByID(ctx, galleryID)
if err != nil {
return nil, "", errors.Wrap(err, 500, "Failed to get gallery")
}
if gallery == nil {
return nil, "", errors.NewNotFound("Gallery")
}
if gallery.PhotographerID != photographerID {
return nil, "", errors.NewForbidden("You do not have permission to view this gallery")
}
// Get the latest submission
submission, err := s.submissionRepo.GetByGallery(ctx, galleryID)
if err != nil {
return nil, "", errors.Wrap(err, 500, "Failed to get submission")
}
if submission == nil {
logger.Warn("No submission found for gallery", map[string]interface{}{
"galleryId": galleryID,
})
return nil, "", eselection.Service.exportLightroom method · go · L394-L418 (25 LOC)backend/internal/domain/selection/service.go
func (s *Service) exportLightroom(photos []*repository.Photo) ([]byte, string, error) {
type lightroomExport struct {
Filenames []string `json:"filenames"`
Count int `json:"count"`
Format string `json:"format"`
}
filenames := make([]string, 0, len(photos))
for _, photo := range photos {
filenames = append(filenames, photo.FileName)
}
export := lightroomExport{
Filenames: filenames,
Count: len(filenames),
Format: "lightroom",
}
data, err := json.Marshal(export)
if err != nil {
return nil, "", errors.Wrap(err, 500, "Failed to marshal export data")
}
return data, "application/json", nil
}selection.Service.exportJSON method · go · L421-L458 (38 LOC)backend/internal/domain/selection/service.go
func (s *Service) exportJSON(photos []*repository.Photo, submission *repository.SelectionSubmission) ([]byte, string, error) {
type exportPhoto struct {
PhotoID string `json:"photoId"`
FileName string `json:"fileName"`
SetID string `json:"setId,omitempty"`
}
type exportData struct {
GalleryID string `json:"galleryId"`
SubmittedAt time.Time `json:"submittedAt"`
Notes string `json:"notes,omitempty"`
PhotoCount int `json:"photoCount"`
Photos []exportPhoto `json:"photos"`
}
data := exportData{
GalleryID: submission.GalleryID,
SubmittedAt: submission.SubmittedAt,
Notes: submission.Notes,
PhotoCount: len(photos),
Photos: make([]exportPhoto, 0, len(photos)),
}
for _, photo := range photos {
data.Photos = append(data.Photos, exportPhoto{
PhotoID: photo.PhotoID,
FileName: photo.FileName,
SetID: photo.SetID,
})
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
selection.Service.exportCSV method · go · L461-L477 (17 LOC)backend/internal/domain/selection/service.go
func (s *Service) exportCSV(photos []*repository.Photo) ([]byte, string, error) {
var buf bytes.Buffer
// Header row
buf.WriteString("PhotoID,FileName,SetID\n")
// Data rows
for _, photo := range photos {
buf.WriteString(fmt.Sprintf("%s,%s,%s\n",
escapeCSV(photo.PhotoID),
escapeCSV(photo.FileName),
escapeCSV(photo.SetID),
))
}
return buf.Bytes(), "text/csv", nil
}selection.escapeCSV function · go · L480-L485 (6 LOC)backend/internal/domain/selection/service.go
func escapeCSV(s string) string {
if strings.ContainsAny(s, ",\"\n") {
return "\"" + strings.ReplaceAll(s, "\"", "\"\"") + "\""
}
return s
}subscription.BandwidthService.CheckBandwidthStatus method · go · L42-L50 (9 LOC)backend/internal/domain/subscription/bandwidth_repository.go
func (s *BandwidthService) CheckBandwidthStatus(ctx context.Context, userID string, tier Tier) (BandwidthStatus, error) {
usage, err := s.GetCurrentMonthUsage(ctx, userID)
if err != nil {
// If we can't get usage, assume 0 (don't block on bandwidth errors)
return CheckBandwidthSoftLimit(tier, 0), nil
}
return CheckBandwidthSoftLimit(tier, usage.BytesUsed), nil
}subscription.CheckGalleryLimit function · go · L15-L33 (19 LOC)backend/internal/domain/subscription/gates.go
func CheckGalleryLimit(tier Tier, currentCount int) GateResult {
limits := GetTierLimits(tier)
if IsUnlimited(limits.MaxGalleries) {
return GateResult{Allowed: true}
}
if currentCount >= limits.MaxGalleries {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Gallery limit reached (%d/%d)", currentCount, limits.MaxGalleries),
UpgradeTier: GetUpgradeTier(tier, "galleries"),
CurrentValue: int64(currentCount),
LimitValue: int64(limits.MaxGalleries),
}
}
return GateResult{Allowed: true}
}Open data scored by Repobility · https://repobility.com
subscription.CheckPhotosPerGalleryLimit function · go · L36-L54 (19 LOC)backend/internal/domain/subscription/gates.go
func CheckPhotosPerGalleryLimit(tier Tier, currentCount int) GateResult {
limits := GetTierLimits(tier)
if IsUnlimited(limits.MaxPhotosPerGallery) {
return GateResult{Allowed: true}
}
if currentCount >= limits.MaxPhotosPerGallery {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Photo limit per gallery reached (%d/%d)", currentCount, limits.MaxPhotosPerGallery),
UpgradeTier: GetUpgradeTier(tier, "photos_per_gallery"),
CurrentValue: int64(currentCount),
LimitValue: int64(limits.MaxPhotosPerGallery),
}
}
return GateResult{Allowed: true}
}subscription.CheckStorageLimit function · go · L57-L73 (17 LOC)backend/internal/domain/subscription/gates.go
func CheckStorageLimit(tier Tier, currentBytes, additionalBytes int64) GateResult {
limits := GetTierLimits(tier)
totalAfterUpload := currentBytes + additionalBytes
if totalAfterUpload > limits.StorageBytes {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Storage limit would be exceeded (%d GB / %d GB)", totalAfterUpload/GB, limits.StorageBytes/GB),
UpgradeTier: GetUpgradeTier(tier, "storage"),
CurrentValue: currentBytes,
LimitValue: limits.StorageBytes,
}
}
return GateResult{Allowed: true}
}subscription.CheckGalleryExpirationLimit function · go · L77-L107 (31 LOC)backend/internal/domain/subscription/gates.go
func CheckGalleryExpirationLimit(tier Tier, days int) GateResult {
limits := GetTierLimits(tier)
// Unlimited expiration
if IsUnlimited(limits.MaxGalleryExpirationDays) {
return GateResult{Allowed: true}
}
// No expiration (0) is only allowed for unlimited tiers
if days == 0 {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Galleries must expire within %d days", limits.MaxGalleryExpirationDays),
UpgradeTier: GetUpgradeTier(tier, "unlimited_expiration"),
CurrentValue: int64(days),
LimitValue: int64(limits.MaxGalleryExpirationDays),
}
}
if days > limits.MaxGalleryExpirationDays {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Gallery expiration exceeds limit (%d days max)", limits.MaxGalleryExpirationDays),
UpgradeTier: GetUpgradeTier(tier, "unlimited_expiration"),
CurrentValue: int64(days),
LimitValue: int64(limits.MaxGalleryExpirationDays),
}
}
return GateResult{Allowed: true}
}subscription.CheckVideoUpload function · go · L110-L132 (23 LOC)backend/internal/domain/subscription/gates.go
func CheckVideoUpload(tier Tier, durationSeconds int) GateResult {
limits := GetTierLimits(tier)
if !limits.VideoEnabled {
return GateResult{
Allowed: false,
Reason: "Video uploads require Pro or Studio tier",
UpgradeTier: GetUpgradeTier(tier, "video"),
}
}
if durationSeconds > limits.VideoMaxDurationSeconds {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Video duration exceeds limit (%d seconds max)", limits.VideoMaxDurationSeconds),
UpgradeTier: GetUpgradeTier(tier, "long_video"),
CurrentValue: int64(durationSeconds),
LimitValue: int64(limits.VideoMaxDurationSeconds),
}
}
return GateResult{Allowed: true}
}subscription.CheckRAWDelivery function · go · L135-L147 (13 LOC)backend/internal/domain/subscription/gates.go
func CheckRAWDelivery(tier Tier) GateResult {
limits := GetTierLimits(tier)
if !limits.RAWDelivery {
return GateResult{
Allowed: false,
Reason: "RAW file delivery requires Studio tier",
UpgradeTier: GetUpgradeTier(tier, "raw_delivery"),
}
}
return GateResult{Allowed: true}
}subscription.CheckCustomDomain function · go · L150-L162 (13 LOC)backend/internal/domain/subscription/gates.go
func CheckCustomDomain(tier Tier) GateResult {
limits := GetTierLimits(tier)
if !limits.CustomDomain {
return GateResult{
Allowed: false,
Reason: "Custom domains require Pro or Studio tier",
UpgradeTier: GetUpgradeTier(tier, "custom_domain"),
}
}
return GateResult{Allowed: true}
}subscription.CheckCustomSubdomain function · go · L165-L177 (13 LOC)backend/internal/domain/subscription/gates.go
func CheckCustomSubdomain(tier Tier) GateResult {
limits := GetTierLimits(tier)
if !limits.CustomSubdomain {
return GateResult{
Allowed: false,
Reason: "Custom subdomains require Pro or Studio tier",
UpgradeTier: GetUpgradeTier(tier, "custom_subdomain"),
}
}
return GateResult{Allowed: true}
}subscription.CheckFullResDownload function · go · L198-L210 (13 LOC)backend/internal/domain/subscription/gates.go
func CheckFullResDownload(tier Tier) GateResult {
limits := GetTierLimits(tier)
if !limits.FullResDownload {
return GateResult{
Allowed: false,
Reason: "Full resolution downloads require Starter tier or higher",
UpgradeTier: GetUpgradeTier(tier, "full_res_download"),
}
}
return GateResult{Allowed: true}
}Repobility analyzer · published findings · https://repobility.com
subscription.CheckClientProofing function · go · L219-L231 (13 LOC)backend/internal/domain/subscription/gates.go
func CheckClientProofing(tier Tier) GateResult {
limits := GetTierLimits(tier)
if !limits.ClientProofing {
return GateResult{
Allowed: false,
Reason: "Client proofing requires Pro or Studio tier",
UpgradeTier: GetUpgradeTier(tier, "client_proofing"),
}
}
return GateResult{Allowed: true}
}subscription.CheckGalleryTemplates function · go · L234-L246 (13 LOC)backend/internal/domain/subscription/gates.go
func CheckGalleryTemplates(tier Tier) GateResult {
limits := GetTierLimits(tier)
if !limits.GalleryTemplates {
return GateResult{
Allowed: false,
Reason: "Gallery templates require Pro or Studio tier",
UpgradeTier: GetUpgradeTier(tier, "gallery_templates"),
}
}
return GateResult{Allowed: true}
}subscription.CheckTeamMemberLimit function · go · L249-L263 (15 LOC)backend/internal/domain/subscription/gates.go
func CheckTeamMemberLimit(tier Tier, currentCount int) GateResult {
limits := GetTierLimits(tier)
if currentCount >= limits.MaxTeamMembers {
return GateResult{
Allowed: false,
Reason: fmt.Sprintf("Team member limit reached (%d/%d)", currentCount, limits.MaxTeamMembers),
UpgradeTier: GetUpgradeTier(tier, "team_members"),
CurrentValue: int64(currentCount),
LimitValue: int64(limits.MaxTeamMembers),
}
}
return GateResult{Allowed: true}
}subscription.CheckGalleryScheduling function · go · L266-L276 (11 LOC)backend/internal/domain/subscription/gates.go
func CheckGalleryScheduling(tier Tier) GateResult {
if !MeetsTierRequirement(tier, TierPro) {
return GateResult{
Allowed: false,
Reason: "Gallery scheduling requires Pro or Studio tier",
UpgradeTier: GetUpgradeTier(tier, "gallery_scheduling"),
}
}
return GateResult{Allowed: true}
}subscription.NewFreeSubscription function · go · L96-L105 (10 LOC)backend/internal/domain/subscription/models.go
func NewFreeSubscription(userID string) *Subscription {
now := time.Now().UTC().Format(time.RFC3339)
return &Subscription{
UserID: userID,
Tier: TierFree,
Status: StatusNone,
CreatedAt: now,
UpdatedAt: now,
}
}subscription.ValidateTier function · go · L118-L125 (8 LOC)backend/internal/domain/subscription/models.go
func ValidateTier(tier string) bool {
switch Tier(tier) {
case TierFree, TierStarter, TierPro, TierStudio:
return true
default:
return false
}
}subscription.TierFromString function · go · L128-L133 (6 LOC)backend/internal/domain/subscription/models.go
func TierFromString(s string) (Tier, error) {
if !ValidateTier(s) {
return "", ErrInvalidTier
}
return Tier(s), nil
}subscription.NewService function · go · L53-L59 (7 LOC)backend/internal/domain/subscription/service.go
func NewService(repo Repository, stripeClient StripeClient, config *ServiceConfig) *Service {
return &Service{
repo: repo,
stripeClient: stripeClient,
config: config,
}
}Repobility's GitHub App fixes findings like these · https://github.com/apps/repobility-bot
subscription.Service.GetSubscription method · go · L62-L72 (11 LOC)backend/internal/domain/subscription/service.go
func (s *Service) GetSubscription(ctx context.Context, userID string) (*Subscription, error) {
sub, err := s.repo.GetByUserID(ctx, userID)
if err == ErrNotFound {
// Return a free subscription for users without a subscription record
return NewFreeSubscription(userID), nil
}
if err != nil {
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
return sub, nil
}subscription.Service.GetSubscriptionWithLimits method · go · L75-L86 (12 LOC)backend/internal/domain/subscription/service.go
func (s *Service) GetSubscriptionWithLimits(ctx context.Context, userID string) (*SubscriptionResponse, error) {
sub, err := s.GetSubscription(ctx, userID)
if err != nil {
return nil, err
}
limits := GetTierLimits(sub.Tier)
return &SubscriptionResponse{
Subscription: sub,
Limits: limits,
}, nil
}subscription.Service.CreateOrGetSubscription method · go · L89-L105 (17 LOC)backend/internal/domain/subscription/service.go
func (s *Service) CreateOrGetSubscription(ctx context.Context, userID string) (*Subscription, error) {
existing, err := s.repo.GetByUserID(ctx, userID)
if err == nil {
return existing, nil
}
if err != ErrNotFound {
return nil, fmt.Errorf("failed to check existing subscription: %w", err)
}
// Create new free subscription
sub := NewFreeSubscription(userID)
if err := s.repo.Create(ctx, sub); err != nil {
return nil, fmt.Errorf("failed to create subscription: %w", err)
}
return sub, nil
}subscription.Service.CreateCheckoutSession method · go · L108-L149 (42 LOC)backend/internal/domain/subscription/service.go
func (s *Service) CreateCheckoutSession(ctx context.Context, userID, email, name, priceID string) (*CheckoutSession, error) {
// Get or create subscription
sub, err := s.CreateOrGetSubscription(ctx, userID)
if err != nil {
return nil, err
}
// Get or create Stripe customer
customerID := sub.StripeCustomerID
if customerID == "" {
customerID, err = s.stripeClient.CreateCustomer(ctx, userID, email, name)
if err != nil {
return nil, fmt.Errorf("failed to create Stripe customer: %w", err)
}
// Update subscription with customer ID - this MUST succeed for sync to work later
sub.StripeCustomerID = customerID
sub.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
if err := s.repo.Update(ctx, sub); err != nil {
return nil, fmt.Errorf("failed to save Stripe customer ID: %w", err)
}
}
// Create checkout session
metadata := map[string]string{
"user_id": userID,
}
session, err := s.stripeClient.CreateCheckoutSession(
ctx,
customerID,
priceID,
s.configsubscription.Service.CreatePortalSession method · go · L152-L168 (17 LOC)backend/internal/domain/subscription/service.go
func (s *Service) CreatePortalSession(ctx context.Context, userID string) (*PortalSession, error) {
sub, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
if sub.StripeCustomerID == "" {
return nil, fmt.Errorf("no Stripe customer associated with this user")
}
session, err := s.stripeClient.CreatePortalSession(ctx, sub.StripeCustomerID, s.config.ReturnURL)
if err != nil {
return nil, fmt.Errorf("failed to create portal session: %w", err)
}
return session, nil
}subscription.Service.GetProrationPreview method · go · L171-L187 (17 LOC)backend/internal/domain/subscription/service.go
func (s *Service) GetProrationPreview(ctx context.Context, userID, newPriceID string) (*ProrationPreview, error) {
sub, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
if sub.StripeSubscriptionID == "" {
return nil, fmt.Errorf("no active subscription to preview changes for")
}
preview, err := s.stripeClient.GetProrationPreview(ctx, sub.StripeSubscriptionID, newPriceID)
if err != nil {
return nil, fmt.Errorf("failed to get proration preview: %w", err)
}
return preview, nil
}subscription.Service.ChangeTier method · go · L190-L207 (18 LOC)backend/internal/domain/subscription/service.go
func (s *Service) ChangeTier(ctx context.Context, userID, newPriceID string) error {
sub, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return fmt.Errorf("failed to get subscription: %w", err)
}
if sub.StripeSubscriptionID == "" {
return fmt.Errorf("no active subscription to change")
}
err = s.stripeClient.UpdateSubscriptionPrice(ctx, sub.StripeSubscriptionID, newPriceID, true)
if err != nil {
return fmt.Errorf("failed to update subscription: %w", err)
}
// The webhook will update the local subscription record
return nil
}subscription.Service.CancelSubscription method · go · L210-L256 (47 LOC)backend/internal/domain/subscription/service.go
func (s *Service) CancelSubscription(ctx context.Context, userID string) (*Subscription, error) {
sub, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
if sub.StripeSubscriptionID == "" {
return nil, fmt.Errorf("no active subscription to cancel")
}
stripeData, err := s.stripeClient.CancelSubscription(ctx, sub.StripeSubscriptionID, true)
if err != nil {
return nil, fmt.Errorf("failed to cancel subscription: %w", err)
}
// Update local record with data from Stripe
sub.CancelAtPeriodEnd = true
sub.CanceledAt = time.Now().UTC().Format(time.RFC3339)
sub.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
// Update period end from Stripe response
if periodEnd, ok := stripeData["current_period_end"].(string); ok && periodEnd != "" {
sub.CurrentPeriodEnd = periodEnd
} else if stripeData["current_period_end"] != nil {
logger.Warn("Unexpected type for current_period_end in Stripe response", map[strinProvenance: Repobility (https://repobility.com) — every score reproducible from /scan/
subscription.Service.ReactivateSubscription method · go · L259-L309 (51 LOC)backend/internal/domain/subscription/service.go
func (s *Service) ReactivateSubscription(ctx context.Context, userID string) (*Subscription, error) {
sub, err := s.repo.GetByUserID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
if sub.StripeSubscriptionID == "" {
return nil, fmt.Errorf("no subscription to reactivate")
}
if !sub.CancelAtPeriodEnd {
return nil, fmt.Errorf("subscription is not scheduled for cancellation")
}
stripeData, err := s.stripeClient.ReactivateSubscription(ctx, sub.StripeSubscriptionID)
if err != nil {
return nil, fmt.Errorf("failed to reactivate subscription: %w", err)
}
// Update local record with data from Stripe
sub.CancelAtPeriodEnd = false
sub.CanceledAt = ""
sub.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
// Update period times from Stripe response
if periodEnd, ok := stripeData["current_period_end"].(string); ok && periodEnd != "" {
sub.CurrentPeriodEnd = periodEnd
} else if stripeData["current_period_end"] != nil {
logsubscription.Service.SyncSubscriptionFromStripe method · go · L317-L404 (88 LOC)backend/internal/domain/subscription/service.go
func (s *Service) SyncSubscriptionFromStripe(ctx context.Context, userID string, priceToTier map[string]Tier) (*Subscription, error) {
sub, err := s.repo.GetByUserID(ctx, userID)
if err == ErrNotFound {
// No subscription record - nothing to sync
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("failed to get subscription: %w", err)
}
var stripeData map[string]interface{}
// Try to get subscription data from Stripe
switch {
case sub.StripeSubscriptionID != "":
// We have a subscription ID, fetch directly
stripeData, err = s.stripeClient.GetSubscription(ctx, sub.StripeSubscriptionID)
if err != nil {
return nil, fmt.Errorf("failed to get subscription from Stripe: %w", err)
}
case sub.StripeCustomerID != "":
// No subscription ID yet - this can happen after checkout before webhooks process
// Try to find active subscription by customer ID
stripeData, err = s.stripeClient.GetActiveSubscriptionByCustomer(ctx, sub.StripeCustomerID)
if err != nil {subscription.mapStripeStatus function · go · L407-L422 (16 LOC)backend/internal/domain/subscription/service.go
func mapStripeStatus(stripeStatus string) Status {
switch stripeStatus {
case "active":
return StatusActive
case "trialing":
return StatusTrialing
case "past_due":
return StatusPastDue
case "canceled":
return StatusCanceled
case "paused":
return StatusPaused
default:
return StatusNone
}
}