perf(subscription-status): cache + parallelize + invalidate on mutations
GET /api/subscription/status/ was the slowest endpoint in the API at p50≈1750ms / p95≈2425ms — about 12× the floor for our cluster→Neon geography. Jaeger traces showed seven sequential SQL queries each costing roughly one transatlantic RTT (~110ms), with the actual queries running in 0.073ms at the database. Pure network serialization, not slow SQL. Three changes, in order of leverage: 1. Cache the assembled SubscriptionStatusResponse per-user in Redis with a 5-minute TTL. Hot path collapses to a single Redis GET (~5ms) on warm reads; the TTL is a safety net against missed invalidations. 2. Parallelize the three independent COUNT queries in getUserUsage (task_task / task_contractor / task_document) via golang.org/x/sync errgroup. Three RTTs collapse to one. Also dropped the redundant residence_residence COUNT — len(residenceIDs) from FindResidenceIDsByOwner is the same number, no need to re-query. 3. Wire explicit invalidation into every mutation that could change a user's response — residence/task/contractor/document CRUD, residence membership changes (JoinWithCode, RemoveUser, DeleteResidence), and every subscription tier flip across the IAP/Stripe/webhook surface. Residence-scoped invalidations fan out to every user with access via a new ResidenceRepository.FindUserIDsByResidence helper, so members of a shared residence don't see stale `usage` numbers when another member adds a task. Net effect: warm path goes from ~1350ms to ~5ms (Redis hit). Cold path goes from ~1350ms to ~250-450ms (5 sequential queries → 2 phases: residence IDs lookup, then parallel task/contractor/document counts). Also fixed a pre-existing CheckLimit signature drift in internal/integration/subscription_is_free_test.go that was blocking the package build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -495,3 +495,64 @@ func (c *CacheService) InvalidateSubscriptionSettings(ctx context.Context) error
|
||||
}
|
||||
return c.Delete(ctx, subscriptionSettingsKey)
|
||||
}
|
||||
|
||||
// === SubscriptionStatus cache (per-user) ===
|
||||
//
|
||||
// SubscriptionStatusResponse aggregates subscription tier, all tier limits, and
|
||||
// per-user usage counts (residences/tasks/contractors/documents). The usage
|
||||
// part requires 4+ COUNT queries against the transatlantic Neon Postgres at
|
||||
// ~110ms RTT each — about a second of wall-clock per call before parallelism.
|
||||
// Caching the assembled response collapses that to a single Redis GET (~5ms).
|
||||
//
|
||||
// TTL is short (5 min) so stale state self-heals if any mutation path forgets
|
||||
// to invalidate. The primary correctness mechanism is explicit invalidation
|
||||
// via InvalidateSubscriptionStatusForUsers — called from every CRUD on
|
||||
// residences, tasks, contractors, documents, and subscription itself, fanning
|
||||
// out to every user with access to the affected residence.
|
||||
|
||||
const (
|
||||
subscriptionStatusKeyPrefix = "sub_status:user:"
|
||||
subscriptionStatusTTL = 5 * time.Minute
|
||||
)
|
||||
|
||||
// CacheSubscriptionStatus stores the assembled SubscriptionStatusResponse for
|
||||
// a user. Caller passes any encodable value to keep this package free of
|
||||
// service-layer types; subscription_service.go marshals/unmarshals.
|
||||
// Best-effort — Redis errors are returned but not fatal.
|
||||
func (c *CacheService) CacheSubscriptionStatus(ctx context.Context, userID uint, status interface{}) error {
|
||||
if c == nil {
|
||||
return nil
|
||||
}
|
||||
key := fmt.Sprintf("%s%d", subscriptionStatusKeyPrefix, userID)
|
||||
data, err := json.Marshal(status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return c.client.Set(ctx, key, data, subscriptionStatusTTL).Err()
|
||||
}
|
||||
|
||||
// GetCachedSubscriptionStatus unmarshals the cached response into dest.
|
||||
// Returns redis.Nil on cache miss so callers can distinguish from genuine errors.
|
||||
func (c *CacheService) GetCachedSubscriptionStatus(ctx context.Context, userID uint, dest interface{}) error {
|
||||
if c == nil {
|
||||
return fmt.Errorf("cache not available")
|
||||
}
|
||||
key := fmt.Sprintf("%s%d", subscriptionStatusKeyPrefix, userID)
|
||||
return c.Get(ctx, key, dest)
|
||||
}
|
||||
|
||||
// InvalidateSubscriptionStatusForUsers drops the cached status for one or more
|
||||
// users. Used by every mutation that could change a user's usage counts —
|
||||
// residence create/delete/share, task/contractor/document CRUD, subscription
|
||||
// purchase/cancel/restore. Membership-changing residence ops fan out to every
|
||||
// user with access to that residence.
|
||||
func (c *CacheService) InvalidateSubscriptionStatusForUsers(ctx context.Context, userIDs ...uint) error {
|
||||
if c == nil || len(userIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, len(userIDs))
|
||||
for i, id := range userIDs {
|
||||
keys[i] = fmt.Sprintf("%s%d", subscriptionStatusKeyPrefix, id)
|
||||
}
|
||||
return c.Delete(ctx, keys...)
|
||||
}
|
||||
|
||||
@@ -146,6 +146,15 @@ func (s *ContractorService) CreateContractor(ctx context.Context, req *requests.
|
||||
return nil, apperrors.Internal(reloadErr)
|
||||
}
|
||||
|
||||
// contractors_count for every user with access to this residence just
|
||||
// changed. Contractor without a residence is rare (created via global
|
||||
// add) and only the creator counts it — drop only their cache then.
|
||||
if req.ResidenceID != nil {
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, *req.ResidenceID)
|
||||
} else if s.cache != nil {
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
||||
}
|
||||
|
||||
resp := responses.NewContractorResponse(contractor)
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -258,6 +267,12 @@ func (s *ContractorService) DeleteContractor(ctx context.Context, contractorID,
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
if contractor.ResidenceID != nil {
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, *contractor.ResidenceID)
|
||||
} else if s.cache != nil {
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -178,6 +178,8 @@ func (s *DocumentService) CreateDocument(ctx context.Context, req *requests.Crea
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, req.ResidenceID)
|
||||
|
||||
resp := responses.NewDocumentResponse(document)
|
||||
return &resp, nil
|
||||
}
|
||||
@@ -282,6 +284,8 @@ func (s *DocumentService) DeleteDocument(ctx context.Context, documentID, userID
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, document.ResidenceID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -266,8 +266,10 @@ func (s *ResidenceService) CreateResidence(ctx context.Context, req *requests.Cr
|
||||
}
|
||||
if s.cache != nil {
|
||||
// Owner now has a new residence — drop cached IDs so the next
|
||||
// list-residences call doesn't omit it.
|
||||
// list-residences call doesn't omit it. Also bust the subscription
|
||||
// status cache so properties_count reflects the new residence.
|
||||
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, ownerID)
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, ownerID)
|
||||
}
|
||||
|
||||
// Reload with relations
|
||||
@@ -450,6 +452,10 @@ func (s *ResidenceService) DeleteResidence(ctx context.Context, residenceID, use
|
||||
}
|
||||
if s.cache != nil && len(affectedUserIDs) > 0 {
|
||||
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, affectedUserIDs...)
|
||||
// All counts (properties + tasks/contractors/documents that lived in
|
||||
// the deleted residence) just dropped for every member, not only the
|
||||
// owner.
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, affectedUserIDs...)
|
||||
}
|
||||
|
||||
// Get updated summary
|
||||
@@ -578,8 +584,11 @@ func (s *ResidenceService) JoinWithCode(ctx context.Context, code string, userID
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
if s.cache != nil {
|
||||
// The joining user's residence-IDs cache is now stale.
|
||||
// The joining user's residence-IDs cache is now stale, and their
|
||||
// subscription status now reflects an extra residence with all of its
|
||||
// tasks/contractors/documents.
|
||||
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userID)
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
||||
}
|
||||
|
||||
// Mark share code as used (one-time use)
|
||||
@@ -663,8 +672,10 @@ func (s *ResidenceService) RemoveUser(ctx context.Context, residenceID, userIDTo
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
if s.cache != nil {
|
||||
// The removed user's residence-IDs cache is now stale.
|
||||
// The removed user lost access to one residence and all of its
|
||||
// tasks/contractors/documents — their counts must be recomputed.
|
||||
_ = s.cache.InvalidateResidenceIDsForUsers(ctx, userIDToRemove)
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userIDToRemove)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -33,6 +33,17 @@ func (s *StripeService) SetCacheService(cache *CacheService) {
|
||||
s.cache = cache
|
||||
}
|
||||
|
||||
// invalidateStatusCache drops the per-user SubscriptionStatusResponse cache
|
||||
// after any tier-changing webhook so the next /api/subscription/status/ call
|
||||
// reflects the new state immediately instead of waiting out the 5-min TTL.
|
||||
// Best-effort: webhook handlers shouldn't fail just because Redis is down.
|
||||
func (s *StripeService) invalidateStatusCache(userID uint) {
|
||||
if s.cache == nil {
|
||||
return
|
||||
}
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(context.Background(), userID)
|
||||
}
|
||||
|
||||
// NewStripeService creates a new Stripe service. It initializes the global
|
||||
// Stripe API key from the STRIPE_SECRET_KEY environment variable. If the key
|
||||
// is not set, a warning is logged but the service is still returned (matching
|
||||
@@ -223,6 +234,7 @@ func (s *StripeService) handleCheckoutCompleted(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.UpgradeToPro(userID, expiresAt, models.PlatformStripe); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(userID)
|
||||
|
||||
customerID := ""
|
||||
if session.Customer != nil {
|
||||
@@ -264,6 +276,7 @@ func (s *StripeService) handleSubscriptionUpdated(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.UpgradeToPro(sub.UserID, expiresAt, models.PlatformStripe); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
log.Info().Uint("user_id", sub.UserID).Str("status", string(subscription.Status)).Msg("Stripe subscription active")
|
||||
|
||||
case stripe.SubscriptionStatusPastDue:
|
||||
@@ -282,6 +295,7 @@ func (s *StripeService) handleSubscriptionUpdated(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.DowngradeToFree(sub.UserID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
log.Info().Uint("user_id", sub.UserID).Str("status", string(subscription.Status)).Msg("User downgraded to Free after Stripe subscription ended")
|
||||
}
|
||||
|
||||
@@ -314,6 +328,7 @@ func (s *StripeService) handleSubscriptionDeleted(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.DowngradeToFree(sub.UserID); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
|
||||
log.Info().Uint("user_id", sub.UserID).Msg("User downgraded to Free after Stripe subscription deleted")
|
||||
|
||||
@@ -346,6 +361,7 @@ func (s *StripeService) handleInvoicePaid(event stripe.Event) error {
|
||||
if err := s.subscriptionRepo.UpgradeToPro(sub.UserID, expiresAt, models.PlatformStripe); err != nil {
|
||||
return apperrors.Internal(err)
|
||||
}
|
||||
s.invalidateStatusCache(sub.UserID)
|
||||
|
||||
log.Info().
|
||||
Uint("user_id", sub.UserID).
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/treytartt/honeydue-api/internal/apperrors"
|
||||
@@ -112,8 +113,24 @@ func (s *SubscriptionService) GetSubscription(ctx context.Context, userID uint)
|
||||
return NewSubscriptionResponse(sub), nil
|
||||
}
|
||||
|
||||
// GetSubscriptionStatus gets detailed subscription status including limits
|
||||
// GetSubscriptionStatus gets detailed subscription status including limits.
|
||||
//
|
||||
// Hot path on the iOS launch screen — runs 7+ sequential SQL queries against
|
||||
// transatlantic Neon Postgres at ~110ms RTT each (~800ms floor before
|
||||
// optimization). The assembled response is cached per-user in Redis with a
|
||||
// 5-minute TTL; mutation paths (residence/task/contractor/document/sub CRUD)
|
||||
// invalidate via cache.InvalidateSubscriptionStatusForUsers, fanning out to
|
||||
// every member of a shared residence.
|
||||
func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, userID uint) (*SubscriptionStatusResponse, error) {
|
||||
// Cache fast path — only used on warm reads. Cold reads, trial-start
|
||||
// branch, and the actual mutation paths below all populate fresh.
|
||||
if s.cache != nil {
|
||||
var cached SubscriptionStatusResponse
|
||||
if err := s.cache.GetCachedSubscriptionStatus(ctx, userID, &cached); err == nil {
|
||||
return &cached, nil
|
||||
}
|
||||
}
|
||||
|
||||
sub, err := s.subscriptionRepo.WithContext(ctx).GetOrCreate(userID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
@@ -204,43 +221,59 @@ func (s *SubscriptionService) GetSubscriptionStatus(ctx context.Context, userID
|
||||
resp.TrialActive = sub.IsTrialActive()
|
||||
resp.SubscriptionSource = sub.SubscriptionSource()
|
||||
|
||||
// Best-effort cache write. Errors are logged at the cache layer, not fatal.
|
||||
if s.cache != nil {
|
||||
_ = s.cache.CacheSubscriptionStatus(ctx, userID, resp)
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// getUserUsage calculates current usage for a user.
|
||||
// P-10: Uses CountByOwner for properties count instead of loading all owned residences.
|
||||
// Uses batch COUNT queries (O(1) queries) instead of per-residence queries (O(N)).
|
||||
//
|
||||
// Performance: residence ID lookup is one query (we use len() for the
|
||||
// properties count instead of a redundant COUNT). The three IN-clause counts
|
||||
// against task_task / task_contractor / task_document don't depend on each
|
||||
// other and run concurrently via errgroup, collapsing 3 transatlantic RTTs
|
||||
// into 1. With residence IDs that's 2 RTT total instead of the prior 5.
|
||||
func (s *SubscriptionService) getUserUsage(ctx context.Context, userID uint) (*UsageResponse, error) {
|
||||
// P-10: Use CountByOwner for an efficient COUNT query instead of loading all records
|
||||
propertiesCount, err := s.residenceRepo.WithContext(ctx).CountByOwner(userID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Still need residence IDs for batch counting tasks/contractors/documents
|
||||
// One query — used both for the properties count (len) and as the IN-list
|
||||
// for the three downstream counts. Replaces the prior CountByOwner +
|
||||
// FindResidenceIDsByOwner pair, which queried residence_residence twice
|
||||
// with the same predicate.
|
||||
residenceIDs, err := s.residenceRepo.WithContext(ctx).FindResidenceIDsByOwner(userID)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Count tasks, contractors, and documents across all residences with single queries each
|
||||
tasksCount, err := s.taskRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
var (
|
||||
tasksCount int64
|
||||
contractorsCount int64
|
||||
documentsCount int64
|
||||
)
|
||||
|
||||
contractorsCount, err := s.contractorRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
|
||||
if err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
documentsCount, err := s.documentRepo.WithContext(ctx).CountByResidenceIDs(residenceIDs)
|
||||
if err != nil {
|
||||
g, gCtx := errgroup.WithContext(ctx)
|
||||
g.Go(func() error {
|
||||
c, err := s.taskRepo.WithContext(gCtx).CountByResidenceIDs(residenceIDs)
|
||||
tasksCount = c
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
c, err := s.contractorRepo.WithContext(gCtx).CountByResidenceIDs(residenceIDs)
|
||||
contractorsCount = c
|
||||
return err
|
||||
})
|
||||
g.Go(func() error {
|
||||
c, err := s.documentRepo.WithContext(gCtx).CountByResidenceIDs(residenceIDs)
|
||||
documentsCount = c
|
||||
return err
|
||||
})
|
||||
if err := g.Wait(); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
return &UsageResponse{
|
||||
PropertiesCount: propertiesCount,
|
||||
PropertiesCount: int64(len(residenceIDs)),
|
||||
TasksCount: tasksCount,
|
||||
ContractorsCount: contractorsCount,
|
||||
DocumentsCount: documentsCount,
|
||||
@@ -416,6 +449,12 @@ func (s *SubscriptionService) ProcessApplePurchase(ctx context.Context, userID u
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Tier flipped — drop cached SubscriptionStatusResponse so the next call
|
||||
// returns Pro immediately instead of stale Free.
|
||||
if s.cache != nil {
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
||||
}
|
||||
|
||||
return s.GetSubscription(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -473,6 +512,10 @@ func (s *SubscriptionService) ProcessGooglePurchase(ctx context.Context, userID
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
if s.cache != nil {
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
||||
}
|
||||
|
||||
return s.GetSubscription(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -481,6 +524,10 @@ func (s *SubscriptionService) CancelSubscription(ctx context.Context, userID uin
|
||||
if err := s.subscriptionRepo.WithContext(ctx).SetAutoRenew(userID, false); err != nil {
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
// auto_renew flips a field surfaced in SubscriptionStatusResponse.
|
||||
if s.cache != nil {
|
||||
_ = s.cache.InvalidateSubscriptionStatusForUsers(ctx, userID)
|
||||
}
|
||||
return s.GetSubscription(ctx, userID)
|
||||
}
|
||||
|
||||
@@ -657,6 +704,31 @@ func NewUpgradeTriggerDataResponse(t *models.UpgradeTrigger) *UpgradeTriggerData
|
||||
}
|
||||
}
|
||||
|
||||
// invalidateSubStatusForResidence drops the per-user subscription_status cache
|
||||
// for every user with access to a residence (owner + members from
|
||||
// residence_residence_users). Used by every mutation that changes shared data
|
||||
// counts — tasks, contractors, documents — so members of a shared residence
|
||||
// don't see stale `usage` numbers.
|
||||
//
|
||||
// Best-effort: failures are logged but never returned. The 5-min cache TTL is
|
||||
// the safety net if this ever silently fails.
|
||||
func invalidateSubStatusForResidence(ctx context.Context, cache *CacheService, residenceRepo *repositories.ResidenceRepository, residenceID uint) {
|
||||
if cache == nil {
|
||||
return
|
||||
}
|
||||
userIDs, err := residenceRepo.FindUserIDsByResidence(residenceID)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("sub_status invalidation: residence lookup failed")
|
||||
return
|
||||
}
|
||||
if len(userIDs) == 0 {
|
||||
return
|
||||
}
|
||||
if err := cache.InvalidateSubscriptionStatusForUsers(ctx, userIDs...); err != nil {
|
||||
log.Warn().Err(err).Uint("residence_id", residenceID).Msg("sub_status invalidation: redis delete failed")
|
||||
}
|
||||
}
|
||||
|
||||
// FeatureBenefitResponse represents a feature benefit
|
||||
type FeatureBenefitResponse struct {
|
||||
FeatureName string `json:"feature_name"`
|
||||
|
||||
@@ -197,6 +197,9 @@ func (s *TaskService) CreateTask(ctx context.Context, req *requests.CreateTaskRe
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// tasks_count for every member of this residence just changed.
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, req.ResidenceID)
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
@@ -273,6 +276,10 @@ func (s *TaskService) BulkCreateTasks(ctx context.Context, req *requests.BulkCre
|
||||
created = append(created, responses.NewTaskResponseWithTime(t, 30, now))
|
||||
}
|
||||
|
||||
// One residence per batch, so a single fanout invalidation covers all
|
||||
// affected users.
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, req.ResidenceID)
|
||||
|
||||
return &responses.BulkCreateTasksResponse{
|
||||
Tasks: created,
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
@@ -385,6 +392,8 @@ func (s *TaskService) DeleteTask(ctx context.Context, taskID, userID uint) (*res
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
|
||||
|
||||
return &responses.DeleteWithSummaryResponse{
|
||||
Data: "task deleted",
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
@@ -469,6 +478,9 @@ func (s *TaskService) CancelTask(ctx context.Context, taskID, userID uint, now t
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// CountByResidenceIDs filters out is_cancelled, so this drops tasks_count.
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
@@ -508,6 +520,9 @@ func (s *TaskService) UncancelTask(ctx context.Context, taskID, userID uint, now
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reverse of Cancel — tasks_count goes back up.
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
@@ -551,6 +566,9 @@ func (s *TaskService) ArchiveTask(ctx context.Context, taskID, userID uint, now
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Same as Cancel — CountByResidenceIDs filters is_archived too.
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
@@ -590,6 +608,9 @@ func (s *TaskService) UnarchiveTask(ctx context.Context, taskID, userID uint, no
|
||||
return nil, apperrors.Internal(err)
|
||||
}
|
||||
|
||||
// Reverse of Archive — tasks_count goes back up.
|
||||
invalidateSubStatusForResidence(ctx, s.cache, s.residenceRepo, task.ResidenceID)
|
||||
|
||||
return &responses.TaskWithSummaryResponse{
|
||||
Data: responses.NewTaskResponseWithTime(task, 30, now),
|
||||
Summary: s.getSummaryForUser(userID),
|
||||
|
||||
Reference in New Issue
Block a user