Skip to main content

Task Breakdown: Lite Seats — Phase 1

Mode: Horizontal — Phase 1 all FE (APIs mocked) → Phase 2 all BE. Effort unit: Man-days (1 day ≈ 8h focused work). Source: RFC §4.D + PRD §9.2, with architecture updates from interactive review session (2026-06-27):

  • Inbox blocklist → lite_config DB table (not hardcode) — B2
  • Role-change restructure → Route B (harden AssignRole, delegate from update.go) — B3
  • Feature flag → qontak-preferences IsEnabledFunc (not company_features SQL) — B5
  • Provisioning → extend existing ModPanel→launchpad subscription callback (not Kafka consumer) — B5

⛔ ACTION REQUIRED BEFORE MERGE

These two values are not decided yet. When they are confirmed, update the constants and seed before raising any PR that touches Task 2.3 or Task 2.8.

B1 — Lite component code strings (blocks Task 2.3)

Ask: What are the exact ModPanel component codes for Lite Seats?

Placeholder used everywhere in this breakdown:

QONTAKCHAT-User-Lite-Initial
QONTAKCHAT-User-Lite-Additional

Where to update when confirmed:

  • Constant definition in internal/pkg/consts/ (wherever LiteUserInitialComponent is defined)
  • Core::Services::Launchpad::ProvisionLiteRoleENV['LITE_USER_INITIAL_COMPONENT_CODE'] value
  • Task 2.3 test fixtures

Who to ask: Billing / ModPanel team (same team that owns QONTAKCHAT-User-Initial / QONTAKCHAT-User-Additional)


B2 — Full inbox disabled_permissions list (blocks Task 2.8, Task 1.2)

Ask: What is the complete list of permission_key values that must be blocked from the Lite User role?

Seed currently contains only:

inbox_general_view

Where to update when confirmed:

  • db/migration/<ts>_create_lite_config.up.sql — seed ARRAY[...]
  • No code change needed — table-driven; DB update is enough

Who to ask: Commercial / Product team


Blocking Summary

IDDescriptionAffects
B1Lite component code strings unconfirmed — placeholder QONTAKCHAT-User-Lite-Initial/-Additional (PRD §14); must confirm with Billing/ModPanel before mergeTask 2.3
B2lite_config.disabled_permissions needs full inbox key list — seed only has inbox_general_view; full list TBD with commercial teamTask 2.8, Task 1.2
B-handlerHandler unidentifiedResolved: Core::UseCases::V2::Subscriptions::InvalidateCache (moderator-be) → DELETE /private/cache/invalidate (launchpad). Task 2.7 now actionable.Task 2.7
FigmaLite-config Figma frame absent (REV-6)Resolved: bifrost/lite-seats/prototype/lite-seats.html (committed 2026-06-30) serves as design reference (~90% UX; UI team delivers polish). Task 1.2 now actionable.Task 1.2

Immediately startable (13 days): Task 1.1, Task 1.2 (depends on Task 2.8 for is_inbox), Task 2.1, Task 2.2, Task 2.4, Task 2.5


Pre-step: Component Audit

ComponentFile (verified)Current State
ValidateUserQuotainternal/app/service/billings/validate_user_quota.go30 lines — pure count vs credit; fork into tiered evaluator
GetBillingInfoBySsoIDinternal/app/service/billings/get_billing_info_by_sso_id.goParses standard components; add Lite component reads
BillingInfoResponseinternal/pkg/response/billing_response.go4 fields — no Lite; add LiteInitialCredit/LiteAdditionalCredit
SsoInvite handlerinternal/app/handler/user_sso_handler.goHas Redis lock + quota gate; add tiered evaluator call
SsoInvite serviceinternal/app/service/users/sso_invite.goCreateUser call; add consumed_bucket field
AssignRoleinternal/app/service/users/assign_role.goNo lock, no quota check; add lock + tiered eval + WithTx
Updateinternal/app/service/users/update.go400 lines, goroutine-based role write (sync import); delegate role change to AssignRole
role_handler.gointernal/app/handler/role_handler.goCrsEditCompanyRole + CrsDeleteRole; add lite_config validator + delete guard
iConsumer.gointernal/app/consumer/iConsumer.goConsumer registry; no new consumer needed (provisioning via subscription callback)
users tabledb/migration/, db/query/users.sqlNo consumed_bucket; net-new migration + 3 queries
lite_config tabledb/migration/Does not exist; net-new migration + seed + 1 query
IsEnabledFuncPattern from internal/app/service/roles/crs_edit.goAlready used for loyalty sync + auto-2FA; add FeatureLiteSeats constant
FieldRoles.vue (invite)app/features/user_management/users/invite/views/FieldRoles.vueLists all roles, filters owner default; add flag gate
FieldRoles.vue (edit)app/features/user_management/users/edit/views/FieldRoles.vueSame pattern; add flag gate
InviteUsers.vueapp/features/user_management/users/invite/views/InviteUsers.vueExisting quota error handling; add Lite-specific error strings
EditUsers.vueapp/features/user_management/users/edit/views/EditUsers.vueRole change form; add quota error handling + revert on block
FeaturePermission.vueapp/features/user_management/roles/edit/views/FeaturePermission.vueCheckbox hierarchy; add N/max_permission counter + inbox disable (blocked Figma)
permissionStore.tsapp/features/user_management/roles/store/permissionStore.tsSelection state; add is_inbox filtering (blocked)
useToggleQontakOne.tsapp/common/composables/useToggleQontakOne.tsReference toggle pattern; mirror for useFeatureFlag

Effort Summary

Phase / AreaFE daysBE daysQA daysTotal
Phase 1 — UI (mocked)4.515.5
Phase 2 — BE143.517.5
Grand total4.5144.523

Confidence: medium. Key uncertainties: (1) update.go goroutine refactor complexity for Route B delegation — 4 days may extend if side-effects (CRS sync, cache invalidation) must be preserved exactly; (2) subscription callback handler unknown — may require moderator-be coordination; (3) lite_config.disabled_permissions full list not yet produced.


Phase 1 — FE (APIs Mocked)

Task 1.1: [FE] Flag gate + Lite User dropdown + quota error strings + roles list indicators

An admin can see and invite users with the Lite User role when lite_seats_enabled is ON, sees clear error messages when quota is exhausted, and sees Lite Seats indicators on the roles list page.

Status: ✅ Actionable — no BE dependency

Design reference: bifrost/lite-seats/prototype/lite-seats.html — Screen 1 (roles list) + flag-gate patterns. Dropdown and error strings reuse existing pixel3 patterns (MpBanner, toast.notify).

What to build

  • New useFeatureFlag composable mirroring useToggleQontakOne.ts — accepts featureName: string, returns isEnabled: ComputedRef<boolean>
  • Gate "Lite User" option in both FieldRoles.vue files (invite + edit views) — filter when flag OFF
  • Quota error string mapping in InviteUsers.vue and EditUsers.vue reading resp_desc.en
  • On role-change block (LITE-S05/ERR-1): revert the role selection in EditUsers.vue
  • Roles list page (when flag ON):
    • "Lite Seats enabled" info banner at top of roles table content area
    • "New" chip badge on Lite User row (pixel3 badge-indigo variant inline with role name)
    • "Lite" access-level badge in the Access Level column for the Lite User row
    • Hide delete icon for Lite User row (render edit icon only)

Implementation Plan

ActionFileWhat changes
createapp/common/composables/useFeatureFlag.tsMirror useToggleQontakOne.ts; accept featureName; return isEnabled ComputedRef
extendapp/features/user_management/users/invite/views/FieldRoles.vueImport useFeatureFlag; add computed filter removing user_access='lite' roles when flag OFF
extendapp/features/user_management/users/edit/views/FieldRoles.vueSame filter — both views share the role dropdown logic
extendapp/features/user_management/users/invite/views/InviteUsers.vueMap 422/503 resp_desc.entoast.notify({variant:'error'}) or MpBanner per RFC Detail 3.C
extendapp/features/user_management/users/edit/views/EditUsers.vueSame error handling; on 422 role-upgrade block, revert selectedRole to previous value
extendapp/features/user_management/roles/views/RolesList.vue (or equivalent list view)When flag ON: render "Lite Seats enabled" info banner; add "New" chip + "Lite" badge on Lite User row; hide delete icon for user_access='lite' row
createapp/features/user_management/composables/useFeatureFlag.spec.tsFollow useRoles.spec.ts pattern: setActivePinia, vi.stubGlobal("useClient",…); assert flag ON/OFF

Implementation Steps

  1. Explore: Open app/common/composables/useToggleQontakOne.ts — read how it calls the feature flag API and returns the computed ref. Note the exact useClient call pattern and response shape. Also open the roles list view — identify where role rows are rendered and where action buttons are conditionally shown.
  2. Write failing tests (red): Create app/features/user_management/composables/useFeatureFlag.spec.ts. Add cases: flag ON → isEnabled = true; flag OFF → isEnabled = false; API error → isEnabled = false (fail-safe). Run pnpm test -- useFeatureFlag.spec.ts — confirm all fail.
  3. Scaffold: Create app/common/composables/useFeatureFlag.ts with the same structure as useToggleQontakOne.ts, parametrized on featureName.
  4. Wire flag gate: In both FieldRoles.vue files, import useFeatureFlag('lite_seats_enabled') and add computed(() => roles.filter(r => r.user_access !== 'lite' || isEnabled.value)) — Lite roles excluded when flag OFF.
  5. Wire quota errors: In InviteUsers.vue and EditUsers.vue, find the existing error catch block (reads resp_desc?.en). Add Lite-specific strings per RFC Detail 3.B:
    • "Standard quota exhausted…"toast.notify({variant:'error', title: resp_desc.en})
    • "No seats available…" → same
    • "Quota check unavailable…" (503) → MpBanner variant="warning"
  6. Wire role-change revert: In EditUsers.vue, on role-change 422 save a previousRole ref before the PUT call; on error, restore selectedRole = previousRole.
  7. Wire roles list indicators (when flag ON): In the roles list view — add notice banner (conditional on isLiteSeatsEnabled); for each role row where user_access === 'lite': render "New" chip (badge-indigo pixel3 variant), render "Lite" in access-level column with badge, suppress delete button render.
  8. Go green: Run pnpm test -- useFeatureFlag.spec.ts until all pass.
  9. Quality gate: pnpm lint && pnpm type-check && pnpm build

Acceptance Criteria

  • "Lite User" option visible in both invite and edit role dropdowns only when lite_seats_enabled = ON
  • "Standard quota exhausted" 422 surfaces as error toast (not silent console)
  • "No seats available" 422 (both buckets exhausted) surfaces as error toast
  • 503 "Quota check unavailable" renders as MpBanner warning
  • Role-change 422 block reverts the role dropdown to previous selection
  • Roles list shows "Lite Seats enabled" banner when flag ON; hidden when OFF
  • Lite User row shows "New" chip and "Lite" access-level badge
  • Lite User row has no delete button; other roles unaffected
  • pnpm type-check passes
  • pnpm test passes (useFeatureFlag spec)
  • pnpm lint passes

Test Strategy

Mount FieldRoles.vue with vi.stubGlobal("useClient", …) returning a stub response with flag ON/OFF; assert Lite role present/absent in rendered option list. For error strings: mock inviteUser/updateUser to reject with each Detail 3.B error shape; assert correct toast variant and message text.

Effort Estimate

DisciplineDays
Frontend2
QA0.5
Total2.5

Assumptions: reuses useToggleQontakOne.ts pattern directly; no new pixel3 components; error strings are copy changes; roles list indicators are simple conditional renders (+0.5d vs original 1.5d estimate).

Run to Verify

pnpm test -- useFeatureFlag.spec.ts && pnpm type-check && pnpm lint

Depends on

None


Task 1.2: [FE] Permission counter + inbox-disable on Lite role editor

An admin configuring the Lite User role sees a live "N / 20" counter in the panel header and footer, cannot add inbox permissions, and cannot save with 0 permissions selected.

Status: ✅ Actionable — design reference available (prototype committed 2026-06-30). Still depends on Task 2.8 BE for is_inbox field in GET /permissions_crs response.

Design reference: bifrost/lite-seats/prototype/lite-seats.html — Screen 2 "Configure Lite User". DS version: @mekari/pixel3 (via qontak-unified-component@v1.3.7). UI team delivers remaining pixel3 token polish on top of prototype.

What to build

  • FeaturePermission.vue: live "N / max_permission" counter in two locations:
    • Counter pill in the permission panel header: "N / 20 selected" — turns red (at-limit variant) at cap
    • Counter text in the sticky footer left: "N of 20 permissions selected"
    • Both derived from permissionStore.selectedPermissions count; shown only when user_access = 'lite'
  • At cap: remaining unchecked non-inbox checkboxes :is-disabled (opacity 0.4, cursor:not-allowed) + warning banner below panel "Maximum 20 permissions reached. Unselect a permission to add another."
  • Inbox group header: opacity:0.6, cursor:default, non-clickable; info icon tooltip "Inbox permissions cannot be added to the Lite User role. Inbox access requires a standard role."
  • Inbox permission rows: opacity:0.6, pointer-events:none, checkboxes :disabled; inline text notice "Inbox permissions cannot be added to the Lite User role." below first inbox row
  • Role name field: <input readonly> bound to role name + helper text "System role names are fixed and cannot be changed."
  • Save button: disabled until ≥ 1 permission selected
  • permissionStore.ts: consume is_inbox: bool per permission from GET /permissions_crs response (added by Task 2.8)
  • EditRoles.vue: show counter widget only for user_access = 'lite' roles

Implementation Plan

ActionFileWhat changes
extendapp/features/user_management/roles/edit/views/FeaturePermission.vueAdd dual counter (header pill + footer text); at-cap disable logic + warning banner; inbox group header grey-out + tooltip; inbox row disable + inline notice; role name readonly field + helper text; save-disabled-at-0
extendapp/features/user_management/roles/store/permissionStore.tsConsume is_inbox field from permissions API response; expose inboxPermissionKeys
extendapp/features/user_management/roles/edit/views/EditRoles.vueRender counter/readonly-name section only when role user_access = 'lite'
extendapp/features/user_management/composables/useRoles.tsParse is_inbox field from fetchPermissionsCrs response
create/extendapp/features/user_management/composables/useRoles.spec.tsCounter increment/cap; inbox disabled; save-disabled-at-0; dual counter sync

Depends on

Task 2.8 (BE is_inbox field in GET /permissions_crs)


Phase 2 — BE

Task 2.1: [BE] consumed_bucket migration + SQLC queries

The system can record and query which quota bucket (lite or standard) each user occupies.

Status: ✅ Actionable — deploy this first, before any application code.

Design reference: n/a (BE)

What to build

Migration db/migration/<ts>_add_consumed_bucket_to_users.up.sql:

ALTER TABLE users
ADD COLUMN consumed_bucket VARCHAR(16) NOT NULL DEFAULT 'standard'
CHECK (consumed_bucket IN ('standard', 'lite'));

CREATE INDEX idx_users_company_bucket
ON users (company_id, consumed_bucket)
WHERE deleted_at IS NULL;

Migration db/migration/<ts>_add_consumed_bucket_to_users.down.sql:

DROP INDEX IF EXISTS idx_users_company_bucket;
ALTER TABLE users DROP COLUMN IF EXISTS consumed_bucket;

New queries in db/query/users.sql:

-- name: CountUserByBucket :one
SELECT COUNT(*) FROM users
WHERE company_id = $1
AND consumed_bucket = $2
AND deleted_at IS NULL
AND (is_consultant IS NULL OR is_consultant = false);

-- name: CountUserByBucketExcluding :one
SELECT COUNT(*) FROM users
WHERE company_id = $1
AND consumed_bucket = $2
AND id != $3
AND deleted_at IS NULL
AND (is_consultant IS NULL OR is_consultant = false);

-- name: UpdateUserConsumedBucket :exec
UPDATE users SET consumed_bucket = $2 WHERE id = $1;

Extend CreateUser query in db/query/users.sql to include consumed_bucket in INSERT column list.

Then: sqlc generate → sync db/sqlc/ to internal/app/repository/.

Implementation Plan

ActionFileWhat changes
createdb/migration/<ts>_add_consumed_bucket_to_users.up.sqlADD COLUMN + index
createdb/migration/<ts>_add_consumed_bucket_to_users.down.sqlReverse
extenddb/query/users.sql3 new queries + extend CreateUser
generatedb/sqlc/internal/app/repository/sqlc generate + sync
createinternal/app/service/billings/consumed_bucket_test.goTable-driven tests for new querier methods

Implementation Steps

  1. Explore: Open db/query/users.sql — read CountUser query (the one validate_user_quota.go calls); mirror its is_consultant filter for the new bucket queries. Note CreateUser column list.
  2. Write failing tests (red): In internal/app/service/billings/, create a test that calls repo.CountUserByBucket and CountUserByBucketExcluding with mockery mocks — assert correct params passed.
  3. Create migration files with the timestamp prefix matching existing files in db/migration/.
  4. Add 3 queries to db/query/users.sql + extend CreateUser with consumed_bucket param.
  5. Run sqlc generate — verify generated files in db/sqlc/.
  6. Sync: Copy relevant generated methods from db/sqlc/ to internal/app/repository/ (strip SQLC headers).
  7. Run make migrate-up on local DB. Run make migrate-down — confirm reversibility.
  8. Go green: make test

Acceptance Criteria

  • make migrate-up succeeds idempotently
  • make migrate-down reverses cleanly — no leftover column or index
  • DEFAULT 'standard' — zero downtime; existing users unaffected
  • CountUserByBucket and CountUserByBucketExcluding filter deleted_at IS NULL and non-consultant (mirrors existing CountUser)
  • CreateUser accepts consumed_bucket param
  • make test passes

Test Strategy

Table-driven tests using mockery Repository mock; assert CountUserByBucketExcluding receives the correct exclude_id ($3).

Effort Estimate

DisciplineDays
Backend1
Total1

Assumptions: migration pattern identical to existing files; sqlc generate is idempotent; no schema conflicts.

Run to Verify

make migrate-up && make test

Depends on

None


Task 2.2: [BE] lite_config table — inbox blocklist + permission cap

The system has a DB-driven config for the Lite role that defines the permission cap and which permission keys are inbox-related (forward-compatible for future Lite role variants).

Status: ✅ Actionable — the table + initial seed is buildable now; disabled_permissions list is partially known (inbox_general_view). Full list added when commercial team produces it.

Design reference: n/a (BE)

What to build

Migration db/migration/<ts>_create_lite_config.up.sql:

CREATE TABLE lite_config (
id SERIAL PRIMARY KEY,
role_name VARCHAR(64) NOT NULL UNIQUE,
max_permission INT NOT NULL DEFAULT 20,
disabled_permissions TEXT[] NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Seed: initial Lite role config
INSERT INTO lite_config (role_name, max_permission, disabled_permissions)
VALUES ('lite', 20, ARRAY['inbox_general_view']);
-- ⚠️ Full inbox key list TBD — add remaining keys when commercial team delivers the list

Migration db/migration/<ts>_create_lite_config.down.sql:

DROP TABLE IF EXISTS lite_config;

New query db/query/lite_config.sql:

-- name: GetLiteConfig :one
SELECT * FROM lite_config WHERE role_name = $1 LIMIT 1;

Service helper in internal/app/service/roles/ (or billings): GetLiteConfig(ctx, roleName string) (repository.LiteConfig, error) — cached in Redis with short TTL (e.g. 60s) to avoid per-request DB hit.

Implementation Plan

ActionFileWhat changes
createdb/migration/<ts>_create_lite_config.up.sqlTable DDL + seed
createdb/migration/<ts>_create_lite_config.down.sqlDROP TABLE
createdb/query/lite_config.sqlGetLiteConfig :one
generatedb/sqlc/internal/app/repository/sqlc generate + sync
createinternal/app/service/roles/get_lite_config.goGetLiteConfig helper with Redis cache
createinternal/app/service/roles/get_lite_config_test.goTests for cache hit + miss

Implementation Steps

  1. Explore: Open an existing db/migration/*.up.sql that creates a table — follow naming convention and Postgres dialect.
  2. Write failing tests (red): Test GetLiteConfig — assert cache hit returns without DB call; cache miss fetches from DB and caches result.
  3. Create migration + seedrole_name = 'lite', max_permission = 20, disabled_permissions = ARRAY['inbox_general_view'].
  4. Create db/query/lite_config.sql + run sqlc generate + sync.
  5. Implement GetLiteConfig service helper — read from Redis (lite_config:{roleName}), on miss fetch from DB and set TTL 60s.
  6. Run make migrate-up; run make migrate-down.
  7. Go green: make test

Acceptance Criteria

  • lite_config table exists with correct columns after migration
  • Seed row present: role_name='lite', max_permission=20, disabled_permissions includes inbox_general_view
  • GetLiteConfig('lite') returns the config row; subsequent call within TTL returns cached
  • make migrate-down drops table cleanly
  • make test passes

Test Strategy

Mockery mock for Repository; Redis mock for cache layer. Assert cache miss → DB call → Redis set; cache hit → no DB call.

Effort Estimate

DisciplineDays
Backend1
Total1

Assumptions: TEXT[] type supported by sqlc; Redis caching follows same pattern as Package:Unified billing cache.

Run to Verify

make migrate-up && make test

Depends on

None (parallel with 2.1)


Task 2.3: [BE] Lite entitlement read from ModPanel

The billing service can read Lite quota values from ModPanel and return them alongside standard quota.

Status: ⚠️ Partially blocked — Lite component code strings unconfirmed (B1). Can build with placeholder; must confirm codes with Billing/ModPanel team before merging.

Design reference: n/a (BE)

What to build

  • Define constants (placeholder — ⚠️ ASK: confirm exact strings with Billing/ModPanel before merging):
    // internal/pkg/consts/billing.go (or billings service consts)
    const (
    LiteUserInitialComponent = "QONTAKCHAT-User-Lite-Initial" // ⚠️ UNCONFIRMED — verify with ModPanel
    LiteUserAdditionalComponent = "QONTAKCHAT-User-Lite-Additional" // ⚠️ UNCONFIRMED — verify with ModPanel
    )
  • Extend internal/app/service/billings/get_billing_info_by_sso_id.go: in the ActiveComponentsSummary loop, match the two new constants and accumulate into LiteInitialCredit / LiteAdditionalCredit; absent → 0 (not error)
  • Extend internal/pkg/response/billing_response.go: add LiteInitialCredit int32 and LiteAdditionalCredit int32 to BillingInfoResponse

Implementation Plan

ActionFileWhat changes
extendinternal/pkg/response/billing_response.goAdd LiteInitialCredit int32, LiteAdditionalCredit int32
extendinternal/app/service/billings/get_billing_info_by_sso_id.goMatch two new component constants in component loop
createinternal/pkg/consts/billing.go (or extend existing consts file)Define placeholder component code constants
extendinternal/app/service/billings/get_billing_info_by_sso_id_test.goAdd test cases: Lite components present → correct credits; absent → 0

Implementation Steps

  1. Explore: Open internal/app/service/billings/get_billing_info_by_sso_id.go — find the ActiveComponentsSummary loop; read exactly how "QONTAKCHAT-User-Initial" and "QONTAKCHAT-User-Additional" are matched. The new Lite components follow the same pattern.
  2. Write failing tests (red): In get_billing_info_by_sso_id_test.go, add two cases: (a) ModPanel response includes both Lite components → LiteInitialCredit and LiteAdditionalCredit populated; (b) Lite components absent → both = 0.
  3. Add LiteInitialCredit, LiteAdditionalCredit to BillingInfoResponse.
  4. Define placeholder constants in consts file with // ⚠️ UNCONFIRMED comment.
  5. Extend the component loop in GetBillingInfoBySsoID — match new constants, accumulate credits.
  6. Go green: make test (billings package)

Acceptance Criteria

  • BillingInfoResponse carries LiteInitialCredit / LiteAdditionalCredit (snake_case in JSON response)
  • Lite components absent → both fields = 0, no error
  • Existing standard credit fields unchanged
  • ⚠️ Component code constants marked UNCONFIRMED — DO NOT MERGE until confirmed with Billing/ModPanel
  • make test passes (billings)

Test Strategy

Extend existing get_billing_info_by_sso_id_test.go table-driven tests; add rows for Lite component presence/absence.

Effort Estimate

DisciplineDays
Backend1
QA0.5
Total1.5

Assumptions: ModPanel response shape unchanged; component parsing is a simple string-match loop; no new HTTP call.

Run to Verify

make test

Depends on

None (parallel with 2.1, 2.2)


Task 2.4: [BE] Feature-flag read via qontak-preferences

The service can check whether lite_seats_enabled is ON for a given company, using the existing preferences module.

Status: ✅ Actionable

Design reference: n/a (BE)

What to build

  • Add FeatureLiteSeats constant in internal/pkg/consts/ (wherever FeatureLoyaltyRoleSync / FeatureAutoActivate2FA live)
  • Add a thin wrapper IsLiteSeatsEnabled(ctx, companyID uuid.UUID) (bool, error) in internal/app/service/billings/ (or a shared location) that calls IsEnabledFunc:
    func IsLiteSeatsEnabled(ctx context.Context, companyID uuid.UUID) (bool, error) {
    return IsEnabledFunc(ctx, preference.IsEnabledParams{
    FeatureName: constants.FeatureLiteSeats,
    UniqueID: companyID.String(),
    IsBlacklist: false,
    })
    }
  • Flag OFF → all quota paths run single-bucket standard logic (SC-5 regression guard)

Implementation Plan

ActionFileWhat changes
extendinternal/pkg/consts/ (feature constants file)Add FeatureLiteSeats = "lite_seats_enabled"
createinternal/app/service/billings/feature_flag.goIsLiteSeatsEnabled wrapper
createinternal/app/service/billings/feature_flag_test.goTwo cases: flag ON → true; flag OFF → false

Implementation Steps

  1. Explore: Open internal/app/service/roles/crs_edit.go lines 165–195 — read the exact IsEnabledFunc call pattern and IsEnabledParams struct fields. Note where constants.FeatureLoyaltyRoleSync is defined.
  2. Add constant: Add FeatureLiteSeats = "lite_seats_enabled" next to existing feature constants.
  3. Create feature_flag.go: Implement IsLiteSeatsEnabled following the pattern from crs_edit.go.
  4. Write tests: Stub IsEnabledFunc (same pattern as get_all_permission_options_test.go) — assert flag ON → true; flag OFF → false; preference service error → false, err.
  5. Go green: make test

Acceptance Criteria

  • IsLiteSeatsEnabled(ctx, companyID) returns true when preference is ON for that company
  • Returns false when OFF — callers take standard path (SC-5)
  • Preference service error propagated; callers handle gracefully
  • make test passes

Test Strategy

Stub IsEnabledFunc package-level var (same injection pattern used in crs_edit_test.go and get_all_permission_options_test.go).

Effort Estimate

DisciplineDays
Backend0.5
Total0.5

Assumptions: IsEnabledFunc injection pattern already established; no new module wiring needed.

Run to Verify

make test

Depends on

None (parallel with 2.1, 2.2, 2.3)


Task 2.5: [BE] Shared Redis lock helper

The invite and role-change paths share one reusable lock primitive instead of duplicating the SetNx pattern.

Status: ✅ Actionable

Design reference: n/a (BE)

What to build

  • Extract the SetNx / Del lock pattern from internal/app/handler/user_sso_handler.go (SsoInvite) into a reusable helper internal/pkg/lock/redis_lock.go:
    // AcquireLock acquires a Redis pessimistic lock. Returns unlock func that releases it.
    func AcquireLock(ctx context.Context, cache CacheRepo, key string, ttl time.Duration) (acquired bool, unlock func(), err error)
  • Swap inline SetNx/defer Del in SsoInvite with lock.AcquireLock(...) — behavior byte-identical
  • AssignRole (Task 2.9) will call the same helper

Implementation Plan

ActionFileWhat changes
createinternal/pkg/lock/redis_lock.goAcquireLock function
createinternal/pkg/lock/redis_lock_test.goUnit tests: acquired → unlock releases; not acquired → returns false
extendinternal/app/handler/user_sso_handler.goSwap inline SetNx/defer Del with lock.AcquireLock

Implementation Steps

  1. Explore: Open internal/app/handler/user_sso_handler.go — find the SetNx block in SsoInvite. Read the lock key pattern ("lock:user_invite:%s"), TTL (10*time.Second), and defer Del call.
  2. Write failing tests (red): In internal/pkg/lock/redis_lock_test.go, assert: AcquireLockacquired=true, unlock() releases key; second call while locked → acquired=false.
  3. Create redis_lock.go with AcquireLock — calls SetNx, on success returns unlock = func(){ cache.Del(ctx, key) }.
  4. Swap in user_sso_handler.go: Replace inline SetNx/defer Del with lock.AcquireLock(ctx, h.cacheRepo, lockKey, 10*time.Second).
  5. Confirm existing SsoInvite tests still pass — behavior must be byte-identical.
  6. Go green: make test

Acceptance Criteria

  • Existing SsoInvite tests pass unchanged
  • AcquireLock helper has its own unit tests
  • unlock() always releases the key (even when called multiple times)
  • make test passes

Test Strategy

Redis cache mock; assert SetNx called with correct key + TTL; assert Del called by unlock().

Effort Estimate

DisciplineDays
Backend0.5
Total0.5

Assumptions: CacheRepo interface already has SetNx and Del — no new interface methods needed.

Run to Verify

make test

Depends on

None (parallel with 2.1–2.4)


Task 2.6: [BE] Tiered quota evaluator

The invite flow deducts from Lite quota first and falls back to standard quota, correctly recording which bucket was consumed.

Status: ⚠️ Partially blocked — depends on Tasks 2.1 (queries), 2.3 (Lite credits), 2.4 (flag), 2.5 (lock)

Design reference: n/a (BE)

What to build

New function in internal/app/service/billings/evaluate_user_quota.go:

// EvaluateUserQuota returns the bucket to assign and validates quota.
// excludeUserID: pass the user being re-bucketed on role change (avoids double-count);
// pass uuid.Nil on new invite.
func (s *BillingService) EvaluateUserQuota(
ctx context.Context,
ssoID uuid.UUID,
roleName string,
excludeUserID uuid.UUID,
) (bucket string, err error)

Branches (Detail 3.A.1):

ConditionResult
IsLiteSeatsEnabled = falsebucket=standard, count only vs standard quota (SC-5)
roleName != 'lite'bucket=standard, count only vs standard quota; Lite never read (LITE-S03)
Lite role, lite_used < lite_quotabucket=lite (LITE-S04/AC-1)
Lite role, lite_used >= lite_quota, std_used < std_quotabucket=standard fallback (LITE-S04/AC-2)
Both exhaustedErrUnprocessableEntity("No seats available…") 422 (LITE-S04/ERR-1)
ModPanel unavailableErrServiceUnavailable("Quota check unavailable…") 503 (LITE-S04/ERR-2)

Call sites:

  • internal/app/service/users/sso_invite.go — after lock acquired, replace ValidateUserQuota call; set consumed_bucket in CreateUser params
  • internal/app/service/users/assign_role.go — Task 2.9

Implementation Plan

ActionFileWhat changes
createinternal/app/service/billings/evaluate_user_quota.goEvaluateUserQuota method
createinternal/app/service/billings/evaluate_user_quota_test.goTable-driven, one case per branch + each error code
extendinternal/app/service/users/sso_invite.goReplace ValidateUserQuota with EvaluateUserQuota; set consumed_bucket on CreateUser
extendinternal/app/service/users/sso_invite_test.goAdd bucket-assignment test cases

Implementation Steps

  1. Explore: Open internal/app/service/billings/validate_user_quota.go — the 30-line single-bucket check. This is the base; EvaluateUserQuota forks from it.
  2. Write failing tests (red): Create evaluate_user_quota_test.go with table-driven cases for all 6 branches above. Use mockery mocks for IStore (CountUserByBucket / CountUserByBucketExcluding) and IBillingService (GetBillingInfoBySsoID). All fail initially.
  3. Scaffold EvaluateUserQuota: Check flag → check role → call GetBillingInfoBySsoID → count buckets → branch logic.
  4. Wire excludeUserID: If not uuid.Nil, call CountUserByBucketExcluding; otherwise CountUserByBucket.
  5. Extend sso_invite.go: Replace h.billingService.ValidateUserQuota(ctx, req.InviterSsoID) with EvaluateUserQuota(ctx, req.InviterSsoID, role.UserAccess, uuid.Nil) — use returned bucket to set consumed_bucket in CreateUser params.
  6. Go green: make test (billings + users packages)

Acceptance Criteria

  • Standard-role invite → Lite quota never read (LITE-S03/SC-3)
  • Lite-role invite, Lite available → bucket=lite (LITE-S04/AC-1)
  • Lite-role invite, Lite exhausted, std available → bucket=standard fallback (LITE-S04/AC-2)
  • Both exhausted → 422 ErrUnprocessableEntity (LITE-S04/ERR-1)
  • ModPanel down → 503 (LITE-S04/ERR-2)
  • Flag OFF → standard-only path, byte-identical to today (SC-5)
  • All 6 branches covered by table-driven tests
  • make test passes (billings + users packages)

Test Strategy

Table-driven with mockery mocks. One row per Detail 3.A.1 branch. Assert correct bucket returned and correct error type/status for each error branch.

Effort Estimate

DisciplineDays
Backend2.5
QA1
Total3.5

Assumptions: mockery mocks for IStore already generated; ErrUnprocessableEntity / ErrServiceUnavailable constructors exist in internal/pkg/http/.

Run to Verify

make test

Depends on

Task 2.1 (bucket count queries), Task 2.3 (Lite credits), Task 2.4 (flag), Task 2.5 (lock helper, for call site in sso_invite.go)


Task 2.7: [BE] Lite role provisioning on subscription update

The Lite User role is automatically provisioned for a company when they activate a Lite Seats package, without manual setup.

Status: ✅ Actionable — handler chain now identified (see below).

Design reference: n/a (BE)

Call chain (verified)

ModPanel payment event
→ moderator-be: Core::UseCases::V2::Subscriptions::InvalidateCache#result
(app/domains/core/use_cases/v2/subscriptions/invalidate_cache.rb)
Line 52: calls invalidate_launchpad_cache
→ Core::Services::Launchpad::InvalidateCache
(app/domains/core/services/launchpad/invalidate_cache.rb)
→ DELETE /private/cache/invalidate?external_company_id={id}
→ launchpad: CacheHandler.InvalidateCachePrivate
(internal/app/handler/cache_handler.go:48)

What to build

Part A — moderator-be (add a new step in Core::UseCases::V2::Subscriptions::InvalidateCache):

After line 52 (invalidate_launchpad_cache), add:

provision_lite_seats_role

New private method (modeled on invalidate_launchpad_cache):

def provision_lite_seats_role
return unless @is_lite_seats_enabled # gated by preference (add to fetch_preference)

# detect Lite component in unified subscription
components = @unified_subscription[:active_components_summary]
has_lite = components&.any? { |c| c[:component_code] == ENV['LITE_USER_INITIAL_COMPONENT_CODE'] }
return unless has_lite

Core::Services::Launchpad::ProvisionLiteRole.new(
external_company_id: @params[:external_company_id]
).call
# failure: log only — do not fail the subscription update
rescue => e
Rails.logger.error("[Launchpad][ProvisionLiteRole] company=#{@params[:external_company_id]} error=#{e.message}")
end

New service Core::Services::Launchpad::ProvisionLiteRole (modeled on InvalidateCache service):

class Core::Services::Launchpad::ProvisionLiteRole < Core::Repositories::AbstractRepository
def initialize(external_company_id:)
@base_url = ENV['LAUNCHPAD_BASE_URL']
@external_company_id = Integer(external_company_id)
end

def call
request = pigeon_post(
service: 'launchpad_provision_lite_role',
path: "/private/company_roles/lite/provision",
headers: { 'Authorization' => auth },
body: { external_company_id: @external_company_id }.to_json
)
parse_response(request)
end

private

def auth
"Basic #{ENV['LAUNCHPAD_AUTH']}"
end
end

Add @is_lite_seats_enabled to fetch_preference in the use case:

@is_lite_seats_enabled = Core::Services::Preference.new.enabled?(:lite_seats_provisioning)

Part B — launchpad (new private endpoint):

New route in internal/server/rest_router.go (inside /private block, near line 199):

r.Method(http.MethodPost, "/company_roles/lite/provision", myHandler(h.RoleHandler.ProvisionLiteRole))

New handler method ProvisionLiteRole in internal/app/handler/role_handler.go:

  • Parse external_company_id from JSON body
  • Resolve company_id (UUID) via repo.GetCompanyByExternalID(ctx, externalCompanyID)
  • Delegate to roleService.ProvisionLiteRole(ctx, companyID)

New service method ProvisionLiteRole in internal/app/service/roles/:

func (s *RoleService) ProvisionLiteRole(ctx context.Context, companyID uuid.UUID) error {
existing, err := s.repo.GetCompanyRoleByDefaultAndUserAccess(ctx, companyID, "lite")
if err == nil && existing.ID != uuid.Nil {
slog.InfoContext(ctx, "lite_role_already_provisioned", slog.String("company_id", companyID.String()))
return nil
}
return s.repo.CreateDefaultCompanyRole(ctx, repository.CreateDefaultCompanyRoleParams{
CompanyID: companyID,
UserAccess: "lite",
})
}

Implementation Plan

ActionFileWhat changes
extendmoderator-be/app/domains/core/use_cases/v2/subscriptions/invalidate_cache.rbAdd provision_lite_seats_role step (line ~52); add @is_lite_seats_enabled to fetch_preference
createmoderator-be/app/domains/core/services/launchpad/provision_lite_role.rbProvisionLiteRole service (pigeon POST)
extendinternal/server/rest_router.goAdd POST /private/company_roles/lite/provision in the private block
extendinternal/app/handler/role_handler.goAdd ProvisionLiteRole handler method
createinternal/app/service/roles/provision_lite_role.goProvisionLiteRole service method with idempotency guard
createinternal/app/service/roles/provision_lite_role_test.goTable-driven: component present → role created; absent → no-op; already exists → no duplicate

Implementation Steps

  1. Explore moderator-be: Open app/domains/core/use_cases/v2/subscriptions/invalidate_cache.rb lines 56–67 (fetch_preference) — add @is_lite_seats_enabled = Core::Services::Preference.new.enabled?(:lite_seats_provisioning). Check what @unified_subscription[:active_components_summary] looks like by reading Core::Services::V2::Billings::BuildCacheUnified.
  2. Explore launchpad: Open db/query/company_roles.sql — find CreateDefaultCompanyRole and GetCompanyRoleByDefaultAndUserAccess param structs.
  3. Write failing tests (red) for launchpad: In provision_lite_role_test.go, assert: Lite role absent → CreateDefaultCompanyRole called once; already exists → not called.
  4. Implement ProvisionLiteRole service in internal/app/service/roles/provision_lite_role.go.
  5. Add route + handler in rest_router.go and role_handler.go.
  6. Create Core::Services::Launchpad::ProvisionLiteRole service in moderator-be.
  7. Add provision_lite_seats_role step to the use case. Gate on @is_lite_seats_enabled preference.
  8. Go green: make test (launchpad); RSpec for moderator-be service

Acceptance Criteria

  • Lite component in subscription → launchpad ProvisionLiteRole called (LITE-S01/AC-1)
  • Replay / re-activation → no duplicate role (idempotency — GetCompanyRoleByDefaultAndUserAccess guard)
  • Lite component absent → no provisioning call
  • @is_lite_seats_enabled = false → step skipped entirely (feature-flag gate)
  • Provisioning failure → logged in moderator-be, subscription update still returns success (non-blocking)
  • New launchpad endpoint protected by BasicAuth (existing middleware on /private block)
  • make test passes (launchpad roles package)

Test Strategy

Launchpad: mockery mock for IStore; assert CreateDefaultCompanyRole call count per scenario. Moderator-be: RSpec double for Core::Services::Launchpad::ProvisionLiteRole; assert .call invoked when Lite component present, not invoked when absent.

Effort Estimate

DisciplineDays
Backend1.5
QA0.5
Total2

Assumptions: CreateDefaultCompanyRole and GetCompanyRoleByDefaultAndUserAccess already exist as SQLC queries; active_components_summary in @unified_subscription contains component_code keys (confirm by reading BuildCacheUnified); moderator-be pigeon HTTP client pattern mirrors existing InvalidateCache service.

Run to Verify

# launchpad
make test
# moderator-be
bundle exec rspec spec/domains/core/use_cases/v2/subscriptions/invalidate_cache_spec.rb

Depends on

Task 2.3 (component constants), Task 2.2 (lite_config seeds — not strictly required but good to have)


Task 2.8: [BE] Role validator (≤max_permission/no-inbox) + delete guard

Admins cannot configure more than 20 permissions or inbox permissions on the Lite role, and cannot delete the Lite role — enforced at the API layer.

Status: ⚠️ Partially blocked — lite_config.disabled_permissions currently only has inbox_general_view. Full inbox key list TBD (commercial team). Can implement with current seed; expand list later via DB update (no code change needed).

Design reference: n/a (BE)

What to build

Extend internal/app/handler/role_handler.go / internal/app/service/roles/:

CrsEditCompanyRole — when target role user_access = 'lite':

  1. Load GetLiteConfig('lite') (Task 2.2)
  2. Count enabled permissions in request → if > liteConfig.MaxPermissionErrUnprocessableEntity("Lite User role cannot exceed N permissions.") (LITE-S02/ERR-3)
  3. Check each permission_key in request ∈ liteConfig.DisabledPermissions → if any match → ErrUnprocessableEntity("Inbox permissions cannot be configured on the Lite User role.") (LITE-S02/ERR-2)

CrsDeleteRole — if company_roles.user_access = 'lite'ErrUnprocessableEntity("The Lite User role is a system role and cannot be deleted.") (LITE-S01 delete guard)

GET /iag/v1/permissions_crs response — add is_inbox bool per permission: is_inbox = (permission_key ∈ liteConfig.DisabledPermissions). FE reads this to disable checkboxes without client-side hardcoding (Open Q10 / REV-5).

Implementation Plan

ActionFileWhat changes
extendinternal/app/handler/role_handler.go or serviceAdd CrsEditCompanyRole validators (cap + inbox); add CrsDeleteRole guard
extendinternal/app/service/roles/crs_edit.goCall GetLiteConfig; validate cap + inbox blocklist
extendinternal/pkg/response/ (permissions_crs response struct)Add IsInbox bool field
extendinternal/app/service/roles/get_all_permission_options.goPopulate IsInbox from liteConfig.DisabledPermissions
extendinternal/app/service/roles/crs_edit_test.goAdd test cases: >cap → 422; inbox key → 422; delete Lite → 422
extendinternal/app/service/roles/get_all_permission_options_test.goAdd IsInbox field assertion

Implementation Steps

  1. Explore: Open internal/app/service/roles/crs_edit.go — find where CrsEditCompanyRole validates/saves permission changes. Note the existing IsEnabledFunc calls as structural reference. Open internal/app/service/roles/get_all_permission_options.go — find where permissions_crs list is built.
  2. Write failing tests (red): Add cases: Lite role + 21 permissions → 422; Lite role + inbox key → 422; delete Lite role → 422; permissions_crs response contains is_inbox=true for inbox keys.
  3. Extend crs_edit.go: After confirming role is user_access='lite', call GetLiteConfig('lite'), check count and inbox set.
  4. Extend delete handler/service: Check user_access='lite' before delete; return 422 guard.
  5. Extend get_all_permission_options.go: Load GetLiteConfig; for each permission, set IsInbox = slices.Contains(liteConfig.DisabledPermissions, permission.PermissionKey).
  6. Go green: make test (roles package)

Acceptance Criteria

  • 422 on count(enabled) > liteConfig.MaxPermission (LITE-S02/ERR-3)
  • 422 on permission_key ∈ liteConfig.DisabledPermissions (LITE-S02/ERR-2)
  • 422 on delete attempt of user_access='lite' role (LITE-S01 guard)
  • permissions_crs response includes is_inbox bool per permission
  • All 422 responses use ErrUnprocessableEntity (HTTP 422, not 400)
  • make test passes (roles)

Test Strategy

Mockery mocks for IStore + GetLiteConfig (or stub the service). Table-driven: one case per 422 condition; one case for the happy path. Assert response is_inbox field on permission list.

Effort Estimate

DisciplineDays
Backend1.5
QA0.5
Total2

Assumptions: crs_edit.go already has a clear entry point per-role; slices.Contains available (Go 1.21+, confirmed go.mod); GetLiteConfig TTL-cached so per-request load is negligible.

Run to Verify

make test

Depends on

Task 2.2 (lite_config + GetLiteConfig)


Task 2.9: [BE] Role-change re-bucket — Route B (harden AssignRole, delegate from update.go) ⚠️ HIGHEST RISK

When an admin changes a user's role, the system atomically re-evaluates quota, updates consumed_bucket, and saves the role change — with no possibility of over-provisioning under concurrency.

Status: ⚠️ Partially blocked — depends on Tasks 2.1, 2.5, 2.6. Route B confirmed: harden AssignRole, then delegate from update.go.

Design reference: n/a (BE)

What to build

Step A — Harden internal/app/service/users/assign_role.go:

  1. Acquire lock.AcquireLock(ctx, cache, "lock:user_invite:"+companyID, 10s) at the top (new wiring — AssignRole has no lock today)
  2. Read current consumed_bucket from user record
  3. Call EvaluateUserQuota(ctx, ssoID, newRole.UserAccess, userID) (excludes this user from count)
  4. Same-bucket no-op: if current consumed_bucket == returned bucket → skip quota check, proceed to role write (LITE-S05/AC-2)
  5. Open WithTx: write role change + UpdateUserConsumedBucket atomically (LITE-S05/AC-1)
  6. Rollback on quota 422/503 → role unchanged (LITE-S05/ERR-1/2)
  7. defer unlock() releases lock

Step B — Delegate from internal/app/service/users/update.go: When params.RoleCrsID changes (role field updated in the edit-user form): instead of writing the role in the goroutine, call s.AssignRole(ctx, ...) from within Update. Non-role fields (name, phone, etc.) continue in the existing goroutine pattern — scope the change to the role-change path only.

Implementation Plan

ActionFileWhat changes
extendinternal/app/service/users/assign_role.goAdd lock + EvaluateUserQuota + same-bucket no-op + WithTx for role+bucket write
extendinternal/app/service/users/update.goWhen role_crs_id changes → delegate to AssignRole instead of goroutine write
extendinternal/app/service/users/assign_role_test.goAdd re-bucket scenarios (table-driven)
extendinternal/app/service/users/update_test.goAssert AssignRole called when role changes; non-role fields still update

Implementation Steps

  1. Explore assign_role.go fully: Read the full 6.3KB file — find where role + CRS writes happen today, where cache invalidation happens, where CRS sync happens. The lock and tx wrap the role+bucket write only; cache invalidation and CRS sync happen after the tx commits.
  2. Explore update.go lines around role write: Find the goroutine block that handles role_crs_id change. This is the goroutine to redirect.
  3. Write failing tests (red) for assign_role.go:
    • Same-bucket upgrade → no quota check called (LITE-S05/AC-2)
    • Lite→standard, std available → bucket updated to standard
    • Lite→standard, std exhausted → 422, role unchanged (LITE-S05/ERR-1)
    • Lock contention → 429 (existing behavior)
    • ModPanel down during role-change → 503, role unchanged (LITE-S05/ERR-2)
    • Tx rollback on DB error → role + bucket both unchanged
  4. Write failing tests (red) for update.go: Assert that when role_crs_id differs from current, AssignRole is called; when non-role fields change, goroutine pattern preserved.
  5. Implement Step A in assign_role.go: Add lock acquire → EvaluateUserQuota → same-bucket check → WithTx → unlock.
  6. Implement Step B in update.go: Detect role_crs_id change; call s.AssignRole(ctx, ...) for that path; leave goroutine for non-role updates.
  7. Go green: make test (users package)

Acceptance Criteria

  • Role + bucket written atomically in one WithTx tx (LITE-S05/AC-1)
  • Redis lock held for duration of role-change critical section (new wiring)
  • Same-bucket change → no quota check called (LITE-S05/AC-2)
  • excludeUserID passed to EvaluateUserQuota — user excluded from own bucket count (LITE-S05/AC-3)
  • Quota exhausted on target bucket → 422, role unchanged (LITE-S05/ERR-1)
  • ModPanel down → 503, role unchanged (LITE-S05/ERR-2)
  • Lock contention → 429 (existing behavior preserved)
  • Non-role fields in Update (name, phone, etc.) unaffected by this change
  • make test passes (users package)

Test Strategy

Mockery mocks for IStore, IBillingService, ICacheRepo. Table-driven for all AC rows. Inject tx mock that simulates rollback on DB error. Assert WithTx called exactly once per non-no-op path.

Effort Estimate

DisciplineDays
Backend4
QA1
Total5

Assumptions: update.go goroutine delegates cleanly — side effects (CRS sync, cache invalidation) remain in the goroutine for non-role fields; WithTx helper and Redis lock helper already available (Tasks 2.5 + existing store.go). 4-day estimate assumes the goroutine restructure in update.go requires careful scoping.

Run to Verify

make test

Depends on

Task 2.1, Task 2.5, Task 2.6


Task 2.10: [BE] Swagger annotations

API documentation reflects all new request/response fields and error codes for the Lite Seats endpoints.

Status: ✅ Actionable (run last — after all handlers are stable)

Design reference: n/a (BE)

What to build

Update swag annotations on changed handlers:

HandlerFileChange
SsoInviteinternal/app/handler/user_sso_handler.goAdd consumed_bucket to response; new 422/503 codes
CrsEditCompanyRoleinternal/app/handler/role_handler.goAdd 422 codes for cap + inbox guard (LITE-S02)
CrsDeleteRoleinternal/app/handler/role_handler.goAdd 422 for Lite delete guard
AssignRoleinternal/app/handler/user_role_handler.goDocument re-bucket 422/503/429
Updateinternal/app/handler/user_sso_handler.goSame re-bucket behavior
GetUnifiedBillinginternal/app/handler/billing_handler.goAdd lite_initial_credit, lite_additional_credit to response
GetAllPermissionOptionshandlerAdd is_inbox bool to permission schema

Run make init → commit docs/swagger.json + docs/swagger.yaml alongside handler changes.

Effort Estimate

DisciplineDays
Backend0.5
Total0.5

Run to Verify

make init && git diff --stat docs/

Depends on

Task 2.6, Task 2.8, Task 2.9 (all handler changes must be in place)


Ordering Rationale

  1. Immediate parallel start: Task 1.1 (FE), 2.1 (migration), 2.2 (lite_config), 2.4 (flag), 2.5 (lock helper) — all independent
  2. 2.1 deploys firstconsumed_bucket migration is additive with DEFAULT 'standard'; zero downtime; deploy before any application code that reads the column
  3. 2.3 → 2.6 → 2.9 is the critical BE path — Lite entitlement read must exist before the evaluator; evaluator must exist before the role-change re-bucket
  4. 2.7 + 2.8 parallel with 2.6 — provisioning and role validator share no dependencies with the evaluator
  5. 2.9 is highest risk — schedule last in sprint; requires thorough review of update.go goroutine scope before merge
  6. 2.10 last — Swagger after all handler changes are stable
  7. 1.2 now actionable — design blocker resolved by prototype (2026-06-30); only hard dependency is Task 2.8 (is_inbox field); can start FE scaffolding in parallel

Skipped Stories

StoryReason
LITE-S01 provisioning (Kafka path)Superseded: provisioning handled by extending existing subscription callback handler (not a new Kafka consumer) — B5 resolution
qontak-billing changes (PRD §14)Out of scope per RFC ADR-2 (count-based model — no deduction/refund API needed)
Mobile (LITE-S01..S06 on mobile)Explicit non-goal — web only this phase (PRD §5.4)
Bulk migration of existing usersExplicit non-goal (PRD §5.6)
Quota reporting dashboardsExplicit non-goal (PRD §5.7)
Task 1.2 originally deferred (Figma)✅ Resolved — prototype committed 2026-06-30; Task 1.2 is now actionable pending Task 2.8