diff --git a/resolution-frontend/drizzle/0005_productive_madripoor.sql b/resolution-frontend/drizzle/0005_productive_madripoor.sql new file mode 100644 index 0000000..f017c3a --- /dev/null +++ b/resolution-frontend/drizzle/0005_productive_madripoor.sql @@ -0,0 +1,19 @@ +CREATE TABLE "submission_closure_exception" ( + "id" text PRIMARY KEY NOT NULL, + "user_id" text NOT NULL, + "season_id" text NOT NULL, + "pathway" "pathway" NOT NULL, + "week_number" integer NOT NULL, + "reason" text NOT NULL, + "is_active" boolean DEFAULT true NOT NULL, + "expires_at" date NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "created_by" text NOT NULL +); +--> statement-breakpoint +ALTER TABLE "submission_closure_exception" ADD CONSTRAINT "submission_closure_exception_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "submission_closure_exception" ADD CONSTRAINT "submission_closure_exception_season_id_program_season_id_fk" FOREIGN KEY ("season_id") REFERENCES "public"."program_season"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "submission_closure_exception" ADD CONSTRAINT "submission_closure_exception_created_by_user_id_fk" FOREIGN KEY ("created_by") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE UNIQUE INDEX "submission_exception_unique_idx" ON "submission_closure_exception" USING btree ("user_id","season_id","pathway","week_number");--> statement-breakpoint +CREATE INDEX "submission_exception_lookup_idx" ON "submission_closure_exception" USING btree ("season_id","pathway","week_number");--> statement-breakpoint +CREATE INDEX "submission_exception_user_idx" ON "submission_closure_exception" USING btree ("user_id","season_id"); \ No newline at end of file diff --git a/resolution-frontend/drizzle/meta/0005_snapshot.json b/resolution-frontend/drizzle/meta/0005_snapshot.json new file mode 100644 index 0000000..098761f --- /dev/null +++ b/resolution-frontend/drizzle/meta/0005_snapshot.json @@ -0,0 +1,1816 @@ +{ + "id": "097f7629-1e63-459c-803a-0dcbfbca8123", + "prevId": "a3a209aa-49a0-47e6-9126-892277549771", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ambassador_pathway": { + "name": "ambassador_pathway", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "ambassador_pathway_unique_idx": { + "name": "ambassador_pathway_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ambassador_pathway_user_id_user_id_fk": { + "name": "ambassador_pathway_user_id_user_id_fk", + "tableFrom": "ambassador_pathway", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ambassador_pathway_assigned_by_user_id_fk": { + "name": "ambassador_pathway_assigned_by_user_id_fk", + "tableFrom": "ambassador_pathway", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ambassador_payout": { + "name": "ambassador_payout", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ambassador_id": { + "name": "ambassador_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "payout_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'DRAFT'" + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "ambassador_payout_ambassador_id_user_id_fk": { + "name": "ambassador_payout_ambassador_id_user_id_fk", + "tableFrom": "ambassador_payout", + "tableTo": "user", + "columnsFrom": [ + "ambassador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ambassador_payout_season_id_program_season_id_fk": { + "name": "ambassador_payout_season_id_program_season_id_fk", + "tableFrom": "ambassador_payout", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ambassador_payout_item": { + "name": "ambassador_payout_item", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "payout_id": { + "name": "payout_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "completion_count": { + "name": "completion_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rate_cents_per_completion": { + "name": "rate_cents_per_completion", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "ambassador_payout_item_payout_id_ambassador_payout_id_fk": { + "name": "ambassador_payout_item_payout_id_ambassador_payout_id_fk", + "tableFrom": "ambassador_payout_item", + "tableTo": "ambassador_payout", + "columnsFrom": [ + "payout_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ambassador_payout_item_workshop_id_workshop_id_fk": { + "name": "ambassador_payout_item_workshop_id_workshop_id_fk", + "tableFrom": "ambassador_payout_item", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pathway_week_content": { + "name": "pathway_week_content", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "week_number": { + "name": "week_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "prize_image_url": { + "name": "prize_image_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_submissions_open": { + "name": "is_submissions_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_edited_by": { + "name": "last_edited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pathway_week_content_unique_idx": { + "name": "pathway_week_content_unique_idx", + "columns": [ + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pathway_week_content_last_edited_by_user_id_fk": { + "name": "pathway_week_content_last_edited_by_user_id_fk", + "tableFrom": "pathway_week_content", + "tableTo": "user", + "columnsFrom": [ + "last_edited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program_enrollment": { + "name": "program_enrollment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "enrollment_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "enrollment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'ACTIVE'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "starting_week": { + "name": "starting_week", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "enrollment_user_season_role_idx": { + "name": "enrollment_user_season_role_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "program_enrollment_user_id_user_id_fk": { + "name": "program_enrollment_user_id_user_id_fk", + "tableFrom": "program_enrollment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "program_enrollment_season_id_program_season_id_fk": { + "name": "program_enrollment_season_id_program_season_id_fk", + "tableFrom": "program_enrollment", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.program_season": { + "name": "program_season", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "signup_opens_at": { + "name": "signup_opens_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "signup_closes_at": { + "name": "signup_closes_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "total_weeks": { + "name": "total_weeks", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 8 + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "program_season_slug_unique": { + "name": "program_season_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_link": { + "name": "referral_link", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "ambassador_id": { + "name": "ambassador_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "referral_link_ambassador_id_user_id_fk": { + "name": "referral_link_ambassador_id_user_id_fk", + "tableFrom": "referral_link", + "tableTo": "user", + "columnsFrom": [ + "ambassador_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referral_link_code_unique": { + "name": "referral_link_code_unique", + "nullsNotDistinct": false, + "columns": [ + "code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referral_signup": { + "name": "referral_signup", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "referral_link_id": { + "name": "referral_link_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "referral_signup_unique_idx": { + "name": "referral_signup_unique_idx", + "columns": [ + { + "expression": "referral_link_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referral_signup_referral_link_id_referral_link_id_fk": { + "name": "referral_signup_referral_link_id_referral_link_id_fk", + "tableFrom": "referral_signup", + "tableTo": "referral_link", + "columnsFrom": [ + "referral_link_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referral_signup_user_id_user_id_fk": { + "name": "referral_signup_user_id_user_id_fk", + "tableFrom": "referral_signup", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reviewer_pathway": { + "name": "reviewer_pathway", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "reviewer_pathway_unique_idx": { + "name": "reviewer_pathway_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reviewer_pathway_user_id_user_id_fk": { + "name": "reviewer_pathway_user_id_user_id_fk", + "tableFrom": "reviewer_pathway", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reviewer_pathway_assigned_by_user_id_fk": { + "name": "reviewer_pathway_assigned_by_user_id_fk", + "tableFrom": "reviewer_pathway", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.submission_closure_exception": { + "name": "submission_closure_exception", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "week_number": { + "name": "week_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "expires_at": { + "name": "expires_at", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "submission_exception_unique_idx": { + "name": "submission_exception_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_exception_lookup_idx": { + "name": "submission_exception_lookup_idx", + "columns": [ + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "submission_exception_user_idx": { + "name": "submission_exception_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "submission_closure_exception_user_id_user_id_fk": { + "name": "submission_closure_exception_user_id_user_id_fk", + "tableFrom": "submission_closure_exception", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_closure_exception_season_id_program_season_id_fk": { + "name": "submission_closure_exception_season_id_program_season_id_fk", + "tableFrom": "submission_closure_exception", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "submission_closure_exception_created_by_user_id_fk": { + "name": "submission_closure_exception_created_by_user_id_fk", + "tableFrom": "submission_closure_exception", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hack_club_id": { + "name": "hack_club_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "slack_id": { + "name": "slack_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "verification_status": { + "name": "verification_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ysws_eligible": { + "name": "ysws_eligible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_hack_club_id_unique": { + "name": "user_hack_club_id_unique", + "nullsNotDistinct": false, + "columns": [ + "hack_club_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_pathway": { + "name": "user_pathway", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_pathway_unique_idx": { + "name": "user_pathway_unique_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pathway", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_pathway_user_id_user_id_fk": { + "name": "user_pathway_user_id_user_id_fk", + "tableFrom": "user_pathway", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.weekly_ship": { + "name": "weekly_ship", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "week_number": { + "name": "week_number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "goal_text": { + "name": "goal_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "ship_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'PLANNED'" + }, + "proof_url": { + "name": "proof_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "shipped_at": { + "name": "shipped_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ship_user_season_week_idx": { + "name": "ship_user_season_week_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "week_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "weekly_ship_user_id_user_id_fk": { + "name": "weekly_ship_user_id_user_id_fk", + "tableFrom": "weekly_ship", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weekly_ship_season_id_program_season_id_fk": { + "name": "weekly_ship_season_id_program_season_id_fk", + "tableFrom": "weekly_ship", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "weekly_ship_workshop_id_workshop_id_fk": { + "name": "weekly_ship_workshop_id_workshop_id_fk", + "tableFrom": "weekly_ship", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workshop": { + "name": "workshop", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pathway": { + "name": "pathway", + "type": "pathway", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "estimated_hours": { + "name": "estimated_hours", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "published": { + "name": "published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workshop_author_id_user_id_fk": { + "name": "workshop_author_id_user_id_fk", + "tableFrom": "workshop", + "tableTo": "user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workshop_season_id_program_season_id_fk": { + "name": "workshop_season_id_program_season_id_fk", + "tableFrom": "workshop", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workshop_analytics": { + "name": "workshop_analytics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "starts": { + "name": "starts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "completions": { + "name": "completions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "avg_completion_mins": { + "name": "avg_completion_mins", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "workshop_analytics_workshop_id_workshop_id_fk": { + "name": "workshop_analytics_workshop_id_workshop_id_fk", + "tableFrom": "workshop_analytics", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workshop_analytics_workshop_id_unique": { + "name": "workshop_analytics_workshop_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workshop_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workshop_completion": { + "name": "workshop_completion", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workshop_id": { + "name": "workshop_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "participant_id": { + "name": "participant_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season_id": { + "name": "season_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "project_url": { + "name": "project_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "completion_workshop_participant_season_idx": { + "name": "completion_workshop_participant_season_idx", + "columns": [ + { + "expression": "workshop_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "participant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "season_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workshop_completion_workshop_id_workshop_id_fk": { + "name": "workshop_completion_workshop_id_workshop_id_fk", + "tableFrom": "workshop_completion", + "tableTo": "workshop", + "columnsFrom": [ + "workshop_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workshop_completion_participant_id_user_id_fk": { + "name": "workshop_completion_participant_id_user_id_fk", + "tableFrom": "workshop_completion", + "tableTo": "user", + "columnsFrom": [ + "participant_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workshop_completion_season_id_program_season_id_fk": { + "name": "workshop_completion_season_id_program_season_id_fk", + "tableFrom": "workshop_completion", + "tableTo": "program_season", + "columnsFrom": [ + "season_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "BEGINNER", + "INTERMEDIATE", + "ADVANCED" + ] + }, + "public.enrollment_role": { + "name": "enrollment_role", + "schema": "public", + "values": [ + "PARTICIPANT", + "AMBASSADOR" + ] + }, + "public.enrollment_status": { + "name": "enrollment_status", + "schema": "public", + "values": [ + "ACTIVE", + "DROPPED", + "COMPLETED" + ] + }, + "public.pathway": { + "name": "pathway", + "schema": "public", + "values": [ + "PYTHON", + "RUST", + "GAME_DEV", + "HARDWARE", + "DESIGN", + "GENERAL_CODING" + ] + }, + "public.payout_status": { + "name": "payout_status", + "schema": "public", + "values": [ + "DRAFT", + "PENDING", + "PAID", + "CANCELED" + ] + }, + "public.ship_status": { + "name": "ship_status", + "schema": "public", + "values": [ + "PLANNED", + "IN_PROGRESS", + "SHIPPED", + "MISSED" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/resolution-frontend/drizzle/meta/_journal.json b/resolution-frontend/drizzle/meta/_journal.json index 004721c..3412586 100644 --- a/resolution-frontend/drizzle/meta/_journal.json +++ b/resolution-frontend/drizzle/meta/_journal.json @@ -36,6 +36,13 @@ "when": 1776130671597, "tag": "0004_left_pretty_boy", "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1776740262163, + "tag": "0005_productive_madripoor", + "breakpoints": true } ] } \ No newline at end of file diff --git a/resolution-frontend/src/lib/server/db/schema.ts b/resolution-frontend/src/lib/server/db/schema.ts index d31c200..5fa715f 100644 --- a/resolution-frontend/src/lib/server/db/schema.ts +++ b/resolution-frontend/src/lib/server/db/schema.ts @@ -1,4 +1,4 @@ -import { pgTable, text, timestamp, boolean, integer, real, pgEnum, uniqueIndex, index } from 'drizzle-orm/pg-core'; +import { pgTable, text, timestamp, boolean, integer, real, pgEnum, uniqueIndex, index, date } from 'drizzle-orm/pg-core'; import { relations } from 'drizzle-orm'; import { createId } from '@paralleldrive/cuid2'; @@ -139,6 +139,35 @@ export const userPathway = pgTable('user_pathway', { uniqueIndex('user_pathway_unique_idx').on(table.userId, table.pathway) ]); +export const submissionClosureException = pgTable('submission_closure_exception', { + id: text('id').primaryKey().$defaultFn(() => createId()), + userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }), + seasonId: text('season_id').notNull().references(() => programSeason.id, { onDelete: 'cascade' }), + pathway: pathwayEnum('pathway').notNull(), + weekNumber: integer('week_number').notNull(), + reason: text('reason').notNull(), + isActive: boolean('is_active').notNull().default(true), + expiresAt: date('expires_at').notNull(), // we only care abt date + want to avoid timezone issues, date returns str vs Date obj + createdAt: timestamp('created_at', { mode: 'date' }).notNull().defaultNow(), + createdBy: text('created_by').notNull().references(() => user.id, { onDelete: 'cascade' }) +}, (table) => [ + uniqueIndex('submission_exception_unique_idx').on( + table.userId, + table.seasonId, + table.pathway, + table.weekNumber + ), + index('submission_exception_lookup_idx').on( + table.seasonId, + table.pathway, + table.weekNumber + ), + index('submission_exception_user_idx').on( + table.userId, + table.seasonId + ) +]); + // Relations export const userRelations = relations(user, ({ many }) => ({ pathways: many(userPathway), @@ -149,7 +178,9 @@ export const userRelations = relations(user, ({ many }) => ({ weeklyShips: many(weeklyShip), payouts: many(ambassadorPayout), referralLinks: many(referralLink), - reviewerAssignments: many(reviewerPathway) + reviewerAssignments: many(reviewerPathway), + exceptions: many(submissionClosureException, { relationName: 'exceptionUser' }), + createdExceptions: many(submissionClosureException, { relationName: 'exceptionCreator' }) })); export const sessionRelations = relations(session, ({ one }) => ({ @@ -161,7 +192,8 @@ export const programSeasonRelations = relations(programSeason, ({ many }) => ({ workshops: many(workshop), completions: many(workshopCompletion), weeklyShips: many(weeklyShip), - payouts: many(ambassadorPayout) + payouts: many(ambassadorPayout), + exceptions: many(submissionClosureException) })); export const programEnrollmentRelations = relations(programEnrollment, ({ one }) => ({ @@ -209,6 +241,12 @@ export const userPathwayRelations = relations(userPathway, ({ one }) => ({ user: one(user, { fields: [userPathway.userId], references: [user.id] }) })); +export const submissionClosureExceptionRelations = relations(submissionClosureException, ({ one }) => ({ + user: one(user, { fields: [submissionClosureException.userId], references: [user.id], relationName: 'exceptionUser' }), + creator: one(user, { fields: [submissionClosureException.createdBy], references: [user.id], relationName: 'exceptionCreator' }), + season: one(programSeason, { fields: [submissionClosureException.seasonId], references: [programSeason.id]}) +})); + // Ambassador pathway assignments - which pathways an ambassador can edit export const ambassadorPathway = pgTable('ambassador_pathway', { id: text('id').primaryKey().$defaultFn(() => createId()), diff --git a/resolution-frontend/src/lib/server/services/exceptionService.test.ts b/resolution-frontend/src/lib/server/services/exceptionService.test.ts new file mode 100644 index 0000000..f3c6769 --- /dev/null +++ b/resolution-frontend/src/lib/server/services/exceptionService.test.ts @@ -0,0 +1,144 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockFindFirstSeason = vi.fn(); +const mockSelect = vi.fn(); +const mockFrom = vi.fn(); +const mockWhere = vi.fn(); +const mockLimit = vi.fn(); + +vi.mock('../db', () => ({ + db: { + query: { + programSeason: { findFirst: (...args: unknown[]) => mockFindFirstSeason(...args) } + }, + select: (...args: unknown[]) => { + mockSelect(...args); + return { + from: (...a: unknown[]) => { + mockFrom(...a); + return { + where: (...w: unknown[]) => { + mockWhere(...w); + return { limit: (...l: unknown[]) => mockLimit(...l) }; + } + }; + } + }; + } + } +})); + +const { ExceptionService } = await import('./exceptionService'); +const { submissionClosureException } = await import('../db/schema'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +/** + * Walk a drizzle SQL/condition node and collect the columns referenced by every + * `eq(column, ...)`/`gte(column, ...)` operator. We assert against this so that + * if anyone removes one of the filters from `getActiveException`, the test fails. + */ +function collectReferencedColumns( + node: unknown, + out: Set = new Set(), + seen: WeakSet = new WeakSet() +): Set { + if (!node || typeof node !== 'object') return out; + if (seen.has(node as object)) return out; + seen.add(node as object); + const n = node as Record; + + // drizzle Column instances expose `.name` plus column-ish metadata. + if (typeof n.name === 'string' && (n.columnType || n.dataType)) { + out.add(n.name as string); + // don't recurse into a column's `.table` back-reference; that's the + // full table definition and would re-walk every other column. + return out; + } + + for (const key of Object.keys(n)) { + // avoid drizzle internal back-references that explode the walk. + if (key === 'table' || key === 'schema') continue; + const value = n[key]; + if (Array.isArray(value)) { + for (const item of value) collectReferencedColumns(item, out, seen); + } else if (value && typeof value === 'object') { + collectReferencedColumns(value, out, seen); + } + } + return out; +} + +describe('ExceptionService.getActiveException', () => { + it('returns null when no active season exists', async () => { + mockFindFirstSeason.mockResolvedValue(undefined); + + const result = await ExceptionService.getActiveException('user-1', 'PYTHON', 1); + expect(result).toBeNull(); + expect(mockSelect).not.toHaveBeenCalled(); + }); + + it('returns null when no exception found', async () => { + mockFindFirstSeason.mockResolvedValue({ id: 'season-1', isActive: true }); + mockLimit.mockResolvedValue([]); + + const result = await ExceptionService.getActiveException('user-1', 'PYTHON', 1); + expect(result).toBeNull(); + }); + + it('returns exception when a valid one exists', async () => { + const exception = { id: 'exc-1', expiresAt: '2026-06-01' }; + mockFindFirstSeason.mockResolvedValue({ id: 'season-1', isActive: true }); + mockLimit.mockResolvedValue([exception]); + + const result = await ExceptionService.getActiveException('user-1', 'PYTHON', 1); + expect(result).toEqual(exception); + }); + + it('queries with limit 1', async () => { + mockFindFirstSeason.mockResolvedValue({ id: 'season-42', isActive: true }); + mockLimit.mockResolvedValue([]); + + await ExceptionService.getActiveException('user-1', 'RUST', 3); + + expect(mockFindFirstSeason).toHaveBeenCalledTimes(1); + expect(mockSelect).toHaveBeenCalledTimes(1); + expect(mockLimit).toHaveBeenCalledWith(1); + }); + + it('filters on userId, seasonId, pathway, weekNumber, isActive, and expiresAt', async () => { + mockFindFirstSeason.mockResolvedValue({ id: 'season-1', isActive: true }); + mockLimit.mockResolvedValue([]); + + await ExceptionService.getActiveException('user-1', 'PYTHON', 1); + + expect(mockWhere).toHaveBeenCalledTimes(1); + const whereArg = mockWhere.mock.calls[0][0]; + const referenced = collectReferencedColumns(whereArg); + + // Each of these filters is critical to the security/correctness of the + // service. Removing any of them would silently allow incorrect bypasses. + const expectedColumns = [ + submissionClosureException.userId.name, + submissionClosureException.seasonId.name, + submissionClosureException.pathway.name, + submissionClosureException.weekNumber.name, + submissionClosureException.isActive.name, + submissionClosureException.expiresAt.name + ]; + + for (const col of expectedColumns) { + expect(referenced.has(col), `expected where clause to reference column "${col}"`).toBe(true); + } + }); + + it('returns null for empty array result', async () => { + mockFindFirstSeason.mockResolvedValue({ id: 'season-1', isActive: true }); + mockLimit.mockResolvedValue([]); + + const result = await ExceptionService.getActiveException('user-1', 'GAME_DEV', 5); + expect(result).toBeNull(); + }); +}); diff --git a/resolution-frontend/src/lib/server/services/exceptionService.ts b/resolution-frontend/src/lib/server/services/exceptionService.ts new file mode 100644 index 0000000..71b3ab2 --- /dev/null +++ b/resolution-frontend/src/lib/server/services/exceptionService.ts @@ -0,0 +1,42 @@ +import { db } from '../db'; +import { submissionClosureException, programSeason } from '../db/schema'; +import { eq, and, gte, sql } from 'drizzle-orm'; +import type { PathwayId } from '$lib/pathways'; + +export const ExceptionService = { + /** + * Check if a user has an active, non-expired exception for a given pathway and week. + * Returns the exception record if found, null otherwise. + */ + async getActiveException( + userId: string, + pathway: PathwayId, + weekNumber: number + ) { + const season = await db.query.programSeason.findFirst({ + where: eq(programSeason.isActive, true) + }); + + if (!season) return null; + + const [exception] = await db + .select({ + id: submissionClosureException.id, + expiresAt: submissionClosureException.expiresAt + }) + .from(submissionClosureException) + .where( + and( + eq(submissionClosureException.userId, userId), + eq(submissionClosureException.seasonId, season.id), + eq(submissionClosureException.pathway, pathway), + eq(submissionClosureException.weekNumber, weekNumber), + eq(submissionClosureException.isActive, true), + gte(submissionClosureException.expiresAt, sql`CURRENT_DATE`) + ) + ) + .limit(1); + + return exception || null; + } +}; diff --git a/resolution-frontend/src/lib/server/services/index.ts b/resolution-frontend/src/lib/server/services/index.ts index 021f500..ba62b66 100644 --- a/resolution-frontend/src/lib/server/services/index.ts +++ b/resolution-frontend/src/lib/server/services/index.ts @@ -1,2 +1,3 @@ export { EnrollmentService } from './enrollmentService'; +export { ExceptionService } from './exceptionService'; export { WeeklyShipService } from './weeklyShipService'; diff --git a/resolution-frontend/src/routes/api/review/submissions/+server.ts b/resolution-frontend/src/routes/api/review/submissions/+server.ts index 702fb52..143515f 100644 --- a/resolution-frontend/src/routes/api/review/submissions/+server.ts +++ b/resolution-frontend/src/routes/api/review/submissions/+server.ts @@ -4,8 +4,8 @@ import { json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { requireAuth } from '$lib/server/auth/guard'; import { db } from '$lib/server/db'; -import { reviewerPathway } from '$lib/server/db/schema'; -import { eq } from 'drizzle-orm'; +import { reviewerPathway, user as userTable } from '$lib/server/db/schema'; +import { eq, inArray, sql } from 'drizzle-orm'; import { PATHWAY_IDS } from '$lib/pathways'; export const GET: RequestHandler = async (event) => { @@ -71,22 +71,46 @@ export const GET: RequestHandler = async (event) => { }) .all(); - const submissions = records.map((record) => ({ - id: record.id, - codeUrl: record.get('Code URL') as string, - playableUrl: record.get('Playable URL') as string, - description: record.get('Description') as string, - firstName: record.get('First Name') as string, - lastName: record.get('Last Name') as string, - email: record.get('Email') as string, - githubUsername: record.get('GitHub Username') as string, - hackatimeProject: record.get('Hackatime Project') as string, - pathway: record.get('Pathway') as string, - week: record.get('Week') as number, - screenshotUrl: (record.get('Screenshot') as Array<{ url: string }> | undefined)?.[0]?.url ?? null, - hoursSpent: (record.get('Optional - Override Hours Spent') as number | undefined) ?? null, - submittedAt: record._rawJson.createdTime as string - })); + const emails = Array.from( + new Set( + records + .map((r) => r.get('Email') as string | undefined) + .filter((e): e is string => typeof e === 'string' && e.length > 0) + .map((e) => e.toLowerCase()) + ) + ); + + const slackIdByEmail = new Map(); + if (emails.length > 0) { + const users = await db + .select({ email: userTable.email, slackId: userTable.slackId }) + .from(userTable) + .where(inArray(sql`lower(${userTable.email})`, emails)); + for (const u of users) { + slackIdByEmail.set(u.email.toLowerCase(), u.slackId); + } + } + + const submissions = records.map((record) => { + const email = record.get('Email') as string; + return { + id: record.id, + codeUrl: record.get('Code URL') as string, + playableUrl: record.get('Playable URL') as string, + description: record.get('Description') as string, + firstName: record.get('First Name') as string, + lastName: record.get('Last Name') as string, + email, + slackId: slackIdByEmail.get(email?.toLowerCase()) ?? null, + githubUsername: record.get('GitHub Username') as string, + hackatimeProject: record.get('Hackatime Project') as string, + pathway: record.get('Pathway') as string, + week: record.get('Week') as number, + screenshotUrl: (record.get('Screenshot') as Array<{ url: string }> | undefined)?.[0]?.url ?? null, + hoursSpent: (record.get('Optional - Override Hours Spent') as number | undefined) ?? null, + submittedAt: record._rawJson.createdTime as string + }; + }); return json(submissions); } catch (err) { diff --git a/resolution-frontend/src/routes/api/ships/submit-project/+server.ts b/resolution-frontend/src/routes/api/ships/submit-project/+server.ts index bb48691..cc11ee9 100644 --- a/resolution-frontend/src/routes/api/ships/submit-project/+server.ts +++ b/resolution-frontend/src/routes/api/ships/submit-project/+server.ts @@ -9,6 +9,7 @@ import { db } from '$lib/server/db'; import { userPathway, pathwayWeekContent } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { type PathwayId } from '$lib/pathways'; +import { ExceptionService } from '$lib/server/services'; const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB (Airtable upload limit) const ALLOWED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp']; @@ -76,7 +77,10 @@ export const POST: RequestHandler = async (event) => { } if (!weekContent.isSubmissionsOpen) { - return json({ error: 'Submissions have been closed for this week' }, { status: 403 }); + const exception = await ExceptionService.getActiveException(user.id, parsed.pathway as PathwayId, parsed.week); + if (!exception) { + return json({ error: 'Submissions have been closed for this week' }, { status: 403 }); + } } const base = new Airtable({ apiKey: env.AIRTABLE_API_TOKEN }).base(env.AIRTABLE_BASE_ID); diff --git a/resolution-frontend/src/routes/app/ambassador/+page.svelte b/resolution-frontend/src/routes/app/ambassador/+page.svelte index cd236b6..b429f26 100644 --- a/resolution-frontend/src/routes/app/ambassador/+page.svelte +++ b/resolution-frontend/src/routes/app/ambassador/+page.svelte @@ -40,10 +40,16 @@

Ambassador Dashboard

Manage your pathway content

- - Referrals - Referral Links - + @@ -158,6 +164,30 @@ background: rgba(255, 255, 255, 1); } + .header-actions { + display: flex; + gap: 0.5rem; + } + + .exceptions-btn { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: rgba(255, 255, 255, 0.8); + border: 1px solid #ff8c37; + color: #ff8c37; + border-radius: 20px; + font-family: 'Kodchasan', sans-serif; + text-decoration: none; + white-space: nowrap; + font-size: 0.9rem; + } + + .exceptions-btn:hover { + background: rgba(255, 255, 255, 1); + } + .empty-state { text-align: center; padding: 3rem; diff --git a/resolution-frontend/src/routes/app/ambassador/exceptions/+page.server.ts b/resolution-frontend/src/routes/app/ambassador/exceptions/+page.server.ts new file mode 100644 index 0000000..224fe48 --- /dev/null +++ b/resolution-frontend/src/routes/app/ambassador/exceptions/+page.server.ts @@ -0,0 +1,308 @@ +import type { PageServerLoad, Actions } from './$types'; +import { db } from '$lib/server/db'; +import { + ambassadorPathway, + submissionClosureException, + programEnrollment, + programSeason, + user, + userPathway +} from '$lib/server/db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { error, fail } from '@sveltejs/kit'; +import { PATHWAY_IDS, type PathwayId } from '$lib/pathways'; + +function isPathwayId(value: string): value is PathwayId { + return (PATHWAY_IDS as readonly string[]).includes(value); +} + +// Postgres unique violation +const PG_UNIQUE_VIOLATION = '23505'; + +export const load: PageServerLoad = async ({ parent }) => { + const { user: currentUser } = await parent(); + + const assignments = await db + .select() + .from(ambassadorPathway) + .where(eq(ambassadorPathway.userId, currentUser.id)); + + if (assignments.length === 0 && !currentUser.isAdmin) { + throw error(403, 'You are not an ambassador'); + } + + // Admins can manage exceptions for any pathway, even without explicit + // ambassador assignments. + const assignedPathways: PathwayId[] = currentUser.isAdmin + ? ([...PATHWAY_IDS] as PathwayId[]) + : assignments.map((a) => a.pathway as PathwayId); + + const season = await db.query.programSeason.findFirst({ + where: eq(programSeason.isActive, true) + }); + + if (!season) { + throw error(500, 'No active season configured'); + } + + const enrolledUsersBaseWhere = and( + eq(programEnrollment.seasonId, season.id), + eq(programEnrollment.status, 'ACTIVE') + ); + + const enrolledUsersQuery = currentUser.isAdmin + ? db + .select({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email + }) + .from(programEnrollment) + .innerJoin(user, eq(programEnrollment.userId, user.id)) + .where(enrolledUsersBaseWhere) + : db + .selectDistinct({ + id: user.id, + firstName: user.firstName, + lastName: user.lastName, + email: user.email + }) + .from(programEnrollment) + .innerJoin(user, eq(programEnrollment.userId, user.id)) + .innerJoin(userPathway, eq(userPathway.userId, user.id)) + .where( + and( + enrolledUsersBaseWhere, + inArray(userPathway.pathway, assignedPathways) + ) + ); + + const [enrolledUsers, exceptions] = await Promise.all([ + enrolledUsersQuery, + + db + .select({ + id: submissionClosureException.id, + userId: submissionClosureException.userId, + pathway: submissionClosureException.pathway, + weekNumber: submissionClosureException.weekNumber, + reason: submissionClosureException.reason, + isActive: submissionClosureException.isActive, + expiresAt: submissionClosureException.expiresAt, + createdAt: submissionClosureException.createdAt, + createdBy: submissionClosureException.createdBy, + userName: user.firstName, + userLastName: user.lastName, + userEmail: user.email + }) + .from(submissionClosureException) + .innerJoin(user, eq(submissionClosureException.userId, user.id)) + .where( + currentUser.isAdmin + ? eq(submissionClosureException.seasonId, season.id) + : and( + eq(submissionClosureException.seasonId, season.id), + inArray(submissionClosureException.pathway, assignedPathways) + ) + ) + ]); + + return { + assignments: assignedPathways, + season: { + id: season.id, + name: season.name, + totalWeeks: season.totalWeeks + }, + enrolledUsers, + exceptions + }; +}; + +export const actions: Actions = { + createException: async ({ request, locals }) => { + if (!locals.user || !locals.session) { + return fail(401, { error: 'Unauthorized' }); + } + + const formData = await request.formData(); + const userId = formData.get('userId') as string; + const pathway = formData.get('pathway') as string; + const weekNumberRaw = formData.get('weekNumber') as string; + const weekNumber = parseInt(weekNumberRaw, 10); + const reason = formData.get('reason') as string; + const expiresAtRaw = formData.get('expiresAt') as string; + + if (!userId || !pathway || !weekNumberRaw || Number.isNaN(weekNumber) || !reason || !expiresAtRaw) { + return fail(400, { error: 'All fields are required' }); + } + + if (!isPathwayId(pathway)) { + return fail(400, { error: 'Invalid pathway' }); + } + + // Validate expiresAt is a real YYYY-MM-DD date and not in the past. + if (!/^\d{4}-\d{2}-\d{2}$/.test(expiresAtRaw)) { + return fail(400, { error: 'Invalid expiration date format' }); + } + const parsedExpires = new Date(expiresAtRaw + 'T00:00:00'); + if (Number.isNaN(parsedExpires.getTime())) { + return fail(400, { error: 'Invalid expiration date' }); + } + const today = new Date(); + const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; + if (expiresAtRaw < todayStr) { + return fail(400, { error: 'Expiration date must be today or in the future' }); + } + + const season = await db.query.programSeason.findFirst({ + where: eq(programSeason.isActive, true) + }); + + if (!season) { + return fail(500, { error: 'No active season' }); + } + + if (weekNumber < 1 || weekNumber > season.totalWeeks) { + return fail(400, { error: 'Invalid week number' }); + } + + // Verify the current ambassador is assigned to this pathway (admins bypass). + if (!locals.user.isAdmin) { + const assignment = await db.query.ambassadorPathway.findFirst({ + where: and( + eq(ambassadorPathway.userId, locals.user.id), + eq(ambassadorPathway.pathway, pathway) + ) + }); + + if (!assignment) { + return fail(403, { error: 'You are not assigned to this pathway' }); + } + } + + // Verify the target user is enrolled in this pathway. + const targetEnrollment = await db.query.userPathway.findFirst({ + where: and(eq(userPathway.userId, userId), eq(userPathway.pathway, pathway)) + }); + + if (!targetEnrollment) { + return fail(400, { error: 'Selected user is not enrolled in this pathway' }); + } + + try { + await db.insert(submissionClosureException).values({ + userId, + seasonId: season.id, + pathway, + weekNumber, + reason, + expiresAt: expiresAtRaw, + createdBy: locals.user.id + }); + } catch (err) { + const code = (err as { code?: string } | null)?.code; + if (code === PG_UNIQUE_VIOLATION) { + return fail(400, { + error: 'An exception already exists for this user, pathway, and week' + }); + } + console.error('[exceptions] Failed to insert exception', err); + return fail(500, { error: 'Failed to create exception' }); + } + + // Audit log: who granted this submission bypass. + console.info( + `[exceptions] createException by user=${locals.user.id} for target=${userId} pathway=${pathway} week=${weekNumber} expiresAt=${expiresAtRaw}` + ); + + return { success: true }; + }, + + toggleException: async ({ request, locals }) => { + if (!locals.user || !locals.session) { + return fail(401, { error: 'Unauthorized' }); + } + + const formData = await request.formData(); + const exceptionId = formData.get('exceptionId') as string; + + if (!exceptionId) { + return fail(400, { error: 'Missing exception ID' }); + } + + const exception = await db.query.submissionClosureException.findFirst({ + where: eq(submissionClosureException.id, exceptionId) + }); + + if (!exception) { + return fail(404, { error: 'Exception not found' }); + } + + if (!locals.user.isAdmin) { + const assignment = await db.query.ambassadorPathway.findFirst({ + where: and( + eq(ambassadorPathway.userId, locals.user.id), + eq(ambassadorPathway.pathway, exception.pathway) + ) + }); + if (!assignment) { + return fail(403, { error: 'You are not assigned to this pathway' }); + } + } + + const newState = !exception.isActive; + await db + .update(submissionClosureException) + .set({ isActive: newState }) + .where(eq(submissionClosureException.id, exceptionId)); + + console.info( + `[exceptions] toggleException by user=${locals.user.id} exception=${exceptionId} isActive=${newState}` + ); + + return { success: true }; + }, + + deleteException: async ({ request, locals }) => { + if (!locals.user || !locals.session) { + return fail(401, { error: 'Unauthorized' }); + } + + const formData = await request.formData(); + const exceptionId = formData.get('exceptionId') as string; + + if (!exceptionId) { + return fail(400, { error: 'Missing exception ID' }); + } + + const exception = await db.query.submissionClosureException.findFirst({ + where: eq(submissionClosureException.id, exceptionId) + }); + + if (!exception) { + return fail(404, { error: 'Exception not found' }); + } + + if (!locals.user.isAdmin) { + const assignment = await db.query.ambassadorPathway.findFirst({ + where: and( + eq(ambassadorPathway.userId, locals.user.id), + eq(ambassadorPathway.pathway, exception.pathway) + ) + }); + if (!assignment) { + return fail(403, { error: 'You are not assigned to this pathway' }); + } + } + + await db.delete(submissionClosureException).where(eq(submissionClosureException.id, exceptionId)); + + console.info( + `[exceptions] deleteException by user=${locals.user.id} exception=${exceptionId}` + ); + + return { success: true }; + } +}; diff --git a/resolution-frontend/src/routes/app/ambassador/exceptions/+page.svelte b/resolution-frontend/src/routes/app/ambassador/exceptions/+page.svelte new file mode 100644 index 0000000..49b30d5 --- /dev/null +++ b/resolution-frontend/src/routes/app/ambassador/exceptions/+page.svelte @@ -0,0 +1,730 @@ + + + + Submission Exceptions - Resolution + + + +
+ + Back + Back to Ambassador Dashboard + + +
+

Submission Exceptions

+

Grant deadline extensions for {data.season.name}

+
+ + +
+

Create Exception

+
{ + return async ({ update }) => { + await update(); + selectedUserId = ''; + searchQuery = ''; + selectedPathway = ''; + selectedWeek = ''; + reason = ''; + expiresAt = ''; + }; + }} + class="create-form" + > + + +
+
+ +
+ { + if (selectedUserId) clearUser(); + }} + autocomplete="off" + /> + {#if selectedUserId} + + {/if} +
+ {#if searchQuery.length >= 2 && !selectedUserId} +
+ {#if filteredUsers.length === 0} +
No users found
+ {:else} + {#each filteredUsers.slice(0, 8) as u} + + {/each} + {/if} +
+ {/if} + {#if selectedUserDisplay} +
+ Selected: + + {[selectedUserDisplay.firstName, selectedUserDisplay.lastName] + .filter(Boolean) + .join(' ') || selectedUserDisplay.email} + + ({selectedUserDisplay.email}) +
+ {/if} +
+ +
+ + +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ +
+
+
+ + + {#if data.exceptions.length === 0} +
+

No exceptions have been created yet.

+

Use the form above to grant a deadline extension.

+
+ {:else} +
+

Exceptions ({data.exceptions.length})

+
+ {#each data.exceptions as exception} + {@const info = pathwayInfo[exception.pathway]} + {@const expired = isExpired(exception.expiresAt)} + {@const currentlyActive = exception.isActive && !expired} +
+
+
+
+ + {[exception.userName, exception.userLastName] + .filter(Boolean) + .join(' ')} + + {exception.userEmail} +
+
+ + {info.label} + + Week {exception.weekNumber} + + {#if !exception.isActive} + Inactive + {:else if expired} + Expired + {:else} + Active + {/if} + + + Expires {formatDate(exception.expiresAt)} + +
+
{exception.reason}
+
+
+
+ + +
+
{ + if (!confirm('Delete this exception? This cannot be undone.')) { + e.preventDefault(); + } + }} + > + + +
+
+
+
+ {/each} +
+
+ {/if} +
+
+ + diff --git a/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.server.ts b/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.server.ts index f89b5ce..8c280ad 100644 --- a/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.server.ts +++ b/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.server.ts @@ -4,6 +4,7 @@ import { userPathway, pathwayWeekContent } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { redirect, error } from '@sveltejs/kit'; import { PATHWAY_IDS, type PathwayId } from '$lib/pathways'; +import { ExceptionService } from '$lib/server/services'; const validPathways = PATHWAY_IDS; type Pathway = PathwayId; @@ -41,11 +42,18 @@ export const load: PageServerLoad = async ({ params, parent }) => { throw error(404, 'This week is not yet available'); } + let exception: { expiresAt: string } | null = null; + if (!content.isSubmissionsOpen) { + exception = await ExceptionService.getActiveException(user.id, pathwayId, weekNumber); + } + return { pathwayId, weekNumber, title: content.title, content: content.content, - isSubmissionsOpen: content.isSubmissionsOpen + isSubmissionsOpen: content.isSubmissionsOpen, + hasException: !!exception, + exception }; }; diff --git a/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.svelte b/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.svelte index 6613da5..132216e 100644 --- a/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.svelte +++ b/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/+page.svelte @@ -16,7 +16,13 @@

Ready to ship?

- {#if data.isSubmissionsOpen} + {#if data.isSubmissionsOpen || data.hasException} + {#if data.exception} +
+ + You have a deadline extension. Submit by {new Date(data.exception.expiresAt + 'T00:00:00').toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} +
+ {/if}

Finished your project for this week? Submit it to earn rewards!

Ship Project @@ -84,4 +90,17 @@ .ship-btn-disabled:hover { background: #b5bfcc; } + + .exception-notice { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + background: #fff3e0; + border: 1px solid #ff8c37; + border-radius: 10px; + font-size: 0.85rem; + color: #1a1a2e; + margin-bottom: 0.75rem; + } diff --git a/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/ship/+page.server.ts b/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/ship/+page.server.ts index 186d85f..93c524e 100644 --- a/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/ship/+page.server.ts +++ b/resolution-frontend/src/routes/app/pathway/[pathway]/week/[week]/ship/+page.server.ts @@ -4,6 +4,7 @@ import { userPathway, pathwayWeekContent } from '$lib/server/db/schema'; import { eq, and } from 'drizzle-orm'; import { redirect, error } from '@sveltejs/kit'; import { PATHWAY_IDS, type PathwayId } from '$lib/pathways'; +import { ExceptionService } from '$lib/server/services'; export const load: PageServerLoad = async ({ params, parent }) => { const { user } = await parent(); @@ -44,7 +45,10 @@ export const load: PageServerLoad = async ({ params, parent }) => { } if (!content[0].isSubmissionsOpen) { - throw error(403, 'Submissions have been closed for this week'); + const exception = await ExceptionService.getActiveException(user.id, pathwayId, weekNumber); + if (!exception) { + throw error(403, 'Submissions have been closed for this week'); + } } return { diff --git a/resolution-frontend/src/routes/app/reviewer/+page.svelte b/resolution-frontend/src/routes/app/reviewer/+page.svelte index 6b2c6c1..76c4299 100644 --- a/resolution-frontend/src/routes/app/reviewer/+page.svelte +++ b/resolution-frontend/src/routes/app/reviewer/+page.svelte @@ -21,6 +21,7 @@ githubUsername: string; hoursSpent: number | null; submittedAt: string; + slackId: string | null; } const pathwayInfo = PATHWAY_INFO; @@ -131,6 +132,10 @@ if (text.length <= max) return text; return text.slice(0, max) + '…'; } + + function isValidSlackId(id: string | null): id is string { + return typeof id === 'string' && /^[A-Z0-9]+$/.test(id); + } @@ -183,7 +188,9 @@ {@const info = pathwayInfo[submission.pathway]}
@@ -426,6 +437,8 @@ display: flex; flex-direction: column; gap: 0.75rem; + min-width: 0; + overflow: hidden; } .card-header { @@ -435,9 +448,13 @@ gap: 0.5rem; } - .submitter-name { + .project-name { font-weight: 600; font-size: 1rem; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .pathway-badge { @@ -453,15 +470,35 @@ .card-meta { display: flex; - gap: 1rem; + flex-wrap: wrap; + gap: 0.5rem 1rem; font-size: 0.8rem; color: #8492a6; + min-width: 0; } .card-meta span { - display: flex; + display: inline-flex; align-items: center; gap: 0.25rem; + min-width: 0; + max-width: 100%; + } + + .slack-label { + position: relative; + z-index: 1; + } + + .slack-label a { + color: inherit; + text-decoration: underline; + word-break: break-all; + overflow-wrap: anywhere; + } + + .slack-label a:hover { + color: #338eda; } .description { @@ -504,14 +541,6 @@ background: rgba(255, 255, 255, 1); } - .hackatime-label { - display: inline-flex; - align-items: center; - gap: 0.375rem; - font-size: 0.8rem; - color: #8492a6; - } - .card-actions { display: flex; gap: 0.5rem;