spaps 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/AI_TOOLS.json +10 -11
  2. package/README.md +267 -110
  3. package/assets/local-runtime/Dockerfile +28 -0
  4. package/assets/local-runtime/alembic/env.py +101 -0
  5. package/assets/local-runtime/alembic/path_bootstrap.py +71 -0
  6. package/assets/local-runtime/alembic/versions/000000000001_baseline_consolidated_schema.py +1076 -0
  7. package/assets/local-runtime/alembic/versions/000000000002_fix_column_types_to_match_prod.py +83 -0
  8. package/assets/local-runtime/alembic/versions/000000000003_fix_email_template_key_uniqueness.py +49 -0
  9. package/assets/local-runtime/alembic/versions/000000000004_add_hold_duration_minutes_to_dayrate_config.py +30 -0
  10. package/assets/local-runtime/alembic/versions/000000000005_resource_scoped_entitlements.py +77 -0
  11. package/assets/local-runtime/alembic/versions/000000000006_cfo_rbac_add_is_admin.py +37 -0
  12. package/assets/local-runtime/alembic/versions/000000000007_agent_approvals.py +158 -0
  13. package/assets/local-runtime/alembic/versions/000000000008_add_company_id_to_cfo_connections.py +35 -0
  14. package/assets/local-runtime/alembic/versions/000000000009_tx_signing.py +62 -0
  15. package/assets/local-runtime/alembic/versions/000000000010_affiliate_referrals.py +235 -0
  16. package/assets/local-runtime/alembic/versions/000000000011_checkin_call_booking.py +137 -0
  17. package/assets/local-runtime/alembic/versions/000000000012_subscription_application_scoping.py +55 -0
  18. package/assets/local-runtime/alembic/versions/000000000013_refresh_token_anomaly_context.py +61 -0
  19. package/assets/local-runtime/alembic/versions/000000000014_buildooor_dayrate_hire_schedule.py +39 -0
  20. package/assets/local-runtime/alembic/versions/000000000015_support_telemetry_platform.py +112 -0
  21. package/assets/local-runtime/alembic/versions/000000000016_issue_reporting_platform.py +54 -0
  22. package/assets/local-runtime/alembic/versions/000000000017_issue_reporting_platform_import_tracking.py +44 -0
  23. package/assets/local-runtime/alembic/versions/000000000018_authorization_policy_engine.py +76 -0
  24. package/assets/local-runtime/alembic.ini +47 -0
  25. package/assets/local-runtime/docker-compose.yml +61 -0
  26. package/assets/local-runtime/manifest.json +8 -0
  27. package/assets/local-runtime/scripts/container-entrypoint.sh +13 -0
  28. package/assets/local-runtime/scripts/fetch-prod-db.sh +112 -0
  29. package/assets/local-runtime/scripts/run-migrations.sh +96 -0
  30. package/package.json +5 -4
  31. package/src/ai-helper.js +176 -234
  32. package/src/ai-tool-spec.js +52 -20
  33. package/src/auth/api-key.js +119 -0
  34. package/src/auth/client-id.js +136 -0
  35. package/src/auth/client.js +169 -0
  36. package/src/auth/credentials.js +110 -0
  37. package/src/auth/device-flow.js +159 -0
  38. package/src/auth/env.js +57 -0
  39. package/src/auth/handlers.js +462 -0
  40. package/src/auth/http.js +74 -0
  41. package/src/cli-dispatcher.js +155 -24
  42. package/src/docs-system.js +7 -7
  43. package/src/error-handler.js +42 -0
  44. package/src/fixture-kernel.js +1143 -0
  45. package/src/handlers.js +252 -15
  46. package/src/help-system.js +3 -1
  47. package/src/local-runtime.js +258 -0
  48. package/src/local-server.js +597 -199
  49. package/src/project-scaffolder.js +441 -0
@@ -0,0 +1,235 @@
1
+ """affiliate referrals
2
+
3
+ Revision ID: 000000000010
4
+ Revises: 000000000009
5
+ Create Date: 2026-02-13
6
+
7
+ Creates affiliates, referral_clicks, referral_attributions,
8
+ affiliate_commissions, affiliate_payouts, and app_affiliate_config tables
9
+ for the multi-level affiliate/referral system.
10
+ """
11
+
12
+ from alembic import op
13
+
14
+ revision = "000000000010"
15
+ down_revision = "000000000009"
16
+ branch_labels = None
17
+ depends_on = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ # -----------------------------------------------------------------------
22
+ # affiliates table
23
+ # -----------------------------------------------------------------------
24
+ op.execute("""
25
+ CREATE TABLE IF NOT EXISTS affiliates (
26
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
27
+ affiliate_type VARCHAR(10) NOT NULL,
28
+ user_id UUID REFERENCES users(id) ON DELETE CASCADE,
29
+ agent_id UUID REFERENCES agents(id) ON DELETE CASCADE,
30
+ slug VARCHAR(50) NOT NULL UNIQUE,
31
+ referred_by_affiliate_id UUID REFERENCES affiliates(id) ON DELETE SET NULL,
32
+ referral_depth INTEGER NOT NULL DEFAULT 0,
33
+ is_active BOOLEAN NOT NULL DEFAULT true,
34
+ payout_wallet_address VARCHAR(200),
35
+ payout_chain VARCHAR(50),
36
+ credit_balance_cents BIGINT NOT NULL DEFAULT 0,
37
+ lifetime_earned_cents BIGINT NOT NULL DEFAULT 0,
38
+ lifetime_paid_out_cents BIGINT NOT NULL DEFAULT 0,
39
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
40
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41
+ CONSTRAINT ck_affiliates_type CHECK (affiliate_type IN ('user', 'agent')),
42
+ CONSTRAINT ck_affiliates_exactly_one_fk CHECK (
43
+ (affiliate_type = 'user' AND user_id IS NOT NULL AND agent_id IS NULL)
44
+ OR (affiliate_type = 'agent' AND agent_id IS NOT NULL AND user_id IS NULL)
45
+ )
46
+ );
47
+ """)
48
+
49
+ # One affiliate per user
50
+ op.execute("""
51
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_affiliates_user
52
+ ON affiliates (user_id)
53
+ WHERE user_id IS NOT NULL;
54
+ """)
55
+
56
+ # One affiliate per agent
57
+ op.execute("""
58
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_affiliates_agent
59
+ ON affiliates (agent_id)
60
+ WHERE agent_id IS NOT NULL;
61
+ """)
62
+
63
+ op.execute("""
64
+ CREATE INDEX IF NOT EXISTS ix_affiliates_referred_by
65
+ ON affiliates (referred_by_affiliate_id);
66
+ """)
67
+
68
+ # -----------------------------------------------------------------------
69
+ # referral_clicks table
70
+ # -----------------------------------------------------------------------
71
+ op.execute("""
72
+ CREATE TABLE IF NOT EXISTS referral_clicks (
73
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
74
+ affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE,
75
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
76
+ clicked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
77
+ ip_hash VARCHAR(64) NOT NULL,
78
+ user_agent VARCHAR(500),
79
+ expires_at TIMESTAMPTZ NOT NULL,
80
+ converted_at TIMESTAMPTZ,
81
+ converted_entity_type VARCHAR(10),
82
+ converted_entity_id UUID
83
+ );
84
+ """)
85
+
86
+ op.execute("""
87
+ CREATE INDEX IF NOT EXISTS ix_referral_clicks_affiliate
88
+ ON referral_clicks (affiliate_id);
89
+ """)
90
+
91
+ op.execute("""
92
+ CREATE INDEX IF NOT EXISTS ix_referral_clicks_app
93
+ ON referral_clicks (application_id);
94
+ """)
95
+
96
+ op.execute("""
97
+ CREATE INDEX IF NOT EXISTS ix_referral_clicks_ip_slug
98
+ ON referral_clicks (ip_hash, affiliate_id);
99
+ """)
100
+
101
+ op.execute("""
102
+ CREATE INDEX IF NOT EXISTS ix_referral_clicks_expires
103
+ ON referral_clicks (expires_at)
104
+ WHERE converted_at IS NULL;
105
+ """)
106
+
107
+ # -----------------------------------------------------------------------
108
+ # referral_attributions table
109
+ # -----------------------------------------------------------------------
110
+ op.execute("""
111
+ CREATE TABLE IF NOT EXISTS referral_attributions (
112
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
113
+ referred_entity_type VARCHAR(10) NOT NULL,
114
+ referred_entity_id UUID NOT NULL,
115
+ affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE,
116
+ referral_click_id UUID REFERENCES referral_clicks(id) ON DELETE SET NULL,
117
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
118
+ attributed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
119
+ CONSTRAINT ck_referral_attributions_type CHECK (referred_entity_type IN ('user', 'agent')),
120
+ CONSTRAINT uq_referral_attributions_entity UNIQUE (referred_entity_type, referred_entity_id)
121
+ );
122
+ """)
123
+
124
+ op.execute("""
125
+ CREATE INDEX IF NOT EXISTS ix_referral_attributions_affiliate
126
+ ON referral_attributions (affiliate_id);
127
+ """)
128
+
129
+ op.execute("""
130
+ CREATE INDEX IF NOT EXISTS ix_referral_attributions_entity
131
+ ON referral_attributions (referred_entity_type, referred_entity_id);
132
+ """)
133
+
134
+ # -----------------------------------------------------------------------
135
+ # affiliate_commissions table
136
+ # -----------------------------------------------------------------------
137
+ op.execute("""
138
+ CREATE TABLE IF NOT EXISTS affiliate_commissions (
139
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
140
+ affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE,
141
+ source_entity_type VARCHAR(10) NOT NULL,
142
+ source_entity_id UUID NOT NULL,
143
+ payment_event_type VARCHAR(50) NOT NULL,
144
+ payment_amount_cents BIGINT NOT NULL,
145
+ commission_level INTEGER NOT NULL,
146
+ commission_rate_bps INTEGER NOT NULL,
147
+ commission_amount_cents BIGINT NOT NULL,
148
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
149
+ credited_at TIMESTAMPTZ NOT NULL DEFAULT now(),
150
+ payment_reference VARCHAR(500) NOT NULL,
151
+ CONSTRAINT ck_affiliate_commissions_level CHECK (commission_level IN (1, 2, 3)),
152
+ CONSTRAINT uq_affiliate_commissions_idempotent UNIQUE (payment_reference, affiliate_id, commission_level)
153
+ );
154
+ """)
155
+
156
+ op.execute("""
157
+ CREATE INDEX IF NOT EXISTS ix_affiliate_commissions_affiliate
158
+ ON affiliate_commissions (affiliate_id);
159
+ """)
160
+
161
+ op.execute("""
162
+ CREATE INDEX IF NOT EXISTS ix_affiliate_commissions_app
163
+ ON affiliate_commissions (application_id);
164
+ """)
165
+
166
+ op.execute("""
167
+ CREATE INDEX IF NOT EXISTS ix_affiliate_commissions_credited_at
168
+ ON affiliate_commissions (credited_at);
169
+ """)
170
+
171
+ # -----------------------------------------------------------------------
172
+ # affiliate_payouts table
173
+ # -----------------------------------------------------------------------
174
+ op.execute("""
175
+ CREATE TABLE IF NOT EXISTS affiliate_payouts (
176
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
177
+ affiliate_id UUID NOT NULL REFERENCES affiliates(id) ON DELETE CASCADE,
178
+ amount_cents BIGINT NOT NULL,
179
+ payout_method VARCHAR(20) NOT NULL DEFAULT 'usdc_onchain',
180
+ wallet_address VARCHAR(200) NOT NULL,
181
+ chain VARCHAR(50) NOT NULL,
182
+ tx_hash VARCHAR(200),
183
+ status VARCHAR(20) NOT NULL DEFAULT 'requested',
184
+ requested_at TIMESTAMPTZ NOT NULL DEFAULT now(),
185
+ approved_at TIMESTAMPTZ,
186
+ completed_at TIMESTAMPTZ,
187
+ approved_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
188
+ denial_reason VARCHAR(500),
189
+ failure_reason VARCHAR(500),
190
+ CONSTRAINT ck_affiliate_payouts_status CHECK (
191
+ status IN ('requested', 'approved', 'completed', 'denied', 'failed')
192
+ )
193
+ );
194
+ """)
195
+
196
+ op.execute("""
197
+ CREATE INDEX IF NOT EXISTS ix_affiliate_payouts_affiliate
198
+ ON affiliate_payouts (affiliate_id);
199
+ """)
200
+
201
+ op.execute("""
202
+ CREATE INDEX IF NOT EXISTS ix_affiliate_payouts_status
203
+ ON affiliate_payouts (status)
204
+ WHERE status IN ('requested', 'approved');
205
+ """)
206
+
207
+ # -----------------------------------------------------------------------
208
+ # app_affiliate_config table
209
+ # -----------------------------------------------------------------------
210
+ op.execute("""
211
+ CREATE TABLE IF NOT EXISTS app_affiliate_config (
212
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
213
+ application_id UUID NOT NULL UNIQUE REFERENCES applications(id) ON DELETE CASCADE,
214
+ enabled BOOLEAN NOT NULL DEFAULT true,
215
+ l1_rate_bps INTEGER NOT NULL DEFAULT 1000,
216
+ l2_rate_bps INTEGER NOT NULL DEFAULT 500,
217
+ l3_rate_bps INTEGER NOT NULL DEFAULT 200,
218
+ attribution_window_days INTEGER NOT NULL DEFAULT 30,
219
+ min_payout_cents BIGINT NOT NULL DEFAULT 2500,
220
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
221
+ CONSTRAINT ck_app_affiliate_config_l1 CHECK (l1_rate_bps >= 0 AND l1_rate_bps <= 5000),
222
+ CONSTRAINT ck_app_affiliate_config_l2 CHECK (l2_rate_bps >= 0 AND l2_rate_bps <= 5000),
223
+ CONSTRAINT ck_app_affiliate_config_l3 CHECK (l3_rate_bps >= 0 AND l3_rate_bps <= 5000),
224
+ CONSTRAINT ck_app_affiliate_config_total_rate CHECK (l1_rate_bps + l2_rate_bps + l3_rate_bps <= 5000)
225
+ );
226
+ """)
227
+
228
+
229
+ def downgrade() -> None:
230
+ op.execute("DROP TABLE IF EXISTS app_affiliate_config CASCADE;")
231
+ op.execute("DROP TABLE IF EXISTS affiliate_payouts CASCADE;")
232
+ op.execute("DROP TABLE IF EXISTS affiliate_commissions CASCADE;")
233
+ op.execute("DROP TABLE IF EXISTS referral_attributions CASCADE;")
234
+ op.execute("DROP TABLE IF EXISTS referral_clicks CASCADE;")
235
+ op.execute("DROP TABLE IF EXISTS affiliates CASCADE;")
@@ -0,0 +1,137 @@
1
+ """checkin call booking
2
+
3
+ Revision ID: 000000000011
4
+ Revises: 000000000010
5
+ Create Date: 2026-02-13
6
+
7
+ Creates booking_policies table and adds new columns to dayrate_bookings
8
+ for the checkin_call_booking domain slice:
9
+ - booking_policies: policy configuration for free booking allotments
10
+ - dayrate_bookings: enrollment_id, replaces_booking_id, policy_key, is_free
11
+ """
12
+
13
+ from alembic import op
14
+
15
+ revision = "000000000011"
16
+ down_revision = "000000000010"
17
+ branch_labels = None
18
+ depends_on = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ # -----------------------------------------------------------------------
23
+ # booking_policies table (new)
24
+ # -----------------------------------------------------------------------
25
+ op.execute("""
26
+ CREATE TABLE IF NOT EXISTS booking_policies (
27
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
28
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
29
+ policy_key TEXT NOT NULL,
30
+ entitlement_key TEXT NOT NULL,
31
+ free_bookings_per_period INTEGER NOT NULL DEFAULT 0,
32
+ period_days INTEGER NOT NULL DEFAULT 28,
33
+ overage_rate INTEGER,
34
+ overage_currency TEXT NOT NULL DEFAULT 'usd',
35
+ overage_product_name TEXT,
36
+ reschedule_is_free BOOLEAN NOT NULL DEFAULT true,
37
+ is_active BOOLEAN NOT NULL DEFAULT true,
38
+ metadata JSONB DEFAULT '{}',
39
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
40
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
41
+ CONSTRAINT uq_booking_policies_app_key UNIQUE (application_id, policy_key)
42
+ );
43
+ """)
44
+
45
+ # Index for policy lookups
46
+ op.execute("""
47
+ CREATE INDEX IF NOT EXISTS idx_booking_policies_app_active
48
+ ON booking_policies (application_id)
49
+ WHERE is_active = true;
50
+ """)
51
+
52
+ # -----------------------------------------------------------------------
53
+ # dayrate_bookings: new columns (all nullable/defaulted for backward compat)
54
+ # -----------------------------------------------------------------------
55
+
56
+ # enrollment_id: cross-system reference to HTMA enrollment
57
+ op.execute("""
58
+ ALTER TABLE dayrate_bookings
59
+ ADD COLUMN IF NOT EXISTS enrollment_id TEXT;
60
+ """)
61
+
62
+ # replaces_booking_id: self-referencing FK for reschedule chain
63
+ op.execute("""
64
+ ALTER TABLE dayrate_bookings
65
+ ADD COLUMN IF NOT EXISTS replaces_booking_id UUID
66
+ REFERENCES dayrate_bookings(id) ON DELETE SET NULL;
67
+ """)
68
+
69
+ # policy_key: which booking policy was applied (text, no FK)
70
+ op.execute("""
71
+ ALTER TABLE dayrate_bookings
72
+ ADD COLUMN IF NOT EXISTS policy_key TEXT;
73
+ """)
74
+
75
+ # is_free: whether this booking was a free allotment booking
76
+ op.execute("""
77
+ ALTER TABLE dayrate_bookings
78
+ ADD COLUMN IF NOT EXISTS is_free BOOLEAN NOT NULL DEFAULT false;
79
+ """)
80
+
81
+ # -----------------------------------------------------------------------
82
+ # Indexes for new query patterns
83
+ # -----------------------------------------------------------------------
84
+
85
+ # Allotment counting: non-cancelled, non-reschedule bookings by user
86
+ op.execute("""
87
+ CREATE INDEX IF NOT EXISTS idx_dayrate_bookings_allotment
88
+ ON dayrate_bookings (application_id, user_id, created_at)
89
+ WHERE status != 'cancelled' AND replaces_booking_id IS NULL;
90
+ """)
91
+
92
+ # Email fallback for allotment counting
93
+ op.execute("""
94
+ CREATE INDEX IF NOT EXISTS idx_dayrate_bookings_email_allotment
95
+ ON dayrate_bookings (application_id, client_email, created_at)
96
+ WHERE status != 'cancelled' AND replaces_booking_id IS NULL;
97
+ """)
98
+
99
+ # Enrollment lookup (for admin bookings list filter)
100
+ op.execute("""
101
+ CREATE INDEX IF NOT EXISTS idx_dayrate_bookings_enrollment
102
+ ON dayrate_bookings (enrollment_id)
103
+ WHERE enrollment_id IS NOT NULL;
104
+ """)
105
+
106
+ # Upcoming bookings by user_id (for batch profile)
107
+ op.execute("""
108
+ CREATE INDEX IF NOT EXISTS idx_dayrate_bookings_upcoming_user
109
+ ON dayrate_bookings (application_id, user_id, date)
110
+ WHERE status != 'cancelled';
111
+ """)
112
+
113
+ # Upcoming bookings by email (for legacy batch profile fallback)
114
+ op.execute("""
115
+ CREATE INDEX IF NOT EXISTS idx_dayrate_bookings_upcoming_email
116
+ ON dayrate_bookings (application_id, client_email, date)
117
+ WHERE status != 'cancelled' AND user_id IS NULL;
118
+ """)
119
+
120
+
121
+ def downgrade() -> None:
122
+ # Drop indexes
123
+ op.execute("DROP INDEX IF EXISTS idx_dayrate_bookings_upcoming_email;")
124
+ op.execute("DROP INDEX IF EXISTS idx_dayrate_bookings_upcoming_user;")
125
+ op.execute("DROP INDEX IF EXISTS idx_dayrate_bookings_enrollment;")
126
+ op.execute("DROP INDEX IF EXISTS idx_dayrate_bookings_email_allotment;")
127
+ op.execute("DROP INDEX IF EXISTS idx_dayrate_bookings_allotment;")
128
+
129
+ # Drop new columns from dayrate_bookings
130
+ op.execute("ALTER TABLE dayrate_bookings DROP COLUMN IF EXISTS is_free;")
131
+ op.execute("ALTER TABLE dayrate_bookings DROP COLUMN IF EXISTS policy_key;")
132
+ op.execute("ALTER TABLE dayrate_bookings DROP COLUMN IF EXISTS replaces_booking_id;")
133
+ op.execute("ALTER TABLE dayrate_bookings DROP COLUMN IF EXISTS enrollment_id;")
134
+
135
+ # Drop booking_policies table
136
+ op.execute("DROP INDEX IF EXISTS idx_booking_policies_app_active;")
137
+ op.execute("DROP TABLE IF EXISTS booking_policies;")
@@ -0,0 +1,55 @@
1
+ """subscription application scoping
2
+
3
+ Revision ID: 000000000012
4
+ Revises: 000000000011
5
+ Create Date: 2026-02-13
6
+
7
+ TM-002: Add application_id column to subscriptions table for multi-tenant isolation.
8
+ This prevents same-user cross-application subscription access vulnerabilities.
9
+
10
+ Migration strategy:
11
+ 1. Add application_id column as nullable (allows existing rows)
12
+ 2. Future migration will backfill application_id from metadata or checkout sessions
13
+ 3. Future migration will add NOT NULL constraint after backfill
14
+ """
15
+
16
+ from alembic import op
17
+
18
+ revision = "000000000012"
19
+ down_revision = "000000000011"
20
+ branch_labels = None
21
+ depends_on = None
22
+
23
+
24
+ def upgrade() -> None:
25
+ # -----------------------------------------------------------------------
26
+ # subscriptions: add application_id column (nullable for backfill)
27
+ # -----------------------------------------------------------------------
28
+ op.execute("""
29
+ ALTER TABLE subscriptions
30
+ ADD COLUMN IF NOT EXISTS application_id UUID
31
+ REFERENCES applications(id) ON DELETE CASCADE;
32
+ """)
33
+
34
+ # Add index for application-scoped subscription lookups
35
+ op.execute("""
36
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_app_user
37
+ ON subscriptions (application_id, user_id)
38
+ WHERE application_id IS NOT NULL;
39
+ """)
40
+
41
+ # Add index for application + stripe_subscription_id lookups (TM-002 requirement)
42
+ op.execute("""
43
+ CREATE INDEX IF NOT EXISTS idx_subscriptions_app_stripe_id
44
+ ON subscriptions (application_id, stripe_subscription_id)
45
+ WHERE application_id IS NOT NULL AND stripe_subscription_id IS NOT NULL;
46
+ """)
47
+
48
+
49
+ def downgrade() -> None:
50
+ # Drop indexes
51
+ op.execute("DROP INDEX IF EXISTS idx_subscriptions_app_stripe_id;")
52
+ op.execute("DROP INDEX IF EXISTS idx_subscriptions_app_user;")
53
+
54
+ # Drop column
55
+ op.execute("ALTER TABLE subscriptions DROP COLUMN IF EXISTS application_id;")
@@ -0,0 +1,61 @@
1
+ """refresh token anomaly context
2
+
3
+ Revision ID: 000000000013
4
+ Revises: 000000000011
5
+ Create Date: 2026-02-13
6
+
7
+ TM-011: Add network context tracking to refresh_tokens table for
8
+ stolen token detection via IP/UA anomaly detection.
9
+
10
+ Adds columns:
11
+ - issued_ip: IP address when token was first issued
12
+ - issued_user_agent: User agent when token was first issued
13
+ - last_seen_ip: Most recent IP address for this token
14
+ - last_seen_user_agent: Most recent user agent for this token
15
+ - anomaly_state: Anomaly detection decision (none/alert/blocked)
16
+
17
+ All columns are nullable for backwards compatibility with existing tokens.
18
+ """
19
+
20
+ from alembic import op
21
+
22
+ revision = "000000000013"
23
+ down_revision = "000000000012"
24
+ branch_labels = None
25
+ depends_on = None
26
+
27
+
28
+ def upgrade() -> None:
29
+ # Add network context columns to refresh_tokens table
30
+ op.execute("""
31
+ ALTER TABLE refresh_tokens
32
+ ADD COLUMN IF NOT EXISTS issued_ip TEXT,
33
+ ADD COLUMN IF NOT EXISTS issued_user_agent TEXT,
34
+ ADD COLUMN IF NOT EXISTS last_seen_ip TEXT,
35
+ ADD COLUMN IF NOT EXISTS last_seen_user_agent TEXT,
36
+ ADD COLUMN IF NOT EXISTS anomaly_state TEXT;
37
+ """)
38
+
39
+ # Add index for anomaly_state to support security monitoring queries
40
+ op.execute("""
41
+ CREATE INDEX IF NOT EXISTS idx_refresh_tokens_anomaly_state
42
+ ON refresh_tokens (anomaly_state)
43
+ WHERE anomaly_state IS NOT NULL;
44
+ """)
45
+
46
+
47
+ def downgrade() -> None:
48
+ # Drop the anomaly state index
49
+ op.execute("""
50
+ DROP INDEX IF EXISTS idx_refresh_tokens_anomaly_state;
51
+ """)
52
+
53
+ # Remove network context columns
54
+ op.execute("""
55
+ ALTER TABLE refresh_tokens
56
+ DROP COLUMN IF EXISTS issued_ip,
57
+ DROP COLUMN IF EXISTS issued_user_agent,
58
+ DROP COLUMN IF EXISTS last_seen_ip,
59
+ DROP COLUMN IF EXISTS last_seen_user_agent,
60
+ DROP COLUMN IF EXISTS anomaly_state;
61
+ """)
@@ -0,0 +1,39 @@
1
+ """Buildooor hire dayrate availability defaults
2
+
3
+ Revision ID: 000000000014
4
+ Revises: 000000000013
5
+ Create Date: 2026-02-22
6
+
7
+ TM-014: Update Buildooor dayrate schedule to wed/sun AM/PM defaults.
8
+ """
9
+
10
+ from alembic import op
11
+
12
+ revision = "000000000014"
13
+ down_revision = "000000000013"
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade() -> None:
19
+ """Configure Buildooor availability to Wednesday + Sunday, AM/PM."""
20
+ sql = """
21
+ UPDATE dayrate_config
22
+ SET available_days = ARRAY['wednesday', 'sunday']::text[],
23
+ slots = ARRAY['AM', 'PM']::text[],
24
+ updated_at = NOW()
25
+ WHERE application_id = '91057ad9-4380-47c5-9393-69db91c5080d'::uuid;
26
+ """
27
+ op.execute(sql)
28
+
29
+
30
+ def downgrade() -> None:
31
+ """Revert Buildooor availability to Thursday + Saturday, AM/PM defaults."""
32
+ sql = """
33
+ UPDATE dayrate_config
34
+ SET available_days = ARRAY['thursday', 'saturday']::text[],
35
+ slots = ARRAY['AM', 'PM']::text[],
36
+ updated_at = NOW()
37
+ WHERE application_id = '91057ad9-4380-47c5-9393-69db91c5080d'::uuid;
38
+ """
39
+ op.execute(sql)
@@ -0,0 +1,112 @@
1
+ """support telemetry platform
2
+
3
+ Revision ID: 000000000015
4
+ Revises: 000000000014
5
+ Create Date: 2026-03-01
6
+
7
+ Creates support_cases and support_case_events tables for centralized
8
+ cross-application support telemetry ingestion and triage lifecycle APIs.
9
+ """
10
+
11
+ from alembic import op
12
+
13
+ revision = "000000000015"
14
+ down_revision = "000000000014"
15
+ branch_labels = None
16
+ depends_on = None
17
+
18
+
19
+ def upgrade() -> None:
20
+ op.execute("""
21
+ CREATE TABLE IF NOT EXISTS support_cases (
22
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
23
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
24
+ external_case_ref VARCHAR(255),
25
+ title VARCHAR(500),
26
+ status VARCHAR(20) NOT NULL DEFAULT 'open',
27
+ priority VARCHAR(20) NOT NULL DEFAULT 'normal',
28
+ highest_severity VARCHAR(20) NOT NULL DEFAULT 'info',
29
+ assigned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
30
+ first_event_at TIMESTAMPTZ NOT NULL,
31
+ last_event_at TIMESTAMPTZ NOT NULL,
32
+ resolved_at TIMESTAMPTZ,
33
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
34
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
35
+ CONSTRAINT ck_support_cases_status CHECK (
36
+ status IN ('open', 'in_progress', 'resolved', 'ignored')
37
+ ),
38
+ CONSTRAINT ck_support_cases_priority CHECK (
39
+ priority IN ('low', 'normal', 'high', 'urgent')
40
+ ),
41
+ CONSTRAINT ck_support_cases_highest_severity CHECK (
42
+ highest_severity IN ('info', 'warning', 'error', 'critical')
43
+ )
44
+ );
45
+ """)
46
+
47
+ op.execute("""
48
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_support_cases_external_ref
49
+ ON support_cases (application_id, external_case_ref)
50
+ WHERE external_case_ref IS NOT NULL;
51
+ """)
52
+ op.execute("""
53
+ CREATE INDEX IF NOT EXISTS ix_support_cases_app_status_priority
54
+ ON support_cases (application_id, status, priority);
55
+ """)
56
+ op.execute("""
57
+ CREATE INDEX IF NOT EXISTS ix_support_cases_assigned_user
58
+ ON support_cases (assigned_to_user_id);
59
+ """)
60
+ op.execute("""
61
+ CREATE INDEX IF NOT EXISTS ix_support_cases_last_event_at
62
+ ON support_cases (last_event_at);
63
+ """)
64
+
65
+ op.execute("""
66
+ CREATE TABLE IF NOT EXISTS support_case_events (
67
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
68
+ case_id UUID NOT NULL REFERENCES support_cases(id) ON DELETE CASCADE,
69
+ application_id UUID NOT NULL REFERENCES applications(id) ON DELETE CASCADE,
70
+ event_key VARCHAR(128) NOT NULL,
71
+ source_channel VARCHAR(32) NOT NULL,
72
+ event_type VARCHAR(100) NOT NULL,
73
+ severity VARCHAR(20) NOT NULL,
74
+ actor_type VARCHAR(32) NOT NULL,
75
+ actor_external_id VARCHAR(255),
76
+ correlation_id VARCHAR(255),
77
+ payload JSONB NOT NULL DEFAULT '{}'::jsonb,
78
+ redacted_keys JSONB NOT NULL DEFAULT '[]'::jsonb,
79
+ occurred_at TIMESTAMPTZ NOT NULL,
80
+ received_at TIMESTAMPTZ NOT NULL DEFAULT now(),
81
+ ingested_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
82
+ CONSTRAINT ck_support_case_events_source_channel CHECK (
83
+ source_channel IN ('issue_log', 'support_thread', 'email', 'frontend', 'api', 'webhook')
84
+ ),
85
+ CONSTRAINT ck_support_case_events_severity CHECK (
86
+ severity IN ('info', 'warning', 'error', 'critical')
87
+ ),
88
+ CONSTRAINT ck_support_case_events_actor_type CHECK (
89
+ actor_type IN ('user', 'practitioner', 'anonymous', 'service', 'support_operator')
90
+ )
91
+ );
92
+ """)
93
+
94
+ op.execute("""
95
+ CREATE UNIQUE INDEX IF NOT EXISTS uq_support_case_events_event_key
96
+ ON support_case_events (application_id, event_key);
97
+ """)
98
+ op.execute("""
99
+ CREATE INDEX IF NOT EXISTS ix_support_case_events_case_occurred
100
+ ON support_case_events (case_id, occurred_at);
101
+ """)
102
+ op.execute("""
103
+ CREATE INDEX IF NOT EXISTS ix_support_case_events_app_correlation
104
+ ON support_case_events (application_id, correlation_id)
105
+ WHERE correlation_id IS NOT NULL;
106
+ """)
107
+
108
+
109
+ def downgrade() -> None:
110
+ op.execute("DROP TABLE IF EXISTS support_case_events CASCADE;")
111
+ op.execute("DROP TABLE IF EXISTS support_cases CASCADE;")
112
+