@@ -111,6 +111,37 @@ class ServiceMigrationSpec:
111111 ],
112112 indexes = [IndexSpec ("ix_user_email" , ["email" ], unique = True )],
113113 ),
114+ # UserOAuthIdentity - links a user to a third-party identity.
115+ # One user can have many identities (GitHub + Google). The
116+ # (provider, provider_user_id) pair is unique to prevent identity
117+ # hijacking across accounts.
118+ TableSpec (
119+ name = "user_oauth_identity" ,
120+ columns = [
121+ ColumnSpec ("id" , "sa.Integer()" , nullable = False , primary_key = True ),
122+ ColumnSpec ("user_id" , "sa.Integer()" , nullable = False ),
123+ ColumnSpec ("provider" , "sa.String(32)" , nullable = False ),
124+ # Stored as string to avoid caring whether the provider
125+ # uses int IDs (GitHub) or UUIDs (some others).
126+ ColumnSpec ("provider_user_id" , "sa.String(128)" , nullable = False ),
127+ ColumnSpec ("provider_username" , "sa.String(128)" , nullable = True ),
128+ ColumnSpec ("provider_email" , "sa.String(255)" , nullable = True ),
129+ ColumnSpec ("avatar_url" , "sa.String(512)" , nullable = True ),
130+ ColumnSpec ("created_at" , "sa.DateTime()" , nullable = False ),
131+ ColumnSpec ("updated_at" , "sa.DateTime()" , nullable = False ),
132+ ],
133+ indexes = [
134+ IndexSpec (
135+ "uq_user_oauth_identity_provider_pid" ,
136+ ["provider" , "provider_user_id" ],
137+ unique = True ,
138+ ),
139+ IndexSpec ("ix_user_oauth_identity_user_id" , ["user_id" ]),
140+ ],
141+ foreign_keys = [
142+ ForeignKeySpec (["user_id" ], "user" , ["id" ]),
143+ ],
144+ ),
114145 ],
115146)
116147
@@ -606,7 +637,9 @@ class ServiceMigrationSpec:
606637 ForeignKeySpec (["metric_type_id" ], "insight_metric_type" , ["id" ]),
607638 ],
608639 ),
609- # InsightEvent - contextual markers
640+ # InsightEvent - contextual markers. The user-coupled
641+ # `created_by_user_id` column + FK + index are added by the
642+ # `insights_auth_link` migration when auth is also included.
610643 TableSpec (
611644 name = "insight_event" ,
612645 columns = [
@@ -615,11 +648,80 @@ class ServiceMigrationSpec:
615648 ColumnSpec ("event_type" , "sa.String(64)" , nullable = False ),
616649 ColumnSpec ("description" , "sa.String(1024)" , nullable = False ),
617650 ColumnSpec ("metadata" , "sa.JSON()" , nullable = False , default = "{}" ),
651+ # `origin` distinguishes collector output from user-created
652+ # annotations so the API/UI only exposes user rows for editing
653+ # and collector cleanups stay scoped to their own rows.
654+ ColumnSpec (
655+ "origin" , "sa.String(16)" , nullable = False , default = "'collector'"
656+ ),
618657 ColumnSpec ("created_at" , "sa.DateTime()" , nullable = False ),
619658 ],
620659 indexes = [
621660 IndexSpec ("ix_insight_event_date" , ["date" ]),
622661 IndexSpec ("ix_insight_event_type_date" , ["event_type" , "date" ]),
662+ IndexSpec ("ix_insight_event_origin" , ["origin" ]),
663+ IndexSpec ("ix_insight_event_origin_date" , ["origin" , "date" ]),
664+ ],
665+ ),
666+ ],
667+ )
668+
669+ # Insights + Auth: glue migration that adds the user-FK column to
670+ # insight_event and creates the user-scoped `insight_goal` table. Only runs
671+ # when both services are included; runs after both base migrations so the
672+ # `user` table exists when the FK is created.
673+ INSIGHTS_AUTH_LINK_MIGRATION = ServiceMigrationSpec (
674+ service_name = "insights_auth_link" ,
675+ description = "Link insight_event/insight_goal to user.id (auth + insights)" ,
676+ tables = [
677+ # InsightGoal - per-user metric goals (target value + window/date,
678+ # status). Goals are scoped to a user and a project_slug; many goals
679+ # per user is supported. See goal_service for progress calculation.
680+ TableSpec (
681+ name = "insight_goal" ,
682+ columns = [
683+ ColumnSpec ("id" , "sa.Integer()" , nullable = False , primary_key = True ),
684+ ColumnSpec ("user_id" , "sa.Integer()" , nullable = False ),
685+ ColumnSpec ("source_project_slug" , "sa.String(64)" , nullable = False ),
686+ ColumnSpec ("metric_key" , "sa.String(64)" , nullable = False ),
687+ ColumnSpec ("kind" , "sa.String(16)" , nullable = False ),
688+ ColumnSpec ("target_value" , "sa.Float()" , nullable = False ),
689+ ColumnSpec ("window_days" , "sa.Integer()" , nullable = True ),
690+ ColumnSpec ("target_date" , "sa.Date()" , nullable = True ),
691+ ColumnSpec (
692+ "status" , "sa.String(16)" , nullable = False , default = "'active'"
693+ ),
694+ ColumnSpec ("created_at" , "sa.DateTime()" , nullable = False ),
695+ ColumnSpec ("updated_at" , "sa.DateTime()" , nullable = False ),
696+ ],
697+ indexes = [
698+ IndexSpec ("ix_insight_goal_user_id" , ["user_id" ]),
699+ IndexSpec (
700+ "ix_insight_goal_source_project_slug" ,
701+ ["source_project_slug" ],
702+ ),
703+ IndexSpec ("ix_insight_goal_metric_key" , ["metric_key" ]),
704+ IndexSpec ("ix_insight_goal_user_status" , ["user_id" , "status" ]),
705+ IndexSpec (
706+ "ix_insight_goal_project_metric" ,
707+ ["source_project_slug" , "metric_key" ],
708+ ),
709+ ],
710+ foreign_keys = [
711+ ForeignKeySpec (["user_id" ], "user" , ["id" ]),
712+ ],
713+ ),
714+ ],
715+ alter_tables = [
716+ AlterTableSpec (
717+ name = "insight_event" ,
718+ add_columns = [
719+ # Audit trail for user-created events. Null on collector and
720+ # CLI-created rows.
721+ ColumnSpec ("created_by_user_id" , "sa.Integer()" , nullable = True ),
722+ ],
723+ add_foreign_keys = [
724+ ForeignKeySpec (["created_by_user_id" ], "user" , ["id" ]),
623725 ],
624726 ),
625727 ],
@@ -814,6 +916,7 @@ class ServiceMigrationSpec:
814916 "payment" : PAYMENT_MIGRATION ,
815917 "payment_auth_link" : PAYMENT_AUTH_LINK_MIGRATION ,
816918 "insights" : INSIGHTS_MIGRATION ,
919+ "insights_auth_link" : INSIGHTS_AUTH_LINK_MIGRATION ,
817920}
818921
819922# ============================================================================
@@ -867,24 +970,28 @@ def upgrade() -> None:
867970
868971{% endfor %}
869972{% for alter in alter_tables %}
870- # Alter {{ alter.name }} table
973+ # Alter {{ alter.name }} table — batch_alter_table is required for
974+ # SQLite, which doesn't support ALTER for FK constraints. Postgres
975+ # treats it as plain ALTER, so this is portable across both backends.
976+ with op.batch_alter_table('{{ alter.name }}') as batch_op:
871977{% for column in alter.add_columns %}
872- op.add_column('{{ alter.name }}', sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{% if column.server_default %}, server_default={{ column.server_default }}{% endif %}))
978+ batch_op.add_column( sa.Column('{{ column.name }}', {{ column.type }}, nullable={{ column.nullable }}{% if column.server_default %}, server_default={{ column.server_default }}{% endif %}))
873979{% endfor %}
874980{% for fk in alter.add_foreign_keys %}
875- op .create_foreign_key('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', '{{ alter.name }}', '{{ fk.ref_table }}', {{ fk.columns }}, {{ fk.ref_columns }})
981+ batch_op .create_foreign_key('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', '{{ fk.ref_table }}', {{ fk.columns }}, {{ fk.ref_columns }})
876982{% endfor %}
877983
878984{% endfor %}
879985
880986def downgrade() -> None:
881987 """Reverse {{ service_name }} migration."""
882988{% for alter in alter_tables|reverse %}
989+ with op.batch_alter_table('{{ alter.name }}') as batch_op:
883990{% for fk in alter.add_foreign_keys|reverse %}
884- op .drop_constraint('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', '{{ alter.name }}', type_='foreignkey')
991+ batch_op .drop_constraint('fk_{{ alter.name }}_{{ fk.columns[0] }}_{{ fk.ref_table }}', type_='foreignkey')
885992{% endfor %}
886993{% for column in alter.add_columns|reverse %}
887- op.drop_column('{{ alter.name }}', '{{ column.name }}')
994+ batch_op.drop_column( '{{ column.name }}')
888995{% endfor %}
889996{% endfor %}
890997{% for table in tables|reverse %}
@@ -1194,7 +1301,8 @@ def get_services_needing_migrations(context: dict[str, Any]) -> list[str]:
11941301
11951302 # Insights service (always needs database)
11961303 include_insights = context .get ("include_insights" )
1197- if include_insights == "yes" or include_insights is True :
1304+ include_insights_on = include_insights == "yes" or include_insights is True
1305+ if include_insights_on :
11981306 services .append ("insights" )
11991307
12001308 # Payment service (always needs database)
@@ -1210,6 +1318,12 @@ def get_services_needing_migrations(context: dict[str, Any]) -> list[str]:
12101318 if include_payment_on and include_auth_on :
12111319 services .append ("payment_auth_link" )
12121320
1321+ # Insights + Auth: add insight_event.created_by_user_id FK and
1322+ # create the user-scoped insight_goal table. Runs after both base
1323+ # migrations so the `user` table exists when the FK is created.
1324+ if include_insights_on and include_auth_on :
1325+ services .append ("insights_auth_link" )
1326+
12131327 return services
12141328
12151329
0 commit comments