ADR: Activity Model Simplification — types → [SLOT_BASED, SERVICE] + hasSeatSelection; merge CINEMA+SHOWS → EVENTS
ADR: Activity Model Simplification — types → [SLOT_BASED, SERVICE] + hasSeatSelection; merge CINEMA+SHOWS → EVENTS
Section titled “ADR: Activity Model Simplification — types → [SLOT_BASED, SERVICE] + hasSeatSelection; merge CINEMA+SHOWS → EVENTS”1. Context
Section titled “1. Context”Spec: specs/activity-model-simplification.md (ClickUp 869dpxbj6).
After dep 869dpa3zp merged, the live shape is:
activity_typePG enum =[SHOW, MOVIE, SLOT_BASED, SERVICE](DINING dropped, the working-copy schema still mirrors the pre-merge state; current canonical is post-dep).spheres.coderows =[SPORT, CINEMA, SHOWS, SERVICES](DINING row deleted by0048).sphere_target_appPG enum =[GYM_APP, TICKETS_APP, SERVICES_APP](locked — NOT touched by this ADR).
This ADR locks the three deltas required by the spec:
- Collapse
activity_typeto[SLOT_BASED, SERVICE]; encode the seating axis as a new booleanactivities.has_seat_selection(defaultfalse, NOT NULL, withCHECK (type != 'SERVICE' OR has_seat_selection = false)). - Migrate live rows:
MOVIE→(SLOT_BASED, has_seat_selection=true);SHOW→(SLOT_BASED, has_seat_selection=false). - Merge
CINEMA+SHOWSsphere rows into a singleEVENTSrow (TICKETS_APP, sort_order=1), backfillactivities.sphere_idandcategories.sphere_id, then deleteCINEMAandSHOWS. Final sphere set =[SPORT, EVENTS, SERVICES].
The dep-merged canonical state (ActivityTypeEnum = [SHOW, MOVIE, SLOT_BASED, SERVICE] in commit 7608735 on origin/dev) is the starting point. The working copy on disk still shows [SHOW, MOVIE, SLOT_BASED, SERVICE] post-dep — confirmed by reading libs/shared/data-access-db/src/lib/schema/activities.schema.ts:39-44 (no DINING). All decisions below assume that starting point.
2. Decisions
Section titled “2. Decisions”D1 — hasSeatSelection as a NOT NULL boolean column with table-level CHECK
Section titled “D1 — hasSeatSelection as a NOT NULL boolean column with table-level CHECK”Decision. Add has_seat_selection boolean NOT NULL DEFAULT false on activities.activities, declared in Drizzle as:
hasSeatSelection: boolean('has_seat_selection').notNull().default(false),Add a single table-level CHECK constraint expressed in Drizzle via the existing check() helper (already imported in activities.schema.ts:14):
hasSeatSelectionTypeCheck: check( 'activities_has_seat_selection_check', sql`${activities.type} <> 'SERVICE' OR ${activities.hasSeatSelection} = false`,),Rationale.
- NOT NULL + default false matches the precedent set by
refundable(activities.schema.ts:222, shipped viaactivity-refundable-and-cancellation-window). ALTER TABLE … ADD COLUMN with a default rewrites existing rows in PG 11+, so the column lands populated. - A table-level CHECK is the right home for the cross-column invariant. Inline
.check()on a column would not seetype. - Naming
activities_has_seat_selection_checkaligns with PG conventions (<table>_<column>_check); AC-3 asserts this exact name. - Drives no FK behaviour and adds no index — the flag is read on the booking-write path (no hot-path filter use case yet).
Alternative rejected: model seat selection as a per-row JSON config on metadata. Hides the invariant from the DB, fails AC-3, and the booking-write path would still need to dereference the json. The boolean column is one PG type-check, one CHECK constraint, one DTO field.
D2 — PG enum rebuild for activity_type: data-migrate BEFORE rebuild
Section titled “D2 — PG enum rebuild for activity_type: data-migrate BEFORE rebuild”Decision. The single Drizzle migration (next number 0049, name auto-assigned) MUST execute strictly:
- Pre-flight
DO $$block: assertcount(*) FROM activities.activities WHERE type::text = 'DINING' = 0andcount(*) FROM activities.spheres WHERE code='DINING' = 0andcount(*) FROM activities.activities WHERE type::text NOT IN ('SHOW','MOVIE','SLOT_BASED','SERVICE') = 0. RAISE EXCEPTION on any non-zero. ALTER TABLE activities.activities ADD COLUMN has_seat_selection boolean NOT NULL DEFAULT false;- Data migration with the enum still in its 4-value shape (
UPDATE … SET type='SLOT_BASED', has_seat_selection=true WHERE type='MOVIE';UPDATE … SET type='SLOT_BASED', has_seat_selection=false WHERE type='SHOW'). UPDATEs run against the OLD enum domain — bothSLOT_BASEDandMOVIE/SHOWare valid values, so the writes are accepted. - PG enum rebuild — matches the
0048_clear_tattoo.sqltemplate:ALTER TABLE activities.activities ALTER COLUMN type SET DATA TYPE text;ALTER TABLE activities.spheres ALTER COLUMN allowed_activity_types SET DATA TYPE text;ALTER TABLE activities.spheres ALTER COLUMN default_activity_type SET DATA TYPE text;DROP TYPE activities.activity_type;CREATE TYPE activities.activity_type AS ENUM('SLOT_BASED', 'SERVICE');ALTER TABLE activities.activities ALTER COLUMN type SET DATA TYPE activities.activity_type USING type::activities.activity_type;ALTER TABLE activities.spheres ALTER COLUMN allowed_activity_types SET DATA TYPE activities.activity_type[] USING allowed_activity_types::activities.activity_type[];ALTER TABLE activities.spheres ALTER COLUMN default_activity_type SET DATA TYPE activities.activity_type USING default_activity_type::activities.activity_type;
- ADD CONSTRAINT
activities_has_seat_selection_check. - Sphere consolidation (D3).
- UPDATE
spheres.allowed_activity_typesanddefault_activity_typefor the remaining 3 rows (SPORT/EVENTS/SERVICES) to the post-collapse values per spec table.
Order matters. Doing the data UPDATE step 3 BEFORE the cast-to-text in step 4 means the enum-typed column is still itself when the conditional WHERE type='MOVIE' runs — Postgres does not have to coerce literals against a dropped type. Doing the data UPDATE AFTER the cast-to-text but BEFORE the new enum is created is also valid; the chosen order (enum-typed UPDATE first) is the one drizzle-generated migrations exhibit because schema edits drive the migration shape, and the spec’s stated step order (§Data Migration steps 1-10) maps cleanly to this layout.
Rationale.
- Postgres has no
ALTER TYPE … DROP VALUE. The rename-and-rebuild dance is the only path (see ADRsphere-targetapp-enum-cleanup-drop-dining-add-services§4 and migration0048). - Pre-flight DO block is the spec’s AC-15 mitigation against unguarded CASCADE / unknown enum values mid-cast.
- All three columns that reference the enum (
activities.type,spheres.allowed_activity_types[],spheres.default_activity_type) must be cast through text and back — same recipe as0048. - Step ordering: ADD COLUMN before data UPDATEs (column must exist), data UPDATEs before enum rebuild (we cast to text first), CHECK constraint after the rebuild (constraint is evaluated against the new enum domain).
Alternative considered. Rename MOVIE and SHOW via ALTER TYPE … RENAME VALUE 'MOVIE' TO 'SLOT_BASED' then DROP VALUE 'SHOW'. Rejected — PG only supports RENAME VALUE (not DROP VALUE), and the rename would collide with the existing SLOT_BASED value. The rebuild is the only safe path.
D3 — Sphere row consolidation: INSERT EVENTS, backfill, then DELETE
Section titled “D3 — Sphere row consolidation: INSERT EVENTS, backfill, then DELETE”Decision. Steps 6-9 of the migration:
-- 6. INSERT EVENTS sphere (idempotent guard via ON CONFLICT DO NOTHING).INSERT INTO activities.spheres (code, name, icon, target_app, allowed_activity_types, default_activity_type, sort_order)VALUES ('EVENTS', '{"uk":"Події","en":"Events","ru":"События","de":"Events","fr":"Événements"}'::jsonb, NULL, 'TICKETS_APP', ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[], 'SLOT_BASED'::activities.activity_type, 1)ON CONFLICT (code) DO NOTHING;
-- 7. Backfill activities pointing at CINEMA or SHOWS → EVENTS.UPDATE activities.activities aSET sphere_id = (SELECT id FROM activities.spheres WHERE code='EVENTS')WHERE a.sphere_id IN (SELECT id FROM activities.spheres WHERE code IN ('CINEMA','SHOWS'));
-- 8. Backfill categories pointing at CINEMA or SHOWS → EVENTS.UPDATE activities.categories cSET sphere_id = (SELECT id FROM activities.spheres WHERE code='EVENTS')WHERE c.sphere_id IN (SELECT id FROM activities.spheres WHERE code IN ('CINEMA','SHOWS'));
-- 9. Delete CINEMA + SHOWS (now safe — no rows reference them).DELETE FROM activities.spheres WHERE code IN ('CINEMA','SHOWS');
-- 10. Re-seed the three remaining rows to the canonical post-collapse values,-- INCLUDING sort_order to satisfy AC-6 (contiguous 0/1/2 sequence).-- Pre-this-MR state: SPORT=0, CINEMA=1, SHOWS=2, SERVICES=3 (DINING=4 already deleted by 0048).-- Target state: SPORT=0, EVENTS=1, SERVICES=2.-- CINEMA/SHOWS get DELETEd in step 9 above, so their sort_order rows vanish.-- EVENTS was INSERTed at sort_order=1 in step 6.-- SERVICES must be re-numbered 3 → 2 here.UPDATE activities.spheresSET allowed_activity_types = ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[], default_activity_type = 'SLOT_BASED'::activities.activity_type, sort_order = 0WHERE code = 'SPORT';UPDATE activities.spheresSET allowed_activity_types = ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[], default_activity_type = 'SLOT_BASED'::activities.activity_type, sort_order = 1WHERE code = 'EVENTS';UPDATE activities.spheresSET allowed_activity_types = ARRAY['SLOT_BASED','SERVICE']::activities.activity_type[], default_activity_type = 'SERVICE'::activities.activity_type, sort_order = 2WHERE code = 'SERVICES';AC-6 cross-reference. Spec AC-6 asserts the final sphere set is [SPORT, EVENTS, SERVICES] with sort_order 0/1/2 contiguous. The pre-this-MR state (verified by reading migration 0037_activity_spheres.sql:42-58 and confirming 0048_clear_tattoo.sql only deletes DINING) is SPORT=0, CINEMA=1, SHOWS=2, SERVICES=3. Without the explicit sort_order = 2 re-seed on SERVICES, post-migration ordering would be SPORT=0, EVENTS=1, SERVICES=3 — non-contiguous and contradicting AC-6. The step 10 re-seed block above includes the SPORT/EVENTS values defensively (they are no-ops on a happy path) so the final sort_order column is fully deterministic for the three remaining rows.
FK behaviour. Both activities.sphere_id and categories.sphere_id are declared ON DELETE no action (0037_activity_spheres.sql:94-95). Backfill MUST run before DELETE — otherwise the DELETE fails on the FK constraint. The migration’s DELETE FROM activities.spheres WHERE code IN ('CINEMA','SHOWS') is the gate; running it before steps 7-8 finish is a guaranteed abort.
Idempotency guards.
- INSERT uses
ON CONFLICT (code) DO NOTHING— re-running on a partially applied DB does not double-insert. - The two UPDATE backfills are naturally idempotent (subqueries return empty after deletion, UPDATEs become no-ops).
- DELETE is naturally idempotent (no rows match on second run).
- The 3 re-seed UPDATEs are naturally idempotent (overwriting with same values is a no-op).
Rationale.
- Per memory
feedback_drizzle_migrations: schema edit +pnpm run db:generateproduces the SQL file; data SQL (INSERT, UPDATE, DELETE) is hand-added to the generated file BEFORE apply. Drizzle never emits data migrations. - Strict order (INSERT → UPDATE → UPDATE → DELETE → re-seed) is the only sequence that satisfies the FK and produces the AC-6/AC-7/AC-8 final state.
- The re-seed at step 10 uses three explicit UPDATEs (not a CASE expression) to keep the SQL diff-readable when reviewers grep it.
D4 — hasSeatSelection-only-with-SLOT_BASED enforced at DTO + DB layer
Section titled “D4 — hasSeatSelection-only-with-SLOT_BASED enforced at DTO + DB layer”Decision. Two layers, both required:
- DB: the CHECK constraint from D1 (
type <> 'SERVICE' OR has_seat_selection = false). This is the source of truth — bypassing the DTO (raw SQL, internal tools, future bulk imports) still hits the constraint and gets23514. - DTO: a small custom class-validator decorator
@HasSeatSelectionRequiresSlotBased()onCreateActivityDtoandUpdateActivityDto. The decorator runs at the class level (not field level) so it can see bothtypeandhasSeatSelectionin one pass. It returnsfalse(validation error) ifftype === 'SERVICE' && hasSeatSelection === true. All other shapes (including undefined/missing fields) pass.
Reasoning on PATCH partial-update handling:
- If only
hasSeatSelection: trueis in the PATCH body and the current activity hastype='SERVICE', the DTO validator alone cannot catch it — it seestype === undefined. The service layer (inactivities.service.tsupdate method) MUST therefore merge the partial against the loaded row and re-run the invariant before write. The DB CHECK is the ultimate backstop. The spec’s AC-12 lists this exact scenario as a 400, so the service-layer merge + re-validate path is mandatory. - If only
type: 'SERVICE'is in the PATCH body and the current activity hashasSeatSelection=true, the service layer MUST also re-validate and either (a) reject with 400 or (b) implicitly resethas_seat_selectionto false in the same UPDATE. Per spec AC-12 (rejectinghasSeatSelection=trueon a SERVICE row), the safer path is (a): reject with 400 + error messageerrors.activity.has_seat_selection_invalid_for_service. Implicit resets violate the principle of least surprise. Locking option (a) here.
Rationale.
- Two-layer enforcement matches the precedent in
sphere-column-type-safety(DTO@IsIn(ActivityTypeEnum.enumValues)+ DB enum type cast both rejectBANANA). - A class-level decorator (vs. field-level
@ValidateIf) is one place to read for the invariant — class-validator’sregisterDecoratorwithvalidate(object)receiving the whole instance is the right tool. - The service-layer merge step is a small code addition in
libs/features/activities/src/lib/services/activities.service.ts(existing file — verified vials). Phase B owns wiring it.
D5 — Contract layout: per-surface hasSeatSelection field; ActivityType enum diff applied identically to all three contracts
Section titled “D5 — Contract layout: per-surface hasSeatSelection field; ActivityType enum diff applied identically to all three contracts”Decision.
Schema changes per surface (auto-emitted by pnpm run sync:contracts after backend lands):
contracts/client.openapi.yaml (canonical line numbers from current snapshot)
components.schemas.ActivityType.enumline 217-221:[SHOW, MOVIE, SLOT_BASED, SERVICE]→[SLOT_BASED, SERVICE].ActivityPreviewClientResponseDto.type(currently barestring— verifiedactivity-client.response.dto.ts:72): leave as barestring(the consumer renders it as a badge); no change. The enum domain narrows because the only legal values on the wire narrow.ActivityPreviewClientResponseDto: addhasSeatSelection: boolean(required). Picked option (a) from the architect-critic review: the field lands on the preview shape, which is structurally a plain DTO class (no shared search-index dependency, noOmitTypetruncation) — verified by readingactivity-client.response.dto.ts:67-110. Justifications: (1)ActivityDetailClientResponseDtois declared asextends OmitType(ActivityPreviewClientResponseDto, ['matchedByContributor'])(activity-client.response.dto.ts:117-122), so adding the field on the preview makes the detail inherit it for free — no double declaration. (2) A near-term follow-up filter ticket (“events with reserved seating”) needs the field on list/search responses; deferring it forces another contract diff. (3) Wire cost is one boolean per row — negligible. The “saves wire bytes” rationale in the original §D5 does not outweigh the future filter cost.ActivityDetailClientResponseDto: inheritshasSeatSelection: booleanautomatically via theOmitType(ActivityPreviewClientResponseDto, ['matchedByContributor'])base. No separate declaration needed.FavoriteActivityDto.type(currently barestring—favorite.dto.ts:40-43): picked option (i) — promote to a named-enum reference via@ApiProperty({ enum: ActivityType, enumName: 'ActivityType' }). Justification: (1) the current@ApiProperty.descriptionliterally embeds the dead enum namesSHOW, MOVIE(verifiedfavorite.dto.ts:41), so a doc-only edit still leaves a typed-vs-described mismatch on the wire. (2) CLAUDE.md OpenAPI authoring rule “Every named enum carriesenumName: 'X'alongsideenum: X” applies — without it, downstream codegens lose the named type for this field. (3) The diff is one decorator change in one file; nopatch-spheres-swagger.jsworkaround needed because Nest’s swagger plugin will emit a$refto the already-namedActivityTypeschema (the schema isenumName-registered byactivity.response.dto.ts:97andsphere.dto.ts:22). Thetype!: stringTS type stays bare-string (the runtime value comes from the DB and is already constrained by the activity column).
contracts/business.openapi.yaml
components.schemas.ActivityType.enumline 235-240: same enum diff.ActivityResponseDto.type(already $refsActivityTypeviaenumName: 'ActivityType'atactivity.response.dto.ts:97): enum narrows automatically.ActivityResponseDto: addhasSeatSelection: boolean(required).CreateActivityDto: addhasSeatSelection?: boolean(optional, defaults to false).UpdateActivityDto(=PartialType(CreateActivityDto),update-activity.dto.ts:4): inheritshasSeatSelection?: booleanautomatically.SphereAdminDto.defaultActivityTypeandallowedActivityTypes(already$ref: ActivityType): enum narrows automatically.
contracts/super-admin.openapi.yaml
components.schemas.ActivityType.enumline 4-8: same enum diff.CreateSphereDto.allowedActivityTypes/defaultActivityTypeandUpdateSphereDto.allowedActivityTypes/defaultActivityType:$ref: ActivityTypealready; enum narrows automatically.- The super-admin surface does NOT expose
hasSeatSelectionon any DTO it owns — super-admin doesn’t CRUD activities. Verified — no super-admin activity controller. So the only super-admin diff is theActivityTypeenum shrink.
Per-surface field-visibility rationale.
/api/client: surfaceshasSeatSelectionon both preview and detail DTOs (the detail inherits viaOmitType). Rationale: (1) the near-term “events with reserved seating” filter ticket needs the field on list responses; (2) one boolean per preview row is negligible wire cost; (3)FavoriteActivityDto— a slim 6-field shape used by gym_app favorites — does NOT receivehasSeatSelection(the favorites card does not render the seat affordance, and the shape is explicitly minimal perfavorite.dto.ts:26-36). TheFavoriteActivityDto.typefield is promoted toenumName: 'ActivityType'so the generated mobile client gets the named enum./api/business: surfaces it onCreateActivityDto(write),UpdateActivityDto(write), andActivityResponseDto(read). Business admin is the ONLY surface that mutateshasSeatSelection./api/superadmin: nothing. Super-admin only edits spheres, not individual activities.
Per the workspace rule “never share types between surfaces — duplicate intentionally”: the ActivityType enum domain is genuinely identical on all three contracts because there is one PG enum backing it. The CONTRACTS are still emitted independently (one per Swagger doc, via pnpm run sync:contracts), but the value set on each is the same. Duplication is achieved by separate emissions, not by sharing a single file. This is consistent with the precedent in ADR sphere-targetapp-enum-cleanup-drop-dining-add-services §8.
Rationale.
- The named
ActivityTypeschema already exists in all three contracts (verified —contracts/client.openapi.yaml:216,contracts/business.openapi.yaml:234,contracts/super-admin.openapi.yaml:3). The diff is purely theenumarray shrink + the additivehasSeatSelectionfield where it’s exposed. enumName: 'ActivityType'on the existing decorators (verified —activity.response.dto.ts:97,sphere.dto.ts:22/29/41/77/88/127/138) means Nest auto-emits the named schema and$refs it. We do NOT need to addenumNameanywhere new.
D6 — Migration safety: pre-flight DO block + single-transaction + idempotency
Section titled “D6 — Migration safety: pre-flight DO block + single-transaction + idempotency”Decision. The single generated .sql file in libs/shared/data-access-db/migrations/0049_<auto-name>.sql runs as one statement-breakpoint script. Pre-flight DO block at the top:
DO $$BEGIN IF (SELECT count(*) FROM activities.activities WHERE type::text = 'DINING') > 0 THEN RAISE EXCEPTION 'activity-model-simplification: DINING rows present — 869dpa3zp incomplete'; END IF; IF (SELECT count(*) FROM activities.spheres WHERE code = 'DINING') > 0 THEN RAISE EXCEPTION 'activity-model-simplification: DINING sphere row present — 869dpa3zp incomplete'; END IF; IF (SELECT count(*) FROM activities.activities WHERE type::text NOT IN ('SHOW','MOVIE','SLOT_BASED','SERVICE')) > 0 THEN RAISE EXCEPTION 'activity-model-simplification: unexpected activity_type values present'; END IF;END $$;Transaction boundaries. Drizzle’s migration runner wraps each .sql file in a single transaction by default. Verified in apps/api/src/migrate.ts:2 — the backend imports migrate from drizzle-orm/node-postgres/migrator, which executes each migration file inside its own BEGIN/COMMIT block (the statement-breakpoint markers split the file into individual client.query calls within that one transaction). Failure at any step (pre-flight RAISE, FK abort on DELETE, CHECK violation, cast failure) rolls back cleanly. Drizzle’s __drizzle_migrations ledger only records the file hash on COMMIT, so a rollback leaves the DB indistinguishable from its pre-attempt state.
Idempotency / re-runnability. Because each migration file is one atomic transaction, the re-run question collapses:
- Failed-mid-flight scenario: the transaction rolls back; nothing was applied; the next attempt starts from the same pre-state as the first.
- Already-applied scenario: the migration ledger has the file hash; drizzle-kit skips it. The SQL is never re-executed.
There is therefore no need for hand-edited IF EXISTS (… pg_enum … 'MOVIE') guards around the enum-rebuild block, nor for an ALTER TABLE … ADD COLUMN IF NOT EXISTS (drizzle-kit emits the bare ADD COLUMN form — verified at migrations/0047_rapid_molly_hayes.sql:1). The guards we DO keep are the ones that protect against bad starting states (not re-runs):
- Pre-flight DO block — RAISEs if a DINING row or non-canonical
activity_typevalue is present. Catches “migration is being applied against an unexpected DB state” (e.g. dep869dpa3zpwas reverted), not “migration is being re-applied”. ON CONFLICT (code) DO NOTHINGon the EVENTS INSERT — defends against the case where a developer manually pre-seeded the row out-of-band; it is not a re-run defence.
Rationale. Drizzle’s single-transaction wrapping is documented behaviour of drizzle-orm/node-postgres/migrator and is the model 0048_clear_tattoo.sql (the same DROP-TYPE-and-rebuild precedent for the dep ticket) already relies on without idempotency guards. Mirroring that pattern keeps the SQL diff-readable and consistent with the in-tree precedent.
3. Code refactor map
Section titled “3. Code refactor map”3.1 tktspace-backend
Section titled “3.1 tktspace-backend”All paths relative to tktspace-backend/. Line numbers verified via Read 2026-06-16.
| File | Change |
|---|---|
libs/shared/data-access-db/src/lib/schema/activities.schema.ts:39-44 | ActivityTypeEnum → ['SLOT_BASED', 'SERVICE']. |
libs/shared/data-access-db/src/lib/schema/activities.schema.ts (activities table, around line 198 next to type) | Add hasSeatSelection: boolean('has_seat_selection').notNull().default(false). Append a 3rd argument (table-builder fn) to expose the CHECK; the current (t) => ({ sphereIdx, titleTrgmIdx }) block at line 243 gains a new hasSeatSelectionTypeCheck: check(…) entry. |
libs/shared/data-access-db/src/lib/schema/activities.schema.ts:79 | Comment // SPORT | CINEMA | SHOWS | SERVICES → // SPORT | EVENTS | SERVICES. |
libs/shared/data-access-db/migrations/0049_<auto>.sql (new) | Generated by pnpm run db:generate from the schema edits; data SQL hand-added per D2/D3/D6 BEFORE pnpm run db:migrate. |
libs/features/activities/src/lib/dto/create-activity.dto.ts:19-24 | TS enum ActivityType → drop SHOW, MOVIE. Two remaining members: SLOT_BASED, SERVICE. |
libs/features/activities/src/lib/dto/create-activity.dto.ts (new field on CreateActivityDto) | Add @ApiProperty({ required: false, default: false, description: 'Seat-map driven flag. Valid only when type === SLOT_BASED.' }) @IsBoolean() @IsOptional() hasSeatSelection?: boolean; and a class-level @HasSeatSelectionRequiresSlotBased() decorator. |
libs/features/activities/src/lib/dto/update-activity.dto.ts:4 | extends PartialType(CreateActivityDto) inherits hasSeatSelection?: boolean and the class-level validator automatically. No edit unless the validator needs separate wiring on the partial. |
libs/features/activities/src/lib/dto/activity.response.dto.ts:97 | @ApiProperty({ enum: ActivityType, enumName: 'ActivityType' }) — enum domain shrinks via the TS enum edit; no decorator change. |
libs/features/activities/src/lib/dto/activity.response.dto.ts (new field) | Add @ApiProperty() hasSeatSelection!: boolean; on ActivityResponseDto. |
libs/features/activities/src/lib/dto/activity-client.response.dto.ts:67-110 (ActivityPreviewClientResponseDto) | Add @ApiProperty() hasSeatSelection!: boolean; to the preview DTO. ActivityDetailClientResponseDto inherits via the OmitType(ActivityPreviewClientResponseDto, ['matchedByContributor']) base at line 117-122 — no separate declaration on detail (D5 rationale, option (a)). |
libs/features/activities/src/lib/dto/favorite.dto.ts:40-43 (FavoriteActivityDto.type) | Promote bare @ApiProperty({ description: '…' }) to @ApiProperty({ enum: ActivityType, enumName: 'ActivityType', description: 'Activity type code. Rendered as a badge on the favorites card.' }). Import ActivityType from ./create-activity.dto. The narrowed enum (post-D2) auto-flows into the generated $ref; no dead SHOW, MOVIE in the description prose. TS field type type!: string stays bare-string. |
libs/features/activities/src/lib/services/activities.service.ts (verify file exists; expected per Phase B) | UPDATE method: after merging PATCH partial with loaded row, re-validate (type, hasSeatSelection) invariant; throw 400 errors.activity.has_seat_selection_invalid_for_service if violated. CREATE method: validator at DTO catches direct violations; pass through to DB which has CHECK backstop. |
libs/features/activities/src/lib/dto/find-activities-client.dto.ts:79 (if referenced) | enumName: 'ActivityType' decorator unchanged; enum shrinks automatically. |
libs/features/activities/src/lib/validators/has-seat-selection-requires-slot-based.decorator.ts (NEW) | New file. Class-level class-validator decorator. ~30 lines. |
libs/features/spheres/src/lib/services/spheres.service.ts:32 | type ActivityTypeValue = 'SHOW' | 'MOVIE' | 'SLOT_BASED' | 'SERVICE' → 'SLOT_BASED' | 'SERVICE'. |
libs/features/spheres/src/lib/dto/sphere.dto.ts:31,80,129 | example: ['SERVICE'] — already shape-correct; verify no MOVIE/SHOW examples linger. |
3.2 tktspace-backend — tests/fixtures (e2e + unit)
Section titled “3.2 tktspace-backend — tests/fixtures (e2e + unit)”Grep results (grep -rn "'MOVIE'\|'SHOW'\|CINEMA\|SHOWS" libs apps --include="*.ts" excluding generated SQL/meta):
| File | Change |
|---|---|
libs/features/spheres/src/lib/__tests__/enum-cleanup.spec.ts:16,51,52 | Update doc + expected enum array to ['SLOT_BASED','SERVICE']. |
libs/features/spheres/src/lib/dto/sphere.dto.spec.ts:30,31,41,61 | Fixture ['SHOW', 'MOVIE'] → ['SLOT_BASED', 'SERVICE']; defaultActivityType = 'SHOW' → 'SLOT_BASED'. Bad-value fixture ['SHOW', 'BANANA'] → ['SLOT_BASED','BANANA'] (preserves the negative-path semantic). |
libs/features/activities/src/lib/spec/activities-client.target-app.spec.ts:20,25,139,156,196,217,223,241,246,267,322,346,365,391,441 | Mass rename of doc comments and sphere lookups from SHOWS/SHOW to EVENTS/SLOT_BASED. Test seeds an “events activity” instead of a “shows activity”; verify the wallet-target-app assertion holds (the TICKETS_APP sphere is now EVENTS). Activity type: 'SHOW' → type: 'SLOT_BASED', hasSeatSelection: false. |
apps/api-e2e/src/spheres/spheres-business.e2e-spec.ts:359-412 | Replace the CINEMA-only-allows-MOVIE narrative entirely. Post-collapse, every sphere allows [SLOT_BASED, SERVICE], so the “type not in allowedActivityTypes” assertion needs a fresh negative path — e.g., temporarily shrink SERVICES.allowedActivityTypes to ['SERVICE'] via super-admin PATCH, then try to create a SLOT_BASED activity under it. Semantic preserved. Spec AC-16 grep clean depends on this. |
apps/api-e2e/src/spheres/spheres-enum-cleanup.e2e-spec.ts:11 | Doc string: 'SHOW','MOVIE','SLOT_BASED','SERVICE' → 'SLOT_BASED','SERVICE'. |
apps/api-e2e/src/spheres/spheres-superadmin.e2e-spec.ts:227,228 | Fixture rewrite: ['SHOW', 'BANANA'] → ['SLOT_BASED','BANANA']; defaultActivityType: 'SHOW' → 'SLOT_BASED'. |
apps/api-e2e/src/spheres/spheres-schema.e2e-spec.ts:46 | expect(codes).toEqual(['SPORT','CINEMA','SHOWS','SERVICES']) → ['SPORT','EVENTS','SERVICES']. Also any toHaveLength(4) → toHaveLength(3). |
apps/api-e2e/src/admin/sessions.spec.ts:40 | Seed type: 'SHOW' → type: 'SLOT_BASED', hasSeatSelection: false. |
apps/api-e2e/src/admin/bookings.spec.ts:41 | Same. |
apps/api-e2e/src/admin/activities.spec.ts:78 | Same. |
apps/api-e2e/src/client/wallet-target-app.e2e-spec.ts:12,35,146,165,171 | Replace SHOWS sphere lookup with EVENTS; activity type: 'SHOW' → type: 'SLOT_BASED'. |
apps/api-e2e/src/spheres/sphere-type-safety-0046.spec.ts:137-191 | Hardcoded enum-domain assertions for activity_type (currently ['SHOW','MOVIE','SLOT_BASED','SERVICE']) → ['SLOT_BASED','SERVICE']. Also any toHaveLength(4) over the enum value array → toHaveLength(2). The test still verifies the type-safety regression guard from migration 0046; only the expected enum domain narrows. |
3.3 tktspace-business
Section titled “3.3 tktspace-business”| File | Change |
|---|---|
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.ts:102-107 | activityTypes: ActivityResponseDto['type'][] = ['SHOW','MOVIE','SLOT_BASED','SERVICE'] → ['SLOT_BASED','SERVICE']. |
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.ts (controls) | Add a hasSeatSelection FormControl (boolean) to the form group. Reactive effect: when type changes to 'SERVICE', set hasSeatSelection to false and disable the control; when type changes to 'SLOT_BASED', enable. |
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.html | Add <tui-switch> (or <tui-checkbox>) labelled “Has seat selection” / i18n#activities.has_seat_selection. Hidden when type === 'SERVICE' (or disabled — UX choice; spec leaves open, “hidden when type === ‘SERVICE’” matches AC-18). |
client/src/app/features/dashboard/activities/forms/activity.form.ts:58-74 | Add 'hasSeatSelection' to editableFields. Add hasSeatSelection: false to defaults (Partial). Verify toCreateDto() returns the field (it will via getRawValue()). |
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.enum-cleanup.spec.ts:10,13,173,176-177 | Rewrite expected enum lists. |
client/src/app/features/dashboard/activities/pages/activity-form/activity-form.page.spec.ts (lines 12, 40, 55-61, 201, 239-250, 312, 314, 342, 364-383, 411-460) | Mass refactor: CINEMA_UUID constant → EVENTS_UUID; code: 'CINEMA' → code: 'EVENTS'; allowedActivityTypes: ['MOVIE'] → ['SLOT_BASED','SERVICE']; type: 'MOVIE' → type: 'SLOT_BASED', hasSeatSelection: true. AC-8.2 “changing sphere to CINEMA clears SLOT_BASED type” no longer holds (all spheres allow SLOT_BASED) — replace with a “changing hasSeatSelection toggle to true while type is SERVICE is rejected” case. Sphere display string 'Cinema' → 'Events'. |
client/src/app/core/api/models/activity-type-code.ts (regenerated) | Auto-regenerates to ['SLOT_BASED','SERVICE']. |
client/src/app/core/api/models/activity-type.ts (regenerated) | Same. |
client/src/app/core/api/models/sphere-code.ts (regenerated) | Auto-regenerates to ['SPORT','EVENTS','SERVICES']. |
client/src/app/core/api/models/create-activity-dto.ts (regenerated) | Auto-emits hasSeatSelection?: boolean. |
client/src/app/core/api/models/activity-response-dto.ts (regenerated) | Auto-emits hasSeatSelection: boolean. |
tools/gen/swagger-api.json (hand-curated snapshot) | Edit components.schemas.ActivityType.enum, components.schemas.ActivityTypeCode.enum (= patch-spheres-swagger.js produces this; see below), components.schemas.SphereCode.enum. Add hasSeatSelection to CreateActivityDto, UpdateActivityDto, ActivityResponseDto. |
tools/gen/patch-spheres-swagger.js:43,49,55 | SphereCode.enum: ['SPORT','CINEMA','SHOWS','SERVICES'] → ['SPORT','EVENTS','SERVICES']. ActivityTypeCode.enum: ['SHOW','MOVIE','SLOT_BASED','SERVICE'] → ['SLOT_BASED','SERVICE']. SphereTargetApp unchanged. |
3.4 tktspace-web
Section titled “3.4 tktspace-web”| File | Change |
|---|---|
src/app/pages/explore/explore.page.ts:113-118 | SPHERE_FALLBACK_ICONS: drop CINEMA and SHOWS entries; add an EVENTS entry with a placeholder icon. Final EVENTS sphere icon decision deferred to Phase C — spec doesn’t lock the glyph, and the in-DB spheres.icon column is NULL for EVENTS per migration step 6. Phase C dev picks the icon and confirms tickets_app mobile sphere-icon-fallback behaviour renders cleanly when the column stays NULL (the Flutter sphere chip widget should already fall back; mobile dev to verify in apps/tickets_app sphere-list screen during the Phase C smoke). |
src/app/core/api/models/activity-type.ts (regenerated by npm run generate) | Auto-narrows enum. |
src/app/core/api/models/activity-detail-client-response-dto.ts (regenerated) | Auto-emits hasSeatSelection: boolean. |
| Web detail page (location TBD — Phase C grep) | If the detail template wants to render a “Has seat selection” badge / hint, wire it. Out of scope per spec — “verify no MOVIE/SHOW hand-written refs”; the field is forward-compatible and consumers can opt in later. |
3.5 tktspace-mobile-app — CRITICAL ESCALATION beyond spec text
Section titled “3.5 tktspace-mobile-app — CRITICAL ESCALATION beyond spec text”The spec §“tktspace-mobile-app” claims “Verify gym_app has no MOVIE/SHOW references (low risk)”. This is incorrect. Grep at 2026-06-16 found:
apps/gym_app/lib/pages/home/search_page.dart:22: (value: ActivityType.show, label: 'search#filter_show'.tr()),apps/gym_app/lib/pages/home/search_page.dart:23: (value: ActivityType.movie, label: 'search#filter_movie'.tr()),apps/gym_app/lib/pages/home/search_page.dart:24: (value: ActivityType.slotBased, label: 'search#filter_slot'.tr()),apps/gym_app/lib/pages/home/search_page.dart:25: (value: ActivityType.dining, label: 'search#filter_dining'.tr()), // ALREADY broken post-869dpa3zpapps/gym_app/lib/pages/home/search_page.dart:26: (value: ActivityType.service, label: 'search#filter_service'.tr()),After the mobile API regen narrows ActivityType to [slotBased, service], ActivityType.show / ActivityType.movie / ActivityType.dining will no longer compile. The search-page type-filter dropdown must shrink. Phase C mobile dev MUST:
| File | Change |
|---|---|
apps/gym_app/lib/pages/home/search_page.dart:21-27 | Drop .show, .movie, .dining entries. Final dropdown: (ActivityType.slotBased, 'search#filter_slot'), (ActivityType.service, 'search#filter_service'). Out-of-scope decision needed: do we still need a “has-seat-selection” sub-filter? Spec scope says “No UI changes” — recommend dropping the search type filter entries cleanly and deferring the seat-selection filter UI to a follow-up. |
packages/favorites/lib/src/favorites_repository.dart:50 | Docstring: drop MOVIE, SHOW, DINING references; replace with 'e.g. SLOT_BASED, SERVICE'. |
packages/favorites/lib/src/favorites_page.dart:238 | Same docstring fix. |
packages/favorites/test/favorites_page_widget_test.dart:141 | Test fixture type: 'SHOW' → type: 'SLOT_BASED'. |
apps/gym_app/integration_test/sphere_filter_test.dart:590,596 | Comments mention ActivityType.slotBased — no change (correct post-collapse). Verify on Phase C re-grep. |
packages/api/swagger-api.json (regenerated by melos run sync:spec) | Auto. |
packages/api/lib/src/generated/swagger_api.enums.swagger.dart (regenerated by melos run generate:api) | Auto-emits enum ActivityType { slotBased, service }. |
Mobile i18n keys search#filter_show, search#filter_movie, search#filter_dining | Dead keys post-edit. Recommend dropping from the i18n CSV emit at task end (per memory feedback_i18n_csv_output). |
Spec scope acknowledgement (explicit, in-scope). Spec line 207 lists apps/gym_app/lib/pages/home/search_page.dart enum prune as “Verify gym_app has no MOVIE/SHOW references (low risk)” — the verification surfaced 3 dead enum entries (ActivityType.show, .movie, .dining) on lines 22-25. This ADR treats the 3-line prune as in-scope for this ticket without amending the spec, on the following reasoning: (a) total code edit is 3 lines + dropdown shrink, well below the spec-amendment threshold for scope creep; (b) the post-codegen Dart enum domain narrows to [slotBased, service], so leaving ActivityType.show/movie/dining referenced is a guaranteed flutter build break — i.e. AC-20 (flutter analyze clean) cannot be satisfied without this edit; (c) the scope line “no UI changes” was intended for “no NEW UI added” — pruning compile-broken references is removal of dead code, not new UI. Phase B/C must NOT add a new “Has seat selection” mobile filter — that is a separate ticket.
3.6 tktspace-landing
Section titled “3.6 tktspace-landing”No code changes — grep clean (grep -rn "MOVIE\|'SHOW'\|CINEMA\|SHOWS" src returned 0 results). i18n strings live in Google Sheets; no Astro-page string changes.
4. Migration outline (preview only — real SQL emitted by drizzle-kit generate)
Section titled “4. Migration outline (preview only — real SQL emitted by drizzle-kit generate)”The migration is generated by pnpm run db:generate AFTER the schema edits in D1 land. drizzle-kit emits:
ALTER TABLE … ADD COLUMN has_seat_selection boolean NOT NULL DEFAULT false;- Enum rebuild block (text-cast → DROP TYPE → CREATE TYPE → cast-back), matching
0048_clear_tattoo.sqlshape. ALTER TABLE activities.activities ADD CONSTRAINT activities_has_seat_selection_check CHECK (…);(drizzle emits CHECK constraints fromcheck()helper — verified viacontributors_roles_not_emptyin 0038/0039).
The dev MUST hand-add the following BEFORE running pnpm run db:migrate:
-- ============================================================================-- Activity model simplification — ticket 869dpxbj6-- Generated SQL ABOVE this comment block; data SQL hand-added per ADR §D2/D3/D6.-- ============================================================================
-- Step 1 — pre-flight (hand-added; drizzle never emits guards).DO $$BEGIN IF (SELECT count(*) FROM activities.activities WHERE type::text = 'DINING') > 0 THEN RAISE EXCEPTION 'activity-model-simplification: DINING rows present'; END IF; IF (SELECT count(*) FROM activities.spheres WHERE code = 'DINING') > 0 THEN RAISE EXCEPTION 'activity-model-simplification: DINING sphere row present'; END IF; IF (SELECT count(*) FROM activities.activities WHERE type::text NOT IN ('SHOW','MOVIE','SLOT_BASED','SERVICE')) > 0 THEN RAISE EXCEPTION 'activity-model-simplification: unexpected activity_type values present'; END IF;END $$;--> statement-breakpoint
-- Step 2 — drizzle-emitted ADD COLUMN (verify present above this comment).-- Step 3 — hand-added data UPDATE (BEFORE the enum rebuild).UPDATE "activities"."activities" SET "type" = 'SLOT_BASED', "has_seat_selection" = true WHERE "type" = 'MOVIE';--> statement-breakpointUPDATE "activities"."activities" SET "type" = 'SLOT_BASED', "has_seat_selection" = false WHERE "type" = 'SHOW';--> statement-breakpoint
-- Step 4 — drizzle-emitted enum rebuild (text-cast / DROP TYPE / CREATE / cast-back).
-- Step 5 — drizzle-emitted ADD CONSTRAINT (verify present below the rebuild).
-- Step 6 — hand-added: INSERT EVENTS sphere.INSERT INTO "activities"."spheres" ("code","name","icon","target_app", "allowed_activity_types","default_activity_type","sort_order")VALUES ('EVENTS', '{"uk":"Події","en":"Events","ru":"События","de":"Events","fr":"Événements"}'::jsonb, NULL, 'TICKETS_APP', ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[], 'SLOT_BASED'::"activities"."activity_type", 1)ON CONFLICT ("code") DO NOTHING;--> statement-breakpoint
-- Step 7 — hand-added: backfill activities.UPDATE "activities"."activities" SET "sphere_id" = (SELECT "id" FROM "activities"."spheres" WHERE "code"='EVENTS') WHERE "sphere_id" IN (SELECT "id" FROM "activities"."spheres" WHERE "code" IN ('CINEMA','SHOWS'));--> statement-breakpoint
-- Step 8 — hand-added: backfill categories.UPDATE "activities"."categories" SET "sphere_id" = (SELECT "id" FROM "activities"."spheres" WHERE "code"='EVENTS') WHERE "sphere_id" IN (SELECT "id" FROM "activities"."spheres" WHERE "code" IN ('CINEMA','SHOWS'));--> statement-breakpoint
-- Step 9 — hand-added: delete obsolete sphere rows.DELETE FROM "activities"."spheres" WHERE "code" IN ('CINEMA','SHOWS');--> statement-breakpoint
-- Step 10 — hand-added: re-seed final 3 sphere rows (allowed types + default + sort_order).-- See ADR §D3 AC-6 cross-reference: SERVICES.sort_order moves 3 → 2 to keep the-- final ordering contiguous (0/1/2).UPDATE "activities"."spheres" SET "allowed_activity_types" = ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[], "default_activity_type" = 'SLOT_BASED'::"activities"."activity_type", "sort_order" = 0 WHERE "code" = 'SPORT';--> statement-breakpointUPDATE "activities"."spheres" SET "allowed_activity_types" = ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[], "default_activity_type" = 'SLOT_BASED'::"activities"."activity_type", "sort_order" = 1 WHERE "code" = 'EVENTS';--> statement-breakpointUPDATE "activities"."spheres" SET "allowed_activity_types" = ARRAY['SLOT_BASED','SERVICE']::"activities"."activity_type"[], "default_activity_type" = 'SERVICE'::"activities"."activity_type", "sort_order" = 2 WHERE "code" = 'SERVICES';A preview-only mirror lands in drafts/migration-activity-model-simplification.sql (gitignored) for the migration-safety-check skill to review before apply.
5. Rollout / rollback
Section titled “5. Rollout / rollback”Rollout. Single MR per consumer repo (backend, business, mobile). No feature flag — the migration is the deploy gate.
- Backend MR lands first: schema + DTOs + service + tests + generated
0049_<auto>.sql+ 3 refreshed YAMLs in_workflow/contracts/. - Business MR rebuilds against refreshed
business.openapi.yamlvia the manual recipe (Step A-E from ADRsphere-targetapp-enum-cleanup-drop-dining-add-services§6.2 — same gotcha applies becausetools/gen/sync-business-contract.jsis still absent). - Web MR runs
npm run generateagainst refreshedclient.openapi.yaml; manual icon-map fix inexplore.page.ts. - Mobile MR runs
melos run sync:spec && melos run generate:api; manual fix insearch_page.dart:21-27+ favorites docstrings.
Runtime smoke (per memory feedback_runtime_smoke). After pnpm run db:migrate, npx nx serve api MUST boot; Swagger UI at http://localhost:5005/api/business/docs MUST show ActivityType: [SLOT_BASED, SERVICE] and ActivityResponseDto.hasSeatSelection: boolean; GET /api/business/spheres MUST return exactly [SPORT, EVENTS, SERVICES] (AC-21).
Rollback. Destructive.
MOVIErows are recoverable from(type='SLOT_BASED', has_seat_selection=true)deterministically.SHOWrows are NOT recoverable from(type='SLOT_BASED', has_seat_selection=false)because pre-existingSLOT_BASEDrows (gym classes, swimming pool slots) also satisfyhas_seat_selection=false. Distinguishing requires a backup or audit log dump.- Mitigation: take a Postgres dump (
pg_dump --schema=activities) BEFOREpnpm run db:migratelands on production. The dump is the rollback artefact. - No automated
down.sqlprovided — drizzle-kit only generates forward migrations; restoration is dump-restore on a failure event.
Production gating. Per memory feedback_drizzle_migrations, this MR ships ONLY after:
- migration-safety-check skill runs clean.
pnpm run sync:contracts:checkreturns 0.- Backend e2e (
pnpm exec jest --testPathPattern=spheres,pnpm exec jest --testPathPattern=activities) green. - Smoke per
feedback_runtime_smoke. - PRE-MIGRATION DUMP TAKEN —
pg_dump --schema=activities $DATABASE_URL > pre-869dpxbj6.sqlarchived off-box. Per §5 Rollback above,SHOWrows are not deterministically distinguishable from pre-existingSLOT_BASED, has_seat_selection=falserows; the dump is the only rollback path. Required checkbox on the production deploy ticket.
Phase D documentation (out of MR scope). Per AC-23, Phase D docs-writer adds glossary entries for hasSeatSelection, the post-collapse [SLOT_BASED, SERVICE] activity type set, and the EVENTS sphere (replacing the old CINEMA/SHOWS pair). These edits live in the docs repo / generated CHANGELOG, not in the backend / business / web / mobile MRs of this ticket.
6. Risks & open questions
Section titled “6. Risks & open questions”-
activity_form.page.spec.ts:201,239-250semantic change. The “AC-8.2: changing sphere to CINEMA clears SLOT_BASED type” scenario depended on a sphere with a restrictiveallowedActivityTypes. Post-collapse all 3 spheres allow[SLOT_BASED, SERVICE], so the test loses its negative path. Phase C dev MUST replace with a different negative-path scenario (e.g., the newhasSeatSelection-vs-SERVICE conflict from AC-12) rather than just deleting. If the test gets simply dropped, AC-16 grep stays clean but test coverage shrinks. Action: spec already lists this in AC-12; verify Phase B test author wires it. -
SERVICESspheredefaultActivityTypeflip. Spec §“spheres.allowedActivityTypes update” table setsSERVICES.defaultActivityType = 'SERVICE'. Current sphere row (post-0045_rebucket_service_activities_from_sport_to_services) likely already has this, but the explicit re-seed in step 10 makes it deterministic. Pre-migration dev DB inspection (SELECT code, default_activity_type FROM activities.spheres) recommended for the migration-safety-check skill. -
None re. mobile UI scope creep. The search-page filter pruning is the minimum required to keep
flutter buildgreen; the spec line “no UI changes” was wrong on this point and is overridden by AC-20 + the Dart compile-break analysis in §3.5. Document in MR description. -
Sphere
nametranslations are DB-resident JSONB, not i18n keys. The EVENTS sphere row’snamecolumn is seeded with a 5-locale JSONB ({"uk":"Події","en":"Events","ru":"События","de":"Events","fr":"Événements"}) directly in the migration step 6. No CSV emit needed at task end per memoryfeedback_i18n_csv_output— that workflow applies to mobile.arb/ web/businessmessages.*.jsonkeys, not to DB JSONB columns. (Mobile i18n keyssearch#filter_show,search#filter_movie,search#filter_dininglisted in §3.5 ARE in-scope for the CSV emit because they live in.arb.) -
sphere_audit_log.sphere_id ON DELETE SET NULL— join-loss, not data-loss. Historical audit rows referencing the deleted CINEMA and SHOWS sphere ids will have theirsphere_idcolumn NULLed after step 9’sDELETE FROM activities.spheres WHERE code IN ('CINEMA','SHOWS'). The companionsphere_codetext column onsphere_audit_logpreserves the readable history ('CINEMA'/'SHOWS'strings remain queryable). Audit queries that JOIN throughsphere_idwill return NULL for those rows; queries that group bysphere_codecontinue to work. This is expected behaviour given the FK is declaredON DELETE SET NULL, not data loss.
STATUS: READY_FOR_REVIEW