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_configDB table (not hardcode) — B2- Role-change restructure → Route B (harden
AssignRole, delegate fromupdate.go) — B3- Feature flag →
qontak-preferencesIsEnabledFunc(notcompany_featuresSQL) — 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/(whereverLiteUserInitialComponentis defined) Core::Services::Launchpad::ProvisionLiteRole—ENV['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— seedARRAY[...]- No code change needed — table-driven; DB update is enough
Who to ask: Commercial / Product team
Blocking Summary
| ID | Description | Affects |
|---|---|---|
| B1 | Lite component code strings unconfirmed — placeholder QONTAKCHAT-User-Lite-Initial/-Additional (PRD §14); must confirm with Billing/ModPanel before merge | Task 2.3 |
| B2 | lite_config.disabled_permissions needs full inbox key list — seed only has inbox_general_view; full list TBD with commercial team | Task 2.8, Task 1.2 |
Core::UseCases::V2::Subscriptions::InvalidateCache (moderator-be) → DELETE /private/cache/invalidate (launchpad). Task 2.7 now actionable. | ||
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. |
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
| Component | File (verified) | Current State |
|---|---|---|
ValidateUserQuota | internal/app/service/billings/validate_user_quota.go | 30 lines — pure count vs credit; fork into tiered evaluator |
GetBillingInfoBySsoID | internal/app/service/billings/get_billing_info_by_sso_id.go | Parses standard components; add Lite component reads |
BillingInfoResponse | internal/pkg/response/billing_response.go | 4 fields — no Lite; add LiteInitialCredit/LiteAdditionalCredit |
SsoInvite handler | internal/app/handler/user_sso_handler.go | Has Redis lock + quota gate; add tiered evaluator call |
SsoInvite service | internal/app/service/users/sso_invite.go | CreateUser call; add consumed_bucket field |
AssignRole | internal/app/service/users/assign_role.go | No lock, no quota check; add lock + tiered eval + WithTx |
Update | internal/app/service/users/update.go | 400 lines, goroutine-based role write (sync import); delegate role change to AssignRole |
role_handler.go | internal/app/handler/role_handler.go | CrsEditCompanyRole + CrsDeleteRole; add lite_config validator + delete guard |
iConsumer.go | internal/app/consumer/iConsumer.go | Consumer registry; no new consumer needed (provisioning via subscription callback) |
users table | db/migration/, db/query/users.sql | No consumed_bucket; net-new migration + 3 queries |
lite_config table | db/migration/ | Does not exist; net-new migration + seed + 1 query |
IsEnabledFunc | Pattern from internal/app/service/roles/crs_edit.go | Already used for loyalty sync + auto-2FA; add FeatureLiteSeats constant |
FieldRoles.vue (invite) | app/features/user_management/users/invite/views/FieldRoles.vue | Lists all roles, filters owner default; add flag gate |
FieldRoles.vue (edit) | app/features/user_management/users/edit/views/FieldRoles.vue | Same pattern; add flag gate |
InviteUsers.vue | app/features/user_management/users/invite/views/InviteUsers.vue | Existing quota error handling; add Lite-specific error strings |
EditUsers.vue | app/features/user_management/users/edit/views/EditUsers.vue | Role change form; add quota error handling + revert on block |
FeaturePermission.vue | app/features/user_management/roles/edit/views/FeaturePermission.vue | Checkbox hierarchy; add N/max_permission counter + inbox disable (blocked Figma) |
permissionStore.ts | app/features/user_management/roles/store/permissionStore.ts | Selection state; add is_inbox filtering (blocked) |
useToggleQontakOne.ts | app/common/composables/useToggleQontakOne.ts | Reference toggle pattern; mirror for useFeatureFlag |
Effort Summary
| Phase / Area | FE days | BE days | QA days | Total |
|---|---|---|---|---|
| Phase 1 — UI (mocked) | 4.5 | — | 1 | 5.5 |
| Phase 2 — BE | — | 14 | 3.5 | 17.5 |
| Grand total | 4.5 | 14 | 4.5 | 23 |
Confidence: medium. Key uncertainties: (1)
update.gogoroutine 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_permissionsfull 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_enabledis 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
useFeatureFlagcomposable mirroringuseToggleQontakOne.ts— acceptsfeatureName: string, returnsisEnabled: ComputedRef<boolean> - Gate "Lite User" option in both
FieldRoles.vuefiles (invite + edit views) — filter when flag OFF - Quota error string mapping in
InviteUsers.vueandEditUsers.vuereadingresp_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-indigovariant 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
| Action | File | What changes |
|---|---|---|
| create | app/common/composables/useFeatureFlag.ts | Mirror useToggleQontakOne.ts; accept featureName; return isEnabled ComputedRef |
| extend | app/features/user_management/users/invite/views/FieldRoles.vue | Import useFeatureFlag; add computed filter removing user_access='lite' roles when flag OFF |
| extend | app/features/user_management/users/edit/views/FieldRoles.vue | Same filter — both views share the role dropdown logic |
| extend | app/features/user_management/users/invite/views/InviteUsers.vue | Map 422/503 resp_desc.en → toast.notify({variant:'error'}) or MpBanner per RFC Detail 3.C |
| extend | app/features/user_management/users/edit/views/EditUsers.vue | Same error handling; on 422 role-upgrade block, revert selectedRole to previous value |
| extend | app/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 |
| create | app/features/user_management/composables/useFeatureFlag.spec.ts | Follow useRoles.spec.ts pattern: setActivePinia, vi.stubGlobal("useClient",…); assert flag ON/OFF |
Implementation Steps
- Explore: Open
app/common/composables/useToggleQontakOne.ts— read how it calls the feature flag API and returns the computed ref. Note the exactuseClientcall pattern and response shape. Also open the roles list view — identify where role rows are rendered and where action buttons are conditionally shown. - 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). Runpnpm test -- useFeatureFlag.spec.ts— confirm all fail. - Scaffold: Create
app/common/composables/useFeatureFlag.tswith the same structure asuseToggleQontakOne.ts, parametrized onfeatureName. - Wire flag gate: In both
FieldRoles.vuefiles, importuseFeatureFlag('lite_seats_enabled')and addcomputed(() => roles.filter(r => r.user_access !== 'lite' || isEnabled.value))— Lite roles excluded when flag OFF. - Wire quota errors: In
InviteUsers.vueandEditUsers.vue, find the existing error catch block (readsresp_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"
- Wire role-change revert: In
EditUsers.vue, on role-change 422 save apreviousRoleref before the PUT call; on error, restoreselectedRole = previousRole. - Wire roles list indicators (when flag ON): In the roles list view — add notice banner (conditional on
isLiteSeatsEnabled); for each role row whereuser_access === 'lite': render "New" chip (badge-indigopixel3 variant), render "Lite" in access-level column with badge, suppress delete button render. - Go green: Run
pnpm test -- useFeatureFlag.spec.tsuntil all pass. - 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
MpBannerwarning - 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-checkpasses -
pnpm testpasses (useFeatureFlag spec) -
pnpm lintpasses
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
| Discipline | Days |
|---|---|
| Frontend | 2 |
| QA | 0.5 |
| Total | 2.5 |
Assumptions: reuses
useToggleQontakOne.tspattern 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-limitvariant) at cap - Counter text in the sticky footer left:
"N of 20 permissions selected" - Both derived from
permissionStore.selectedPermissionscount; shown only whenuser_access = 'lite'
- Counter pill in the permission panel header:
- 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: consumeis_inbox: boolper permission fromGET /permissions_crsresponse (added by Task 2.8)EditRoles.vue: show counter widget only foruser_access = 'lite'roles
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | app/features/user_management/roles/edit/views/FeaturePermission.vue | Add 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 |
| extend | app/features/user_management/roles/store/permissionStore.ts | Consume is_inbox field from permissions API response; expose inboxPermissionKeys |
| extend | app/features/user_management/roles/edit/views/EditRoles.vue | Render counter/readonly-name section only when role user_access = 'lite' |
| extend | app/features/user_management/composables/useRoles.ts | Parse is_inbox field from fetchPermissionsCrs response |
| create/extend | app/features/user_management/composables/useRoles.spec.ts | Counter 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
| Action | File | What changes |
|---|---|---|
| create | db/migration/<ts>_add_consumed_bucket_to_users.up.sql | ADD COLUMN + index |
| create | db/migration/<ts>_add_consumed_bucket_to_users.down.sql | Reverse |
| extend | db/query/users.sql | 3 new queries + extend CreateUser |
| generate | db/sqlc/ → internal/app/repository/ | sqlc generate + sync |
| create | internal/app/service/billings/consumed_bucket_test.go | Table-driven tests for new querier methods |
Implementation Steps
- Explore: Open
db/query/users.sql— readCountUserquery (the onevalidate_user_quota.gocalls); mirror itsis_consultantfilter for the new bucket queries. NoteCreateUsercolumn list. - Write failing tests (red): In
internal/app/service/billings/, create a test that callsrepo.CountUserByBucketandCountUserByBucketExcludingwith mockery mocks — assert correct params passed. - Create migration files with the timestamp prefix matching existing files in
db/migration/. - Add 3 queries to
db/query/users.sql+ extendCreateUserwithconsumed_bucketparam. - Run
sqlc generate— verify generated files indb/sqlc/. - Sync: Copy relevant generated methods from
db/sqlc/tointernal/app/repository/(strip SQLC headers). - Run
make migrate-upon local DB. Runmake migrate-down— confirm reversibility. - Go green:
make test
Acceptance Criteria
-
make migrate-upsucceeds idempotently -
make migrate-downreverses cleanly — no leftover column or index -
DEFAULT 'standard'— zero downtime; existing users unaffected -
CountUserByBucketandCountUserByBucketExcludingfilterdeleted_at IS NULLand non-consultant (mirrors existingCountUser) -
CreateUseracceptsconsumed_bucketparam -
make testpasses
Test Strategy
Table-driven tests using mockery Repository mock; assert CountUserByBucketExcluding receives the correct exclude_id ($3).
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 1 |
| Total | 1 |
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
| Action | File | What changes |
|---|---|---|
| create | db/migration/<ts>_create_lite_config.up.sql | Table DDL + seed |
| create | db/migration/<ts>_create_lite_config.down.sql | DROP TABLE |
| create | db/query/lite_config.sql | GetLiteConfig :one |
| generate | db/sqlc/ → internal/app/repository/ | sqlc generate + sync |
| create | internal/app/service/roles/get_lite_config.go | GetLiteConfig helper with Redis cache |
| create | internal/app/service/roles/get_lite_config_test.go | Tests for cache hit + miss |
Implementation Steps
- Explore: Open an existing
db/migration/*.up.sqlthat creates a table — follow naming convention and Postgres dialect. - Write failing tests (red): Test
GetLiteConfig— assert cache hit returns without DB call; cache miss fetches from DB and caches result. - Create migration + seed —
role_name = 'lite',max_permission = 20,disabled_permissions = ARRAY['inbox_general_view']. - Create
db/query/lite_config.sql+ runsqlc generate+ sync. - Implement
GetLiteConfigservice helper — read from Redis (lite_config:{roleName}), on miss fetch from DB and set TTL 60s. - Run
make migrate-up; runmake migrate-down. - Go green:
make test
Acceptance Criteria
-
lite_configtable exists with correct columns after migration - Seed row present:
role_name='lite',max_permission=20,disabled_permissionsincludesinbox_general_view -
GetLiteConfig('lite')returns the config row; subsequent call within TTL returns cached -
make migrate-downdrops table cleanly -
make testpasses
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
| Discipline | Days |
|---|---|
| Backend | 1 |
| Total | 1 |
Assumptions:
TEXT[]type supported by sqlc; Redis caching follows same pattern asPackage:Unifiedbilling 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 ModPanelLiteUserAdditionalComponent = "QONTAKCHAT-User-Lite-Additional" // ⚠️ UNCONFIRMED — verify with ModPanel)
- Extend
internal/app/service/billings/get_billing_info_by_sso_id.go: in theActiveComponentsSummaryloop, match the two new constants and accumulate intoLiteInitialCredit/LiteAdditionalCredit; absent → 0 (not error) - Extend
internal/pkg/response/billing_response.go: addLiteInitialCredit int32andLiteAdditionalCredit int32toBillingInfoResponse
Implementation Plan
| Action | File | What changes |
|---|---|---|
| extend | internal/pkg/response/billing_response.go | Add LiteInitialCredit int32, LiteAdditionalCredit int32 |
| extend | internal/app/service/billings/get_billing_info_by_sso_id.go | Match two new component constants in component loop |
| create | internal/pkg/consts/billing.go (or extend existing consts file) | Define placeholder component code constants |
| extend | internal/app/service/billings/get_billing_info_by_sso_id_test.go | Add test cases: Lite components present → correct credits; absent → 0 |
Implementation Steps
- Explore: Open
internal/app/service/billings/get_billing_info_by_sso_id.go— find theActiveComponentsSummaryloop; read exactly how"QONTAKCHAT-User-Initial"and"QONTAKCHAT-User-Additional"are matched. The new Lite components follow the same pattern. - Write failing tests (red): In
get_billing_info_by_sso_id_test.go, add two cases: (a) ModPanel response includes both Lite components →LiteInitialCreditandLiteAdditionalCreditpopulated; (b) Lite components absent → both = 0. - Add
LiteInitialCredit,LiteAdditionalCredittoBillingInfoResponse. - Define placeholder constants in consts file with
// ⚠️ UNCONFIRMEDcomment. - Extend the component loop in
GetBillingInfoBySsoID— match new constants, accumulate credits. - Go green:
make test(billings package)
Acceptance Criteria
-
BillingInfoResponsecarriesLiteInitialCredit/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 testpasses (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
| Discipline | Days |
|---|---|
| Backend | 1 |
| QA | 0.5 |
| Total | 1.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_enabledis ON for a given company, using the existing preferences module.
Status: ✅ Actionable
Design reference: n/a (BE)
What to build
- Add
FeatureLiteSeatsconstant ininternal/pkg/consts/(whereverFeatureLoyaltyRoleSync/FeatureAutoActivate2FAlive) - Add a thin wrapper
IsLiteSeatsEnabled(ctx, companyID uuid.UUID) (bool, error)ininternal/app/service/billings/(or a shared location) that callsIsEnabledFunc: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
| Action | File | What changes |
|---|---|---|
| extend | internal/pkg/consts/ (feature constants file) | Add FeatureLiteSeats = "lite_seats_enabled" |
| create | internal/app/service/billings/feature_flag.go | IsLiteSeatsEnabled wrapper |
| create | internal/app/service/billings/feature_flag_test.go | Two cases: flag ON → true; flag OFF → false |
Implementation Steps
- Explore: Open
internal/app/service/roles/crs_edit.golines 165–195 — read the exactIsEnabledFunccall pattern andIsEnabledParamsstruct fields. Note whereconstants.FeatureLoyaltyRoleSyncis defined. - Add constant: Add
FeatureLiteSeats = "lite_seats_enabled"next to existing feature constants. - Create
feature_flag.go: ImplementIsLiteSeatsEnabledfollowing the pattern fromcrs_edit.go. - Write tests: Stub
IsEnabledFunc(same pattern asget_all_permission_options_test.go) — assert flag ON →true; flag OFF →false; preference service error →false, err. - Go green:
make test
Acceptance Criteria
-
IsLiteSeatsEnabled(ctx, companyID)returnstruewhen preference is ON for that company - Returns
falsewhen OFF — callers take standard path (SC-5) - Preference service error propagated; callers handle gracefully
-
make testpasses
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
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| Total | 0.5 |
Assumptions:
IsEnabledFuncinjection 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
SetNxpattern.
Status: ✅ Actionable
Design reference: n/a (BE)
What to build
- Extract the
SetNx/Dellock pattern frominternal/app/handler/user_sso_handler.go(SsoInvite) into a reusable helperinternal/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 DelinSsoInvitewithlock.AcquireLock(...)— behavior byte-identical AssignRole(Task 2.9) will call the same helper
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/pkg/lock/redis_lock.go | AcquireLock function |
| create | internal/pkg/lock/redis_lock_test.go | Unit tests: acquired → unlock releases; not acquired → returns false |
| extend | internal/app/handler/user_sso_handler.go | Swap inline SetNx/defer Del with lock.AcquireLock |
Implementation Steps
- Explore: Open
internal/app/handler/user_sso_handler.go— find theSetNxblock inSsoInvite. Read the lock key pattern ("lock:user_invite:%s"), TTL (10*time.Second), anddefer Delcall. - Write failing tests (red): In
internal/pkg/lock/redis_lock_test.go, assert:AcquireLock→acquired=true,unlock()releases key; second call while locked →acquired=false. - Create
redis_lock.gowithAcquireLock— callsSetNx, on success returnsunlock = func(){ cache.Del(ctx, key) }. - Swap in
user_sso_handler.go: Replace inlineSetNx/defer Delwithlock.AcquireLock(ctx, h.cacheRepo, lockKey, 10*time.Second). - Confirm existing
SsoInvitetests still pass — behavior must be byte-identical. - Go green:
make test
Acceptance Criteria
- Existing
SsoInvitetests pass unchanged -
AcquireLockhelper has its own unit tests -
unlock()always releases the key (even when called multiple times) -
make testpasses
Test Strategy
Redis cache mock; assert SetNx called with correct key + TTL; assert Del called by unlock().
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| Total | 0.5 |
Assumptions:
CacheRepointerface already hasSetNxandDel— 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):
| Condition | Result |
|---|---|
IsLiteSeatsEnabled = false | bucket=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_quota | bucket=lite (LITE-S04/AC-1) |
Lite role, lite_used >= lite_quota, std_used < std_quota | bucket=standard fallback (LITE-S04/AC-2) |
| Both exhausted | ErrUnprocessableEntity("No seats available…") 422 (LITE-S04/ERR-1) |
| ModPanel unavailable | ErrServiceUnavailable("Quota check unavailable…") 503 (LITE-S04/ERR-2) |
Call sites:
internal/app/service/users/sso_invite.go— after lock acquired, replaceValidateUserQuotacall; setconsumed_bucketinCreateUserparamsinternal/app/service/users/assign_role.go— Task 2.9
Implementation Plan
| Action | File | What changes |
|---|---|---|
| create | internal/app/service/billings/evaluate_user_quota.go | EvaluateUserQuota method |
| create | internal/app/service/billings/evaluate_user_quota_test.go | Table-driven, one case per branch + each error code |
| extend | internal/app/service/users/sso_invite.go | Replace ValidateUserQuota with EvaluateUserQuota; set consumed_bucket on CreateUser |
| extend | internal/app/service/users/sso_invite_test.go | Add bucket-assignment test cases |
Implementation Steps
- Explore: Open
internal/app/service/billings/validate_user_quota.go— the 30-line single-bucket check. This is the base;EvaluateUserQuotaforks from it. - Write failing tests (red): Create
evaluate_user_quota_test.gowith table-driven cases for all 6 branches above. Use mockery mocks forIStore(CountUserByBucket / CountUserByBucketExcluding) andIBillingService(GetBillingInfoBySsoID). All fail initially. - Scaffold
EvaluateUserQuota: Check flag → check role → callGetBillingInfoBySsoID→ count buckets → branch logic. - Wire
excludeUserID: If notuuid.Nil, callCountUserByBucketExcluding; otherwiseCountUserByBucket. - Extend
sso_invite.go: Replaceh.billingService.ValidateUserQuota(ctx, req.InviterSsoID)withEvaluateUserQuota(ctx, req.InviterSsoID, role.UserAccess, uuid.Nil)— use returnedbucketto setconsumed_bucketinCreateUserparams. - 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=standardfallback (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 testpasses (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
| Discipline | Days |
|---|---|
| Backend | 2.5 |
| QA | 1 |
| Total | 3.5 |
Assumptions: mockery mocks for
IStorealready generated;ErrUnprocessableEntity/ErrServiceUnavailableconstructors exist ininternal/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_idfrom JSON body - Resolve
company_id(UUID) viarepo.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
| Action | File | What changes |
|---|---|---|
| extend | moderator-be/app/domains/core/use_cases/v2/subscriptions/invalidate_cache.rb | Add provision_lite_seats_role step (line ~52); add @is_lite_seats_enabled to fetch_preference |
| create | moderator-be/app/domains/core/services/launchpad/provision_lite_role.rb | ProvisionLiteRole service (pigeon POST) |
| extend | internal/server/rest_router.go | Add POST /private/company_roles/lite/provision in the private block |
| extend | internal/app/handler/role_handler.go | Add ProvisionLiteRole handler method |
| create | internal/app/service/roles/provision_lite_role.go | ProvisionLiteRole service method with idempotency guard |
| create | internal/app/service/roles/provision_lite_role_test.go | Table-driven: component present → role created; absent → no-op; already exists → no duplicate |
Implementation Steps
- Explore moderator-be: Open
app/domains/core/use_cases/v2/subscriptions/invalidate_cache.rblines 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 readingCore::Services::V2::Billings::BuildCacheUnified. - Explore launchpad: Open
db/query/company_roles.sql— findCreateDefaultCompanyRoleandGetCompanyRoleByDefaultAndUserAccessparam structs. - Write failing tests (red) for launchpad: In
provision_lite_role_test.go, assert: Lite role absent →CreateDefaultCompanyRolecalled once; already exists → not called. - Implement
ProvisionLiteRoleservice ininternal/app/service/roles/provision_lite_role.go. - Add route + handler in
rest_router.goandrole_handler.go. - Create
Core::Services::Launchpad::ProvisionLiteRoleservice in moderator-be. - Add
provision_lite_seats_rolestep to the use case. Gate on@is_lite_seats_enabledpreference. - Go green:
make test(launchpad); RSpec for moderator-be service
Acceptance Criteria
- Lite component in subscription → launchpad
ProvisionLiteRolecalled (LITE-S01/AC-1) - Replay / re-activation → no duplicate role (idempotency —
GetCompanyRoleByDefaultAndUserAccessguard) - 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
/privateblock) -
make testpasses (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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Assumptions:
CreateDefaultCompanyRoleandGetCompanyRoleByDefaultAndUserAccessalready exist as SQLC queries;active_components_summaryin@unified_subscriptioncontainscomponent_codekeys (confirm by readingBuildCacheUnified); moderator-be pigeon HTTP client pattern mirrors existingInvalidateCacheservice.
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':
- Load
GetLiteConfig('lite')(Task 2.2) - Count enabled permissions in request → if >
liteConfig.MaxPermission→ErrUnprocessableEntity("Lite User role cannot exceed N permissions.")(LITE-S02/ERR-3) - Check each
permission_keyin 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
| Action | File | What changes |
|---|---|---|
| extend | internal/app/handler/role_handler.go or service | Add CrsEditCompanyRole validators (cap + inbox); add CrsDeleteRole guard |
| extend | internal/app/service/roles/crs_edit.go | Call GetLiteConfig; validate cap + inbox blocklist |
| extend | internal/pkg/response/ (permissions_crs response struct) | Add IsInbox bool field |
| extend | internal/app/service/roles/get_all_permission_options.go | Populate IsInbox from liteConfig.DisabledPermissions |
| extend | internal/app/service/roles/crs_edit_test.go | Add test cases: >cap → 422; inbox key → 422; delete Lite → 422 |
| extend | internal/app/service/roles/get_all_permission_options_test.go | Add IsInbox field assertion |
Implementation Steps
- Explore: Open
internal/app/service/roles/crs_edit.go— find whereCrsEditCompanyRolevalidates/saves permission changes. Note the existingIsEnabledFunccalls as structural reference. Openinternal/app/service/roles/get_all_permission_options.go— find wherepermissions_crslist is built. - Write failing tests (red): Add cases: Lite role + 21 permissions → 422; Lite role + inbox key → 422; delete Lite role → 422;
permissions_crsresponse containsis_inbox=truefor inbox keys. - Extend
crs_edit.go: After confirming role isuser_access='lite', callGetLiteConfig('lite'), check count and inbox set. - Extend delete handler/service: Check
user_access='lite'before delete; return 422 guard. - Extend
get_all_permission_options.go: LoadGetLiteConfig; for each permission, setIsInbox = slices.Contains(liteConfig.DisabledPermissions, permission.PermissionKey). - 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_crsresponse includesis_inbox boolper permission - All 422 responses use
ErrUnprocessableEntity(HTTP 422, not 400) -
make testpasses (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
| Discipline | Days |
|---|---|
| Backend | 1.5 |
| QA | 0.5 |
| Total | 2 |
Assumptions:
crs_edit.goalready has a clear entry point per-role;slices.Containsavailable (Go 1.21+, confirmed go.mod);GetLiteConfigTTL-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:
- Acquire
lock.AcquireLock(ctx, cache, "lock:user_invite:"+companyID, 10s)at the top (new wiring —AssignRolehas no lock today) - Read current
consumed_bucketfrom user record - Call
EvaluateUserQuota(ctx, ssoID, newRole.UserAccess, userID)(excludes this user from count) - Same-bucket no-op: if current
consumed_bucket == returned bucket→ skip quota check, proceed to role write (LITE-S05/AC-2) - Open
WithTx: write role change +UpdateUserConsumedBucketatomically (LITE-S05/AC-1) - Rollback on quota 422/503 → role unchanged (LITE-S05/ERR-1/2)
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
| Action | File | What changes |
|---|---|---|
| extend | internal/app/service/users/assign_role.go | Add lock + EvaluateUserQuota + same-bucket no-op + WithTx for role+bucket write |
| extend | internal/app/service/users/update.go | When role_crs_id changes → delegate to AssignRole instead of goroutine write |
| extend | internal/app/service/users/assign_role_test.go | Add re-bucket scenarios (table-driven) |
| extend | internal/app/service/users/update_test.go | Assert AssignRole called when role changes; non-role fields still update |
Implementation Steps
- Explore
assign_role.gofully: 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. - Explore
update.golines around role write: Find the goroutine block that handlesrole_crs_idchange. This is the goroutine to redirect. - 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
- Write failing tests (red) for
update.go: Assert that whenrole_crs_iddiffers from current,AssignRoleis called; when non-role fields change, goroutine pattern preserved. - Implement Step A in
assign_role.go: Add lock acquire →EvaluateUserQuota→ same-bucket check →WithTx→ unlock. - Implement Step B in
update.go: Detectrole_crs_idchange; calls.AssignRole(ctx, ...)for that path; leave goroutine for non-role updates. - Go green:
make test(users package)
Acceptance Criteria
- Role + bucket written atomically in one
WithTxtx (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)
-
excludeUserIDpassed toEvaluateUserQuota— 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 testpasses (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
| Discipline | Days |
|---|---|
| Backend | 4 |
| QA | 1 |
| Total | 5 |
Assumptions:
update.gogoroutine delegates cleanly — side effects (CRS sync, cache invalidation) remain in the goroutine for non-role fields;WithTxhelper and Redis lock helper already available (Tasks 2.5 + existingstore.go). 4-day estimate assumes the goroutine restructure inupdate.gorequires 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:
| Handler | File | Change |
|---|---|---|
SsoInvite | internal/app/handler/user_sso_handler.go | Add consumed_bucket to response; new 422/503 codes |
CrsEditCompanyRole | internal/app/handler/role_handler.go | Add 422 codes for cap + inbox guard (LITE-S02) |
CrsDeleteRole | internal/app/handler/role_handler.go | Add 422 for Lite delete guard |
AssignRole | internal/app/handler/user_role_handler.go | Document re-bucket 422/503/429 |
Update | internal/app/handler/user_sso_handler.go | Same re-bucket behavior |
GetUnifiedBilling | internal/app/handler/billing_handler.go | Add lite_initial_credit, lite_additional_credit to response |
GetAllPermissionOptions | handler | Add is_inbox bool to permission schema |
Run make init → commit docs/swagger.json + docs/swagger.yaml alongside handler changes.
Effort Estimate
| Discipline | Days |
|---|---|
| Backend | 0.5 |
| Total | 0.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
- Immediate parallel start: Task 1.1 (FE), 2.1 (migration), 2.2 (lite_config), 2.4 (flag), 2.5 (lock helper) — all independent
- 2.1 deploys first —
consumed_bucketmigration is additive withDEFAULT 'standard'; zero downtime; deploy before any application code that reads the column - 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
- 2.7 + 2.8 parallel with 2.6 — provisioning and role validator share no dependencies with the evaluator
- 2.9 is highest risk — schedule last in sprint; requires thorough review of
update.gogoroutine scope before merge - 2.10 last — Swagger after all handler changes are stable
- 1.2 now actionable — design blocker resolved by prototype (2026-06-30); only hard dependency is Task 2.8 (
is_inboxfield); can start FE scaffolding in parallel
Skipped Stories
| Story | Reason |
|---|---|
| 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 users | Explicit non-goal (PRD §5.6) |
| Quota reporting dashboards | Explicit 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 |