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::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 — 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 unidentified — Resolved: 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.5—15.5
Phase 2 — BE—143.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.en → toast.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 + seed — role_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: AcquireLock → acquired=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.MaxPermission → ErrUnprocessableEntity("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 first — consumed_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