mirror of
https://github.com/warmbly/warmbly.git
synced 2026-09-11 00:05:01 +00:00
Wire the customer self-serve path for asking "please give me more
mailboxes / campaigns / contacts." Migration 000046 adds
limit_increase_requests with a partial unique index ensuring only one
pending request per (org, field) so the queue can't be spammed, plus
a CHECK requiring requested > current_effective so no-op rows never
reach an admin.
Service layer:
- SubmitLimitIncreaseRequest validates membership, rejects unknown
fields, snapshots the user's current effective limit at submission
time so the queue row carries the context the admin needs.
- CancelLimitRequest lets the original submitter walk back a pending
request; approved/rejected rows are immutable as the audit record.
- ApproveLimitRequest stamps the row and writes the corresponding
column on organization_limit_overrides via SetLimitOverrides —
same write path direct admin overrides use, so granted_by and
notes carry through and the audit log treats both flows uniformly.
- RejectLimitRequest stamps the row with required review notes.
Routes:
POST /v1/organization/:orgId/limit-requests
GET /v1/organization/:orgId/limit-requests
DELETE /v1/limit-requests/:id (submitter only)
GET /admin/limit-requests?status=pending
POST /admin/limit-requests/:id/approve
POST /admin/limit-requests/:id/reject
Admin approval and rejection both fire admin audit log entries with
field + requested + notes so the decision history survives any future
reorg of the request table.
UI (admin queue page + dashboard request form) plus the ToS clause
giving Warmbly the right to refuse any increase land in the next commit.
249 lines
10 KiB
Go
249 lines
10 KiB
Go
package models
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Organization represents a multi-user organization/workspace
|
|
type Organization struct {
|
|
ID uuid.UUID `json:"id"`
|
|
Name string `json:"name"`
|
|
Slug *string `json:"slug,omitempty"`
|
|
AvatarURL *string `json:"avatar_url,omitempty"`
|
|
OwnerUserID uuid.UUID `json:"owner_user_id"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
|
|
// Soft-delete window. When DeletionScheduledFor is non-nil the
|
|
// organization is "pending deletion" and will be hard-deleted at
|
|
// that timestamp unless cancelled.
|
|
DeletionScheduledAt *time.Time `json:"deletion_scheduled_at,omitempty"`
|
|
DeletionScheduledFor *time.Time `json:"deletion_scheduled_for,omitempty"`
|
|
|
|
// Joined data
|
|
Owner *User `json:"owner,omitempty"`
|
|
}
|
|
|
|
// IsPendingDeletion reports whether the organization is currently
|
|
// scheduled for a delayed hard delete.
|
|
func (o *Organization) IsPendingDeletion() bool {
|
|
return o.DeletionScheduledFor != nil
|
|
}
|
|
|
|
// OrganizationMember represents a user's membership in an organization
|
|
type OrganizationMember struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
UserID uuid.UUID `json:"user_id"`
|
|
Role string `json:"role"`
|
|
Permissions OrganizationPermission `json:"permissions"`
|
|
InvitedBy *uuid.UUID `json:"invited_by,omitempty"`
|
|
InvitedAt time.Time `json:"invited_at"`
|
|
AcceptedAt *time.Time `json:"accepted_at,omitempty"`
|
|
|
|
// Joined data
|
|
User *User `json:"user,omitempty"`
|
|
Organization *Organization `json:"organization,omitempty"`
|
|
}
|
|
|
|
// HasPermission checks if the member has a specific permission
|
|
func (m *OrganizationMember) HasPermission(perm OrganizationPermission) bool {
|
|
return m.Permissions.HasPermission(perm)
|
|
}
|
|
|
|
// IsOwner returns true if the member is the organization owner
|
|
func (m *OrganizationMember) IsOwner() bool {
|
|
return m.Role == string(RoleOwner)
|
|
}
|
|
|
|
// OrganizationInvitation represents a pending invitation to join an organization
|
|
type OrganizationInvitation struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
Email string `json:"email"`
|
|
Role string `json:"role"`
|
|
Permissions OrganizationPermission `json:"permissions"`
|
|
InvitedBy uuid.UUID `json:"invited_by"`
|
|
Token string `json:"-"` // Never expose token in JSON
|
|
ExpiresAt time.Time `json:"expires_at"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
|
|
// Joined data
|
|
Organization *Organization `json:"organization,omitempty"`
|
|
InvitedByUser *User `json:"invited_by_user,omitempty"`
|
|
}
|
|
|
|
// IsExpired returns true if the invitation has expired
|
|
func (i *OrganizationInvitation) IsExpired() bool {
|
|
return time.Now().After(i.ExpiresAt)
|
|
}
|
|
|
|
// EnterpriseInquiry represents a contact request for enterprise pricing
|
|
type EnterpriseInquiry struct {
|
|
ID uuid.UUID `json:"id"`
|
|
CompanyName string `json:"company_name"`
|
|
ContactName string `json:"contact_name"`
|
|
ContactEmail string `json:"contact_email"`
|
|
EstimatedVolume *int `json:"estimated_volume,omitempty"`
|
|
TeamSize *int `json:"team_size,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
Status string `json:"status"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
ProcessedAt *time.Time `json:"processed_at,omitempty"`
|
|
ProcessedBy *uuid.UUID `json:"processed_by,omitempty"`
|
|
}
|
|
|
|
// EnterpriseInquiryStatus represents the status of an enterprise inquiry
|
|
type EnterpriseInquiryStatus string
|
|
|
|
const (
|
|
EnterpriseInquiryStatusPending EnterpriseInquiryStatus = "pending"
|
|
EnterpriseInquiryStatusContacted EnterpriseInquiryStatus = "contacted"
|
|
EnterpriseInquiryStatusConverted EnterpriseInquiryStatus = "converted"
|
|
EnterpriseInquiryStatusDeclined EnterpriseInquiryStatus = "declined"
|
|
)
|
|
|
|
// CreateOrganizationRequest represents the request to create a new organization
|
|
type CreateOrganizationRequest struct {
|
|
Name string `json:"name" binding:"required,min=1,max=255"`
|
|
}
|
|
|
|
// UpdateOrganizationRequest represents the request to update an organization
|
|
type UpdateOrganizationRequest struct {
|
|
Name *string `json:"name,omitempty"`
|
|
Slug *string `json:"slug,omitempty"`
|
|
}
|
|
|
|
// InviteMemberRequest represents the request to invite a new member
|
|
type InviteMemberRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Role string `json:"role,omitempty"`
|
|
Permissions *uint16 `json:"permissions,omitempty"`
|
|
}
|
|
|
|
// UpdateMemberRequest represents the request to update a member's role/permissions
|
|
type UpdateMemberRequest struct {
|
|
Role *string `json:"role,omitempty"`
|
|
Permissions *uint16 `json:"permissions,omitempty"`
|
|
}
|
|
|
|
// TransferOwnershipRequest represents the request to transfer organization ownership
|
|
type TransferOwnershipRequest struct {
|
|
NewOwnerUserID uuid.UUID `json:"new_owner_user_id" binding:"required"`
|
|
}
|
|
|
|
// AcceptInvitationRequest represents the request to accept an invitation
|
|
type AcceptInvitationRequest struct {
|
|
Token string `json:"token" binding:"required"`
|
|
}
|
|
|
|
// OrganizationCounts represents resource counts for an organization
|
|
type OrganizationCounts struct {
|
|
TotalCampaigns int `json:"total_campaigns"`
|
|
ActiveCampaigns int `json:"active_campaigns"`
|
|
TotalContacts int `json:"total_contacts"`
|
|
TotalMembers int `json:"total_members"`
|
|
EmailAccounts int `json:"email_accounts"`
|
|
EmailsSentToday int `json:"emails_sent_today"`
|
|
}
|
|
|
|
// OrganizationLimits represents the limits for an organization based on their plan
|
|
type OrganizationLimits struct {
|
|
MaxCampaigns *int `json:"max_campaigns,omitempty"`
|
|
MaxActiveCampaigns *int `json:"max_active_campaigns,omitempty"`
|
|
MaxTeamMembers *int `json:"max_team_members,omitempty"`
|
|
MaxEmailAccounts *int `json:"max_email_accounts,omitempty"`
|
|
MaxContacts *int `json:"max_contacts,omitempty"`
|
|
DailyCampaignLimit *int `json:"daily_campaign_limit,omitempty"`
|
|
}
|
|
|
|
// OrganizationWithLimits combines organization with its limits and counts
|
|
type OrganizationWithLimits struct {
|
|
Organization
|
|
Limits *OrganizationLimits `json:"limits,omitempty"`
|
|
Counts *OrganizationCounts `json:"counts,omitempty"`
|
|
}
|
|
|
|
// OrganizationLimitOverrides is the per-org override row. Each numeric
|
|
// field uses 0 as the "no override, inherit from plan" sentinel —
|
|
// resolution happens in GetEffectiveLimits, not here. The row is upsert-
|
|
// only; reverting an override is a write of 0 so the granted_by audit
|
|
// trail survives across revisions.
|
|
type OrganizationLimitOverrides struct {
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
MaxCampaigns int `json:"max_campaigns"`
|
|
MaxActiveCampaigns int `json:"max_active_campaigns"`
|
|
MaxTeamMembers int `json:"max_team_members"`
|
|
MaxEmailAccounts int `json:"max_email_accounts"`
|
|
MaxContacts int `json:"max_contacts"`
|
|
DailyCampaignLimit int `json:"daily_campaign_limit"`
|
|
GrantedBy *uuid.UUID `json:"granted_by,omitempty"`
|
|
GrantedAt time.Time `json:"granted_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
Notes string `json:"notes"`
|
|
}
|
|
|
|
// UpdateOrgOverridesRequest is the payload for PUT /admin/organizations/:id/overrides.
|
|
// Pointer fields so the caller can leave any column untouched; setting
|
|
// a value to 0 explicitly removes that column's override.
|
|
type UpdateOrgOverridesRequest struct {
|
|
MaxCampaigns *int `json:"max_campaigns,omitempty"`
|
|
MaxActiveCampaigns *int `json:"max_active_campaigns,omitempty"`
|
|
MaxTeamMembers *int `json:"max_team_members,omitempty"`
|
|
MaxEmailAccounts *int `json:"max_email_accounts,omitempty"`
|
|
MaxContacts *int `json:"max_contacts,omitempty"`
|
|
DailyCampaignLimit *int `json:"daily_campaign_limit,omitempty"`
|
|
Notes *string `json:"notes,omitempty"`
|
|
}
|
|
|
|
// LimitRequestStatus mirrors the postgres enum from migration 000046.
|
|
type LimitRequestStatus string
|
|
|
|
const (
|
|
LimitRequestStatusPending LimitRequestStatus = "pending"
|
|
LimitRequestStatusApproved LimitRequestStatus = "approved"
|
|
LimitRequestStatusRejected LimitRequestStatus = "rejected"
|
|
LimitRequestStatusCancelled LimitRequestStatus = "cancelled"
|
|
)
|
|
|
|
// LimitIncreaseRequest is one row of the queue. Approving translates
|
|
// the row into a write on organization_limit_overrides — same path the
|
|
// admin override editor uses, so the audit story is unified.
|
|
type LimitIncreaseRequest struct {
|
|
ID uuid.UUID `json:"id"`
|
|
OrganizationID uuid.UUID `json:"organization_id"`
|
|
Field string `json:"field"`
|
|
CurrentEffective int `json:"current_effective"`
|
|
Requested int `json:"requested"`
|
|
Reason string `json:"reason"`
|
|
Status LimitRequestStatus `json:"status"`
|
|
SubmittedBy uuid.UUID `json:"submitted_by"`
|
|
SubmittedAt time.Time `json:"submitted_at"`
|
|
ReviewedBy *uuid.UUID `json:"reviewed_by,omitempty"`
|
|
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
|
|
ReviewNotes string `json:"review_notes"`
|
|
|
|
// Joined data — populated by admin queries.
|
|
Organization *Organization `json:"organization,omitempty"`
|
|
SubmittedByUser *User `json:"submitted_by_user,omitempty"`
|
|
}
|
|
|
|
// CreateLimitIncreaseRequest is what the dashboard sends. Field must
|
|
// match one of the OrganizationLimits keys; the service rejects unknown
|
|
// fields and refuses Requested values that aren't strictly greater than
|
|
// the current effective limit.
|
|
type CreateLimitIncreaseRequest struct {
|
|
Field string `json:"field" binding:"required"`
|
|
Requested int `json:"requested" binding:"required,min=1"`
|
|
Reason string `json:"reason" binding:"required,min=1,max=2000"`
|
|
}
|
|
|
|
// ReviewLimitRequestBody is what the admin sends to approve/reject. The
|
|
// approve handler writes the corresponding override; reject just stamps
|
|
// the row.
|
|
type ReviewLimitRequestBody struct {
|
|
Notes string `json:"notes"`
|
|
}
|