From 7df019ec5eed6dc4e6e91af2f7994292ab43e9e4 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 11 Aug 2025 15:34:23 +0530 Subject: [PATCH 01/20] feat(db): add contributor aggregation tables --- packages/db/drizzle/0022_faulty_gauntlet.sql | 26 + packages/db/drizzle/meta/0022_snapshot.json | 2244 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/src/schema/contributions.ts | 85 + packages/db/src/schema/index.ts | 1 + 5 files changed, 2363 insertions(+) create mode 100644 packages/db/drizzle/0022_faulty_gauntlet.sql create mode 100644 packages/db/drizzle/meta/0022_snapshot.json create mode 100644 packages/db/src/schema/contributions.ts diff --git a/packages/db/drizzle/0022_faulty_gauntlet.sql b/packages/db/drizzle/0022_faulty_gauntlet.sql new file mode 100644 index 00000000..78ea443f --- /dev/null +++ b/packages/db/drizzle/0022_faulty_gauntlet.sql @@ -0,0 +1,26 @@ +CREATE TYPE "public"."contrib_provider" AS ENUM('github', 'gitlab');--> statement-breakpoint +CREATE TABLE "contrib_daily" ( + "user_id" uuid NOT NULL, + "provider" "contrib_provider" NOT NULL, + "date_utc" date NOT NULL, + "commits" integer DEFAULT 0 NOT NULL, + "prs" integer DEFAULT 0 NOT NULL, + "issues" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "contrib_totals" ( + "user_id" uuid NOT NULL, + "provider" "contrib_provider" NOT NULL, + "all_time" integer DEFAULT 0 NOT NULL, + "last_30d" integer DEFAULT 0 NOT NULL, + "last_365d" integer DEFAULT 0 NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX "contrib_daily_user_prov_day_uidx" ON "contrib_daily" USING btree ("user_id","provider","date_utc");--> statement-breakpoint +CREATE INDEX "contrib_daily_provider_day_idx" ON "contrib_daily" USING btree ("provider","date_utc");--> statement-breakpoint +CREATE INDEX "contrib_daily_user_day_idx" ON "contrib_daily" USING btree ("user_id","date_utc");--> statement-breakpoint +CREATE UNIQUE INDEX "contrib_totals_user_prov_uidx" ON "contrib_totals" USING btree ("user_id","provider");--> statement-breakpoint +CREATE INDEX "contrib_totals_user_idx" ON "contrib_totals" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0022_snapshot.json b/packages/db/drizzle/meta/0022_snapshot.json new file mode 100644 index 00000000..bddcd385 --- /dev/null +++ b/packages/db/drizzle/meta/0022_snapshot.json @@ -0,0 +1,2244 @@ +{ + "id": "6fb6ddee-a1e3-4b3c-9278-7f9ce841f33d", + "prevId": "814e9fd6-ff3a-4057-9b76-33a82118ba0f", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor": { + "name": "competitor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_repo_url": { + "name": "git_repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_host": { + "name": "git_host", + "type": "git_host", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_tag_relations": { + "name": "competitor_tag_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "competitor_id": { + "name": "competitor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_tag_relations_competitor_id_idx": { + "name": "competitor_tag_relations_competitor_id_idx", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_tag_relations_tag_id_idx": { + "name": "competitor_tag_relations_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_competitor_tag": { + "name": "unique_competitor_tag", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_tag_relations_competitor_id_competitor_id_fk": { + "name": "competitor_tag_relations_competitor_id_competitor_id_fk", + "tableFrom": "competitor_tag_relations", + "tableTo": "competitor", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_tag_relations_tag_id_category_tags_id_fk": { + "name": "competitor_tag_relations_tag_id_category_tags_id_fk", + "tableFrom": "competitor_tag_relations", + "tableTo": "category_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_repo_url": { + "name": "git_repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_host": { + "name": "git_host", + "type": "git_host", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approval_status": { + "name": "approval_status", + "type": "project_approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_looking_for_contributors": { + "name": "is_looking_for_contributors", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_looking_for_investors": { + "name": "is_looking_for_investors", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_hiring": { + "name": "is_hiring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_been_acquired": { + "name": "has_been_acquired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_repo_private": { + "name": "is_repo_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "acquired_by": { + "name": "acquired_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stars_count": { + "name": "stars_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars_updated_at": { + "name": "stars_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_updated_at": { + "name": "forks_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_status_id_idx": { + "name": "project_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_type_id_idx": { + "name": "project_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_stars_count_desc_idx": { + "name": "project_stars_count_desc_idx", + "columns": [ + { + "expression": "stars_count", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_forks_count_desc_idx": { + "name": "project_forks_count_desc_idx", + "columns": [ + { + "expression": "forks_count", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "project_status_id_category_project_statuses_id_fk": { + "name": "project_status_id_category_project_statuses_id_fk", + "tableFrom": "project", + "tableTo": "category_project_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_type_id_category_project_types_id_fk": { + "name": "project_type_id_category_project_types_id_fk", + "tableFrom": "project", + "tableTo": "category_project_types", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_acquired_by_competitor_id_fk": { + "name": "project_acquired_by_competitor_id_fk", + "tableFrom": "project", + "tableTo": "competitor", + "columnsFrom": [ + "acquired_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_git_repo_url_unique": { + "name": "project_git_repo_url_unique", + "nullsNotDistinct": false, + "columns": [ + "git_repo_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_tag_relations": { + "name": "project_tag_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_tag_relations_project_id_idx": { + "name": "project_tag_relations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_tag_relations_tag_id_idx": { + "name": "project_tag_relations_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_project_tag": { + "name": "unique_project_tag", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_tag_relations_project_id_project_id_fk": { + "name": "project_tag_relations_project_id_project_id_fk", + "tableFrom": "project_tag_relations", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tag_relations_tag_id_category_tags_id_fk": { + "name": "project_tag_relations_tag_id_category_tags_id_fk", + "tableFrom": "project_tag_relations", + "tableTo": "category_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_competitors": { + "name": "project_competitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alternative_competitor_type": { + "name": "alternative_competitor_type", + "type": "alternative_competitor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alternative_project_id": { + "name": "alternative_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "alternative_competitor_id": { + "name": "alternative_competitor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_competitors_project_id_idx": { + "name": "project_competitors_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_competitors_alt_project_id_idx": { + "name": "project_competitors_alt_project_id_idx", + "columns": [ + { + "expression": "alternative_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_competitors_alt_competitor_id_idx": { + "name": "project_competitors_alt_competitor_id_idx", + "columns": [ + { + "expression": "alternative_competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_competitors_project_id_project_id_fk": { + "name": "project_competitors_project_id_project_id_fk", + "tableFrom": "project_competitors", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_competitors_alternative_project_id_project_id_fk": { + "name": "project_competitors_alternative_project_id_project_id_fk", + "tableFrom": "project_competitors", + "tableTo": "project", + "columnsFrom": [ + "alternative_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_competitors_alternative_competitor_id_competitor_id_fk": { + "name": "project_competitors_alternative_competitor_id_competitor_id_fk", + "tableFrom": "project_competitors", + "tableTo": "competitor", + "columnsFrom": [ + "alternative_competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_claim": { + "name": "project_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification_details": { + "name": "verification_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_reason": { + "name": "error_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_claim_project_id_project_id_fk": { + "name": "project_claim_project_id_project_id_fk", + "tableFrom": "project_claim", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_claim_user_id_user_id_fk": { + "name": "project_claim_user_id_user_id_fk", + "tableFrom": "project_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_project_statuses": { + "name": "category_project_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_project_statuses_name_unique": { + "name": "category_project_statuses_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_project_types": { + "name": "category_project_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_project_types_name_unique": { + "name": "category_project_types_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_tags": { + "name": "category_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_tags_name_unique": { + "name": "category_tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_launch": { + "name": "project_launch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detailed_description": { + "name": "detailed_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_date": { + "name": "launch_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "launch_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_launch_project_id_idx": { + "name": "project_launch_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_launch_launch_date_idx": { + "name": "project_launch_launch_date_idx", + "columns": [ + { + "expression": "launch_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_launch_status_idx": { + "name": "project_launch_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_launch_project_id_project_id_fk": { + "name": "project_launch_project_id_project_id_fk", + "tableFrom": "project_launch", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_launch_project_id_unique": { + "name": "project_launch_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_vote": { + "name": "project_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_vote_project_id_idx": { + "name": "project_vote_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_vote_user_id_idx": { + "name": "project_vote_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_vote_project_id_project_id_fk": { + "name": "project_vote_project_id_project_id_fk", + "tableFrom": "project_vote", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_vote_user_id_user_id_fk": { + "name": "project_vote_user_id_user_id_fk", + "tableFrom": "project_vote", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_vote_project_id_user_id_unique": { + "name": "project_vote_project_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_comment": { + "name": "project_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_comment_project_id_idx": { + "name": "project_comment_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_comment_user_id_idx": { + "name": "project_comment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_comment_parent_id_idx": { + "name": "project_comment_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_comment_project_id_project_id_fk": { + "name": "project_comment_project_id_project_id_fk", + "tableFrom": "project_comment", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_comment_user_id_user_id_fk": { + "name": "project_comment_user_id_user_id_fk", + "tableFrom": "project_comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_comment_parent_id_project_comment_id_fk": { + "name": "project_comment_parent_id_project_comment_id_fk", + "tableFrom": "project_comment", + "tableTo": "project_comment", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_report": { + "name": "project_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_report_project_id_idx": { + "name": "project_report_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_report_user_id_idx": { + "name": "project_report_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_report_project_id_project_id_fk": { + "name": "project_report_project_id_project_id_fk", + "tableFrom": "project_report", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_report_user_id_user_id_fk": { + "name": "project_report_user_id_user_id_fk", + "tableFrom": "project_report", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_report_project_id_user_id_unique": { + "name": "project_report_project_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contrib_daily": { + "name": "contrib_daily", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "contrib_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "date_utc": { + "name": "date_utc", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "commits": { + "name": "commits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prs": { + "name": "prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "issues": { + "name": "issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contrib_daily_user_prov_day_uidx": { + "name": "contrib_daily_user_prov_day_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_utc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_daily_provider_day_idx": { + "name": "contrib_daily_provider_day_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_utc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_daily_user_day_idx": { + "name": "contrib_daily_user_day_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_utc", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contrib_totals": { + "name": "contrib_totals", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "contrib_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "all_time": { + "name": "all_time", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_30d": { + "name": "last_30d", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_365d": { + "name": "last_365d", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contrib_totals_user_prov_uidx": { + "name": "contrib_totals_user_prov_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_totals_user_idx": { + "name": "contrib_totals_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acquisition_type": { + "name": "acquisition_type", + "schema": "public", + "values": [ + "ipo", + "acquisition", + "other" + ] + }, + "public.project_approval_status": { + "name": "project_approval_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.alternative_competitor_type": { + "name": "alternative_competitor_type", + "schema": "public", + "values": [ + "project", + "competitor" + ] + }, + "public.git_host": { + "name": "git_host", + "schema": "public", + "values": [ + "github", + "gitlab" + ] + }, + "public.project_provider": { + "name": "project_provider", + "schema": "public", + "values": [ + "github", + "gitlab" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user", + "moderator" + ] + }, + "public.launch_status": { + "name": "launch_status", + "schema": "public", + "values": [ + "scheduled", + "live", + "ended" + ] + }, + "public.contrib_provider": { + "name": "contrib_provider", + "schema": "public", + "values": [ + "github", + "gitlab" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index e0594e96..fab04242 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -155,6 +155,13 @@ "when": 1753793933429, "tag": "0021_ambitious_purifiers", "breakpoints": true + }, + { + "idx": 22, + "version": "7", + "when": 1754906352408, + "tag": "0022_faulty_gauntlet", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/contributions.ts b/packages/db/src/schema/contributions.ts new file mode 100644 index 00000000..dd63c153 --- /dev/null +++ b/packages/db/src/schema/contributions.ts @@ -0,0 +1,85 @@ +// packages/db/src/schema/contributions.ts +import { + pgEnum, + pgTable, + uuid, + date, + integer, + timestamp, + uniqueIndex, + index, +} from "drizzle-orm/pg-core"; + +/** + * Keep provider values narrow and explicit. + * Name the enum specifically for this feature to avoid collisions. + */ +export const contribProvider = pgEnum("contrib_provider", ["github", "gitlab"]); + +/** + * Grain: 1 row per (user_id, provider, date_utc). + * Used for daily deltas and rolling-window math. + */ +export const contribDaily = pgTable( + "contrib_daily", + { + userId: uuid("user_id").notNull(), // FK to users.id (kept loose here to avoid cross-package import loops) + provider: contribProvider("provider").notNull(), + dateUtc: date("date_utc").notNull(), // UTC calendar day + + commits: integer("commits").notNull().default(0), + prs: integer("prs").notNull().default(0), + issues: integer("issues").notNull().default(0), + + // Bookkeeping + createdAt: timestamp("created_at", { withTimezone: true }) + .notNull() + .defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + // Idempotency & fast upserts + uniqueIndex("contrib_daily_user_prov_day_uidx").on( + t.userId, + t.provider, + t.dateUtc, + ), + // Helpful for rebuilds or per-day scans + index("contrib_daily_provider_day_idx").on(t.provider, t.dateUtc), + index("contrib_daily_user_day_idx").on(t.userId, t.dateUtc), + ], +); + +/** + * Pre-aggregated, for fast reads: + * - allTime = lifetime public contributions + * - last30d, last365d maintained via rolling updates + */ +export const contribTotals = pgTable( + "contrib_totals", + { + userId: uuid("user_id").notNull(), + provider: contribProvider("provider").notNull(), + + allTime: integer("all_time").notNull().default(0), + last30d: integer("last_30d").notNull().default(0), + last365d: integer("last_365d").notNull().default(0), + + updatedAt: timestamp("updated_at", { withTimezone: true }) + .notNull() + .defaultNow(), + }, + (t) => [ + uniqueIndex("contrib_totals_user_prov_uidx").on(t.userId, t.provider), + index("contrib_totals_user_idx").on(t.userId), + ], +); + +/** + * NOTE on FKs: + * If your users table is exported in this package as `users`, + * you may add `.references(() => users.id)` to the `userId` columns. + * We keep it decoupled here to avoid cross-file import cycles in monorepos. + */ diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4797559c..dd1e135a 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -11,3 +11,4 @@ export * from './project-votes'; export * from './project-comments'; export * from './project-claims'; export * from './project-reports'; +export * from './contributions'; From 78bebcbf223f6f4d586ce3f1b23b64b5af1f4bff Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 11 Aug 2025 18:24:16 +0530 Subject: [PATCH 02/20] feat(cron): add a authorized cron function --- packages/db/src/schema/contributions.ts | 7 ------- packages/env/src/verifyCron.ts | 9 +++++++++ 2 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 packages/env/src/verifyCron.ts diff --git a/packages/db/src/schema/contributions.ts b/packages/db/src/schema/contributions.ts index dd63c153..015f4c95 100644 --- a/packages/db/src/schema/contributions.ts +++ b/packages/db/src/schema/contributions.ts @@ -76,10 +76,3 @@ export const contribTotals = pgTable( index("contrib_totals_user_idx").on(t.userId), ], ); - -/** - * NOTE on FKs: - * If your users table is exported in this package as `users`, - * you may add `.references(() => users.id)` to the `userId` columns. - * We keep it decoupled here to avoid cross-file import cycles in monorepos. - */ diff --git a/packages/env/src/verifyCron.ts b/packages/env/src/verifyCron.ts new file mode 100644 index 00000000..d4452e46 --- /dev/null +++ b/packages/env/src/verifyCron.ts @@ -0,0 +1,9 @@ +import { env } from './server'; + +export function isCronAuthorized(headerValue: string | null | undefined) { + if (!headerValue) return false; + const token = headerValue.startsWith('Bearer ') + ? headerValue.slice('Bearer '.length).trim() + : headerValue.trim(); + return token.length > 0 && token === env.CRON_SECRET; +} From 3e403d15097a62f37b33766be7fa684dc9d23116 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 11 Aug 2025 18:39:54 +0530 Subject: [PATCH 03/20] feat(providers/github): add GraphQL contributions client (Commit 3/n for #142) --- packages/api/src/providers/github.ts | 188 +++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/api/src/providers/github.ts diff --git a/packages/api/src/providers/github.ts b/packages/api/src/providers/github.ts new file mode 100644 index 00000000..d384878f --- /dev/null +++ b/packages/api/src/providers/github.ts @@ -0,0 +1,188 @@ +const GITHUB_GQL_ENDPOINT = "https://api.github.com/graphql"; + +import { z } from "zod/v4"; + +const RateLimitSchema = z + .object({ + cost: z.number(), + remaining: z.number(), + resetAt: z.string(), // ISO datetime + }) + .optional(); + +const ContributionsSchema = z.object({ + restrictedContributionsCount: z.number().optional(), + totalCommitContributions: z.number(), + totalPullRequestContributions: z.number(), + totalIssueContributions: z.number(), +}); + +const UserContribsSchema = z.object({ + id: z.string(), + login: z.string(), + contributionsCollection: ContributionsSchema, +}); + +const GraphQLDataSchema = z.object({ + user: UserContribsSchema.nullable(), + rateLimit: RateLimitSchema, +}); + +const GraphQLResponseSchema = z.object({ + data: GraphQLDataSchema.optional(), + errors: z + .array( + z.object({ + message: z.string(), + type: z.string().optional(), + path: z.array(z.union([z.string(), z.number()])).optional(), + }), + ) + .optional(), +}); + +export type GithubContributionTotals = { + login: string; + commits: number; + prs: number; + issues: number; + rateLimit?: { + cost: number; + remaining: number; + resetAt: string; + }; +}; + +export type DateLike = string | Date; +export type DateRange = { from: DateLike; to: DateLike }; + +function toIso8601(input: DateLike): string { + if (input instanceof Date) return input.toISOString(); + const maybe = new Date(input); + return isNaN(maybe.getTime()) ? String(input) : maybe.toISOString(); +} + +function startOfUtcDay(d: DateLike): Date { + const date = d instanceof Date ? new Date(d) : new Date(d); + return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)); +} + +function addDaysUTC(d: Date, days: number): Date { + const copy = new Date(d); + copy.setUTCDate(copy.getUTCDate() + days); + return copy; +} + +async function githubGraphQLRequest({ + token, + query, + variables, +}: { + token: string; + query: string; + variables: Record; +}): Promise { + if (!token) { + throw new Error("GitHub GraphQL token is required. Pass GITHUB_TOKEN."); + } + + const res = await fetch(GITHUB_GQL_ENDPOINT, { + method: "POST", + headers: { + Authorization: `bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ query, variables }), + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(`GitHub GraphQL HTTP ${res.status}: ${text || res.statusText}`); + } + + const json = (await res.json()) as unknown; + const parsed = GraphQLResponseSchema.safeParse(json); + if (!parsed.success) { + throw new Error("Unexpected GitHub GraphQL response shape"); + } + + if (parsed.data.errors?.length) { + const msgs = parsed.data.errors.map((e) => e.message).join("; "); + throw new Error(`GitHub GraphQL error(s): ${msgs}`); + } + + const data = parsed.data.data; + if (!data) { + throw new Error("GitHub GraphQL returned no data"); + } + + return data as T; +} + +export async function getGithubContributionTotals( + login: string, + range: DateRange, + token: string, +): Promise { + const query = /* GraphQL */ ` + query($login: String!, $from: DateTime!, $to: DateTime!) { + user(login: $login) { + id + login + contributionsCollection(from: $from, to: $to) { + restrictedContributionsCount + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + } + rateLimit { + cost + remaining + resetAt + } + } + `; + + const variables = { + login, + from: toIso8601(range.from), + to: toIso8601(range.to), + }; + + const data = await githubGraphQLRequest>({ + token, + query, + variables, + }); + + if (!data.user) { + // If the user/login doesn't exist or is not visible, return zeros. + return { + login, + commits: 0, + prs: 0, + issues: 0, + rateLimit: data.rateLimit ? { ...data.rateLimit } : undefined, + }; + } + + const cc = data.user.contributionsCollection; + return { + login: data.user.login, + commits: cc.totalCommitContributions, + prs: cc.totalPullRequestContributions, + issues: cc.totalIssueContributions, + rateLimit: data.rateLimit ? { ...data.rateLimit } : undefined, + }; +} + +export async function getGithubContributionTotalsForDay( + login: string, + dayUtc: DateLike, + token: string, +): Promise { + const start = startOfUtcDay(dayUtc); + const end = addDaysUTC(start, 1); + return getGithubContributionTotals(login, { from: start, to: end }, token); +} From 1e2b3524232105615508bb56201b344dd93cf168 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Tue, 12 Aug 2025 16:56:01 +0530 Subject: [PATCH 04/20] feat(providers/gitlab): add Events client with timeout, Retry-After, and early-stop (Commit 3/n for #142) --- packages/api/src/providers/gitlab.ts | 257 +++++++++++++++++++++++++++ 1 file changed, 257 insertions(+) create mode 100644 packages/api/src/providers/gitlab.ts diff --git a/packages/api/src/providers/gitlab.ts b/packages/api/src/providers/gitlab.ts new file mode 100644 index 00000000..fbae6f63 --- /dev/null +++ b/packages/api/src/providers/gitlab.ts @@ -0,0 +1,257 @@ +import { z } from 'zod/v4'; + +export type DateLike = string | Date; +export type DateRange = { from: DateLike; to: DateLike }; + +export type GitlabContributionTotals = { + username: string; + commits: number; + mrs: number; + issues: number; + meta?: { + pagesFetched: number; + perPage: number; + }; +}; + +const GitlabUserSchema = z.object({ + id: z.number(), + username: z.string(), + name: z.string().optional(), +}); + +const GitlabEventSchema = z.object({ + id: z.number(), + action_name: z.string().optional(), + target_type: z.string().nullable().optional(), + created_at: z.string(), + push_data: z + .object({ + commit_count: z.number().optional(), + }) + .optional(), +}); + + +function cleanBaseUrl(url: string): string { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +function toIso8601(input: DateLike): string { + if (input instanceof Date) return input.toISOString(); + const d = new Date(input); + return Number.isNaN(d.getTime()) ? String(input) : d.toISOString(); +} + +function startOfUtcDay(d: DateLike): Date { + const date = d instanceof Date ? new Date(d) : new Date(d); + return new Date( + Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0), + ); +} + +function addDaysUTC(d: Date, days: number): Date { + const copy = new Date(d); + copy.setUTCDate(copy.getUTCDate() + days); + return copy; +} + +function sleep(ms: number) { + return new Promise((r) => setTimeout(r, ms)); +} + + +/** + * GET wrapper with: + * - PRIVATE-TOKEN header (preferred) or Authorization: Bearer + * - 15s timeout + * - 429 handling + honor Retry-After (1..10s clamp), one retry + */ +async function glGet( + baseUrl: string, + path: string, + token?: string, + query?: Record, +): Promise { + const u = new URL(cleanBaseUrl(baseUrl) + path); + if (query) { + for (const [k, v] of Object.entries(query)) { + if (v !== undefined && v !== null) u.searchParams.set(k, String(v)); + } + } + + const headers: Record = { 'Content-Type': 'application/json' }; + if (token) { + headers['PRIVATE-TOKEN'] = token; + headers['Authorization'] = `Bearer ${token}`; + } + + const controller = new AbortController(); + const to = setTimeout(() => controller.abort(), 15_000); + + try { + let res = await fetch(u.toString(), { headers, signal: controller.signal }); + + if (res.status === 429) { + const retryAfter = Number(res.headers.get('Retry-After') || 1); + const waitSec = Math.min(Math.max(retryAfter, 1), 10); + await sleep(waitSec * 1000); + res = await fetch(u.toString(), { headers, signal: controller.signal }); + } + + if (!res.ok) { + const text = await res.text().catch(() => ''); + throw new Error(`GitLab HTTP ${res.status}: ${text || res.statusText} (${u})`); + } + + return res; + } finally { + clearTimeout(to); + } +} + + +export async function resolveGitlabUserId( + username: string, + baseUrl: string, + token?: string, +): Promise<{ id: number; username: string } | null> { + const res = await glGet(baseUrl, `/api/v4/users`, token, { username, per_page: 1 }); + const json = await res.json(); + const arr = z.array(GitlabUserSchema).parse(json); + if (!arr.length) return null; + return { id: arr[0]!.id, username: arr[0]!.username }; +} + +type FetchEventsOptions = { + afterIso: string; + beforeIso: string; + perPage?: number; + maxPages?: number; +}; + +async function fetchUserEventsByWindow( + userId: number, + baseUrl: string, + token: string | undefined, + opts: FetchEventsOptions, +): Promise<{ events: z.infer[]; pagesFetched: number; perPage: number }> { + const perPage = Math.min(Math.max(opts.perPage ?? 100, 20), 100); + const maxPages = Math.min(Math.max(opts.maxPages ?? 10, 1), 50); + const lowerMs = new Date(opts.afterIso).getTime(); + const upperMs = new Date(opts.beforeIso).getTime(); + + let page = 1; + let pagesFetched = 0; + const out: z.infer[] = []; + + while (true) { + const res = await glGet(baseUrl, `/api/v4/users/${userId}/events`, token, { + after: opts.afterIso, + before: opts.beforeIso, + per_page: perPage, + page, + scope: 'all', + }); + pagesFetched++; + + const json = await res.json(); + const events = z.array(GitlabEventSchema).parse(json); + + const filtered = events.filter((e) => { + const t = new Date(e.created_at).getTime(); + return t >= lowerMs && t < upperMs; + }); + out.push(...filtered); + + if ( + filtered.length === 0 && + events.length > 0 && + Math.max(...events.map((e) => new Date(e.created_at).getTime())) < lowerMs + ) { + break; + } + + const nextPageHeader = res.headers.get('X-Next-Page'); + const hasNext = !!nextPageHeader && nextPageHeader !== '0'; + if (!hasNext) break; + + const next = Number(nextPageHeader); + if (!Number.isFinite(next) || next <= 0) break; + if (next > maxPages) break; + + page = next; + } + + return { events: out, pagesFetched, perPage }; +} + +function reduceContributionCounts(events: z.infer[]) { + let commits = 0; + let mrs = 0; + let issues = 0; + + for (const e of events) { + const target = e.target_type ?? undefined; + const action = (e.action_name || '').toLowerCase(); + + if (e.push_data && typeof e.push_data.commit_count === 'number') { + if (action.includes('push')) { + commits += Math.max(0, e.push_data.commit_count || 0); + continue; + } + } + + if (target === 'MergeRequest' && action === 'opened') { + mrs += 1; + continue; + } + + if (target === 'Issue' && action === 'opened') { + issues += 1; + continue; + } + } + + return { commits, mrs, issues }; +} + +export async function getGitlabContributionTotals( + username: string, + range: DateRange, + baseUrl: string, + token?: string, +): Promise { + const fromIso = toIso8601(range.from); + const toIso = toIso8601(range.to); + + const user = await resolveGitlabUserId(username, baseUrl, token); + if (!user) { + return { username, commits: 0, mrs: 0, issues: 0 }; + } + + const { events, pagesFetched, perPage } = await fetchUserEventsByWindow(user.id, baseUrl, token, { + afterIso: fromIso, + beforeIso: toIso, + perPage: 100, + maxPages: 25, + }); + + const totals = reduceContributionCounts(events); + return { + username: user.username, + ...totals, + meta: { pagesFetched, perPage }, + }; +} + +export async function getGitlabContributionTotalsForDay( + username: string, + dayUtc: DateLike, + baseUrl: string, + token?: string, +): Promise { + const start = startOfUtcDay(dayUtc); + const end = addDaysUTC(start, 1); + return getGitlabContributionTotals(username, { from: start, to: end }, baseUrl, token); +} From f16513831b4bda354083f3d68111e02d75829523 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Tue, 12 Aug 2025 17:22:23 +0530 Subject: [PATCH 05/20] =?UTF-8?q?perf(leaderboard):=20optimize=20aggregato?= =?UTF-8?q?r=20=E2=80=94=20single=20totals=20recompute=20+=20concurrency?= =?UTF-8?q?=20(Commit=204/n=20for=20#142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/leaderboard/aggregator.ts | 256 +++++++++++++++++++++ 1 file changed, 256 insertions(+) create mode 100644 packages/api/src/leaderboard/aggregator.ts diff --git a/packages/api/src/leaderboard/aggregator.ts b/packages/api/src/leaderboard/aggregator.ts new file mode 100644 index 00000000..4725062e --- /dev/null +++ b/packages/api/src/leaderboard/aggregator.ts @@ -0,0 +1,256 @@ +// packages/api/src/leaderboard/aggregator.ts +/** + * Aggregator (optimized): + * - Per-day provider fetches → upsert contrib_daily (idempotent) + * - Recompute contrib_totals once at the end for each provider + * - Optional concurrency for multi-day ranges + */ + +import { and, eq, gte, lt, sql } from "drizzle-orm"; +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; + +import { getGithubContributionTotalsForDay } from "../providers/github"; +import { getGitlabContributionTotalsForDay } from "../providers/gitlab"; + +// Ensure this path matches your db package export +import { + contribDaily, + contribTotals, + contribProvider, +} from "@workspace/db/schema"; + +/* ------------------------------ Types ------------------------------ */ + +export type Provider = (typeof contribProvider.enumValues)[number]; + +export type AggregatorDeps = { db: PostgresJsDatabase }; + +export type RefreshUserDayArgs = { + userId: string; + githubLogin?: string | null; + gitlabUsername?: string | null; + dayUtc: Date | string; + githubToken?: string; + gitlabToken?: string; + gitlabBaseUrl?: string; + /** When true, skip totals recompute (caller will recompute once at the end) */ + skipTotalsRecompute?: boolean; +}; + +type GithubDayResult = { commits: number; prs: number; issues: number }; +type GitlabDayResult = { commits: number; mrs: number; issues: number }; +type RefreshResults = { github?: GithubDayResult; gitlab?: GitlabDayResult }; + +/* ------------------------------ Date helpers ------------------------------ */ + +function startOfUtcDay(d: Date | string): Date { + const x = d instanceof Date ? new Date(d) : new Date(d); + return new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate(), 0, 0, 0, 0)); +} +function addDaysUTC(d: Date, days: number): Date { + const x = new Date(d); + x.setUTCDate(x.getUTCDate() + days); + return x; +} +function ymdUTC(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${dd}`; +} + +/* ------------------------------ DB helpers ------------------------------ */ + +async function upsertDaily( + db: PostgresJsDatabase, + args: { userId: string; provider: Provider; day: Date; commits: number; prs: number; issues: number }, +): Promise { + const dayStr = ymdUTC(args.day); + await db + .insert(contribDaily) + .values({ + userId: args.userId, + provider: args.provider, + dateUtc: dayStr, + commits: args.commits, + prs: args.prs, + issues: args.issues, + updatedAt: sql`now()`, + }) + .onConflictDoUpdate({ + target: [contribDaily.userId, contribDaily.provider, contribDaily.dateUtc], + set: { + commits: args.commits, + prs: args.prs, + issues: args.issues, + updatedAt: sql`now()`, + }, + }); +} + +async function sumWindow( + db: PostgresJsDatabase, + userId: string, + provider: Provider, + fromInclusive: Date, + toExclusive: Date, +): Promise { + const [row] = await db + .select({ + total: sql`coalesce(sum(${contribDaily.commits} + ${contribDaily.prs} + ${contribDaily.issues}), 0)`, + }) + .from(contribDaily) + .where( + and( + eq(contribDaily.userId, userId), + eq(contribDaily.provider, provider), + gte(contribDaily.dateUtc, ymdUTC(fromInclusive)), + lt(contribDaily.dateUtc, ymdUTC(toExclusive)), + ), + ); + return (row?.total ?? 0) as number; +} + +async function sumAllTime(db: PostgresJsDatabase, userId: string, provider: Provider): Promise { + const [row] = await db + .select({ + total: sql`coalesce(sum(${contribDaily.commits} + ${contribDaily.prs} + ${contribDaily.issues}), 0)`, + }) + .from(contribDaily) + .where(and(eq(contribDaily.userId, userId), eq(contribDaily.provider, provider))); + return (row?.total ?? 0) as number; +} + +async function upsertTotals( + db: PostgresJsDatabase, + args: { userId: string; provider: Provider; allTime: number; last30d: number; last365d: number }, +): Promise { + await db + .insert(contribTotals) + .values({ + userId: args.userId, + provider: args.provider, + allTime: args.allTime, + last30d: args.last30d, + last365d: args.last365d, + updatedAt: sql`now()`, + }) + .onConflictDoUpdate({ + target: [contribTotals.userId, contribTotals.provider], + set: { + allTime: args.allTime, + last30d: args.last30d, + last365d: args.last365d, + updatedAt: sql`now()`, + }, + }); +} + +async function recomputeProviderTotals( + db: PostgresJsDatabase, + userId: string, + provider: Provider, + now: Date = new Date(), +): Promise<{ allTime: number; last30d: number; last365d: number }> { + const today = startOfUtcDay(now); + const tomorrow = addDaysUTC(today, 1); + const d30 = addDaysUTC(today, -30); + const d365 = addDaysUTC(today, -365); + + const [last30d, last365d, allTime] = await Promise.all([ + sumWindow(db, userId, provider, d30, tomorrow), + sumWindow(db, userId, provider, d365, tomorrow), + sumAllTime(db, userId, provider), + ]); + + await upsertTotals(db, { userId, provider, allTime, last30d, last365d }); + return { allTime, last30d, last365d }; +} + +/* ------------------------------ Public API ------------------------------ */ + +export async function refreshUserDay( + deps: AggregatorDeps, + args: RefreshUserDayArgs, +): Promise<{ day: string; updatedProviders: string[]; results: RefreshResults }> { + const db = deps.db; + const day = startOfUtcDay(args.dayUtc); + const results: RefreshResults = {}; + + // GitHub + if (args.githubLogin && args.githubLogin.trim() && args.githubToken) { + const gh = await getGithubContributionTotalsForDay(args.githubLogin.trim(), day, args.githubToken); + results.github = { commits: gh.commits, prs: gh.prs, issues: gh.issues }; + await upsertDaily(db, { userId: args.userId, provider: "github", day, commits: gh.commits, prs: gh.prs, issues: gh.issues }); + if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, "github", day); + } + + // GitLab + if (args.gitlabUsername && args.gitlabUsername.trim()) { + const base = args.gitlabBaseUrl?.trim() || "https://gitlab.com"; + const gl = await getGitlabContributionTotalsForDay(args.gitlabUsername.trim(), day, base, args.gitlabToken); + results.gitlab = { commits: gl.commits, mrs: gl.mrs, issues: gl.issues }; + await upsertDaily(db, { userId: args.userId, provider: "gitlab", day, commits: gl.commits, prs: gl.mrs, issues: gl.issues }); + if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, "gitlab", day); + } + + return { day: ymdUTC(day), updatedProviders: Object.keys(results), results }; +} + +/** tiny concurrency limiter */ +async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { + const ret: R[] = []; + let i = 0; + const workers = Array.from({ length: Math.max(1, limit) }, async () => { + while (i < items.length) { + const idx = i++; + ret[idx] = await fn(items[idx]!); + } + }); + await Promise.all(workers); + return ret; +} + +/** + * Refresh a range of UTC days (inclusive). + * - Skips per-day recompute; recomputes once at the end for each provider. + * - `concurrency` controls how many days are processed in parallel (default 4). + */ +export async function refreshUserDayRange( + deps: AggregatorDeps, + args: Omit & { + fromDayUtc: Date | string; + toDayUtc: Date | string; + concurrency?: number; + }, +): Promise<{ daysRefreshed: string[] }> { + const from = startOfUtcDay(args.fromDayUtc); + const toInclusive = startOfUtcDay(args.toDayUtc); + const days: Date[] = []; + for (let d = new Date(from); d.getTime() <= toInclusive.getTime(); d = addDaysUTC(d, 1)) days.push(new Date(d)); + + const daysStr: string[] = []; + + const concurrency = Math.max(1, Math.min(args.concurrency ?? 4, 10)); // be gentle with rate limits + + await mapWithConcurrency(days, concurrency, async (d) => { + const res = await refreshUserDay(deps, { + ...args, + dayUtc: d, + skipTotalsRecompute: true, // defer recompute to the end + }); + daysStr.push(res.day); + }); + + // Single recompute at the end (fast) + if (args.githubLogin && args.githubToken) { + await recomputeProviderTotals(deps.db, args.userId, "github", new Date()); + } + if (args.gitlabUsername) { + await recomputeProviderTotals(deps.db, args.userId, "gitlab", new Date()); + } + + // Sort for pretty output + daysStr.sort(); + return { daysRefreshed: daysStr }; +} From 87598d2d673bce7e2dbff2726d274518b6ce1da2 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Wed, 13 Aug 2025 00:37:26 +0530 Subject: [PATCH 06/20] fix(leaderboard/redis): robust ZRANGE parsing + add lb-sync-one script (Commit 5/n for #142) --- packages/api/src/leaderboard/aggregator.ts | 28 +--- packages/api/src/leaderboard/redis.ts | 106 ++++++++++++++ packages/api/src/redis/client.ts | 7 + packages/api/src/redis/lock.ts | 32 +++++ packages/api/src/utils/cache.ts | 8 +- packages/api/src/utils/rate-limit.ts | 8 +- scripts/ag-test.ts | 158 +++++++++++++++++++++ scripts/lb-sync-one.ts | 42 ++++++ 8 files changed, 349 insertions(+), 40 deletions(-) create mode 100644 packages/api/src/leaderboard/redis.ts create mode 100644 packages/api/src/redis/client.ts create mode 100644 packages/api/src/redis/lock.ts create mode 100644 scripts/ag-test.ts create mode 100644 scripts/lb-sync-one.ts diff --git a/packages/api/src/leaderboard/aggregator.ts b/packages/api/src/leaderboard/aggregator.ts index 4725062e..7efc4808 100644 --- a/packages/api/src/leaderboard/aggregator.ts +++ b/packages/api/src/leaderboard/aggregator.ts @@ -1,25 +1,15 @@ -// packages/api/src/leaderboard/aggregator.ts -/** - * Aggregator (optimized): - * - Per-day provider fetches → upsert contrib_daily (idempotent) - * - Recompute contrib_totals once at the end for each provider - * - Optional concurrency for multi-day ranges - */ - import { and, eq, gte, lt, sql } from "drizzle-orm"; import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { getGithubContributionTotalsForDay } from "../providers/github"; import { getGitlabContributionTotalsForDay } from "../providers/gitlab"; -// Ensure this path matches your db package export import { contribDaily, contribTotals, contribProvider, } from "@workspace/db/schema"; -/* ------------------------------ Types ------------------------------ */ export type Provider = (typeof contribProvider.enumValues)[number]; @@ -33,7 +23,6 @@ export type RefreshUserDayArgs = { githubToken?: string; gitlabToken?: string; gitlabBaseUrl?: string; - /** When true, skip totals recompute (caller will recompute once at the end) */ skipTotalsRecompute?: boolean; }; @@ -41,7 +30,6 @@ type GithubDayResult = { commits: number; prs: number; issues: number }; type GitlabDayResult = { commits: number; mrs: number; issues: number }; type RefreshResults = { github?: GithubDayResult; gitlab?: GitlabDayResult }; -/* ------------------------------ Date helpers ------------------------------ */ function startOfUtcDay(d: Date | string): Date { const x = d instanceof Date ? new Date(d) : new Date(d); @@ -59,7 +47,6 @@ function ymdUTC(d: Date): string { return `${y}-${m}-${dd}`; } -/* ------------------------------ DB helpers ------------------------------ */ async function upsertDaily( db: PostgresJsDatabase, @@ -167,7 +154,6 @@ async function recomputeProviderTotals( return { allTime, last30d, last365d }; } -/* ------------------------------ Public API ------------------------------ */ export async function refreshUserDay( deps: AggregatorDeps, @@ -177,7 +163,6 @@ export async function refreshUserDay( const day = startOfUtcDay(args.dayUtc); const results: RefreshResults = {}; - // GitHub if (args.githubLogin && args.githubLogin.trim() && args.githubToken) { const gh = await getGithubContributionTotalsForDay(args.githubLogin.trim(), day, args.githubToken); results.github = { commits: gh.commits, prs: gh.prs, issues: gh.issues }; @@ -185,7 +170,6 @@ export async function refreshUserDay( if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, "github", day); } - // GitLab if (args.gitlabUsername && args.gitlabUsername.trim()) { const base = args.gitlabBaseUrl?.trim() || "https://gitlab.com"; const gl = await getGitlabContributionTotalsForDay(args.gitlabUsername.trim(), day, base, args.gitlabToken); @@ -197,7 +181,6 @@ export async function refreshUserDay( return { day: ymdUTC(day), updatedProviders: Object.keys(results), results }; } -/** tiny concurrency limiter */ async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { const ret: R[] = []; let i = 0; @@ -211,11 +194,6 @@ async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) return ret; } -/** - * Refresh a range of UTC days (inclusive). - * - Skips per-day recompute; recomputes once at the end for each provider. - * - `concurrency` controls how many days are processed in parallel (default 4). - */ export async function refreshUserDayRange( deps: AggregatorDeps, args: Omit & { @@ -231,18 +209,17 @@ export async function refreshUserDayRange( const daysStr: string[] = []; - const concurrency = Math.max(1, Math.min(args.concurrency ?? 4, 10)); // be gentle with rate limits + const concurrency = Math.max(1, Math.min(args.concurrency ?? 4, 10)); await mapWithConcurrency(days, concurrency, async (d) => { const res = await refreshUserDay(deps, { ...args, dayUtc: d, - skipTotalsRecompute: true, // defer recompute to the end + skipTotalsRecompute: true, }); daysStr.push(res.day); }); - // Single recompute at the end (fast) if (args.githubLogin && args.githubToken) { await recomputeProviderTotals(deps.db, args.userId, "github", new Date()); } @@ -250,7 +227,6 @@ export async function refreshUserDayRange( await recomputeProviderTotals(deps.db, args.userId, "gitlab", new Date()); } - // Sort for pretty output daysStr.sort(); return { daysRefreshed: daysStr }; } diff --git a/packages/api/src/leaderboard/redis.ts b/packages/api/src/leaderboard/redis.ts new file mode 100644 index 00000000..47995913 --- /dev/null +++ b/packages/api/src/leaderboard/redis.ts @@ -0,0 +1,106 @@ +import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import { eq } from "drizzle-orm"; +import { contribTotals } from "@workspace/db/schema"; +import { redis } from "../redis/client"; + +type ProviderKey = "github" | "gitlab"; +type WindowKey = "all" | "30d" | "365d"; + +const COMBINED_KEYS = { + all: "lb:total:all", + "30d": "lb:total:30d", + "365d": "lb:total:365d", +} as const; + +const PROVIDER_KEYS: Record> = { + github: { + all: "lb:github:all", + "30d": "lb:github:30d", + "365d": "lb:github:365d", + }, + gitlab: { + all: "lb:gitlab:all", + "30d": "lb:gitlab:30d", + "365d": "lb:gitlab:365d", + }, +} as const; + +export async function syncUserLeaderboards(db: PostgresJsDatabase, userId: string): Promise { + const rows = await db + .select({ + provider: contribTotals.provider, + allTime: contribTotals.allTime, + last30d: contribTotals.last30d, + last365d: contribTotals.last365d, + }) + .from(contribTotals) + .where(eq(contribTotals.userId, userId)); + + let combinedAll = 0; + let combined30 = 0; + let combined365 = 0; + + const pipe = redis.pipeline(); + + for (const r of rows) { + const p = r.provider as ProviderKey; + const all = Number(r.allTime || 0); + const d30 = Number(r.last30d || 0); + const d365 = Number(r.last365d || 0); + + combinedAll += all; + combined30 += d30; + combined365 += d365; + + const k = PROVIDER_KEYS[p]; + pipe.zadd(k.all, { score: all, member: userId }); + pipe.zadd(k["30d"], { score: d30, member: userId }); + pipe.zadd(k["365d"], { score: d365, member: userId }); + } + + // Always write combined sets (even zeros) for stable ordering + pipe.zadd(COMBINED_KEYS.all, { score: combinedAll, member: userId }); + pipe.zadd(COMBINED_KEYS["30d"], { score: combined30, member: userId }); + pipe.zadd(COMBINED_KEYS["365d"], { score: combined365, member: userId }); + + await pipe.exec(); +} + +export async function removeUserFromLeaderboards(userId: string): Promise { + const keys = [ + COMBINED_KEYS.all, + COMBINED_KEYS["30d"], + COMBINED_KEYS["365d"], + ...Object.values(PROVIDER_KEYS).flatMap((k) => [k.all, k["30d"], k["365d"]]), + ]; + const pipe = redis.pipeline(); + for (const k of keys) pipe.zrem(k, userId); + await pipe.exec(); +} + +export async function topCombined(limit = 10, window: WindowKey = "30d") { + const key = COMBINED_KEYS[window]; + const res = await redis.zrange(key, 0, limit - 1, { + rev: true, + withScores: true, + }); + + if (Array.isArray(res) && res.length && typeof res[0] === "object" && res[0] && "member" in res[0]) { + return (res as Array<{ member: string; score: number | string }>).map(({ member, score }) => ({ + userId: member, + score: typeof score === "string" ? Number(score) : Number(score ?? 0), + })); + } + + if (Array.isArray(res)) { + const out: Array<{ userId: string; score: number }> = []; + for (let i = 0; i < res.length; i += 2) { + const member = String(res[i] ?? ""); + const score = Number(res[i + 1] ?? 0); + out.push({ userId: member, score }); + } + return out; + } + + return []; +} diff --git a/packages/api/src/redis/client.ts b/packages/api/src/redis/client.ts new file mode 100644 index 00000000..4c56ef90 --- /dev/null +++ b/packages/api/src/redis/client.ts @@ -0,0 +1,7 @@ +import { Redis } from "@upstash/redis"; +import { env } from "@workspace/env/server"; + +export const redis = new Redis({ + url: env.UPSTASH_REDIS_REST_URL, + token: env.UPSTASH_REDIS_REST_TOKEN, +}); diff --git a/packages/api/src/redis/lock.ts b/packages/api/src/redis/lock.ts new file mode 100644 index 00000000..f0e853a4 --- /dev/null +++ b/packages/api/src/redis/lock.ts @@ -0,0 +1,32 @@ +import { redis } from './client'; + +export async function acquireLock(key: string, ttlSec = 60): Promise { + const ok = await redis.set(key, '1', { nx: true, ex: ttlSec }); + return ok === 'OK'; +} + +export async function releaseLock(key: string): Promise { + try { + await redis.del(key); + } catch { + //ignore + } +} + +export async function withLock(key: string, ttlSec: number, fn: () => Promise): Promise { + const got = await acquireLock(key, ttlSec); + if (!got) throw new Error(`Lock in use: ${key}`); + try { + return await fn(); + } finally { + await releaseLock(key); + } +} + +export function dailyLockKey(provider: 'github' | 'gitlab', userId: string, yyyymmdd: string) { + return `lock:daily:${provider}:${userId}:${yyyymmdd}`; +} + +export function backfillLockKey(provider: 'github' | 'gitlab', userId: string) { + return `lock:backfill:${provider}:${userId}`; +} diff --git a/packages/api/src/utils/cache.ts b/packages/api/src/utils/cache.ts index f5c396f3..21837e72 100644 --- a/packages/api/src/utils/cache.ts +++ b/packages/api/src/utils/cache.ts @@ -1,10 +1,4 @@ -import { env } from '@workspace/env/server'; -import { Redis } from '@upstash/redis'; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); +import { redis } from "../redis/client"; interface CacheOptions { ttl?: number; diff --git a/packages/api/src/utils/rate-limit.ts b/packages/api/src/utils/rate-limit.ts index 3f3aa9bd..b402e1c5 100644 --- a/packages/api/src/utils/rate-limit.ts +++ b/packages/api/src/utils/rate-limit.ts @@ -1,11 +1,5 @@ import { Ratelimit } from '@upstash/ratelimit'; -import { env } from '@workspace/env/server'; -import { Redis } from '@upstash/redis'; - -const redis = new Redis({ - url: env.UPSTASH_REDIS_REST_URL, - token: env.UPSTASH_REDIS_REST_TOKEN, -}); +import { redis } from "../redis/client"; const ratelimitCache: Record = {}; diff --git a/scripts/ag-test.ts b/scripts/ag-test.ts new file mode 100644 index 00000000..004192ed --- /dev/null +++ b/scripts/ag-test.ts @@ -0,0 +1,158 @@ +/** + * Aggregator test script + * + * Usage: + * bun scripts/agg-test.ts --user-id 11111111-1111-1111-1111-111111111111 --gh yourGithubLogin --gl yourGitLabUsername --days 30 + * bun scripts/agg-test.ts --user-id 11111111-1111-1111-1111-111111111111 --gh yourGithubLogin --date 2025-08-10 + */ + +import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +// @ts-ignore -- Bun supports default import from 'postgres' +import postgres, { type Sql } from 'postgres'; + +import { refreshUserDay, refreshUserDayRange } from '../packages/api/src/leaderboard/aggregator'; +import { contribTotals } from '../packages/db/src/schema/contributions'; +import { eq } from 'drizzle-orm'; + +type Args = { + userId?: string; + gh?: string; + gl?: string; + days?: number; + date?: string; +}; + +function parseArgs(argv: string[]): Args { + const out: Args = {}; + for (let i = 2; i < argv.length; i++) { + const a = argv[i]; + const n = argv[i + 1]; + if (a === '--user-id') ((out.userId = n), i++); + else if (a === '--gh') ((out.gh = n), i++); + else if (a === '--gl') ((out.gl = n), i++); + else if (a === '--days') ((out.days = Number(n)), i++); + else if (a === '--date') ((out.date = n), i++); + } + return out; +} + +function startOfUtcDay(d: Date | string): Date { + const x = d instanceof Date ? new Date(d) : new Date(d); + return new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate(), 0, 0, 0, 0)); +} +function addDaysUTC(d: Date, days: number): Date { + const x = new Date(d); + x.setUTCDate(x.getUTCDate() + days); + return x; +} +function ymdUTC(d: Date): string { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); + return `${y}-${m}-${dd}`; +} + +async function makeDb(): Promise<{ db: PostgresJsDatabase; client: Sql }> { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('Missing DATABASE_URL in env'); + + const needsSSL = /neon\.tech/i.test(url) || /sslmode=require/i.test(url); + const client = postgres(url, needsSSL ? { ssl: 'require' as const } : {}); + const db = drizzle(client); + return { db, client }; +} + +async function main() { + const args = parseArgs(process.argv); + if (!args.userId) { + args.userId = crypto.randomUUID(); + console.log(`No --user-id provided. Using generated UUID: ${args.userId}`); + } + + if (!args.gh && !args.gl) { + console.error('Provide at least one provider username: --gh or --gl '); + process.exit(1); + } + + const { db, client } = await makeDb(); + + const githubToken = process.env.GITHUB_GRAPHQL_TOKEN || process.env.GITHUB_TOKEN; + const gitlabToken = process.env.GITLAB_TOKEN; + const gitlabBaseUrl = process.env.GITLAB_ISSUER || 'https://gitlab.com'; + + try { + if (args.date) { + const day = startOfUtcDay(args.date); + console.log(`Refreshing single UTC day: ${ymdUTC(day)} for user ${args.userId}`); + const res = await refreshUserDay( + { db }, + { + userId: args.userId, + githubLogin: args.gh, + gitlabUsername: args.gl, + dayUtc: day, + githubToken, + gitlabToken, + gitlabBaseUrl, + }, + ); + console.log('Updated providers:', res.updatedProviders); + } else { + const days = Math.max(1, Math.min(Number(args.days || 2), 31)); + const today = startOfUtcDay(new Date()); + const from = addDaysUTC(today, -(days - 1)); + console.log(`Refreshing UTC day range ${ymdUTC(from)} → ${ymdUTC(today)} (inclusive), user ${args.userId}`); + const res = await refreshUserDayRange( + { db }, + { + userId: args.userId, + githubLogin: args.gh, + gitlabUsername: args.gl, + fromDayUtc: from, + toDayUtc: today, + githubToken, + gitlabToken, + gitlabBaseUrl, + }, + ); + console.log('Days refreshed:', res.daysRefreshed.join(', ')); + } + + const rows = await db + .select({ + provider: contribTotals.provider, + allTime: contribTotals.allTime, + last30d: contribTotals.last30d, + last365d: contribTotals.last365d, + updatedAt: contribTotals.updatedAt, + }) + .from(contribTotals) + .where(eq(contribTotals.userId, args.userId)); + + const combined = rows.reduce( + (acc, r) => { + acc.allTime += Number(r.allTime || 0); + acc.last30d += Number(r.last30d || 0); + acc.last365d += Number(r.last365d || 0); + return acc; + }, + { allTime: 0, last30d: 0, last365d: 0 }, + ); + + console.log('\ncontrib_totals (per provider):'); + for (const r of rows) { + console.log( + ` ${r.provider}: all=${r.allTime} 30d=${r.last30d} 365d=${r.last365d} (updated ${r.updatedAt?.toISOString?.() || r.updatedAt})`, + ); + } + console.log('\ncombined totals:'); + console.log(` all=${combined.allTime} 30d=${combined.last30d} 365d=${combined.last365d}`); + } finally { + await client.end({ timeout: 5 }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/lb-sync-one.ts b/scripts/lb-sync-one.ts new file mode 100644 index 00000000..ad192131 --- /dev/null +++ b/scripts/lb-sync-one.ts @@ -0,0 +1,42 @@ +/** + * Sync one user's DB totals -> Redis ZSETs, then print Top N. + * Usage: + * bun scripts/lb-sync-one.ts [limit] [window] + * Example: + * bun scripts/lb-sync-one.ts 11111111-1111-1111-1111-111111111111 5 30d +*/ +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +// @ts-ignore -- Bun supports default import from 'postgres' +import postgres, { type Sql } from "postgres"; + +import { syncUserLeaderboards, topCombined } from "../packages/api/src/leaderboard/redis"; + +type WindowKey = "all" | "30d" | "365d"; + +async function makeDb(): Promise<{ db: PostgresJsDatabase; client: Sql }> { + const url = process.env.DATABASE_URL; + if (!url) throw new Error("Missing DATABASE_URL"); + const needsSSL = /neon\.tech/i.test(url) || /sslmode=require/i.test(url); + const client = postgres(url, needsSSL ? { ssl: "require" as const } : {}); + return { db: drizzle(client), client }; +} + +async function main() { + const userId = process.argv[2] || "11111111-1111-1111-1111-111111111111"; + const limit = Number(process.argv[3] || 5); + const window: WindowKey = (process.argv[4] as WindowKey) || "30d"; + + const { db, client } = await makeDb(); + try { + await syncUserLeaderboards(db, userId); + const top = await topCombined(limit, window); + console.log(JSON.stringify(top, null, 2)); + } finally { + await client.end({ timeout: 5 }); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); From 7391d12155e8ec8f91e458b9ad5906a77d39a492 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Wed, 13 Aug 2025 13:28:27 +0530 Subject: [PATCH 07/20] feat(leaderboard): optimized cron-protected backfill & refresh routes (Commit 6/n for #142) --- .../internal/leaderboard/backfill/route.ts | 133 +++++++++++++++++ .../internal/leaderboard/refresh-day/route.ts | 136 ++++++++++++++++++ packages/api/package.json | 5 +- packages/api/src/leaderboard/aggregator.ts | 16 +-- packages/api/src/leaderboard/redis.ts | 4 +- packages/env/package.json | 3 +- 6 files changed, 285 insertions(+), 12 deletions(-) create mode 100644 apps/web/app/api/internal/leaderboard/backfill/route.ts create mode 100644 apps/web/app/api/internal/leaderboard/refresh-day/route.ts diff --git a/apps/web/app/api/internal/leaderboard/backfill/route.ts b/apps/web/app/api/internal/leaderboard/backfill/route.ts new file mode 100644 index 00000000..2eb33a42 --- /dev/null +++ b/apps/web/app/api/internal/leaderboard/backfill/route.ts @@ -0,0 +1,133 @@ +// apps/web/app/api/internal/leaderboard/backfill/route.ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { env } from "@workspace/env/server"; +import { isCronAuthorized } from "@workspace/env/verify-cron"; + +import { db } from "@workspace/db"; +import { refreshUserDayRange } from "@workspace/api/aggregator"; +import { syncUserLeaderboards } from "@workspace/api/redis"; +import { backfillLockKey, withLock, acquireLock, releaseLock } from "@workspace/api/locks"; + +function startOfUtcDay(d = new Date()) { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); +} +function addDaysUTC(d: Date, days: number) { + const x = new Date(d); + x.setUTCDate(x.getUTCDate() + days); + return x; +} +function ymd(d: Date) { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${dd}`; +} + +const Body = z + .object({ + userId: z.string().min(1), + githubLogin: z.string().min(1).optional(), + gitlabUsername: z.string().min(1).optional(), + days: z.number().int().min(1).max(365).optional(), + concurrency: z.number().int().min(1).max(8).optional(), + }) + .refine((b) => !!b.githubLogin || !!b.gitlabUsername, { + message: "At least one of githubLogin or gitlabUsername is required.", + }); + +export async function POST(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!isCronAuthorized(auth)) return new Response("Unauthorized", { status: 401 }); + + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + const body = parsed.data; + + const today = startOfUtcDay(new Date()); + const days = Math.min(Math.max(body.days ?? 30, 1), 365); + const from = addDaysUTC(today, -(days - 1)); + + const providers = ([ + ...(body.githubLogin ? (["github"] as const) : []), + ...(body.gitlabUsername ? (["gitlab"] as const) : []), + ] as Array<"github" | "gitlab">).sort(); + + const ttlSec = Math.min(15 * 60, Math.max(2 * 60, days * 2)); + + const autoConcurrency = days > 180 ? 3 : days > 60 ? 4 : 6; + const concurrency = Math.min(Math.max(body.concurrency ?? autoConcurrency, 1), 8); + + const githubToken = env.GITHUB_TOKEN; + const gitlabToken = env.GITLAB_TOKEN; + const gitlabBaseUrl = env.GITLAB_ISSUER || "https://gitlab.com"; + + async function run() { + const res = await refreshUserDayRange( + { db }, + { + userId: body.userId, + githubLogin: body.githubLogin?.trim(), + gitlabUsername: body.gitlabUsername?.trim(), + fromDayUtc: from, + toDayUtc: today, + githubToken, + gitlabToken, + gitlabBaseUrl, + concurrency, + }, + ); + await syncUserLeaderboards(db, body.userId); + return res; + } + + try { + if (providers.length === 2) { + const k1 = backfillLockKey(providers[0]!, body.userId); + const k2 = backfillLockKey(providers[1]!, body.userId); + return await withLock(k1, ttlSec, async () => { + const got2 = await acquireLock(k2, ttlSec); + if (!got2) throw new Error(`LOCK_CONFLICT:${providers[1]}`); + try { + const out = await run(); + return Response.json({ + ok: true, + userId: body.userId, + providers, + window: { from: ymd(from), to: ymd(today) }, + daysRefreshed: out.daysRefreshed, + concurrency, + }); + } finally { + await releaseLock(k2); + } + }); + } + + const p = providers[0]!; + const key = backfillLockKey(p, body.userId); + return await withLock(key, ttlSec, async () => { + const out = await run(); + return Response.json({ + ok: true, + userId: body.userId, + providers, + window: { from: ymd(from), to: ymd(today) }, + daysRefreshed: out.daysRefreshed, + concurrency, + }); + }); + } catch (err: unknown) { + const msg = String(err instanceof Error ? err.message : err); + if (msg.startsWith("LOCK_CONFLICT")) { + const p = msg.split(":")[1] || "unknown"; + return new Response(`Conflict: backfill already running for ${p}`, { status: 409 }); + } + return new Response(`Internal Error: ${msg}`, { status: 500 }); + } +} diff --git a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts new file mode 100644 index 00000000..51e2a68f --- /dev/null +++ b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts @@ -0,0 +1,136 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { env } from "@workspace/env/server"; +import { isCronAuthorized } from "@workspace/env/verify-cron"; + +import { db } from "@workspace/db"; +import { refreshUserDayRange } from "@workspace/api/aggregator"; +import { syncUserLeaderboards } from "@workspace/api/redis"; +import { withLock, acquireLock, releaseLock } from "@workspace/api/locks"; + +function startOfUtcDay(d = new Date()) { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); +} +function ymd(d: Date) { + const y = d.getUTCFullYear(); + const m = String(d.getUTCMonth() + 1).padStart(2, "0"); + const dd = String(d.getUTCDate()).padStart(2, "0"); + return `${y}-${m}-${dd}`; +} + +const Body = z + .object({ + userId: z.string().min(1), + githubLogin: z.string().min(1).optional(), + gitlabUsername: z.string().min(1).optional(), + fromDayUtc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + toDayUtc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + concurrency: z.number().int().min(1).max(8).optional(), + }) + .refine((b) => !!b.githubLogin || !!b.gitlabUsername, { + message: "At least one of githubLogin or gitlabUsername is required.", + }); + +export async function POST(req: NextRequest) { + const auth = req.headers.get("authorization"); + if (!isCronAuthorized(auth)) return new Response("Unauthorized", { status: 401 }); + + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + const body = parsed.data; + + const today = startOfUtcDay(new Date()); + const yesterday = startOfUtcDay(new Date(today)); + yesterday.setUTCDate(yesterday.getUTCDate() - 1); + + const fromDay = body.fromDayUtc ? new Date(`${body.fromDayUtc}T00:00:00Z`) : yesterday; + const toDay = body.toDayUtc ? new Date(`${body.toDayUtc}T00:00:00Z`) : today; + if (fromDay.getTime() > toDay.getTime()) { + return new Response("Bad Request: fromDayUtc must be <= toDayUtc", { status: 400 }); + } + + const providers = ([ + ...(body.githubLogin ? (["github"] as const) : []), + ...(body.gitlabUsername ? (["gitlab"] as const) : []), + ] as Array<"github" | "gitlab">).sort(); + + const ttlSec = 3 * 60; + + const autoConcurrency = 6; + const concurrency = Math.min(Math.max(body.concurrency ?? autoConcurrency, 1), 8); + + const githubToken = env.GITHUB_TOKEN; + const gitlabToken = env.GITLAB_TOKEN; + const gitlabBaseUrl = env.GITLAB_ISSUER || "https://gitlab.com"; + + const lockKey = (p: "github" | "gitlab") => + `lock:refresh:${p}:${body.userId}:${ymd(fromDay)}:${ymd(toDay)}`; + + async function run() { + const res = await refreshUserDayRange( + { db }, + { + userId: body.userId, + githubLogin: body.githubLogin?.trim(), + gitlabUsername: body.gitlabUsername?.trim(), + fromDayUtc: fromDay, + toDayUtc: toDay, + githubToken, + gitlabToken, + gitlabBaseUrl, + concurrency, + }, + ); + await syncUserLeaderboards(db, body.userId); + return res; + } + + try { + if (providers.length === 2) { + const k1 = lockKey(providers[0]!); + const k2 = lockKey(providers[1]!); + return await withLock(k1, ttlSec, async () => { + const got2 = await acquireLock(k2, ttlSec); + if (!got2) throw new Error(`LOCK_CONFLICT:${providers[1]}`); + try { + const out = await run(); + return Response.json({ + ok: true, + userId: body.userId, + providers, + range: { from: ymd(fromDay), to: ymd(toDay) }, + daysRefreshed: out.daysRefreshed, + concurrency, + }); + } finally { + await releaseLock(k2); + } + }); + } + + const p = providers[0]!; + return await withLock(lockKey(p), ttlSec, async () => { + const out = await run(); + return Response.json({ + ok: true, + userId: body.userId, + providers, + range: { from: ymd(fromDay), to: ymd(toDay) }, + daysRefreshed: out.daysRefreshed, + concurrency, + }); + }); + } catch (err: unknown) { + const msg = String(err instanceof Error ? err.message : err); + if (msg.startsWith("LOCK_CONFLICT")) { + const p = msg.split(":")[1] || "unknown"; + return new Response(`Conflict: refresh already running for ${p}`, { status: 409 }); + } + return new Response(`Internal Error: ${msg}`, { status: 500 }); + } +} diff --git a/packages/api/package.json b/packages/api/package.json index bb113b24..8a9650e1 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -5,7 +5,10 @@ "exports": { ".": "./src/root.ts", "./trpc": "./src/trpc.ts", - "./providers/types": "./src/providers/types.ts" + "./providers/types": "./src/providers/types.ts", + "./aggregator": "./src/leaderboard/aggregator.ts", + "./redis": "./src/leaderboard/redis.ts", + "./locks": "./src/redis/lock.ts" }, "scripts": { "lint": "eslint ." diff --git a/packages/api/src/leaderboard/aggregator.ts b/packages/api/src/leaderboard/aggregator.ts index 7efc4808..3d425fe3 100644 --- a/packages/api/src/leaderboard/aggregator.ts +++ b/packages/api/src/leaderboard/aggregator.ts @@ -1,5 +1,5 @@ import { and, eq, gte, lt, sql } from "drizzle-orm"; -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; +import type { DB } from "@workspace/db"; import { getGithubContributionTotalsForDay } from "../providers/github"; import { getGitlabContributionTotalsForDay } from "../providers/gitlab"; @@ -13,7 +13,7 @@ import { export type Provider = (typeof contribProvider.enumValues)[number]; -export type AggregatorDeps = { db: PostgresJsDatabase }; +export type AggregatorDeps = { db: DB }; export type RefreshUserDayArgs = { userId: string; @@ -49,7 +49,7 @@ function ymdUTC(d: Date): string { async function upsertDaily( - db: PostgresJsDatabase, + db: DB, args: { userId: string; provider: Provider; day: Date; commits: number; prs: number; issues: number }, ): Promise { const dayStr = ymdUTC(args.day); @@ -76,7 +76,7 @@ async function upsertDaily( } async function sumWindow( - db: PostgresJsDatabase, + db: DB, userId: string, provider: Provider, fromInclusive: Date, @@ -98,7 +98,7 @@ async function sumWindow( return (row?.total ?? 0) as number; } -async function sumAllTime(db: PostgresJsDatabase, userId: string, provider: Provider): Promise { +async function sumAllTime(db: DB, userId: string, provider: Provider): Promise { const [row] = await db .select({ total: sql`coalesce(sum(${contribDaily.commits} + ${contribDaily.prs} + ${contribDaily.issues}), 0)`, @@ -109,7 +109,7 @@ async function sumAllTime(db: PostgresJsDatabase, userId: string, provider: Prov } async function upsertTotals( - db: PostgresJsDatabase, + db: DB, args: { userId: string; provider: Provider; allTime: number; last30d: number; last365d: number }, ): Promise { await db @@ -134,7 +134,7 @@ async function upsertTotals( } async function recomputeProviderTotals( - db: PostgresJsDatabase, + db: DB, userId: string, provider: Provider, now: Date = new Date(), @@ -209,7 +209,7 @@ export async function refreshUserDayRange( const daysStr: string[] = []; - const concurrency = Math.max(1, Math.min(args.concurrency ?? 4, 10)); + const concurrency = Math.max(1, Math.min(args.concurrency ?? 4, 10)); await mapWithConcurrency(days, concurrency, async (d) => { const res = await refreshUserDay(deps, { diff --git a/packages/api/src/leaderboard/redis.ts b/packages/api/src/leaderboard/redis.ts index 47995913..00b0bb9e 100644 --- a/packages/api/src/leaderboard/redis.ts +++ b/packages/api/src/leaderboard/redis.ts @@ -1,7 +1,7 @@ -import type { PostgresJsDatabase } from "drizzle-orm/postgres-js"; import { eq } from "drizzle-orm"; import { contribTotals } from "@workspace/db/schema"; import { redis } from "../redis/client"; +import type { DB } from "@workspace/db"; type ProviderKey = "github" | "gitlab"; type WindowKey = "all" | "30d" | "365d"; @@ -25,7 +25,7 @@ const PROVIDER_KEYS: Record> = { }, } as const; -export async function syncUserLeaderboards(db: PostgresJsDatabase, userId: string): Promise { +export async function syncUserLeaderboards(db: DB, userId: string): Promise { const rows = await db .select({ provider: contribTotals.provider, diff --git a/packages/env/package.json b/packages/env/package.json index f15e4bfe..77a17b44 100644 --- a/packages/env/package.json +++ b/packages/env/package.json @@ -5,7 +5,8 @@ "exports": { "./client": "./src/client.ts", "./server": "./src/server.ts", - "./utils": "./src/utils.ts" + "./utils": "./src/utils.ts", + "./verify-cron": "./src/verifyCron.ts" }, "scripts": { "lint": "eslint ." From 17763f019b242118b092d05cc64c8dc6ce776078 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 18 Aug 2025 13:16:51 +0530 Subject: [PATCH 08/20] feat(leaderboard): public read API with Redis Top-N and DB fallback (Commit 7/n for #142) --- .../internal/leaderboard/backfill/route.ts | 3 +- .../internal/leaderboard/refresh-day/route.ts | 1 + apps/web/app/api/leaderboard/route.ts | 41 +++++++ packages/api/package.json | 3 +- packages/api/src/leaderboard/read.ts | 116 ++++++++++++++++++ 5 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 apps/web/app/api/leaderboard/route.ts create mode 100644 packages/api/src/leaderboard/read.ts diff --git a/apps/web/app/api/internal/leaderboard/backfill/route.ts b/apps/web/app/api/internal/leaderboard/backfill/route.ts index 2eb33a42..ed2d8db2 100644 --- a/apps/web/app/api/internal/leaderboard/backfill/route.ts +++ b/apps/web/app/api/internal/leaderboard/backfill/route.ts @@ -1,4 +1,3 @@ -// apps/web/app/api/internal/leaderboard/backfill/route.ts export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -16,11 +15,13 @@ import { backfillLockKey, withLock, acquireLock, releaseLock } from "@workspace/ function startOfUtcDay(d = new Date()) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); } + function addDaysUTC(d: Date, days: number) { const x = new Date(d); x.setUTCDate(x.getUTCDate() + days); return x; } + function ymd(d: Date) { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); diff --git a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts index 51e2a68f..8677aa73 100644 --- a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts +++ b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts @@ -15,6 +15,7 @@ import { withLock, acquireLock, releaseLock } from "@workspace/api/locks"; function startOfUtcDay(d = new Date()) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); } + function ymd(d: Date) { const y = d.getUTCFullYear(); const m = String(d.getUTCMonth() + 1).padStart(2, "0"); diff --git a/apps/web/app/api/leaderboard/route.ts b/apps/web/app/api/leaderboard/route.ts new file mode 100644 index 00000000..73a21438 --- /dev/null +++ b/apps/web/app/api/leaderboard/route.ts @@ -0,0 +1,41 @@ +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; + +import { db } from "@workspace/db"; +import { getLeaderboardPage, type WindowKey, type ProviderSel } from "@workspace/api/read"; + +const Query = z.object({ + window: z.enum(["all", "30d", "365d"]).default("30d"), + provider: z.enum(["combined", "github", "gitlab"]).default("combined"), + limit: z.coerce.number().int().min(1).max(100).default(25), + cursor: z.coerce.number().int().min(0).optional(), +}); + +export async function GET(req: NextRequest) { + const parsed = Query.safeParse(Object.fromEntries(req.nextUrl.searchParams.entries())); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const q = parsed.data; + + const { entries, nextCursor, source } = await getLeaderboardPage(db, { + window: q.window as WindowKey, + provider: q.provider as ProviderSel, + limit: q.limit, + cursor: q.cursor, + }); + + return Response.json({ + ok: true, + window: q.window, + provider: q.provider, + limit: q.limit, + cursor: q.cursor ?? 0, + nextCursor, + source, + entries, + }); +} diff --git a/packages/api/package.json b/packages/api/package.json index 8a9650e1..e39a0852 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -8,7 +8,8 @@ "./providers/types": "./src/providers/types.ts", "./aggregator": "./src/leaderboard/aggregator.ts", "./redis": "./src/leaderboard/redis.ts", - "./locks": "./src/redis/lock.ts" + "./locks": "./src/redis/lock.ts", + "./read": "./src/leaderboard/read.ts" }, "scripts": { "lint": "eslint ." diff --git a/packages/api/src/leaderboard/read.ts b/packages/api/src/leaderboard/read.ts new file mode 100644 index 00000000..4ddca0eb --- /dev/null +++ b/packages/api/src/leaderboard/read.ts @@ -0,0 +1,116 @@ +import { sql, eq, desc } from "drizzle-orm"; +import type { DB } from "@workspace/db"; +import { redis } from "../redis/client"; +import { contribTotals } from "@workspace/db/schema"; + +export type WindowKey = "all" | "30d" | "365d"; +export type ProviderSel = "combined" | "github" | "gitlab"; + +const COMBINED_KEYS = { + all: "lb:total:all", + "30d": "lb:total:30d", + "365d": "lb:total:365d", +} as const; + +const PROVIDER_KEYS = { + github: { + all: "lb:github:all", + "30d": "lb:github:30d", + "365d": "lb:github:365d", + }, + gitlab: { + all: "lb:gitlab:all", + "30d": "lb:gitlab:30d", + "365d": "lb:gitlab:365d", + }, +} as const; + +type ZRangeItemObj = { member: string; score: number | string }; +type LeaderRow = { userId: string; score: number }; + +function keyFor(provider: ProviderSel, window: WindowKey): string { + if (provider === "combined") return COMBINED_KEYS[window]; + return PROVIDER_KEYS[provider][window]; +} + +function parseZRange(res: unknown): LeaderRow[] { + if (Array.isArray(res) && res.length && typeof res[0] === "object" && res[0] && "member" in (res[0])) { + return (res as ZRangeItemObj[]).map(({ member, score }) => ({ + userId: String(member), + score: typeof score === "string" ? Number(score) : Number(score ?? 0), + })); + } + if (Array.isArray(res)) { + const out: LeaderRow[] = []; + for (let i = 0; i < res.length; i += 2) { + out.push({ userId: String(res[i] ?? ""), score: Number(res[i + 1] ?? 0) }); + } + return out; + } + return []; +} + +async function topFromRedis(provider: ProviderSel, window: WindowKey, start: number, stop: number): Promise { + const key = keyFor(provider, window); + const res = await redis.zrange(key, start, stop, { rev: true, withScores: true }); + return parseZRange(res); +} + + +async function topFromDb(db: DB, provider: ProviderSel, window: WindowKey, limit: number, offset: number): Promise { + const col = window === "all" ? contribTotals.allTime + : window === "30d" ? contribTotals.last30d + : contribTotals.last365d; + + if (provider === "combined") { + const rows = await db + .select({ + userId: contribTotals.userId, + score: sql`SUM(${col})`.as("score"), + }) + .from(contribTotals) + .groupBy(contribTotals.userId) + .orderBy(desc(sql`SUM(${col})`)) + .limit(limit) + .offset(offset); + + return rows.map(r => ({ userId: r.userId, score: Number(r.score || 0) })); + } + + const rows = await db + .select({ + userId: contribTotals.userId, + score: col, + }) + .from(contribTotals) + .where(eq(contribTotals.provider, provider)) + .orderBy(desc(col)) + .limit(limit) + .offset(offset); + + return rows.map(r => ({ userId: r.userId, score: Number(r.score || 0) })); +} + +export async function getLeaderboardPage( + db: DB, + opts: { + provider: ProviderSel; + window: WindowKey; + limit: number; + cursor?: number; + }, +): Promise<{ entries: LeaderRow[]; nextCursor: number | null; source: "redis" | "db" }> { + const limit = Math.min(Math.max(opts.limit, 1), 100); + const start = Math.max(opts.cursor ?? 0, 0); + const stop = start + limit - 1; + + const fromRedis = await topFromRedis(opts.provider, opts.window, start, stop); + if (fromRedis.length > 0) { + const next = fromRedis.length === limit ? start + limit : null; + return { entries: fromRedis, nextCursor: next, source: "redis" }; + } + + const fromDb = await topFromDb(db, opts.provider, opts.window, limit, start); + const next = fromDb.length === limit ? start + limit : null; + return { entries: fromDb, nextCursor: next, source: "db" }; +} From 8fbde3172bde6aae46d0a308560d360bd52bee04 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 18 Aug 2025 14:17:59 +0530 Subject: [PATCH 09/20] fix(leaderboard/aggregator): race-free concurrency + single-pass day refresh --- packages/api/src/leaderboard/aggregator.ts | 138 +++++++++++++-------- scripts/agg-range-test.ts | 135 ++++++++++++++++++++ 2 files changed, 223 insertions(+), 50 deletions(-) create mode 100644 scripts/agg-range-test.ts diff --git a/packages/api/src/leaderboard/aggregator.ts b/packages/api/src/leaderboard/aggregator.ts index 3d425fe3..f8464bfa 100644 --- a/packages/api/src/leaderboard/aggregator.ts +++ b/packages/api/src/leaderboard/aggregator.ts @@ -1,15 +1,10 @@ -import { and, eq, gte, lt, sql } from "drizzle-orm"; -import type { DB } from "@workspace/db"; +import { and, eq, gte, lt, sql } from 'drizzle-orm'; +import type { DB } from '@workspace/db'; -import { getGithubContributionTotalsForDay } from "../providers/github"; -import { getGitlabContributionTotalsForDay } from "../providers/gitlab"; - -import { - contribDaily, - contribTotals, - contribProvider, -} from "@workspace/db/schema"; +import { getGitlabContributionTotalsForDay } from '../providers/gitlab'; +import { getGithubContributionTotalsForDay } from '../providers/github'; +import { contribDaily, contribTotals, contribProvider } from '@workspace/db/schema'; export type Provider = (typeof contribProvider.enumValues)[number]; @@ -30,7 +25,6 @@ type GithubDayResult = { commits: number; prs: number; issues: number }; type GitlabDayResult = { commits: number; mrs: number; issues: number }; type RefreshResults = { github?: GithubDayResult; gitlab?: GitlabDayResult }; - function startOfUtcDay(d: Date | string): Date { const x = d instanceof Date ? new Date(d) : new Date(d); return new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate(), 0, 0, 0, 0)); @@ -42,15 +36,21 @@ function addDaysUTC(d: Date, days: number): Date { } function ymdUTC(d: Date): string { const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } - async function upsertDaily( db: DB, - args: { userId: string; provider: Provider; day: Date; commits: number; prs: number; issues: number }, + args: { + userId: string; + provider: Provider; + day: Date; + commits: number; + prs: number; + issues: number; + }, ): Promise { const dayStr = ymdUTC(args.day); await db @@ -154,7 +154,6 @@ async function recomputeProviderTotals( return { allTime, last30d, last365d }; } - export async function refreshUserDay( deps: AggregatorDeps, args: RefreshUserDayArgs, @@ -164,39 +163,74 @@ export async function refreshUserDay( const results: RefreshResults = {}; if (args.githubLogin && args.githubLogin.trim() && args.githubToken) { - const gh = await getGithubContributionTotalsForDay(args.githubLogin.trim(), day, args.githubToken); + const gh = await getGithubContributionTotalsForDay( + args.githubLogin.trim(), + day, + args.githubToken, + ); results.github = { commits: gh.commits, prs: gh.prs, issues: gh.issues }; - await upsertDaily(db, { userId: args.userId, provider: "github", day, commits: gh.commits, prs: gh.prs, issues: gh.issues }); - if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, "github", day); + await upsertDaily(db, { + userId: args.userId, + provider: 'github', + day, + commits: gh.commits, + prs: gh.prs, + issues: gh.issues, + }); + if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, 'github', day); } if (args.gitlabUsername && args.gitlabUsername.trim()) { - const base = args.gitlabBaseUrl?.trim() || "https://gitlab.com"; - const gl = await getGitlabContributionTotalsForDay(args.gitlabUsername.trim(), day, base, args.gitlabToken); + const base = args.gitlabBaseUrl?.trim() || 'https://gitlab.com'; + const gl = await getGitlabContributionTotalsForDay( + args.gitlabUsername.trim(), + day, + base, + args.gitlabToken, + ); results.gitlab = { commits: gl.commits, mrs: gl.mrs, issues: gl.issues }; - await upsertDaily(db, { userId: args.userId, provider: "gitlab", day, commits: gl.commits, prs: gl.mrs, issues: gl.issues }); - if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, "gitlab", day); + await upsertDaily(db, { + userId: args.userId, + provider: 'gitlab', + day, + commits: gl.commits, + prs: gl.mrs, + issues: gl.issues, + }); + if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, 'gitlab', day); } return { day: ymdUTC(day), updatedProviders: Object.keys(results), results }; } -async function mapWithConcurrency(items: T[], limit: number, fn: (item: T) => Promise): Promise { - const ret: R[] = []; - let i = 0; - const workers = Array.from({ length: Math.max(1, limit) }, async () => { - while (i < items.length) { - const idx = i++; - ret[idx] = await fn(items[idx]!); +async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise, +): Promise { + const n = items.length; + const results = new Array(n); + const batchSize = Math.max(1, limit | 0); + + for (let start = 0; start < n; start += batchSize) { + const end = Math.min(start + batchSize, n); + const pending: Promise[] = []; + for (let i = start; i < end; i++) { + pending.push( + fn(items[i]!, i).then((r) => { + results[i] = r; + }), + ); } - }); - await Promise.all(workers); - return ret; + await Promise.all(pending); + } + + return results; } export async function refreshUserDayRange( deps: AggregatorDeps, - args: Omit & { + args: Omit & { fromDayUtc: Date | string; toDayUtc: Date | string; concurrency?: number; @@ -204,29 +238,33 @@ export async function refreshUserDayRange( ): Promise<{ daysRefreshed: string[] }> { const from = startOfUtcDay(args.fromDayUtc); const toInclusive = startOfUtcDay(args.toDayUtc); - const days: Date[] = []; - for (let d = new Date(from); d.getTime() <= toInclusive.getTime(); d = addDaysUTC(d, 1)) days.push(new Date(d)); + if (from.getTime() > toInclusive.getTime()) { + throw new Error('fromDayUtc must be <= toDayUtc'); + } - const daysStr: string[] = []; + // Build list of UTC days + const daysList: Date[] = []; + for (let d = new Date(from); d.getTime() <= toInclusive.getTime(); d = addDaysUTC(d, 1)) { + daysList.push(new Date(d)); + } - const concurrency = Math.max(1, Math.min(args.concurrency ?? 4, 10)); + // Run once per day with totals recompute skipped (we'll recompute once at the end) + const concurrency = Math.max(1, args.concurrency ?? 4); + const results = await mapWithConcurrency(daysList, concurrency, (day) => + refreshUserDay(deps, { ...args, dayUtc: day, skipTotalsRecompute: true }), + ); - await mapWithConcurrency(days, concurrency, async (d) => { - const res = await refreshUserDay(deps, { - ...args, - dayUtc: d, - skipTotalsRecompute: true, - }); - daysStr.push(res.day); - }); + const days = results.map((r) => r.day); + days.sort(); // canonical order YYYY-MM-DD + // Recompute provider totals once (current window) if (args.githubLogin && args.githubToken) { - await recomputeProviderTotals(deps.db, args.userId, "github", new Date()); + await recomputeProviderTotals(deps.db, args.userId, 'github', new Date()); } if (args.gitlabUsername) { - await recomputeProviderTotals(deps.db, args.userId, "gitlab", new Date()); + await recomputeProviderTotals(deps.db, args.userId, 'gitlab', new Date()); } - daysStr.sort(); - return { daysRefreshed: daysStr }; + return { daysRefreshed: days }; } + diff --git a/scripts/agg-range-test.ts b/scripts/agg-range-test.ts new file mode 100644 index 00000000..b906f34e --- /dev/null +++ b/scripts/agg-range-test.ts @@ -0,0 +1,135 @@ +#!/usr/bin/env bun +/** + * Aggregator range smoke test: + * - Runs refreshUserDayRange with bounded concurrency + * - Prints contrib_daily counts, contrib_totals snapshot + * - Publishes to Redis and prints top combined + * + * Usage: + * bun scripts/agg-range-test.ts --user-id --gh --days 14 --concurrency 6 + */ + +import { drizzle, type PostgresJsDatabase } from "drizzle-orm/postgres-js"; +// @ts-ignore — Bun supports default import from 'postgres' +import postgres, { type Sql } from "postgres"; +import { and, eq, gte, lte, sql } from "drizzle-orm"; + +import { refreshUserDayRange } from "../packages/api/src/leaderboard/aggregator"; +import { syncUserLeaderboards, topCombined } from "../packages/api/src/leaderboard/redis"; +import { contribDaily, contribTotals } from "../packages/db/src/schema/contributions"; + +type Args = { + userId?: string; + gh?: string; + gl?: string; + days?: number; + concurrency?: number; +}; + +function parseArgs(argv: string[]): Args { + const a: Args = {}; + for (let i = 2; i < argv.length; i++) { + const k = argv[i], v = argv[i + 1]; + if (k === "--user-id") (a.userId = v, i++); + else if (k === "--gh") (a.gh = v, i++); + else if (k === "--gl") (a.gl = v, i++); + else if (k === "--days") (a.days = Number(v), i++); + else if (k === "--concurrency") (a.concurrency = Number(v), i++); + } + return a; +} + +function startOfUtcDay(d = new Date()) { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0,0,0,0)); +} +function addDaysUTC(d: Date, days: number) { + const x = new Date(d); + x.setUTCDate(x.getUTCDate() + days); + return x; +} +function ymd(d: Date) { + const y = d.getUTCFullYear(), m = String(d.getUTCMonth()+1).padStart(2,"0"), dd = String(d.getUTCDate()).padStart(2,"0"); + return `${y}-${m}-${dd}`; +} + +async function makeDb(): Promise<{ db: PostgresJsDatabase; client: Sql }> { + const url = process.env.DATABASE_URL!; + const needsSSL = /neon\.tech/i.test(url) || /sslmode=require/i.test(url); + const client = postgres(url, needsSSL ? { ssl: "require" as const } : {}); + return { db: drizzle(client), client }; +} + +async function main() { + const args = parseArgs(process.argv); + const userId = args.userId || crypto.randomUUID(); + if (!args.gh && !args.gl) { + console.error("Provide at least one provider: --gh or --gl "); + process.exit(1); + } + const days = Math.min(Math.max(args.days ?? 14, 1), 60); + const concurrency = Math.min(Math.max(args.concurrency ?? 6, 1), 8); + + const { db, client } = await makeDb(); + const today = startOfUtcDay(new Date()); + const from = addDaysUTC(today, -(days - 1)); + + console.log(`User ${userId} • window ${ymd(from)} → ${ymd(today)} • conc=${concurrency}`); + + const res = await refreshUserDayRange( + { db: db as any }, // typed DB + { + userId, + githubLogin: args.gh, + gitlabUsername: args.gl, + fromDayUtc: from, + toDayUtc: today, + githubToken: process.env.GITHUB_GRAPHQL_TOKEN || process.env.GITHUB_TOKEN, + gitlabToken: process.env.GITLAB_TOKEN, + gitlabBaseUrl: process.env.GITLAB_ISSUER || "https://gitlab.com", + concurrency, + }, + ); + console.log("Days refreshed:", res.daysRefreshed.length); + + // contrib_daily rows in window (per provider) + const [ghDaily] = await db.select({ + n: sql`count(*)`, + }).from(contribDaily).where( + and( + eq(contribDaily.userId, userId), + eq(contribDaily.provider, "github" as any), + gte(contribDaily.dateUtc, ymd(from)), + lte(contribDaily.dateUtc, ymd(today)), + ), + ); + const [glDaily] = await db.select({ + n: sql`count(*)`, + }).from(contribDaily).where( + and( + eq(contribDaily.userId, userId), + eq(contribDaily.provider, "gitlab" as any), + gte(contribDaily.dateUtc, ymd(from)), + lte(contribDaily.dateUtc, ymd(today)), + ), + ); + console.log(`contrib_daily: github=${Number(ghDaily?.n||0)} gitlab=${Number(glDaily?.n||0)} (expect ~${days} per active provider)`); + + // contrib_totals snapshot + const totals = await db.select({ + provider: contribTotals.provider, + all: contribTotals.allTime, + d30: contribTotals.last30d, + d365: contribTotals.last365d, + updatedAt: contribTotals.updatedAt, + }).from(contribTotals).where(eq(contribTotals.userId, userId)); + console.log("contrib_totals:", totals); + + // publish to Redis & show top + await syncUserLeaderboards(db as any, userId); + const top = await topCombined(10, "30d"); + console.log("topCombined(10, 30d):", top); + + await client.end({ timeout: 5 }); +} + +main().catch((e) => { console.error(e); process.exit(1); }); From cd497b7b6f9de613d6f52675f277a346ab4c0814 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 18 Aug 2025 14:43:31 +0530 Subject: [PATCH 10/20] fix(leaderboard): make Redis read resilient; graceful DB fallback on errors and made ui of table --- apps/web/app/(public)/leaderboard/page.tsx | 31 ++ apps/web/app/api/leaderboard/details/route.ts | 56 ++++ .../leaderboard/leaderboard-client.tsx | 273 ++++++++++++++++++ packages/api/src/leaderboard/read.ts | 122 +++++--- 4 files changed, 438 insertions(+), 44 deletions(-) create mode 100644 apps/web/app/(public)/leaderboard/page.tsx create mode 100644 apps/web/app/api/leaderboard/details/route.ts create mode 100644 apps/web/components/leaderboard/leaderboard-client.tsx diff --git a/apps/web/app/(public)/leaderboard/page.tsx b/apps/web/app/(public)/leaderboard/page.tsx new file mode 100644 index 00000000..c7128d1e --- /dev/null +++ b/apps/web/app/(public)/leaderboard/page.tsx @@ -0,0 +1,31 @@ +import LeaderboardClient from "@/components/leaderboard/leaderboard-client"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + + +type WindowKey = "all" | "30d" | "365d"; + +function getWindow(searchParams?: Record): WindowKey { + const w = (searchParams?.window as string) || "30d"; + return w === "all" || w === "30d" || w === "365d" ? (w as WindowKey) : "30d"; +} + +export default async function LeaderboardPage({ + searchParams, +}: { + searchParams?: Record; +}) { + const window = getWindow(searchParams); + return ( +
+
+

Global Leaderboard

+

+ Top contributors across GitHub and GitLab. +

+
+ +
+ ); +} diff --git a/apps/web/app/api/leaderboard/details/route.ts b/apps/web/app/api/leaderboard/details/route.ts new file mode 100644 index 00000000..e3c61081 --- /dev/null +++ b/apps/web/app/api/leaderboard/details/route.ts @@ -0,0 +1,56 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; + +import { contribTotals } from '@workspace/db/schema'; +import { NextRequest } from 'next/server'; +import { inArray } from 'drizzle-orm'; +import { db } from '@workspace/db'; +import { z } from 'zod/v4'; + +const Body = z.object({ + userIds: z.array(z.string().min(1)).min(1).max(200), + window: z.enum(['all', '30d', '365d']).default('30d'), +}); + +export async function POST(req: NextRequest) { + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { userIds, window } = parsed.data; + + const col = + window === 'all' + ? contribTotals.allTime + : window === '30d' + ? contribTotals.last30d + : contribTotals.last365d; + + const rows = await db + .select({ + userId: contribTotals.userId, + provider: contribTotals.provider, + score: col, + }) + .from(contribTotals) + .where(inArray(contribTotals.userId, userIds)); + + const map = new Map(); + for (const r of rows) { + const id = r.userId; + const cur = map.get(id) ?? { userId: id, github: 0, gitlab: 0, total: 0 }; + const s = Number(r.score || 0); + if (r.provider === 'github') cur.github = s; + else if (r.provider === 'gitlab') cur.gitlab = s; + cur.total = cur.github + cur.gitlab; + map.set(id, cur); + } + for (const id of userIds) { + if (!map.has(id)) map.set(id, { userId: id, github: 0, gitlab: 0, total: 0 }); + } + const data = userIds.map((id) => map.get(id)!); + + return Response.json({ ok: true, window, entries: data }); +} + diff --git a/apps/web/components/leaderboard/leaderboard-client.tsx b/apps/web/components/leaderboard/leaderboard-client.tsx new file mode 100644 index 00000000..f647622f --- /dev/null +++ b/apps/web/components/leaderboard/leaderboard-client.tsx @@ -0,0 +1,273 @@ +"use client"; + +import * as React from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +// shadcn/ui +import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@workspace/ui/components/select"; +import { Input } from "@workspace/ui/components/input"; +import { Button } from "@workspace/ui/components/button"; +import { + Table, TableHeader, TableRow, TableHead, TableBody, TableCell, +} from "@workspace/ui/components/table"; +import { Card, CardContent } from "@workspace/ui/components/card"; + +type WindowKey = "all" | "30d" | "365d"; + +type TopEntry = { userId: string; score: number }; +type DetailsEntry = { userId: string; github: number; gitlab: number; total: number }; + +type LeaderRow = { + userId: string; + total: number; + github: number; + gitlab: number; +}; + +type SortKey = "rank" | "userId" | "total" | "github" | "gitlab"; +type SortDir = "asc" | "desc"; + +async function fetchTop(window: WindowKey, limit: number, cursor = 0) { + const url = `/api/leaderboard?window=${window}&provider=combined&limit=${limit}&cursor=${cursor}`; + const res = await fetch(url, { cache: "no-store" }); + if (!res.ok) throw new Error(`Failed to fetch leaderboard: ${await res.text()}`); + return (await res.json()) as { + ok: boolean; + entries: TopEntry[]; + nextCursor: number | null; + source: "redis" | "db"; + }; +} + +async function fetchDetails(window: WindowKey, userIds: string[]) { + if (userIds.length === 0) return { ok: true, window, entries: [] as DetailsEntry[] }; + const res = await fetch(`/api/leaderboard/details`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + cache: "no-store", + body: JSON.stringify({ window, userIds }), + }); + if (!res.ok) throw new Error(`Failed to fetch details: ${await res.text()}`); + return (await res.json()) as { ok: true; window: WindowKey; entries: DetailsEntry[] }; +} + +export default function LeaderboardClient({ initialWindow }: { initialWindow: WindowKey }) { + const router = useRouter(); + const search = useSearchParams(); + + const [window, setWindow] = React.useState(initialWindow); + const [rows, setRows] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + const [limit, setLimit] = React.useState(25); + const [cursor, setCursor] = React.useState(0); + const [nextCursor, setNextCursor] = React.useState(null); + + const [sortKey, setSortKey] = React.useState("rank"); + const [sortDir, setSortDir] = React.useState("asc"); + + const doFetch = React.useCallback(async (w: WindowKey, lim: number, cur: number) => { + setLoading(true); + setError(null); + try { + const top = await fetchTop(w, lim, cur); + const ids = top.entries.map((e) => e.userId); + const details = await fetchDetails(w, ids); + + const detailMap = new Map(details.entries.map((d) => [d.userId, d])); + const merged: LeaderRow[] = top.entries.map((e) => { + const d = detailMap.get(e.userId); + return { + userId: e.userId, + total: d?.total ?? e.score ?? 0, + github: d?.github ?? 0, + gitlab: d?.gitlab ?? 0, + }; + }); + + setRows(merged); + setNextCursor(top.nextCursor); + } catch (err: any) { + setError(String(err?.message || err)); + setRows([]); + setNextCursor(null); + } finally { + setLoading(false); + } + }, []); + + React.useEffect(() => { doFetch(window, limit, cursor); }, [window, limit, cursor, doFetch]); + + // keep URL in sync with window + React.useEffect(() => { + const params = new URLSearchParams(search?.toString?.() || ""); + params.set("window", window); + router.replace(`/leaderboard?${params.toString()}`); + }, [window]); // eslint-disable-line react-hooks/exhaustive-deps + + function toggleSort(k: SortKey) { + if (sortKey === k) setSortDir(sortDir === "asc" ? "desc" : "asc"); + else { setSortKey(k); setSortDir(k === "rank" ? "asc" : "desc"); } + } + + const sortedRows = React.useMemo(() => { + if (!rows) return []; + const copy = [...rows]; + copy.sort((a, b) => { + let av: number | string = 0; + let bv: number | string = 0; + switch (sortKey) { + case "userId": av = a.userId; bv = b.userId; break; + case "total": av = a.total; bv = b.total; break; + case "github": av = a.github; bv = b.github; break; + case "gitlab": av = a.gitlab; bv = b.gitlab; break; + case "rank": default: av = 0; bv = 0; break; // original rank order + } + if (typeof av === "string" && typeof bv === "string") { + return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av); + } + return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); + }); + return sortKey === "rank" ? rows : copy; + }, [rows, sortKey, sortDir]); + + return ( +
+ {/* Controls */} + + +
+
+ + +
+ +
+ + { + const n = Math.max(5, Math.min(100, Number(e.target.value || 25))); + setLimit(n); + setCursor(0); + }} + /> + +
+
+
+
+ + {/* Table */} + + +
+ + + + toggleSort("rank")}> + Rank {sortKey === "rank" ? (sortDir === "asc" ? "↑" : "↓") : ""} + + toggleSort("userId")}> + User {sortKey === "userId" ? (sortDir === "asc" ? "↑" : "↓") : ""} + + toggleSort("total")}> + Total {sortKey === "total" ? (sortDir === "asc" ? "↑" : "↓") : ""} + + toggleSort("github")}> + GitHub {sortKey === "github" ? (sortDir === "asc" ? "↑" : "↓") : ""} + + toggleSort("gitlab")}> + GitLab {sortKey === "gitlab" ? (sortDir === "asc" ? "↑" : "↓") : ""} + + + + + {loading && ( + + + Loading… + + + )} + + {!loading && error && ( + + + {error} + + + )} + + {!loading && !error && sortedRows.length === 0 && ( + + + No entries yet. + + + )} + + {!loading && !error && sortedRows.map((r, idx) => ( + + {(cursor || 0) + idx + 1} + +
+
+
+
{r.userId}
+
UUID
+
+
+ + {r.total} + {r.github} + {r.gitlab} + + ))} + +
+
+
+
+ + {/* Pagination */} +
+ +
+ {rows?.length || 0} rows • {window.toUpperCase()} +
+ +
+
+ ); +} diff --git a/packages/api/src/leaderboard/read.ts b/packages/api/src/leaderboard/read.ts index 4ddca0eb..2f71a238 100644 --- a/packages/api/src/leaderboard/read.ts +++ b/packages/api/src/leaderboard/read.ts @@ -1,80 +1,112 @@ -import { sql, eq, desc } from "drizzle-orm"; -import type { DB } from "@workspace/db"; -import { redis } from "../redis/client"; -import { contribTotals } from "@workspace/db/schema"; +import { contribTotals } from '@workspace/db/schema'; +import { sql, eq, desc } from 'drizzle-orm'; +import { redis } from '../redis/client'; +import type { DB } from '@workspace/db'; -export type WindowKey = "all" | "30d" | "365d"; -export type ProviderSel = "combined" | "github" | "gitlab"; +export type WindowKey = 'all' | '30d' | '365d'; +export type ProviderSel = 'combined' | 'github' | 'gitlab'; const COMBINED_KEYS = { - all: "lb:total:all", - "30d": "lb:total:30d", - "365d": "lb:total:365d", + all: 'lb:total:all', + '30d': 'lb:total:30d', + '365d': 'lb:total:365d', } as const; const PROVIDER_KEYS = { github: { - all: "lb:github:all", - "30d": "lb:github:30d", - "365d": "lb:github:365d", + all: 'lb:github:all', + '30d': 'lb:github:30d', + '365d': 'lb:github:365d', }, gitlab: { - all: "lb:gitlab:all", - "30d": "lb:gitlab:30d", - "365d": "lb:gitlab:365d", + all: 'lb:gitlab:all', + '30d': 'lb:gitlab:30d', + '365d': 'lb:gitlab:365d', }, } as const; -type ZRangeItemObj = { member: string; score: number | string }; -type LeaderRow = { userId: string; score: number }; +type ZRangeItemObj = { member?: unknown; score?: unknown }; +export type LeaderRow = { userId: string; score: number }; function keyFor(provider: ProviderSel, window: WindowKey): string { - if (provider === "combined") return COMBINED_KEYS[window]; + if (provider === 'combined') return COMBINED_KEYS[window]; return PROVIDER_KEYS[provider][window]; } +/** Robustly parse Upstash zrange results (supports object form and [member,score,...] form). */ function parseZRange(res: unknown): LeaderRow[] { - if (Array.isArray(res) && res.length && typeof res[0] === "object" && res[0] && "member" in (res[0])) { - return (res as ZRangeItemObj[]).map(({ member, score }) => ({ - userId: String(member), - score: typeof score === "string" ? Number(score) : Number(score ?? 0), - })); + if (!res) return []; + + // Upstash JS SDK commonly returns [{ member, score }, ...] + if (Array.isArray(res) && res.length > 0 && typeof res[0] === 'object' && res[0] !== null) { + return (res as ZRangeItemObj[]).flatMap((x) => { + const id = typeof x.member === 'string' ? x.member : String(x.member ?? ''); + const n = Number(x.score ?? 0); + return id ? [{ userId: id, score: Number.isFinite(n) ? n : 0 }] : []; + }); } + + // Some clients can return a flat tuple list: [member, score, member, score, ...] if (Array.isArray(res)) { const out: LeaderRow[] = []; for (let i = 0; i < res.length; i += 2) { - out.push({ userId: String(res[i] ?? ""), score: Number(res[i + 1] ?? 0) }); + const id = String(res[i] ?? ''); + const n = Number(res[i + 1] ?? 0); + if (id) out.push({ userId: id, score: Number.isFinite(n) ? n : 0 }); } return out; } + return []; } -async function topFromRedis(provider: ProviderSel, window: WindowKey, start: number, stop: number): Promise { - const key = keyFor(provider, window); - const res = await redis.zrange(key, start, stop, { rev: true, withScores: true }); - return parseZRange(res); +/** Read a page from Redis; swallow errors to allow DB fallback. */ +async function topFromRedis( + provider: ProviderSel, + window: WindowKey, + start: number, + stop: number, +): Promise { + try { + const key = keyFor(provider, window); + const res = await redis.zrange(key, start, stop, { rev: true, withScores: true }); + return parseZRange(res); + } catch (err) { + // Do not fail the request if Redis is unavailable; let DB handle it. + + console.error('Redis error in topFromRedis:', err); + return []; + } } - -async function topFromDb(db: DB, provider: ProviderSel, window: WindowKey, limit: number, offset: number): Promise { - const col = window === "all" ? contribTotals.allTime - : window === "30d" ? contribTotals.last30d - : contribTotals.last365d; - - if (provider === "combined") { +async function topFromDb( + db: DB, + provider: ProviderSel, + window: WindowKey, + limit: number, + offset: number, +): Promise { + const col = + window === 'all' + ? contribTotals.allTime + : window === '30d' + ? contribTotals.last30d + : contribTotals.last365d; + + if (provider === 'combined') { + const sumExpr = sql`SUM(${col})`; const rows = await db .select({ userId: contribTotals.userId, - score: sql`SUM(${col})`.as("score"), + score: sumExpr.as('score'), }) .from(contribTotals) .groupBy(contribTotals.userId) - .orderBy(desc(sql`SUM(${col})`)) + .orderBy(desc(sumExpr)) .limit(limit) .offset(offset); - return rows.map(r => ({ userId: r.userId, score: Number(r.score || 0) })); + return rows.map((r) => ({ userId: r.userId, score: Number(r.score ?? 0) })); } const rows = await db @@ -88,7 +120,7 @@ async function topFromDb(db: DB, provider: ProviderSel, window: WindowKey, limit .limit(limit) .offset(offset); - return rows.map(r => ({ userId: r.userId, score: Number(r.score || 0) })); + return rows.map((r) => ({ userId: r.userId, score: Number(r.score ?? 0) })); } export async function getLeaderboardPage( @@ -99,18 +131,20 @@ export async function getLeaderboardPage( limit: number; cursor?: number; }, -): Promise<{ entries: LeaderRow[]; nextCursor: number | null; source: "redis" | "db" }> { +): Promise<{ entries: LeaderRow[]; nextCursor: number | null; source: 'redis' | 'db' }> { const limit = Math.min(Math.max(opts.limit, 1), 100); const start = Math.max(opts.cursor ?? 0, 0); const stop = start + limit - 1; + // 1) Try Redis first; if it fails or empty, fallback below. const fromRedis = await topFromRedis(opts.provider, opts.window, start, stop); if (fromRedis.length > 0) { - const next = fromRedis.length === limit ? start + limit : null; - return { entries: fromRedis, nextCursor: next, source: "redis" }; + const nextCursor = fromRedis.length === limit ? start + limit : null; + return { entries: fromRedis, nextCursor, source: 'redis' }; } + // 2) Fallback to DB const fromDb = await topFromDb(db, opts.provider, opts.window, limit, start); - const next = fromDb.length === limit ? start + limit : null; - return { entries: fromDb, nextCursor: next, source: "db" }; + const nextCursor = fromDb.length === limit ? start + limit : null; + return { entries: fromDb, nextCursor, source: 'db' }; } From f30a9cc324843d0050d46c20720cdd52b64b25ef Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 18 Aug 2025 15:11:59 +0530 Subject: [PATCH 11/20] feat(leaderboard): add Redis-backed profiles + API and hydrate UI --- .../internal/leaderboard/backfill/route.ts | 50 ++-- .../internal/leaderboard/refresh-day/route.ts | 2 + .../web/app/api/leaderboard/profiles/route.ts | 22 ++ .../leaderboard/leaderboard-client.tsx | 230 +++++++++++++----- packages/api/package.json | 3 +- packages/api/src/leaderboard/useMeta.ts | 61 +++++ scripts/lb-set-meta.ts | 44 ++++ 7 files changed, 321 insertions(+), 91 deletions(-) create mode 100644 apps/web/app/api/leaderboard/profiles/route.ts create mode 100644 packages/api/src/leaderboard/useMeta.ts create mode 100644 scripts/lb-set-meta.ts diff --git a/apps/web/app/api/internal/leaderboard/backfill/route.ts b/apps/web/app/api/internal/leaderboard/backfill/route.ts index ed2d8db2..897bfb29 100644 --- a/apps/web/app/api/internal/leaderboard/backfill/route.ts +++ b/apps/web/app/api/internal/leaderboard/backfill/route.ts @@ -1,16 +1,17 @@ -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -import { NextRequest } from "next/server"; -import { z } from "zod/v4"; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; -import { env } from "@workspace/env/server"; -import { isCronAuthorized } from "@workspace/env/verify-cron"; +import { isCronAuthorized } from '@workspace/env/verify-cron'; +import { env } from '@workspace/env/server'; -import { db } from "@workspace/db"; -import { refreshUserDayRange } from "@workspace/api/aggregator"; -import { syncUserLeaderboards } from "@workspace/api/redis"; -import { backfillLockKey, withLock, acquireLock, releaseLock } from "@workspace/api/locks"; +import { backfillLockKey, withLock, acquireLock, releaseLock } from '@workspace/api/locks'; +import { refreshUserDayRange } from '@workspace/api/aggregator'; +import { setUserMetaFromProviders } from '@workspace/api/meta'; +import { syncUserLeaderboards } from '@workspace/api/redis'; +import { db } from '@workspace/db'; function startOfUtcDay(d = new Date()) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); @@ -24,8 +25,8 @@ function addDaysUTC(d: Date, days: number) { function ymd(d: Date) { const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } @@ -38,12 +39,12 @@ const Body = z concurrency: z.number().int().min(1).max(8).optional(), }) .refine((b) => !!b.githubLogin || !!b.gitlabUsername, { - message: "At least one of githubLogin or gitlabUsername is required.", + message: 'At least one of githubLogin or gitlabUsername is required.', }); export async function POST(req: NextRequest) { - const auth = req.headers.get("authorization"); - if (!isCronAuthorized(auth)) return new Response("Unauthorized", { status: 401 }); + const auth = req.headers.get('authorization'); + if (!isCronAuthorized(auth)) return new Response('Unauthorized', { status: 401 }); const json = await req.json().catch(() => ({})); const parsed = Body.safeParse(json); @@ -54,19 +55,21 @@ export async function POST(req: NextRequest) { const days = Math.min(Math.max(body.days ?? 30, 1), 365); const from = addDaysUTC(today, -(days - 1)); - const providers = ([ - ...(body.githubLogin ? (["github"] as const) : []), - ...(body.gitlabUsername ? (["gitlab"] as const) : []), - ] as Array<"github" | "gitlab">).sort(); + const providers = ( + [ + ...(body.githubLogin ? (['github'] as const) : []), + ...(body.gitlabUsername ? (['gitlab'] as const) : []), + ] as Array<'github' | 'gitlab'> + ).sort(); const ttlSec = Math.min(15 * 60, Math.max(2 * 60, days * 2)); const autoConcurrency = days > 180 ? 3 : days > 60 ? 4 : 6; const concurrency = Math.min(Math.max(body.concurrency ?? autoConcurrency, 1), 8); - const githubToken = env.GITHUB_TOKEN; + const githubToken = env.GITHUB_TOKEN; const gitlabToken = env.GITLAB_TOKEN; - const gitlabBaseUrl = env.GITLAB_ISSUER || "https://gitlab.com"; + const gitlabBaseUrl = env.GITLAB_ISSUER || 'https://gitlab.com'; async function run() { const res = await refreshUserDayRange( @@ -84,6 +87,7 @@ export async function POST(req: NextRequest) { }, ); await syncUserLeaderboards(db, body.userId); + await setUserMetaFromProviders(body.userId, body.githubLogin, body.gitlabUsername); return res; } @@ -125,8 +129,8 @@ export async function POST(req: NextRequest) { }); } catch (err: unknown) { const msg = String(err instanceof Error ? err.message : err); - if (msg.startsWith("LOCK_CONFLICT")) { - const p = msg.split(":")[1] || "unknown"; + if (msg.startsWith('LOCK_CONFLICT')) { + const p = msg.split(':')[1] || 'unknown'; return new Response(`Conflict: backfill already running for ${p}`, { status: 409 }); } return new Response(`Internal Error: ${msg}`, { status: 500 }); diff --git a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts index 8677aa73..0069d089 100644 --- a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts +++ b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts @@ -9,6 +9,7 @@ import { isCronAuthorized } from "@workspace/env/verify-cron"; import { db } from "@workspace/db"; import { refreshUserDayRange } from "@workspace/api/aggregator"; +import { setUserMetaFromProviders } from "@workspace/api/meta"; import { syncUserLeaderboards } from "@workspace/api/redis"; import { withLock, acquireLock, releaseLock } from "@workspace/api/locks"; @@ -88,6 +89,7 @@ export async function POST(req: NextRequest) { }, ); await syncUserLeaderboards(db, body.userId); + await setUserMetaFromProviders(body.userId, body.githubLogin, body.gitlabUsername); return res; } diff --git a/apps/web/app/api/leaderboard/profiles/route.ts b/apps/web/app/api/leaderboard/profiles/route.ts new file mode 100644 index 00000000..53ecaf1d --- /dev/null +++ b/apps/web/app/api/leaderboard/profiles/route.ts @@ -0,0 +1,22 @@ +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { getUserMetas } from '@workspace/api/meta'; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; + +const Body = z.object({ + userIds: z.array(z.string().min(1)).min(1).max(200), +}); + +export async function POST(req: NextRequest) { + const json = await req.json().catch(() => ({})); + const parsed = Body.safeParse(json); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { userIds } = parsed.data; + const entries = await getUserMetas(userIds); + return Response.json({ ok: true, entries }); +} diff --git a/apps/web/components/leaderboard/leaderboard-client.tsx b/apps/web/components/leaderboard/leaderboard-client.tsx index f647622f..3bb33eb3 100644 --- a/apps/web/components/leaderboard/leaderboard-client.tsx +++ b/apps/web/components/leaderboard/leaderboard-client.tsx @@ -1,50 +1,62 @@ -"use client"; +'use client'; -import * as React from "react"; -import { useRouter, useSearchParams } from "next/navigation"; +import { useRouter, useSearchParams } from 'next/navigation'; +import * as React from 'react'; // shadcn/ui -import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@workspace/ui/components/select"; -import { Input } from "@workspace/ui/components/input"; -import { Button } from "@workspace/ui/components/button"; import { - Table, TableHeader, TableRow, TableHead, TableBody, TableCell, -} from "@workspace/ui/components/table"; -import { Card, CardContent } from "@workspace/ui/components/card"; + Table, + TableHeader, + TableRow, + TableHead, + TableBody, + TableCell, +} from '@workspace/ui/components/table'; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from '@workspace/ui/components/select'; +import { Card, CardContent } from '@workspace/ui/components/card'; +import { Button } from '@workspace/ui/components/button'; +import { Input } from '@workspace/ui/components/input'; -type WindowKey = "all" | "30d" | "365d"; +type WindowKey = 'all' | '30d' | '365d'; type TopEntry = { userId: string; score: number }; type DetailsEntry = { userId: string; github: number; gitlab: number; total: number }; type LeaderRow = { + _profile: any; userId: string; total: number; github: number; gitlab: number; }; -type SortKey = "rank" | "userId" | "total" | "github" | "gitlab"; -type SortDir = "asc" | "desc"; +type SortKey = 'rank' | 'userId' | 'total' | 'github' | 'gitlab'; +type SortDir = 'asc' | 'desc'; async function fetchTop(window: WindowKey, limit: number, cursor = 0) { const url = `/api/leaderboard?window=${window}&provider=combined&limit=${limit}&cursor=${cursor}`; - const res = await fetch(url, { cache: "no-store" }); + const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to fetch leaderboard: ${await res.text()}`); return (await res.json()) as { ok: boolean; entries: TopEntry[]; nextCursor: number | null; - source: "redis" | "db"; + source: 'redis' | 'db'; }; } async function fetchDetails(window: WindowKey, userIds: string[]) { if (userIds.length === 0) return { ok: true, window, entries: [] as DetailsEntry[] }; const res = await fetch(`/api/leaderboard/details`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - cache: "no-store", + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', body: JSON.stringify({ window, userIds }), }); if (!res.ok) throw new Error(`Failed to fetch details: ${await res.text()}`); @@ -63,8 +75,32 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi const [cursor, setCursor] = React.useState(0); const [nextCursor, setNextCursor] = React.useState(null); - const [sortKey, setSortKey] = React.useState("rank"); - const [sortDir, setSortDir] = React.useState("asc"); + const [sortKey, setSortKey] = React.useState('rank'); + const [sortDir, setSortDir] = React.useState('asc'); + + type Profile = { + userId: string; + username?: string; + avatarUrl?: string; + githubLogin?: string; + gitlabUsername?: string; + }; + + async function fetchProfiles(userIds: string[]): Promise { + if (!userIds.length) return []; + const res = await fetch('/api/leaderboard/profiles', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + cache: 'no-store', + body: JSON.stringify({ userIds }), + }); + if (!res.ok) { + const t = await res.text(); + throw new Error(`profiles ${res.status}: ${t.slice(0, 160)}${t.length > 160 ? '…' : ''}`); + } + const data = (await res.json()) as { ok: true; entries: Profile[] }; + return data.entries; + } const doFetch = React.useCallback(async (w: WindowKey, lim: number, cur: number) => { setLoading(true); @@ -72,16 +108,21 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi try { const top = await fetchTop(w, lim, cur); const ids = top.entries.map((e) => e.userId); - const details = await fetchDetails(w, ids); + + const [details, profiles] = await Promise.all([fetchDetails(w, ids), fetchProfiles(ids)]); const detailMap = new Map(details.entries.map((d) => [d.userId, d])); + const profileMap = new Map(profiles.map((p) => [p.userId, p])); + const merged: LeaderRow[] = top.entries.map((e) => { const d = detailMap.get(e.userId); + const p = profileMap.get(e.userId); return { userId: e.userId, total: d?.total ?? e.score ?? 0, github: d?.github ?? 0, gitlab: d?.gitlab ?? 0, + _profile: p, }; }); @@ -96,18 +137,23 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi } }, []); - React.useEffect(() => { doFetch(window, limit, cursor); }, [window, limit, cursor, doFetch]); + React.useEffect(() => { + doFetch(window, limit, cursor); + }, [window, limit, cursor, doFetch]); // keep URL in sync with window React.useEffect(() => { - const params = new URLSearchParams(search?.toString?.() || ""); - params.set("window", window); + const params = new URLSearchParams(search?.toString?.() || ''); + params.set('window', window); router.replace(`/leaderboard?${params.toString()}`); }, [window]); // eslint-disable-line react-hooks/exhaustive-deps function toggleSort(k: SortKey) { - if (sortKey === k) setSortDir(sortDir === "asc" ? "desc" : "asc"); - else { setSortKey(k); setSortDir(k === "rank" ? "asc" : "desc"); } + if (sortKey === k) setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); + else { + setSortKey(k); + setSortDir(k === 'rank' ? 'asc' : 'desc'); + } } const sortedRows = React.useMemo(() => { @@ -117,18 +163,34 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi let av: number | string = 0; let bv: number | string = 0; switch (sortKey) { - case "userId": av = a.userId; bv = b.userId; break; - case "total": av = a.total; bv = b.total; break; - case "github": av = a.github; bv = b.github; break; - case "gitlab": av = a.gitlab; bv = b.gitlab; break; - case "rank": default: av = 0; bv = 0; break; // original rank order + case 'userId': + av = a.userId; + bv = b.userId; + break; + case 'total': + av = a.total; + bv = b.total; + break; + case 'github': + av = a.github; + bv = b.github; + break; + case 'gitlab': + av = a.gitlab; + bv = b.gitlab; + break; + case 'rank': + default: + av = 0; + bv = 0; + break; // original rank order } - if (typeof av === "string" && typeof bv === "string") { - return sortDir === "asc" ? av.localeCompare(bv) : bv.localeCompare(av); + if (typeof av === 'string' && typeof bv === 'string') { + return sortDir === 'asc' ? av.localeCompare(bv) : bv.localeCompare(av); } - return sortDir === "asc" ? Number(av) - Number(bv) : Number(bv) - Number(av); + return sortDir === 'asc' ? Number(av) - Number(bv) : Number(bv) - Number(av); }); - return sortKey === "rank" ? rows : copy; + return sortKey === 'rank' ? rows : copy; }, [rows, sortKey, sortDir]); return ( @@ -136,10 +198,16 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi {/* Controls */} -
+
- { + setCursor(0); + setWindow(v); + }} + > @@ -184,27 +252,39 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi - toggleSort("rank")}> - Rank {sortKey === "rank" ? (sortDir === "asc" ? "↑" : "↓") : ""} + toggleSort('rank')}> + Rank {sortKey === 'rank' ? (sortDir === 'asc' ? '↑' : '↓') : ''} - toggleSort("userId")}> - User {sortKey === "userId" ? (sortDir === "asc" ? "↑" : "↓") : ""} + toggleSort('userId')} + > + User {sortKey === 'userId' ? (sortDir === 'asc' ? '↑' : '↓') : ''} - toggleSort("total")}> - Total {sortKey === "total" ? (sortDir === "asc" ? "↑" : "↓") : ""} + toggleSort('total')} + > + Total {sortKey === 'total' ? (sortDir === 'asc' ? '↑' : '↓') : ''} - toggleSort("github")}> - GitHub {sortKey === "github" ? (sortDir === "asc" ? "↑" : "↓") : ""} + toggleSort('github')} + > + GitHub {sortKey === 'github' ? (sortDir === 'asc' ? '↑' : '↓') : ''} - toggleSort("gitlab")}> - GitLab {sortKey === "gitlab" ? (sortDir === "asc" ? "↑" : "↓") : ""} + toggleSort('gitlab')} + > + GitLab {sortKey === 'gitlab' ? (sortDir === 'asc' ? '↑' : '↓') : ''} {loading && ( - + Loading… @@ -212,7 +292,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi {!loading && error && ( - + {error} @@ -220,29 +300,43 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi {!loading && !error && sortedRows.length === 0 && ( - + No entries yet. )} - {!loading && !error && sortedRows.map((r, idx) => ( - - {(cursor || 0) + idx + 1} - -
-
-
-
{r.userId}
-
UUID
+ {!loading && + !error && + sortedRows.map((r, idx) => ( + + {(cursor || 0) + idx + 1} + +
+
+ {r?._profile?.avatarUrl ? ( + + ) : null} +
+
+
+ {r._profile?.username || r.userId} +
+
+ {r._profile?.githubLogin || r._profile?.gitlabUsername || '—'} +
+
-
- - {r.total} - {r.github} - {r.gitlab} - - ))} + + {r.total} + {r.github} + {r.gitlab} + + ))}
@@ -258,12 +352,14 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi > Previous -
+
{rows?.length || 0} rows • {window.toUpperCase()}
diff --git a/packages/api/package.json b/packages/api/package.json index e39a0852..8154bb69 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -9,7 +9,8 @@ "./aggregator": "./src/leaderboard/aggregator.ts", "./redis": "./src/leaderboard/redis.ts", "./locks": "./src/redis/lock.ts", - "./read": "./src/leaderboard/read.ts" + "./read": "./src/leaderboard/read.ts", + "./meta": "./src/leaderboard/useMeta.ts" }, "scripts": { "lint": "eslint ." diff --git a/packages/api/src/leaderboard/useMeta.ts b/packages/api/src/leaderboard/useMeta.ts new file mode 100644 index 00000000..94b99cdb --- /dev/null +++ b/packages/api/src/leaderboard/useMeta.ts @@ -0,0 +1,61 @@ +import { redis } from "../redis/client"; + +export type UserMeta = { + userId: string; + username?: string; + avatarUrl?: string; + githubLogin?: string; + gitlabUsername?: string; +}; + +const metaKey = (userId: string) => `lb:user:${userId}`; + +export async function setUserMetaFromProviders( + userId: string, + githubLogin?: string | null, + gitlabUsername?: string | null, +): Promise { + const updates: Record = {}; + + if (githubLogin && githubLogin.trim()) { + const gh = githubLogin.trim(); + updates.githubLogin = gh; + if (!updates.username) updates.username = gh; + if (!updates.avatarUrl) updates.avatarUrl = `https://github.com/${gh}.png?size=80`; + } + if (gitlabUsername && gitlabUsername.trim()) { + const gl = gitlabUsername.trim(); + updates.gitlabUsername = gl; + if (!updates.username) updates.username = gl; + if (!updates.avatarUrl) updates.avatarUrl = `https://gitlab.com/${gl}.png?width=80`; + } + + if (Object.keys(updates).length > 0) { + await redis.hset(metaKey(userId), updates); + } +} + +/** Bulk read profile meta for a list of userIds (order preserved). */ +export async function getUserMetas(userIds: string[]): Promise { + const pipe = redis.pipeline(); + for (const id of userIds) pipe.hgetall(metaKey(id)); + const rows = await pipe.exec(); + + return rows.map((raw, i) => { + const id = userIds[i]!; + const m = (raw || {}) as Record; + const username = m.username || m.githubLogin || m.gitlabUsername || id.slice(0, 8); + const avatarUrl = + m.avatarUrl || + (m.githubLogin ? `https://github.com/${m.githubLogin}.png?size=80` : undefined) || + (m.gitlabUsername ? `https://gitlab.com/${m.gitlabUsername}.png?width=80` : undefined); + + return { + userId: id, + username, + avatarUrl, + githubLogin: m.githubLogin, + gitlabUsername: m.gitlabUsername, + }; + }); +} diff --git a/scripts/lb-set-meta.ts b/scripts/lb-set-meta.ts new file mode 100644 index 00000000..40870086 --- /dev/null +++ b/scripts/lb-set-meta.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env bun +import { redis } from "../packages/api/src/redis/client"; + +function usage() { + console.log("Usage: bun scripts/lb-set-meta.ts --user-id [--gh ] [--gl ] [--name ] [--avatar ]"); + process.exit(1); +} + +type Args = { userId?: string; gh?: string; gl?: string; name?: string; avatar?: string; }; +function parse(argv: string[]): Args { + const a: Args = {}; + for (let i = 2; i < argv.length; i++) { + const k = argv[i], v = argv[i + 1]; + if (!v) continue; + if (k === "--user-id") a.userId = v, i++; + else if (k === "--gh") a.gh = v, i++; + else if (k === "--gl") a.gl = v, i++; + else if (k === "--name") a.name = v, i++; + else if (k === "--avatar") a.avatar = v, i++; + } + return a; +} + +const args = parse(process.argv); +if (!args.userId || (!args.gh && !args.gl && !args.name && !args.avatar)) usage(); + +const key = (id: string) => `lb:user:${id}`; + +const updates: Record = {}; +if (args.name) updates.username = args.name; +if (args.avatar) updates.avatarUrl = args.avatar; +if (args.gh) { + updates.githubLogin = args.gh; + if (!updates.username) updates.username = args.gh; + if (!updates.avatarUrl) updates.avatarUrl = `https://github.com/${args.gh}.png?size=80`; +} +if (args.gl) { + updates.gitlabUsername = args.gl; + if (!updates.username) updates.username = args.gl; + if (!updates.avatarUrl) updates.avatarUrl = `https://gitlab.com/${args.gl}.png?width=80`; +} + +await redis.hset(key(args.userId!), updates); +console.log("OK set", key(args.userId!), updates); From e0474cc5c84587af991b02009125e26923ff4eb5 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 18 Aug 2025 15:23:30 +0530 Subject: [PATCH 12/20] fix(export): guard user map writes to satisfy TS and avoid undefined access --- apps/web/app/api/leaderboard/export/route.ts | 131 ++++++++++++++++++ .../leaderboard/leaderboard-client.tsx | 41 +++++- scripts/lb-set-meta.ts | 44 ------ 3 files changed, 167 insertions(+), 49 deletions(-) create mode 100644 apps/web/app/api/leaderboard/export/route.ts delete mode 100644 scripts/lb-set-meta.ts diff --git a/apps/web/app/api/leaderboard/export/route.ts b/apps/web/app/api/leaderboard/export/route.ts new file mode 100644 index 00000000..eae0f5af --- /dev/null +++ b/apps/web/app/api/leaderboard/export/route.ts @@ -0,0 +1,131 @@ +// apps/web/app/api/leaderboard/export/route.ts +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +import { getLeaderboardPage, type ProviderSel, type WindowKey } from '@workspace/api/read'; // or '@workspace/api/leaderboard/read' +import { contribTotals } from '@workspace/db/schema'; +import { getUserMetas } from '@workspace/api/meta'; // or '@workspace/api/leaderboard/userMeta' +import { NextRequest } from 'next/server'; +import { inArray } from 'drizzle-orm'; +import { db } from '@workspace/db'; +import { z } from 'zod/v4'; + +const Query = z.object({ + provider: z.enum(['combined', 'github', 'gitlab']).default('combined'), + window: z.enum(['all', '30d', '365d']).default('30d'), + limit: z.coerce.number().int().min(1).max(2000).default(500), + cursor: z.coerce.number().int().min(0).default(0), +}); + +export async function GET(req: NextRequest) { + const parsed = Query.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { provider, window, limit, cursor } = parsed.data; + + // Page through leaderboard to collect up to `limit` rows + let entries: Array<{ userId: string; score: number }> = []; + let next = cursor; + + while (entries.length < limit) { + const page = await getLeaderboardPage(db, { + provider: provider as ProviderSel, + window: window as WindowKey, + limit: Math.min(200, limit - entries.length), // page chunk + cursor: next, + }); + entries.push(...page.entries); + if (page.nextCursor == null) break; + next = page.nextCursor; + } + entries = entries.slice(0, limit); + + // Per-provider breakdown for this window + const userIds = entries.map((e) => e.userId); + const col = + window === 'all' + ? contribTotals.allTime + : window === '30d' + ? contribTotals.last30d + : contribTotals.last365d; + + const rows = userIds.length + ? await db + .select({ + userId: contribTotals.userId, + provider: contribTotals.provider, // 'github' | 'gitlab' + score: col, + }) + .from(contribTotals) + .where(inArray(contribTotals.userId, userIds)) + : []; + + // Ensure we always have an object for each userId before assignment + const byUser: Record = Object.create(null); + for (const id of userIds) byUser[id] = { github: 0, gitlab: 0, total: 0 }; + + for (const r of rows) { + const s = Number(r.score ?? 0); + const u = (byUser[r.userId] ??= { github: 0, gitlab: 0, total: 0 }); + if (r.provider === 'github') u.github = s; + else if (r.provider === 'gitlab') u.gitlab = s; + } + for (const id of userIds) { + const u = byUser[id]; + if (u) { + u.total = u.github + u.gitlab; + } + } + + // Profiles (username/avatar; avatar not used in CSV) + const metas = await getUserMetas(userIds); + const metaMap = new Map(metas.map((m) => [m.userId, m])); + + // Build CSV + const header = [ + 'rank', + 'userId', + 'username', + 'githubLogin', + 'gitlabUsername', + 'total', + 'github', + 'gitlab', + ]; + + const lines = [header.join(',')]; + + entries.forEach((e, idx) => { + const rank = cursor + idx + 1; + const m = metaMap.get(e.userId); + const agg = byUser[e.userId] || { github: 0, gitlab: 0, total: e.score }; + const row = [ + rank, + e.userId, + m?.username ?? '', + m?.githubLogin ?? '', + m?.gitlabUsername ?? '', + agg.total, + agg.github, + agg.gitlab, + ] + .map((v) => (typeof v === 'string' ? `"${v.replace(/"/g, '""')}"` : String(v))) + .join(','); + + lines.push(row); + }); + + const csv = lines.join('\n'); + const filename = `leaderboard_${provider}_${window}_${new Date().toISOString().slice(0, 10)}.csv`; + + return new Response(csv, { + status: 200, + headers: { + 'Content-Type': 'text/csv; charset=utf-8', + 'Content-Disposition': `attachment; filename="${filename}"`, + 'Cache-Control': 'no-store', + }, + }); +} diff --git a/apps/web/components/leaderboard/leaderboard-client.tsx b/apps/web/components/leaderboard/leaderboard-client.tsx index 3bb33eb3..7e968a04 100644 --- a/apps/web/components/leaderboard/leaderboard-client.tsx +++ b/apps/web/components/leaderboard/leaderboard-client.tsx @@ -27,6 +27,7 @@ type WindowKey = 'all' | '30d' | '365d'; type TopEntry = { userId: string; score: number }; type DetailsEntry = { userId: string; github: number; gitlab: number; total: number }; +type ProviderSel = 'combined' | 'github' | 'gitlab'; type LeaderRow = { _profile: any; @@ -39,8 +40,8 @@ type LeaderRow = { type SortKey = 'rank' | 'userId' | 'total' | 'github' | 'gitlab'; type SortDir = 'asc' | 'desc'; -async function fetchTop(window: WindowKey, limit: number, cursor = 0) { - const url = `/api/leaderboard?window=${window}&provider=combined&limit=${limit}&cursor=${cursor}`; +async function fetchTop(window: WindowKey, provider: ProviderSel, limit: number, cursor = 0) { + const url = `/api/leaderboard?window=${window}&provider=${provider}&limit=${limit}&cursor=${cursor}`; const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to fetch leaderboard: ${await res.text()}`); return (await res.json()) as { @@ -74,6 +75,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi const [limit, setLimit] = React.useState(25); const [cursor, setCursor] = React.useState(0); const [nextCursor, setNextCursor] = React.useState(null); + const [provider, setProvider] = React.useState('combined'); const [sortKey, setSortKey] = React.useState('rank'); const [sortDir, setSortDir] = React.useState('asc'); @@ -106,7 +108,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi setLoading(true); setError(null); try { - const top = await fetchTop(w, lim, cur); + const top = await fetchTop(w, provider, lim, cur); const ids = top.entries.map((e) => e.userId); const [details, profiles] = await Promise.all([fetchDetails(w, ids), fetchProfiles(ids)]); @@ -141,12 +143,12 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi doFetch(window, limit, cursor); }, [window, limit, cursor, doFetch]); - // keep URL in sync with window React.useEffect(() => { const params = new URLSearchParams(search?.toString?.() || ''); params.set('window', window); + params.set('provider', provider); router.replace(`/leaderboard?${params.toString()}`); - }, [window]); // eslint-disable-line react-hooks/exhaustive-deps + }, [window, provider]); // eslint-disable-line react-hooks/exhaustive-deps function toggleSort(k: SortKey) { if (sortKey === k) setSortDir(sortDir === 'asc' ? 'desc' : 'asc'); @@ -364,6 +366,35 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi Next
+ +
+ + +
+ + + Export CSV +
); } diff --git a/scripts/lb-set-meta.ts b/scripts/lb-set-meta.ts deleted file mode 100644 index 40870086..00000000 --- a/scripts/lb-set-meta.ts +++ /dev/null @@ -1,44 +0,0 @@ -#!/usr/bin/env bun -import { redis } from "../packages/api/src/redis/client"; - -function usage() { - console.log("Usage: bun scripts/lb-set-meta.ts --user-id [--gh ] [--gl ] [--name ] [--avatar ]"); - process.exit(1); -} - -type Args = { userId?: string; gh?: string; gl?: string; name?: string; avatar?: string; }; -function parse(argv: string[]): Args { - const a: Args = {}; - for (let i = 2; i < argv.length; i++) { - const k = argv[i], v = argv[i + 1]; - if (!v) continue; - if (k === "--user-id") a.userId = v, i++; - else if (k === "--gh") a.gh = v, i++; - else if (k === "--gl") a.gl = v, i++; - else if (k === "--name") a.name = v, i++; - else if (k === "--avatar") a.avatar = v, i++; - } - return a; -} - -const args = parse(process.argv); -if (!args.userId || (!args.gh && !args.gl && !args.name && !args.avatar)) usage(); - -const key = (id: string) => `lb:user:${id}`; - -const updates: Record = {}; -if (args.name) updates.username = args.name; -if (args.avatar) updates.avatarUrl = args.avatar; -if (args.gh) { - updates.githubLogin = args.gh; - if (!updates.username) updates.username = args.gh; - if (!updates.avatarUrl) updates.avatarUrl = `https://github.com/${args.gh}.png?size=80`; -} -if (args.gl) { - updates.gitlabUsername = args.gl; - if (!updates.username) updates.username = args.gl; - if (!updates.avatarUrl) updates.avatarUrl = `https://gitlab.com/${args.gl}.png?width=80`; -} - -await redis.hset(key(args.userId!), updates); -console.log("OK set", key(args.userId!), updates); From 8102979991092a344e673963eb2c7ed58def3e1e Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Mon, 18 Aug 2025 16:33:09 +0530 Subject: [PATCH 13/20] feat(cron): add the cron job for fetching contributions --- apps/web/app/(public)/leaderboard/page.tsx | 2 +- .../internal/leaderboard/backfill/route.ts | 4 +- .../internal/leaderboard/cron/daily/route.ts | 155 ++++++++++++++++++ .../internal/leaderboard/refresh-day/route.ts | 64 ++++---- apps/web/app/api/leaderboard/export/route.ts | 5 +- .../web/app/api/leaderboard/profiles/route.ts | 2 +- .../leaderboard/leaderboard-client.tsx | 99 ++++++----- packages/api/package.json | 6 +- packages/api/src/leaderboard/meta.ts | 41 +++++ packages/api/src/leaderboard/redis.ts | 67 +++++--- packages/auth/src/leaderboard-hooks.ts | 40 +++++ packages/auth/src/server.ts | 39 +++++ scripts/lb-seed-from-total.ts | 52 ++++++ vercel.json | 4 + 14 files changed, 475 insertions(+), 105 deletions(-) create mode 100644 apps/web/app/api/internal/leaderboard/cron/daily/route.ts create mode 100644 packages/api/src/leaderboard/meta.ts create mode 100644 packages/auth/src/leaderboard-hooks.ts create mode 100644 scripts/lb-seed-from-total.ts diff --git a/apps/web/app/(public)/leaderboard/page.tsx b/apps/web/app/(public)/leaderboard/page.tsx index c7128d1e..d0bc6834 100644 --- a/apps/web/app/(public)/leaderboard/page.tsx +++ b/apps/web/app/(public)/leaderboard/page.tsx @@ -19,7 +19,7 @@ export default async function LeaderboardPage({ const window = getWindow(searchParams); return (
-
+

Global Leaderboard

Top contributors across GitHub and GitLab. diff --git a/apps/web/app/api/internal/leaderboard/backfill/route.ts b/apps/web/app/api/internal/leaderboard/backfill/route.ts index 897bfb29..3a6b8366 100644 --- a/apps/web/app/api/internal/leaderboard/backfill/route.ts +++ b/apps/web/app/api/internal/leaderboard/backfill/route.ts @@ -9,8 +9,8 @@ import { env } from '@workspace/env/server'; import { backfillLockKey, withLock, acquireLock, releaseLock } from '@workspace/api/locks'; import { refreshUserDayRange } from '@workspace/api/aggregator'; -import { setUserMetaFromProviders } from '@workspace/api/meta'; -import { syncUserLeaderboards } from '@workspace/api/redis'; +import { setUserMetaFromProviders } from '@workspace/api/use-meta'; +import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; import { db } from '@workspace/db'; function startOfUtcDay(d = new Date()) { diff --git a/apps/web/app/api/internal/leaderboard/cron/daily/route.ts b/apps/web/app/api/internal/leaderboard/cron/daily/route.ts new file mode 100644 index 00000000..92c86f93 --- /dev/null +++ b/apps/web/app/api/internal/leaderboard/cron/daily/route.ts @@ -0,0 +1,155 @@ +// apps/web/app/api/internal/leaderboard/cron/daily/route.ts +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; +export const revalidate = 0; + +import { NextRequest } from "next/server"; +import { z } from "zod/v4"; +import { env } from "@workspace/env/server"; +import { isCronAuthorized } from "@workspace/env/verify-cron"; + +import { db } from "@workspace/db"; +// NOTE: adjust these imports to match your barrels if needed: +import { refreshUserDayRange } from "@workspace/api/aggregator"; +import { syncUserLeaderboards } from "@workspace/api/leaderboard/redis"; +import { redis } from "@workspace/api/redis"; + +const USER_SET = "lb:users"; +const META = (id: string) => `lb:user:${id}`; + +const Query = z.object({ + limit: z.coerce.number().int().min(1).max(5000).default(1000), + concurrency: z.coerce.number().int().min(1).max(8).default(4), + dry: z + .union([z.literal("1"), z.literal("true"), z.literal("0"), z.literal("false")]) + .optional(), +}); + +function startOfUtcDay(d = new Date()) { + return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); +} + +export async function GET(req: NextRequest) { + // Auth: CRON_SECRET or Vercel Cron header + const ok = + isCronAuthorized(req.headers.get("authorization")) || + !!req.headers.get("x-vercel-cron"); + if (!ok) return new Response("Unauthorized", { status: 401 }); + + const parsed = Query.safeParse(Object.fromEntries(req.nextUrl.searchParams)); + if (!parsed.success) { + return new Response(`Bad Request: ${parsed.error.message}`, { status: 400 }); + } + const { limit, concurrency, dry } = parsed.data; + const isDry = dry === "1" || dry === "true"; + + const today = startOfUtcDay(new Date()); + const yesterday = new Date(today); yesterday.setUTCDate(yesterday.getUTCDate() - 1); + + try { + // Quick Redis sanity check + await redis.ping(); + + // Enumerate users to process + const allIdsRaw = await redis.smembers(USER_SET); + const userIds = (Array.isArray(allIdsRaw) ? allIdsRaw : []).map(String).slice(0, limit); + + // Early exit if none + if (userIds.length === 0) { + return Response.json({ + ok: true, + scanned: 0, + processed: 0, + skipped: 0, + errors: [], + window: { from: yesterday.toISOString().slice(0, 10), to: today.toISOString().slice(0, 10) }, + note: `No user IDs in Redis set "${USER_SET}". Run a backfill/refresh to seed it.`, + }); + } + + // Read provider handles from meta + const pipe = redis.pipeline(); + for (const id of userIds) pipe.hgetall(META(id)); + const metaRows = await pipe.exec(); + + let processed = 0; + let skipped = 0; + const errors: Array<{ userId: string; error: string }> = []; + + if (isDry) { + // Return a preview of what would run + const preview = userIds.map((id, i) => { + const m = (metaRows[i] || {}) as Record; + return { userId: id, githubLogin: m.githubLogin || null, gitlabUsername: m.gitlabUsername || null }; + }); + return Response.json({ + ok: true, + dryRun: true, + scanned: userIds.length, + sample: preview.slice(0, 10), + window: { from: yesterday.toISOString().slice(0, 10), to: today.toISOString().slice(0, 10) }, + }); + } + + // Bounded parallelism + const workers = Math.max(1, Math.min(concurrency, 8)); + let idx = 0; + const tasks = Array.from({ length: workers }, async () => { + while (true) { + const i = idx++; + if (i >= userIds.length) break; + + const userId = userIds[i]!; + const m = (metaRows[i] || {}) as Record; + const githubLogin = m.githubLogin?.trim() || undefined; + const gitlabUsername = m.gitlabUsername?.trim() || undefined; + + if (!githubLogin && !gitlabUsername) { + skipped++; + continue; + } + + try { + await refreshUserDayRange( + { db }, + { + userId, + githubLogin, + gitlabUsername, + fromDayUtc: yesterday, + toDayUtc: today, + githubToken: env.GITHUB_TOKEN, // may be undefined ⇒ GH skipped + gitlabToken: env.GITLAB_TOKEN, // optional + gitlabBaseUrl: env.GITLAB_ISSUER || "https://gitlab.com", + concurrency: workers, + }, + ); + + await syncUserLeaderboards(db, userId); + processed++; + } catch (err: unknown) { + errors.push({ userId, error: String(err instanceof Error ? err.message : err) }); + } + } + }); + + await Promise.all(tasks); + + return Response.json({ + ok: true, + scanned: userIds.length, + processed, + skipped, + errors, + window: { from: yesterday.toISOString().slice(0, 10), to: today.toISOString().slice(0, 10) }, + }); + } catch (err: unknown) { + // Surface the actual error during development + const msg = String(err instanceof Error ? `${err.name}: ${err.message}` : err); + if (env.VERCEL_ENV !== "production") { + console.error("[cron/daily] fatal:", err); + return new Response(`Internal Error: ${msg}`, { status: 500 }); + } + return new Response("Internal Error", { status: 500 }); + } +} diff --git a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts index 0069d089..da6f10d5 100644 --- a/apps/web/app/api/internal/leaderboard/refresh-day/route.ts +++ b/apps/web/app/api/internal/leaderboard/refresh-day/route.ts @@ -1,17 +1,17 @@ -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; -import { NextRequest } from "next/server"; -import { z } from "zod/v4"; +import { NextRequest } from 'next/server'; +import { z } from 'zod/v4'; -import { env } from "@workspace/env/server"; -import { isCronAuthorized } from "@workspace/env/verify-cron"; +import { isCronAuthorized } from '@workspace/env/verify-cron'; +import { env } from '@workspace/env/server'; -import { db } from "@workspace/db"; -import { refreshUserDayRange } from "@workspace/api/aggregator"; -import { setUserMetaFromProviders } from "@workspace/api/meta"; -import { syncUserLeaderboards } from "@workspace/api/redis"; -import { withLock, acquireLock, releaseLock } from "@workspace/api/locks"; +import { withLock, acquireLock, releaseLock } from '@workspace/api/locks'; +import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; +import { setUserMetaFromProviders } from '@workspace/api/use-meta'; +import { refreshUserDayRange } from '@workspace/api/aggregator'; +import { db } from '@workspace/db'; function startOfUtcDay(d = new Date()) { return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); @@ -19,8 +19,8 @@ function startOfUtcDay(d = new Date()) { function ymd(d: Date) { const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, "0"); - const dd = String(d.getUTCDate()).padStart(2, "0"); + const m = String(d.getUTCMonth() + 1).padStart(2, '0'); + const dd = String(d.getUTCDate()).padStart(2, '0'); return `${y}-${m}-${dd}`; } @@ -29,17 +29,23 @@ const Body = z userId: z.string().min(1), githubLogin: z.string().min(1).optional(), gitlabUsername: z.string().min(1).optional(), - fromDayUtc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), - toDayUtc: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), + fromDayUtc: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), + toDayUtc: z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/) + .optional(), concurrency: z.number().int().min(1).max(8).optional(), }) .refine((b) => !!b.githubLogin || !!b.gitlabUsername, { - message: "At least one of githubLogin or gitlabUsername is required.", + message: 'At least one of githubLogin or gitlabUsername is required.', }); export async function POST(req: NextRequest) { - const auth = req.headers.get("authorization"); - if (!isCronAuthorized(auth)) return new Response("Unauthorized", { status: 401 }); + const auth = req.headers.get('authorization'); + if (!isCronAuthorized(auth)) return new Response('Unauthorized', { status: 401 }); const json = await req.json().catch(() => ({})); const parsed = Body.safeParse(json); @@ -53,24 +59,26 @@ export async function POST(req: NextRequest) { const fromDay = body.fromDayUtc ? new Date(`${body.fromDayUtc}T00:00:00Z`) : yesterday; const toDay = body.toDayUtc ? new Date(`${body.toDayUtc}T00:00:00Z`) : today; if (fromDay.getTime() > toDay.getTime()) { - return new Response("Bad Request: fromDayUtc must be <= toDayUtc", { status: 400 }); + return new Response('Bad Request: fromDayUtc must be <= toDayUtc', { status: 400 }); } - const providers = ([ - ...(body.githubLogin ? (["github"] as const) : []), - ...(body.gitlabUsername ? (["gitlab"] as const) : []), - ] as Array<"github" | "gitlab">).sort(); + const providers = ( + [ + ...(body.githubLogin ? (['github'] as const) : []), + ...(body.gitlabUsername ? (['gitlab'] as const) : []), + ] as Array<'github' | 'gitlab'> + ).sort(); const ttlSec = 3 * 60; const autoConcurrency = 6; const concurrency = Math.min(Math.max(body.concurrency ?? autoConcurrency, 1), 8); - const githubToken = env.GITHUB_TOKEN; + const githubToken = env.GITHUB_TOKEN; const gitlabToken = env.GITLAB_TOKEN; - const gitlabBaseUrl = env.GITLAB_ISSUER || "https://gitlab.com"; + const gitlabBaseUrl = env.GITLAB_ISSUER || 'https://gitlab.com'; - const lockKey = (p: "github" | "gitlab") => + const lockKey = (p: 'github' | 'gitlab') => `lock:refresh:${p}:${body.userId}:${ymd(fromDay)}:${ymd(toDay)}`; async function run() { @@ -130,8 +138,8 @@ export async function POST(req: NextRequest) { }); } catch (err: unknown) { const msg = String(err instanceof Error ? err.message : err); - if (msg.startsWith("LOCK_CONFLICT")) { - const p = msg.split(":")[1] || "unknown"; + if (msg.startsWith('LOCK_CONFLICT')) { + const p = msg.split(':')[1] || 'unknown'; return new Response(`Conflict: refresh already running for ${p}`, { status: 409 }); } return new Response(`Internal Error: ${msg}`, { status: 500 }); diff --git a/apps/web/app/api/leaderboard/export/route.ts b/apps/web/app/api/leaderboard/export/route.ts index eae0f5af..d824c3e9 100644 --- a/apps/web/app/api/leaderboard/export/route.ts +++ b/apps/web/app/api/leaderboard/export/route.ts @@ -4,8 +4,8 @@ export const dynamic = 'force-dynamic'; export const revalidate = 0; import { getLeaderboardPage, type ProviderSel, type WindowKey } from '@workspace/api/read'; // or '@workspace/api/leaderboard/read' +import { getUserMetas } from '@workspace/api/use-meta'; // or '@workspace/api/leaderboard/userMeta' import { contribTotals } from '@workspace/db/schema'; -import { getUserMetas } from '@workspace/api/meta'; // or '@workspace/api/leaderboard/userMeta' import { NextRequest } from 'next/server'; import { inArray } from 'drizzle-orm'; import { db } from '@workspace/db'; @@ -63,7 +63,8 @@ export async function GET(req: NextRequest) { : []; // Ensure we always have an object for each userId before assignment - const byUser: Record = Object.create(null); + const byUser: Record = + Object.create(null); for (const id of userIds) byUser[id] = { github: 0, gitlab: 0, total: 0 }; for (const r of rows) { diff --git a/apps/web/app/api/leaderboard/profiles/route.ts b/apps/web/app/api/leaderboard/profiles/route.ts index 53ecaf1d..a640f190 100644 --- a/apps/web/app/api/leaderboard/profiles/route.ts +++ b/apps/web/app/api/leaderboard/profiles/route.ts @@ -2,7 +2,7 @@ export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const revalidate = 0; -import { getUserMetas } from '@workspace/api/meta'; +import { getUserMetas } from '@workspace/api/use-meta'; import { NextRequest } from 'next/server'; import { z } from 'zod/v4'; diff --git a/apps/web/components/leaderboard/leaderboard-client.tsx b/apps/web/components/leaderboard/leaderboard-client.tsx index 7e968a04..12eefaf9 100644 --- a/apps/web/components/leaderboard/leaderboard-client.tsx +++ b/apps/web/components/leaderboard/leaderboard-client.tsx @@ -104,40 +104,44 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi return data.entries; } - const doFetch = React.useCallback(async (w: WindowKey, lim: number, cur: number) => { - setLoading(true); - setError(null); - try { - const top = await fetchTop(w, provider, lim, cur); - const ids = top.entries.map((e) => e.userId); + const doFetch = React.useCallback( + async (w: WindowKey, lim: number, cur: number) => { + setLoading(true); + setError(null); + try { + const top = await fetchTop(w, provider, lim, cur); + const ids = top.entries.map((e) => e.userId); - const [details, profiles] = await Promise.all([fetchDetails(w, ids), fetchProfiles(ids)]); + const [details, profiles] = await Promise.all([fetchDetails(w, ids), fetchProfiles(ids)]); - const detailMap = new Map(details.entries.map((d) => [d.userId, d])); - const profileMap = new Map(profiles.map((p) => [p.userId, p])); + const detailMap = new Map(details.entries.map((d) => [d.userId, d])); + const profileMap = new Map(profiles.map((p) => [p.userId, p])); - const merged: LeaderRow[] = top.entries.map((e) => { - const d = detailMap.get(e.userId); - const p = profileMap.get(e.userId); - return { - userId: e.userId, - total: d?.total ?? e.score ?? 0, - github: d?.github ?? 0, - gitlab: d?.gitlab ?? 0, - _profile: p, - }; - }); + const merged: LeaderRow[] = top.entries.map((e) => { + const d = detailMap.get(e.userId); + const p = profileMap.get(e.userId); + return { + userId: e.userId, + total: d?.total ?? e.score ?? 0, + github: d?.github ?? 0, + gitlab: d?.gitlab ?? 0, + _profile: p, + }; + }); - setRows(merged); - setNextCursor(top.nextCursor); - } catch (err: any) { - setError(String(err?.message || err)); - setRows([]); - setNextCursor(null); - } finally { - setLoading(false); - } - }, []); + setRows(merged); + setNextCursor(top.nextCursor); + } catch (err: any) { + setError(String(err?.message || err)); + setRows([]); + setNextCursor(null); + } finally { + setLoading(false); + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [provider], + ); React.useEffect(() => { doFetch(window, limit, cursor); @@ -198,7 +202,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi return (

{/* Controls */} - +
@@ -210,13 +214,19 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi setWindow(v); }} > - + - - Last 30 days - Last year - All time + + + Last 30 days + + + Last year + + + All time +
@@ -224,7 +234,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi
doFetch(window, limit, 0)} disabled={loading} + className='rounded-none' > Refresh @@ -248,7 +259,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi {/* Table */} - +
@@ -349,6 +360,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi
- + +
+ + +
+
+ + { + const n = Math.max(5, Math.min(100, Number(e.target.value || 25))); + setLimit(n); + setCursor(0); + }} + /> +
{/* Table */} - +
toggleSort('rank')}> - Rank {sortKey === 'rank' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + Rank toggleSort('userId')} > - User {sortKey === 'userId' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + User toggleSort('total')} > - Total {sortKey === 'total' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + Total + + toggleSort('commits')} + > + Commits toggleSort('github')} + onClick={() => toggleSort('prs')} > - GitHub {sortKey === 'github' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + PRs toggleSort('gitlab')} + onClick={() => toggleSort('issues')} > - GitLab {sortKey === 'gitlab' ? (sortDir === 'asc' ? '↑' : '↓') : ''} + Issues {loading && ( - + Loading… )} - {!loading && error && ( - + {error} )} - {!loading && !error && sortedRows.length === 0 && ( - + No entries yet. )} - {!loading && !error && sortedRows.map((r, idx) => ( - {(cursor || 0) + idx + 1} + {(cursor || 0) + idx + 1}
- {r?._profile?.avatarUrl ? ( + {r._profile?.avatarUrl && ( - ) : null} + )}
@@ -346,13 +351,17 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi
{r.total} - {r.github} - {r.gitlab} + {r.commits} + {r.prs} + {r.issues} ))}
+
+ Source: {source ?? '—'} {source === 'redis' ? '(live / warming)' : ''} +
@@ -360,7 +369,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi
-
- - -
-
diff --git a/packages/api/package.json b/packages/api/package.json index 4be1c9bb..9b11fb64 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -12,7 +12,8 @@ "./locks": "./src/redis/lock.ts", "./read": "./src/leaderboard/read.ts", "./use-meta": "./src/leaderboard/useMeta.ts", - "./meta": "./src/leaderboard/meta.ts" + "./meta": "./src/leaderboard/meta.ts", + "./kickoff": "./src/leaderboard/kickoff.ts" }, "scripts": { "lint": "eslint ." diff --git a/packages/api/src/leaderboard/aggregator.ts b/packages/api/src/leaderboard/aggregator.ts index f8464bfa..2d0a2747 100644 --- a/packages/api/src/leaderboard/aggregator.ts +++ b/packages/api/src/leaderboard/aggregator.ts @@ -1,270 +1,202 @@ -import { and, eq, gte, lt, sql } from 'drizzle-orm'; -import type { DB } from '@workspace/db'; +// packages/api/aggregators/contribRollups.ts +import { sql } from "drizzle-orm"; +import type { DB } from "@workspace/db"; -import { getGitlabContributionTotalsForDay } from '../providers/gitlab'; -import { getGithubContributionTotalsForDay } from '../providers/github'; +import { contribRollups } from "@workspace/db/schema"; // new table (user_id, period, commits, prs, issues, total, fetched_at, updated_at) +import { getGithubContributionRollups } from "../providers/github"; +import { getGitlabContributionRollups } from "../providers/gitlab"; -import { contribDaily, contribTotals, contribProvider } from '@workspace/db/schema'; - -export type Provider = (typeof contribProvider.enumValues)[number]; +// Providers: use the rollup functions that fetch today's snapshot windows export type AggregatorDeps = { db: DB }; -export type RefreshUserDayArgs = { +export type RefreshUserRollupsArgs = { userId: string; + + // Exactly one of these should be present for a user profile githubLogin?: string | null; gitlabUsername?: string | null; - dayUtc: Date | string; + + // Provider creds/config githubToken?: string; gitlabToken?: string; - gitlabBaseUrl?: string; - skipTotalsRecompute?: boolean; + gitlabBaseUrl?: string; // defaulted to https://gitlab.com if omitted }; -type GithubDayResult = { commits: number; prs: number; issues: number }; -type GitlabDayResult = { commits: number; mrs: number; issues: number }; -type RefreshResults = { github?: GithubDayResult; gitlab?: GitlabDayResult }; +type PeriodKey = "last_30d" | "last_365d"; -function startOfUtcDay(d: Date | string): Date { - const x = d instanceof Date ? new Date(d) : new Date(d); - return new Date(Date.UTC(x.getUTCFullYear(), x.getUTCMonth(), x.getUTCDate(), 0, 0, 0, 0)); -} -function addDaysUTC(d: Date, days: number): Date { - const x = new Date(d); - x.setUTCDate(x.getUTCDate() + days); - return x; -} -function ymdUTC(d: Date): string { - const y = d.getUTCFullYear(); - const m = String(d.getUTCMonth() + 1).padStart(2, '0'); - const dd = String(d.getUTCDate()).padStart(2, '0'); - return `${y}-${m}-${dd}`; +function nowUtc(): Date { + return new Date(); } -async function upsertDaily( +async function upsertRollup( db: DB, - args: { + params: { userId: string; - provider: Provider; - day: Date; + period: PeriodKey; commits: number; - prs: number; + prs: number; // for GitLab, pass MRs here issues: number; + fetchedAt: Date; }, -): Promise { - const dayStr = ymdUTC(args.day); - await db - .insert(contribDaily) - .values({ - userId: args.userId, - provider: args.provider, - dateUtc: dayStr, - commits: args.commits, - prs: args.prs, - issues: args.issues, - updatedAt: sql`now()`, - }) - .onConflictDoUpdate({ - target: [contribDaily.userId, contribDaily.provider, contribDaily.dateUtc], - set: { - commits: args.commits, - prs: args.prs, - issues: args.issues, - updatedAt: sql`now()`, - }, - }); -} +) { + const total = params.commits + params.prs + params.issues; -async function sumWindow( - db: DB, - userId: string, - provider: Provider, - fromInclusive: Date, - toExclusive: Date, -): Promise { - const [row] = await db - .select({ - total: sql`coalesce(sum(${contribDaily.commits} + ${contribDaily.prs} + ${contribDaily.issues}), 0)`, - }) - .from(contribDaily) - .where( - and( - eq(contribDaily.userId, userId), - eq(contribDaily.provider, provider), - gte(contribDaily.dateUtc, ymdUTC(fromInclusive)), - lt(contribDaily.dateUtc, ymdUTC(toExclusive)), - ), - ); - return (row?.total ?? 0) as number; -} - -async function sumAllTime(db: DB, userId: string, provider: Provider): Promise { - const [row] = await db - .select({ - total: sql`coalesce(sum(${contribDaily.commits} + ${contribDaily.prs} + ${contribDaily.issues}), 0)`, - }) - .from(contribDaily) - .where(and(eq(contribDaily.userId, userId), eq(contribDaily.provider, provider))); - return (row?.total ?? 0) as number; -} - -async function upsertTotals( - db: DB, - args: { userId: string; provider: Provider; allTime: number; last30d: number; last365d: number }, -): Promise { await db - .insert(contribTotals) + .insert(contribRollups) .values({ - userId: args.userId, - provider: args.provider, - allTime: args.allTime, - last30d: args.last30d, - last365d: args.last365d, + userId: params.userId, + period: params.period, + commits: params.commits, + prs: params.prs, + issues: params.issues, + total, + fetchedAt: params.fetchedAt, updatedAt: sql`now()`, }) .onConflictDoUpdate({ - target: [contribTotals.userId, contribTotals.provider], + target: [contribRollups.userId, contribRollups.period], set: { - allTime: args.allTime, - last30d: args.last30d, - last365d: args.last365d, + commits: params.commits, + prs: params.prs, + issues: params.issues, + total, + fetchedAt: params.fetchedAt, updatedAt: sql`now()`, }, }); } -async function recomputeProviderTotals( - db: DB, - userId: string, - provider: Provider, - now: Date = new Date(), -): Promise<{ allTime: number; last30d: number; last365d: number }> { - const today = startOfUtcDay(now); - const tomorrow = addDaysUTC(today, 1); - const d30 = addDaysUTC(today, -30); - const d365 = addDaysUTC(today, -365); +export async function refreshUserRollups( + deps: AggregatorDeps, + args: RefreshUserRollupsArgs, +): Promise<{ + provider: "github" | "gitlab" | "none"; + wrote: { + last_30d?: { commits: number; prs: number; issues: number; total: number }; + last_365d?: { commits: number; prs: number; issues: number; total: number }; + }; +}> { + const db = deps.db; + const now = nowUtc(); - const [last30d, last365d, allTime] = await Promise.all([ - sumWindow(db, userId, provider, d30, tomorrow), - sumWindow(db, userId, provider, d365, tomorrow), - sumAllTime(db, userId, provider), - ]); + // Decide provider (your app-level invariant: one or the other) + const hasGithub = !!args.githubLogin && !!args.githubToken; + const hasGitlab = !!args.gitlabUsername; // token optional for public, but recommended - await upsertTotals(db, { userId, provider, allTime, last30d, last365d }); - return { allTime, last30d, last365d }; -} + if (!hasGithub && !hasGitlab) { + return { provider: "none", wrote: {} }; + } -export async function refreshUserDay( - deps: AggregatorDeps, - args: RefreshUserDayArgs, -): Promise<{ day: string; updatedProviders: string[]; results: RefreshResults }> { - const db = deps.db; - const day = startOfUtcDay(args.dayUtc); - const results: RefreshResults = {}; + if (hasGithub && hasGitlab) { + // If this can never happen by design, you can throw instead. + // We'll prefer GitHub if both are accidentally present. + // throw new Error('User cannot have both GitHub and GitLab identities'); + } + + if (hasGithub) { + const roll = await getGithubContributionRollups(args.githubLogin!.trim(), args.githubToken!); - if (args.githubLogin && args.githubLogin.trim() && args.githubToken) { - const gh = await getGithubContributionTotalsForDay( - args.githubLogin.trim(), - day, - args.githubToken, - ); - results.github = { commits: gh.commits, prs: gh.prs, issues: gh.issues }; - await upsertDaily(db, { + await upsertRollup(db, { userId: args.userId, - provider: 'github', - day, - commits: gh.commits, - prs: gh.prs, - issues: gh.issues, + period: "last_30d", + commits: roll.last30d.commits, + prs: roll.last30d.prs, + issues: roll.last30d.issues, + fetchedAt: now, }); - if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, 'github', day); - } - if (args.gitlabUsername && args.gitlabUsername.trim()) { - const base = args.gitlabBaseUrl?.trim() || 'https://gitlab.com'; - const gl = await getGitlabContributionTotalsForDay( - args.gitlabUsername.trim(), - day, - base, - args.gitlabToken, - ); - results.gitlab = { commits: gl.commits, mrs: gl.mrs, issues: gl.issues }; - await upsertDaily(db, { + await upsertRollup(db, { userId: args.userId, - provider: 'gitlab', - day, - commits: gl.commits, - prs: gl.mrs, - issues: gl.issues, + period: "last_365d", + commits: roll.last365d.commits, + prs: roll.last365d.prs, + issues: roll.last365d.issues, + fetchedAt: now, }); - if (!args.skipTotalsRecompute) await recomputeProviderTotals(db, args.userId, 'gitlab', day); - } - - return { day: ymdUTC(day), updatedProviders: Object.keys(results), results }; -} -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T, index: number) => Promise, -): Promise { - const n = items.length; - const results = new Array(n); - const batchSize = Math.max(1, limit | 0); - - for (let start = 0; start < n; start += batchSize) { - const end = Math.min(start + batchSize, n); - const pending: Promise[] = []; - for (let i = start; i < end; i++) { - pending.push( - fn(items[i]!, i).then((r) => { - results[i] = r; - }), - ); - } - await Promise.all(pending); + return { + provider: "github", + wrote: { + last_30d: { + commits: roll.last30d.commits, + prs: roll.last30d.prs, + issues: roll.last30d.issues, + total: roll.last30d.commits + roll.last30d.prs + roll.last30d.issues, + }, + last_365d: { + commits: roll.last365d.commits, + prs: roll.last365d.prs, + issues: roll.last365d.issues, + total: roll.last365d.commits + roll.last365d.prs + roll.last365d.issues, + }, + }, + }; } - return results; + // GitLab path + const base = (args.gitlabBaseUrl?.trim() || "https://gitlab.com") as string; + const r = await getGitlabContributionRollups(args.gitlabUsername!.trim(), base, args.gitlabToken); + + // Map MRs -> prs for DB + await upsertRollup(db, { + userId: args.userId, + period: "last_30d", + commits: r.last30d.commits, + prs: r.last30d.mrs, + issues: r.last30d.issues, + fetchedAt: now, + }); + + await upsertRollup(db, { + userId: args.userId, + period: "last_365d", + commits: r.last365d.commits, + prs: r.last365d.mrs, + issues: r.last365d.issues, + fetchedAt: now, + }); + + return { + provider: "gitlab", + wrote: { + last_30d: { + commits: r.last30d.commits, + prs: r.last30d.mrs, + issues: r.last30d.issues, + total: r.last30d.commits + r.last30d.mrs + r.last30d.issues, + }, + last_365d: { + commits: r.last365d.commits, + prs: r.last365d.mrs, + issues: r.last365d.issues, + total: r.last365d.commits + r.last365d.mrs + r.last365d.issues, + }, + }, + }; } -export async function refreshUserDayRange( +/** + * Batch helper: refresh many users with a concurrency limit. + * Pass a list of user descriptors (each with either githubLogin or gitlabUsername). + */ +export async function refreshManyUsersRollups( deps: AggregatorDeps, - args: Omit & { - fromDayUtc: Date | string; - toDayUtc: Date | string; - concurrency?: number; - }, -): Promise<{ daysRefreshed: string[] }> { - const from = startOfUtcDay(args.fromDayUtc); - const toInclusive = startOfUtcDay(args.toDayUtc); - if (from.getTime() > toInclusive.getTime()) { - throw new Error('fromDayUtc must be <= toDayUtc'); - } - - // Build list of UTC days - const daysList: Date[] = []; - for (let d = new Date(from); d.getTime() <= toInclusive.getTime(); d = addDaysUTC(d, 1)) { - daysList.push(new Date(d)); - } - - // Run once per day with totals recompute skipped (we'll recompute once at the end) - const concurrency = Math.max(1, args.concurrency ?? 4); - const results = await mapWithConcurrency(daysList, concurrency, (day) => - refreshUserDay(deps, { ...args, dayUtc: day, skipTotalsRecompute: true }), - ); - - const days = results.map((r) => r.day); - days.sort(); // canonical order YYYY-MM-DD - - // Recompute provider totals once (current window) - if (args.githubLogin && args.githubToken) { - await recomputeProviderTotals(deps.db, args.userId, 'github', new Date()); - } - if (args.gitlabUsername) { - await recomputeProviderTotals(deps.db, args.userId, 'gitlab', new Date()); - } + users: readonly T[], + opts?: { concurrency?: number; onProgress?: (done: number, total: number) => void }, +) { + const limit = Math.max(1, opts?.concurrency ?? 4); + const total = users.length; + let idx = 0; + + const worker = async () => { + while (true) { + const i = idx++; + if (i >= total) return; + await refreshUserRollups(deps, users[i]!); + opts?.onProgress?.(i + 1, total); + } + }; - return { daysRefreshed: days }; + await Promise.all(Array.from({ length: limit }, worker)); } - diff --git a/packages/api/src/leaderboard/meta.ts b/packages/api/src/leaderboard/meta.ts index 23ab5520..1d38825f 100644 --- a/packages/api/src/leaderboard/meta.ts +++ b/packages/api/src/leaderboard/meta.ts @@ -1,11 +1,10 @@ // packages/api/src/leaderboard/meta.ts -import { redis } from "../redis/client"; -import { syncUserLeaderboards } from "./redis"; - -const USER_SET = "lb:users"; -const META = (id: string) => `lb:user:${id}`; +import { syncUserLeaderboards } from './redis'; +import { redis } from '../redis/client'; export type UserMetaInput = { + username?: string | null; + avatar?: string | null; githubLogin?: string | null; gitlabUsername?: string | null; }; @@ -16,26 +15,29 @@ export async function setUserMeta( opts: { seedLeaderboards?: boolean } = { seedLeaderboards: true }, ): Promise { const updates: Record = {}; - if (meta.githubLogin != null && meta.githubLogin.trim() !== "") { - updates.githubLogin = meta.githubLogin.trim(); - } - if (meta.gitlabUsername != null && meta.gitlabUsername.trim() !== "") { - updates.gitlabUsername = meta.gitlabUsername.trim(); - } + const put = (k: string, v?: string | null) => { + if (v && v.trim()) updates[k] = v.trim(); + }; + + // display fields + put('username', meta.username); + // write the avatar to multiple keys so any reader finds it + put('avatar', meta.avatar); + put('image', meta.avatar); + put('avatarUrl', meta.avatar); + put('imageUrl', meta.avatar); + + // provider handles + put('githubLogin', meta.githubLogin); + put('gitlabUsername', meta.gitlabUsername); - const pipe = redis.pipeline(); if (Object.keys(updates).length > 0) { - pipe.hset(META(userId), updates); + await redis.hset(`lb:user:${userId}`, updates); } - pipe.sadd(USER_SET, userId); - await pipe.exec(); + await redis.sadd('lb:users', userId); if (opts.seedLeaderboards) { - try { - const { db } = await import("@workspace/db"); - await syncUserLeaderboards(db, userId); - } catch (err) { - console.error("[setUserMeta] syncUserLeaderboards failed:", err); - } + const { db } = await import('@workspace/db'); + await syncUserLeaderboards(db, userId); } } diff --git a/packages/api/src/leaderboard/read.ts b/packages/api/src/leaderboard/read.ts index 2f71a238..39b8f5cc 100644 --- a/packages/api/src/leaderboard/read.ts +++ b/packages/api/src/leaderboard/read.ts @@ -1,56 +1,49 @@ -import { contribTotals } from '@workspace/db/schema'; -import { sql, eq, desc } from 'drizzle-orm'; -import { redis } from '../redis/client'; -import type { DB } from '@workspace/db'; - -export type WindowKey = 'all' | '30d' | '365d'; -export type ProviderSel = 'combined' | 'github' | 'gitlab'; - -const COMBINED_KEYS = { - all: 'lb:total:all', - '30d': 'lb:total:30d', - '365d': 'lb:total:365d', +import { sql, desc, eq } from "drizzle-orm"; +import type { DB } from "@workspace/db"; +import { redis } from "../redis/client"; + +// NEW schema +import { contribRollups } from "@workspace/db/schema"; + +/** Windows we support in rollups */ +export type WindowKey = "30d" | "365d"; + +/** Materialized period enum values in DB */ +type PeriodKey = "last_30d" | "last_365d"; + +const PERIOD_FROM_WINDOW: Record = { + "30d": "last_30d", + "365d": "last_365d", } as const; -const PROVIDER_KEYS = { - github: { - all: 'lb:github:all', - '30d': 'lb:github:30d', - '365d': 'lb:github:365d', - }, - gitlab: { - all: 'lb:gitlab:all', - '30d': 'lb:gitlab:30d', - '365d': 'lb:gitlab:365d', - }, +/** Redis keys for sorted sets (ZSET) with scores = totals */ +const REDIS_KEYS: Record = { + "30d": "lb:rollups:30d", + "365d": "lb:rollups:365d", } as const; -type ZRangeItemObj = { member?: unknown; score?: unknown }; export type LeaderRow = { userId: string; score: number }; -function keyFor(provider: ProviderSel, window: WindowKey): string { - if (provider === 'combined') return COMBINED_KEYS[window]; - return PROVIDER_KEYS[provider][window]; -} +type ZRangeItemObj = { member?: unknown; score?: unknown }; -/** Robustly parse Upstash zrange results (supports object form and [member,score,...] form). */ +/** Robustly parse Upstash zrange results (object form or tuple list). */ function parseZRange(res: unknown): LeaderRow[] { if (!res) return []; - // Upstash JS SDK commonly returns [{ member, score }, ...] - if (Array.isArray(res) && res.length > 0 && typeof res[0] === 'object' && res[0] !== null) { + // Common Upstash return: [{ member, score }, ...] + if (Array.isArray(res) && res.length > 0 && typeof res[0] === "object" && res[0] !== null) { return (res as ZRangeItemObj[]).flatMap((x) => { - const id = typeof x.member === 'string' ? x.member : String(x.member ?? ''); + const id = typeof x.member === "string" ? x.member : String(x.member ?? ""); const n = Number(x.score ?? 0); return id ? [{ userId: id, score: Number.isFinite(n) ? n : 0 }] : []; }); } - // Some clients can return a flat tuple list: [member, score, member, score, ...] + // Some clients return [member, score, member, score, ...] if (Array.isArray(res)) { const out: LeaderRow[] = []; for (let i = 0; i < res.length; i += 2) { - const id = String(res[i] ?? ''); + const id = String(res[i] ?? ""); const n = Number(res[i + 1] ?? 0); if (id) out.push({ userId: id, score: Number.isFinite(n) ? n : 0 }); } @@ -60,91 +53,71 @@ function parseZRange(res: unknown): LeaderRow[] { return []; } -/** Read a page from Redis; swallow errors to allow DB fallback. */ +/** Read a page from Redis; swallow errors so DB can be the fallback. */ async function topFromRedis( - provider: ProviderSel, window: WindowKey, start: number, stop: number, ): Promise { try { - const key = keyFor(provider, window); + const key = REDIS_KEYS[window]; const res = await redis.zrange(key, start, stop, { rev: true, withScores: true }); return parseZRange(res); } catch (err) { - // Do not fail the request if Redis is unavailable; let DB handle it. - - console.error('Redis error in topFromRedis:', err); + console.error("Redis error in topFromRedis:", err); return []; } } +/** DB fallback: read rollups for a window (period), ordered by total desc. */ async function topFromDb( db: DB, - provider: ProviderSel, window: WindowKey, limit: number, offset: number, ): Promise { - const col = - window === 'all' - ? contribTotals.allTime - : window === '30d' - ? contribTotals.last30d - : contribTotals.last365d; - - if (provider === 'combined') { - const sumExpr = sql`SUM(${col})`; - const rows = await db - .select({ - userId: contribTotals.userId, - score: sumExpr.as('score'), - }) - .from(contribTotals) - .groupBy(contribTotals.userId) - .orderBy(desc(sumExpr)) - .limit(limit) - .offset(offset); - - return rows.map((r) => ({ userId: r.userId, score: Number(r.score ?? 0) })); - } + const period = PERIOD_FROM_WINDOW[window]; // 'last_30d' | 'last_365d' const rows = await db .select({ - userId: contribTotals.userId, - score: col, + userId: contribRollups.userId, + score: contribRollups.total, }) - .from(contribTotals) - .where(eq(contribTotals.provider, provider)) - .orderBy(desc(col)) + .from(contribRollups) + .where(eq(contribRollups.period, period)) + .orderBy(desc(contribRollups.total)) .limit(limit) .offset(offset); return rows.map((r) => ({ userId: r.userId, score: Number(r.score ?? 0) })); } +/** + * Public API + * - Tries Redis ZSET first (fast path). + * - Falls back to DB scan on contribRollups for the requested window. + */ export async function getLeaderboardPage( db: DB, opts: { - provider: ProviderSel; - window: WindowKey; - limit: number; - cursor?: number; + window: WindowKey; // '30d' | '365d' + limit: number; // page size (1..100) + cursor?: number; // 0-based offset }, -): Promise<{ entries: LeaderRow[]; nextCursor: number | null; source: 'redis' | 'db' }> { +): Promise<{ entries: LeaderRow[]; nextCursor: number | null; source: "redis" | "db" }> { const limit = Math.min(Math.max(opts.limit, 1), 100); const start = Math.max(opts.cursor ?? 0, 0); const stop = start + limit - 1; - // 1) Try Redis first; if it fails or empty, fallback below. - const fromRedis = await topFromRedis(opts.provider, opts.window, start, stop); + // 1) Try Redis + const fromRedis = await topFromRedis(opts.window, start, stop); if (fromRedis.length > 0) { const nextCursor = fromRedis.length === limit ? start + limit : null; - return { entries: fromRedis, nextCursor, source: 'redis' }; + return { entries: fromRedis, nextCursor, source: "redis" }; } // 2) Fallback to DB - const fromDb = await topFromDb(db, opts.provider, opts.window, limit, start); + const fromDb = await topFromDb(db, opts.window, limit, start); const nextCursor = fromDb.length === limit ? start + limit : null; - return { entries: fromDb, nextCursor, source: 'db' }; + return { entries: fromDb, nextCursor, source: "db" }; } diff --git a/packages/api/src/leaderboard/redis.ts b/packages/api/src/leaderboard/redis.ts index b2ed0206..efd78718 100644 --- a/packages/api/src/leaderboard/redis.ts +++ b/packages/api/src/leaderboard/redis.ts @@ -1,111 +1,71 @@ -import { contribTotals } from '@workspace/db/schema'; -import { redis } from '../redis/client'; -import type { DB } from '@workspace/db'; -import { eq } from 'drizzle-orm'; +import { contribRollups } from "@workspace/db/schema"; +import { redis } from "../redis/client"; +import type { DB } from "@workspace/db"; +import { eq } from "drizzle-orm"; -type ProviderKey = 'github' | 'gitlab'; -type WindowKey = 'all' | '30d' | '365d'; +type PeriodKey = "last_30d" | "last_365d" | "all_time"; // depending on what you store -const COMBINED_KEYS = { - all: 'lb:total:all', - '30d': 'lb:total:30d', - '365d': 'lb:total:365d', -} as const; +// Redis ZSET keys for each period +const PERIOD_KEYS: Record = { + last_30d: "lb:rollups:30d", + last_365d: "lb:rollups:365d", + all_time: "lb:rollups:all", +}; -const PROVIDER_KEYS: Record> = { - github: { - all: 'lb:github:all', - '30d': 'lb:github:30d', - '365d': 'lb:github:365d', - }, - gitlab: { - all: 'lb:gitlab:all', - '30d': 'lb:gitlab:30d', - '365d': 'lb:gitlab:365d', - }, -} as const; - -const USER_SET = 'lb:users'; +const USER_SET = "lb:users"; export async function syncUserLeaderboards(db: DB, userId: string): Promise { + await redis.sadd(USER_SET, userId); + const rows = await db .select({ - provider: contribTotals.provider, - allTime: contribTotals.allTime, - last30d: contribTotals.last30d, - last365d: contribTotals.last365d, + period: contribRollups.period, + total: contribRollups.total, }) - .from(contribTotals) - .where(eq(contribTotals.userId, userId)); - - let combinedAll = 0; - let combined30 = 0; - let combined365 = 0; + .from(contribRollups) + .where(eq(contribRollups.userId, userId)); const pipe = redis.pipeline(); for (const r of rows) { - const p = r.provider as ProviderKey; - const all = Number(r.allTime || 0); - const d30 = Number(r.last30d || 0); - const d365 = Number(r.last365d || 0); - - combinedAll += all; - combined30 += d30; - combined365 += d365; - - const k = PROVIDER_KEYS[p]; - pipe.zadd(k.all, { score: all, member: userId }); - pipe.zadd(k['30d'], { score: d30, member: userId }); - pipe.zadd(k['365d'], { score: d365, member: userId }); + const period = r.period as PeriodKey; + const total = Number(r.total ?? 0); + const key = PERIOD_KEYS[period]; + if (key) { + pipe.zadd(key, { score: total, member: userId }); + } } - // Always write combined sets (even zeros) for stable ordering - pipe.zadd(COMBINED_KEYS.all, { score: combinedAll, member: userId }); - pipe.zadd(COMBINED_KEYS['30d'], { score: combined30, member: userId }); - pipe.zadd(COMBINED_KEYS['365d'], { score: combined365, member: userId }); - pipe.sadd(USER_SET, userId); - await pipe.exec(); } +/** Remove a user entirely from all leaderboard ZSETs. */ export async function removeUserFromLeaderboards(userId: string): Promise { - const keys = [ - COMBINED_KEYS.all, - COMBINED_KEYS['30d'], - COMBINED_KEYS['365d'], - ...Object.values(PROVIDER_KEYS).flatMap((k) => [k.all, k['30d'], k['365d']]), - ]; + const keys = Object.values(PERIOD_KEYS); const pipe = redis.pipeline(); for (const k of keys) pipe.zrem(k, userId); + pipe.srem(USER_SET, userId); await pipe.exec(); } -export async function topCombined(limit = 10, window: WindowKey = '30d') { - const key = COMBINED_KEYS[window]; - const res = await redis.zrange(key, 0, limit - 1, { - rev: true, - withScores: true, - }); +/** Get the top N users for a given window. */ +export async function topPeriod(limit = 10, period: PeriodKey = "last_30d") { + const key = PERIOD_KEYS[period]; + const res = await redis.zrange(key, 0, limit - 1, { rev: true, withScores: true }); - if ( - Array.isArray(res) && - res.length && - typeof res[0] === 'object' && - res[0] && - 'member' in res[0] - ) { + // parse Upstash results (objects or tuples) + if (Array.isArray(res) && res.length && typeof res[0] === "object") { return (res as Array<{ member: string; score: number | string }>).map(({ member, score }) => ({ userId: member, - score: typeof score === 'string' ? Number(score) : Number(score ?? 0), + score: typeof score === "string" ? Number(score) : Number(score ?? 0), })); } if (Array.isArray(res)) { const out: Array<{ userId: string; score: number }> = []; for (let i = 0; i < res.length; i += 2) { - const member = String(res[i] ?? ''); + const member = String(res[i] ?? ""); const score = Number(res[i + 1] ?? 0); out.push({ userId: member, score }); } @@ -115,6 +75,7 @@ export async function topCombined(limit = 10, window: WindowKey = '30d') { return []; } +/** List all user IDs ever synced into Redis leaderboards. */ export async function allKnownUserIds(): Promise { const ids = await redis.smembers(USER_SET); return Array.isArray(ids) ? ids.map(String) : []; diff --git a/packages/api/src/providers/github.ts b/packages/api/src/providers/github.ts index d384878f..4c21aa62 100644 --- a/packages/api/src/providers/github.ts +++ b/packages/api/src/providers/github.ts @@ -1,30 +1,51 @@ -const GITHUB_GQL_ENDPOINT = "https://api.github.com/graphql"; +// packages/api/providers/githubProvider.ts +/* GitHub provider: one-shot rollups for last 30d & last 365d */ +const GITHUB_GQL_ENDPOINT = 'https://api.github.com/graphql'; -import { z } from "zod/v4"; +import { z } from 'zod/v4'; +/* ------------------------- Types ------------------------- */ +export type DateLike = string | Date; +export type DateRange = { from: DateLike; to: DateLike }; + +export type GithubContributionTotals = { + login: string; + commits: number; // totalCommitContributions + prs: number; // totalPullRequestContributions + issues: number; // totalIssueContributions + rateLimit?: { + cost: number; + remaining: number; + resetAt: string; + }; +}; + +/* ------------------------- Schemas ------------------------- */ const RateLimitSchema = z .object({ cost: z.number(), remaining: z.number(), - resetAt: z.string(), // ISO datetime + resetAt: z.string(), }) .optional(); -const ContributionsSchema = z.object({ - restrictedContributionsCount: z.number().optional(), +const ContributionsWindowSchema = z.object({ totalCommitContributions: z.number(), totalPullRequestContributions: z.number(), totalIssueContributions: z.number(), }); -const UserContribsSchema = z.object({ +const UserWindowsSchema = z.object({ id: z.string(), login: z.string(), - contributionsCollection: ContributionsSchema, + // We alias two windows (c30, c365) or a generic one (cwin) + c30: ContributionsWindowSchema.optional(), + c365: ContributionsWindowSchema.optional(), + cwin: ContributionsWindowSchema.optional(), }); const GraphQLDataSchema = z.object({ - user: UserContribsSchema.nullable(), + user: UserWindowsSchema.nullable(), rateLimit: RateLimitSchema, }); @@ -41,36 +62,10 @@ const GraphQLResponseSchema = z.object({ .optional(), }); -export type GithubContributionTotals = { - login: string; - commits: number; - prs: number; - issues: number; - rateLimit?: { - cost: number; - remaining: number; - resetAt: string; - }; -}; - -export type DateLike = string | Date; -export type DateRange = { from: DateLike; to: DateLike }; - -function toIso8601(input: DateLike): string { - if (input instanceof Date) return input.toISOString(); - const maybe = new Date(input); - return isNaN(maybe.getTime()) ? String(input) : maybe.toISOString(); -} - -function startOfUtcDay(d: DateLike): Date { - const date = d instanceof Date ? new Date(d) : new Date(d); - return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0)); -} - -function addDaysUTC(d: Date, days: number): Date { - const copy = new Date(d); - copy.setUTCDate(copy.getUTCDate() + days); - return copy; +/* ------------------------- Utils ------------------------- */ +function toIsoDateTime(x: DateLike): string { + const d = typeof x === 'string' ? new Date(x) : x; + return d.toISOString(); } async function githubGraphQLRequest({ @@ -83,54 +78,50 @@ async function githubGraphQLRequest({ variables: Record; }): Promise { if (!token) { - throw new Error("GitHub GraphQL token is required. Pass GITHUB_TOKEN."); + throw new Error('GitHub GraphQL token is required. Pass GITHUB_TOKEN.'); } const res = await fetch(GITHUB_GQL_ENDPOINT, { - method: "POST", + method: 'POST', headers: { Authorization: `bearer ${token}`, - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify({ query, variables }), }); if (!res.ok) { - const text = await res.text().catch(() => ""); + const text = await res.text().catch(() => ''); throw new Error(`GitHub GraphQL HTTP ${res.status}: ${text || res.statusText}`); } const json = (await res.json()) as unknown; const parsed = GraphQLResponseSchema.safeParse(json); - if (!parsed.success) { - throw new Error("Unexpected GitHub GraphQL response shape"); - } - + if (!parsed.success) throw new Error('Unexpected GitHub GraphQL response shape'); if (parsed.data.errors?.length) { - const msgs = parsed.data.errors.map((e) => e.message).join("; "); + const msgs = parsed.data.errors.map((e) => e.message).join('; '); throw new Error(`GitHub GraphQL error(s): ${msgs}`); } const data = parsed.data.data; - if (!data) { - throw new Error("GitHub GraphQL returned no data"); - } - + if (!data) throw new Error('GitHub GraphQL returned no data'); return data as T; } +/* ------------------------- Public API ------------------------- */ + +/** Arbitrary range fetch (respects the provided DateRange). */ export async function getGithubContributionTotals( login: string, range: DateRange, token: string, ): Promise { - const query = /* GraphQL */ ` - query($login: String!, $from: DateTime!, $to: DateTime!) { + const query = ` + query ($login: String!, $from: DateTime!, $to: DateTime!) { user(login: $login) { id login - contributionsCollection(from: $from, to: $to) { - restrictedContributionsCount + cwin: contributionsCollection(from: $from, to: $to) { totalCommitContributions totalPullRequestContributions totalIssueContributions @@ -144,20 +135,13 @@ export async function getGithubContributionTotals( } `; - const variables = { - login, - from: toIso8601(range.from), - to: toIso8601(range.to), - }; - const data = await githubGraphQLRequest>({ token, query, - variables, + variables: { login, from: toIsoDateTime(range.from), to: toIsoDateTime(range.to) }, }); - if (!data.user) { - // If the user/login doesn't exist or is not visible, return zeros. + if (!data.user || !data.user.cwin) { return { login, commits: 0, @@ -167,22 +151,95 @@ export async function getGithubContributionTotals( }; } - const cc = data.user.contributionsCollection; + const w = data.user.cwin; return { login: data.user.login, - commits: cc.totalCommitContributions, - prs: cc.totalPullRequestContributions, - issues: cc.totalIssueContributions, + commits: w.totalCommitContributions, + prs: w.totalPullRequestContributions, + issues: w.totalIssueContributions, rateLimit: data.rateLimit ? { ...data.rateLimit } : undefined, }; } +/** + * One-shot rollups for LAST 30D & LAST 365D (as of "now"). + * Use this in your daily cron, then upsert two rows: + * - (userId, 'last_30d', commits/prs/issues/total) + * - (userId, 'last_365d', commits/prs/issues/total) + */ +export async function getGithubContributionRollups( + login: string, + token: string, +): Promise<{ + login: string; + last30d: GithubContributionTotals; + last365d: GithubContributionTotals; + rateLimit?: GithubContributionTotals['rateLimit']; +}> { + const now = new Date(); + const to = now.toISOString(); + const from30 = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + const from365 = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(); + + const query = ` + query ($login: String!, $from30: DateTime!, $from365: DateTime!, $to: DateTime!) { + user(login: $login) { + id + login + c30: contributionsCollection(from: $from30, to: $to) { + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + c365: contributionsCollection(from: $from365, to: $to) { + totalCommitContributions + totalPullRequestContributions + totalIssueContributions + } + } + rateLimit { + cost + remaining + resetAt + } + } + `; + + const data = await githubGraphQLRequest>({ + token, + query, + variables: { login, from30, from365, to }, + }); + + const rl = data.rateLimit ? { ...data.rateLimit } : undefined; + const userLogin = data.user?.login ?? login; + + const pick = (w?: z.infer): GithubContributionTotals => ({ + login: userLogin, + commits: w?.totalCommitContributions ?? 0, + prs: w?.totalPullRequestContributions ?? 0, + issues: w?.totalIssueContributions ?? 0, + rateLimit: rl, + }); + + return { + login: userLogin, + last30d: pick(data.user?.c30), + last365d: pick(data.user?.c365), + rateLimit: rl, + }; +} + +/** Optional: day-specific, kept for compatibility. */ export async function getGithubContributionTotalsForDay( login: string, dayUtc: DateLike, token: string, ): Promise { - const start = startOfUtcDay(dayUtc); - const end = addDaysUTC(start, 1); - return getGithubContributionTotals(login, { from: start, to: end }, token); + const d = typeof dayUtc === 'string' ? new Date(dayUtc) : dayUtc; + const from = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 0, 0, 0, 0)); + const to = new Date( + Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate(), 23, 59, 59, 999), + ); + return getGithubContributionTotals(login, { from, to }, token); } diff --git a/packages/api/src/providers/gitlab.ts b/packages/api/src/providers/gitlab.ts index fbae6f63..c6901d56 100644 --- a/packages/api/src/providers/gitlab.ts +++ b/packages/api/src/providers/gitlab.ts @@ -1,19 +1,25 @@ +// packages/api/providers/gitlabProvider.ts +/* GitLab provider: fetch 365d once, derive 30d from that same event set. */ import { z } from 'zod/v4'; +/* ------------------------- Types ------------------------- */ export type DateLike = string | Date; export type DateRange = { from: DateLike; to: DateLike }; export type GitlabContributionTotals = { username: string; commits: number; - mrs: number; + mrs: number; // map to "prs" when writing to DB issues: number; meta?: { pagesFetched: number; perPage: number; + publicEventsCount?: number; + totalEventsScanned?: number; }; }; +/* ------------------------- Schemas ------------------------- */ const GitlabUserSchema = z.object({ id: z.number(), username: z.string(), @@ -22,6 +28,7 @@ const GitlabUserSchema = z.object({ const GitlabEventSchema = z.object({ id: z.number(), + project_id: z.number().optional(), action_name: z.string().optional(), target_type: z.string().nullable().optional(), created_at: z.string(), @@ -32,7 +39,7 @@ const GitlabEventSchema = z.object({ .optional(), }); - +/* ------------------------- Utils ------------------------- */ function cleanBaseUrl(url: string): string { return url.endsWith('/') ? url.slice(0, -1) : url; } @@ -60,13 +67,7 @@ function sleep(ms: number) { return new Promise((r) => setTimeout(r, ms)); } - -/** - * GET wrapper with: - * - PRIVATE-TOKEN header (preferred) or Authorization: Bearer - * - 15s timeout - * - 429 handling + honor Retry-After (1..10s clamp), one retry - */ +/* ------------------------- HTTP ------------------------- */ async function glGet( baseUrl: string, path: string, @@ -92,6 +93,7 @@ async function glGet( try { let res = await fetch(u.toString(), { headers, signal: controller.signal }); + // Basic rate limiting retry if (res.status === 429) { const retryAfter = Number(res.headers.get('Retry-After') || 1); const waitSec = Math.min(Math.max(retryAfter, 1), 10); @@ -110,7 +112,7 @@ async function glGet( } } - +/* ------------------------- Core helpers ------------------------- */ export async function resolveGitlabUserId( username: string, baseUrl: string, @@ -130,12 +132,39 @@ type FetchEventsOptions = { maxPages?: number; }; +const projectVisibilityCache = new Map(); + +async function getProjectVisibility( + baseUrl: string, + projectId: number, + token?: string, +): Promise<'public' | 'private' | 'internal' | 'unknown'> { + const cached = projectVisibilityCache.get(projectId); + if (cached) return cached; + + try { + const res = await glGet(baseUrl, `/api/v4/projects/${projectId}`, token); + const data = (await res.json()) as { visibility?: string }; + const vis = (data?.visibility ?? 'unknown') as 'public' | 'private' | 'internal' | 'unknown'; + projectVisibilityCache.set(projectId, vis); + return vis; + } catch { + projectVisibilityCache.set(projectId, 'unknown'); + return 'unknown'; + } +} + async function fetchUserEventsByWindow( userId: number, baseUrl: string, token: string | undefined, opts: FetchEventsOptions, -): Promise<{ events: z.infer[]; pagesFetched: number; perPage: number }> { +): Promise<{ + events: z.infer[]; + pagesFetched: number; + perPage: number; + totalScanned: number; +}> { const perPage = Math.min(Math.max(opts.perPage ?? 100, 20), 100); const maxPages = Math.min(Math.max(opts.maxPages ?? 10, 1), 50); const lowerMs = new Date(opts.afterIso).getTime(); @@ -143,6 +172,7 @@ async function fetchUserEventsByWindow( let page = 1; let pagesFetched = 0; + let totalScanned = 0; const out: z.infer[] = []; while (true) { @@ -157,15 +187,18 @@ async function fetchUserEventsByWindow( const json = await res.json(); const events = z.array(GitlabEventSchema).parse(json); + totalScanned += events.length; - const filtered = events.filter((e) => { + // Enforce window just in case + const filteredByWindow = events.filter((e) => { const t = new Date(e.created_at).getTime(); return t >= lowerMs && t < upperMs; }); - out.push(...filtered); + out.push(...filteredByWindow); + // If page fully older than lower bound, we can break early if ( - filtered.length === 0 && + filteredByWindow.length === 0 && events.length > 0 && Math.max(...events.map((e) => new Date(e.created_at).getTime())) < lowerMs ) { @@ -183,10 +216,38 @@ async function fetchUserEventsByWindow( page = next; } - return { events: out, pagesFetched, perPage }; + return { events: out, pagesFetched, perPage, totalScanned }; } -function reduceContributionCounts(events: z.infer[]) { +async function filterPublicEvents( + baseUrl: string, + token: string | undefined, + events: z.infer[], +): Promise[]> { + const byProject = new Map[]>(); + const orphan: z.infer[] = []; + + for (const e of events) { + if (typeof e.project_id === 'number') { + const arr = byProject.get(e.project_id) ?? []; + arr.push(e); + byProject.set(e.project_id, arr); + } else { + orphan.push(e); + } + } + + const out: z.infer[] = []; + for (const [pid, list] of byProject) { + const vis = await getProjectVisibility(baseUrl, pid, token); + if (vis === 'public') out.push(...list); + } + // Orphans (no project_id) are ignored; they typically can't be attributed safely. + + return out; +} + +function reducePublicContributionCounts(events: z.infer[]) { let commits = 0; let mrs = 0; let issues = 0; @@ -195,6 +256,7 @@ function reduceContributionCounts(events: z.infer[]) { const target = e.target_type ?? undefined; const action = (e.action_name || '').toLowerCase(); + // Commits from push events if (e.push_data && typeof e.push_data.commit_count === 'number') { if (action.includes('push')) { commits += Math.max(0, e.push_data.commit_count || 0); @@ -202,11 +264,13 @@ function reduceContributionCounts(events: z.infer[]) { } } + // Opened MRs if (target === 'MergeRequest' && action === 'opened') { mrs += 1; continue; } + // Opened Issues if (target === 'Issue' && action === 'opened') { issues += 1; continue; @@ -216,12 +280,17 @@ function reduceContributionCounts(events: z.infer[]) { return { commits, mrs, issues }; } +/* ------------------------- Public API ------------------------- */ + +/** Arbitrary range (kept for completeness and testing). */ export async function getGitlabContributionTotals( username: string, range: DateRange, baseUrl: string, token?: string, ): Promise { + projectVisibilityCache.clear(); + const fromIso = toIso8601(range.from); const toIso = toIso8601(range.to); @@ -230,21 +299,146 @@ export async function getGitlabContributionTotals( return { username, commits: 0, mrs: 0, issues: 0 }; } - const { events, pagesFetched, perPage } = await fetchUserEventsByWindow(user.id, baseUrl, token, { - afterIso: fromIso, - beforeIso: toIso, - perPage: 100, - maxPages: 25, - }); + const { events, pagesFetched, perPage, totalScanned } = await fetchUserEventsByWindow( + user.id, + baseUrl, + token, + { + afterIso: fromIso, + beforeIso: toIso, + perPage: 100, + maxPages: 25, + }, + ); + + const publicEvents = await filterPublicEvents(baseUrl, token, events); + const totals = reducePublicContributionCounts(publicEvents); - const totals = reduceContributionCounts(events); return { username: user.username, ...totals, - meta: { pagesFetched, perPage }, + meta: { + pagesFetched, + perPage, + publicEventsCount: publicEvents.length, + totalEventsScanned: totalScanned, + }, + }; +} + +/** + * One-shot rollups for LAST 30D & LAST 365D (as of "now"). + * Implementation fetches 365d once and derives 30d from that same set. + */ +export async function getGitlabContributionRollups( + username: string, + baseUrl: string, + token?: string, +): Promise<{ + username: string; + last30d: GitlabContributionTotals; // map mrs -> prs on write + last365d: GitlabContributionTotals; // map mrs -> prs on write + meta: { + pagesFetched: number; + perPage: number; + publicEventsCount365: number; + publicEventsCount30: number; + totalEventsScanned: number; + windowFrom365: string; + windowTo: string; + }; +}> { + projectVisibilityCache.clear(); + + const now = new Date(); + const toIso = now.toISOString(); + const from365Iso = new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000).toISOString(); + const from30Iso = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000).toISOString(); + + const user = await resolveGitlabUserId(username, baseUrl, token); + if (!user) { + const empty: GitlabContributionTotals = { username, commits: 0, mrs: 0, issues: 0 }; + return { + username, + last30d: empty, + last365d: empty, + meta: { + pagesFetched: 0, + perPage: 0, + publicEventsCount365: 0, + publicEventsCount30: 0, + totalEventsScanned: 0, + windowFrom365: from365Iso, + windowTo: toIso, + }, + }; + } + + // Fetch once for 365d + const { events, pagesFetched, perPage, totalScanned } = await fetchUserEventsByWindow( + user.id, + baseUrl, + token, + { + afterIso: from365Iso, + beforeIso: toIso, + perPage: 100, + maxPages: 25, + }, + ); + + // Keep only public events + const publicEvents365 = await filterPublicEvents(baseUrl, token, events); + + // Derive 30d subset + const from30Ms = new Date(from30Iso).getTime(); + const publicEvents30 = publicEvents365.filter( + (e) => new Date(e.created_at).getTime() >= from30Ms, + ); + + // Reduce + const totals365 = reducePublicContributionCounts(publicEvents365); + const totals30 = reducePublicContributionCounts(publicEvents30); + + const last365d: GitlabContributionTotals = { + username: user.username, + ...totals365, + meta: { + pagesFetched, + perPage, + publicEventsCount: publicEvents365.length, + totalEventsScanned: totalScanned, + }, + }; + + const last30d: GitlabContributionTotals = { + username: user.username, + ...totals30, + meta: { + pagesFetched, + perPage, + publicEventsCount: publicEvents30.length, + totalEventsScanned: totalScanned, + }, + }; + + return { + username: user.username, + last30d, + last365d, + meta: { + pagesFetched, + perPage, + publicEventsCount365: publicEvents365.length, + publicEventsCount30: publicEvents30.length, + totalEventsScanned: totalScanned, + windowFrom365: from365Iso, + windowTo: toIso, + }, }; } +/** Optional: day-specific helper for parity. */ export async function getGitlabContributionTotalsForDay( username: string, dayUtc: DateLike, diff --git a/packages/api/src/redis/lock.ts b/packages/api/src/redis/lock.ts index f0e853a4..a7d65420 100644 --- a/packages/api/src/redis/lock.ts +++ b/packages/api/src/redis/lock.ts @@ -15,7 +15,7 @@ export async function releaseLock(key: string): Promise { export async function withLock(key: string, ttlSec: number, fn: () => Promise): Promise { const got = await acquireLock(key, ttlSec); - if (!got) throw new Error(`Lock in use: ${key}`); + if (!got) throw new Error('LOCK_CONFLICT'); // normalized try { return await fn(); } finally { diff --git a/packages/auth/src/server.ts b/packages/auth/src/server.ts index c2c7fdac..f90153c9 100644 --- a/packages/auth/src/server.ts +++ b/packages/auth/src/server.ts @@ -1,14 +1,21 @@ import { drizzleAdapter } from 'better-auth/adapters/drizzle'; import { secondaryStorage } from './secondary-storage'; +import { account } from '@workspace/db/schema'; import { env } from '@workspace/env/server'; import { admin } from 'better-auth/plugins'; import { betterAuth } from 'better-auth'; import { db } from '@workspace/db'; import 'server-only'; -import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; +import { setUserMetaFromProviders } from '@workspace/api/use-meta'; import { createAuthMiddleware } from 'better-auth/api'; import { setUserMeta } from '@workspace/api/meta'; +import { eq } from 'drizzle-orm'; + +const ORIGIN = + env.VERCEL_ENV === 'production' + ? `https://${env.VERCEL_PROJECT_PRODUCTION_URL}` + : `http://${env.VERCEL_PROJECT_PRODUCTION_URL || 'localhost:3000'}`; export const auth = betterAuth({ database: drizzleAdapter(db, { @@ -26,32 +33,110 @@ export const auth = betterAuth({ if (!session) return; const userId = session.user.id; - const path = ctx.path || ''; const username = session.user?.username as string | undefined; + const avatar: string | null | undefined = session.user?.image ?? null; + + const newAccount = ctx?.context?.newAccount as + | { + providerId?: string; // 'github' | 'gitlab' + accountId?: string; // handle (sometimes numeric id depending on provider) + userId?: string; + accessToken?: string; + } + | undefined; + + async function githubIdToLogin(id: string): Promise { + try { + const res = await fetch(`https://api.github.com/user/${id}`, { + headers: { 'User-Agent': 'ossdotnow' }, + }); + if (!res.ok) return undefined; + const j = await res.json().catch(() => null); + return (j && typeof j.login === 'string' && j.login) || undefined; + } catch { + return undefined; + } + } let githubLogin: string | undefined; let gitlabUsername: string | undefined; - if (path.includes('/oauth/github') || path.includes('/sign-up/github')) { - githubLogin = username; - } else if (path.includes('/oauth/gitlab') || path.includes('/sign-up/gitlab')) { - gitlabUsername = username; + if (newAccount?.providerId === 'github') { + githubLogin = session.user?.username; + } else if (newAccount?.providerId === 'gitlab') { + gitlabUsername = session.user?.username; } try { await setUserMeta( userId, - { githubLogin, gitlabUsername }, + { + username, + avatar, + githubLogin, + gitlabUsername, + }, { seedLeaderboards: false }, ); } catch (e) { - console.error('setUserMeta failed:', e); + console.error('[auth] setUserMeta failed:', e); + } + + if (!githubLogin && !gitlabUsername) { + const links = await db + .select({ providerId: account.providerId, accountId: account.accountId }) + .from(account) + .where(eq(account.userId, userId)); + + for (const l of links) { + if (!githubLogin && l.providerId === 'github' && l.accountId) { + const raw = l.accountId.trim(); + githubLogin = /^\d+$/.test(raw) ? await githubIdToLogin(raw) : raw; + } + if (!gitlabUsername && l.providerId === 'gitlab' && l.accountId) { + gitlabUsername = l.accountId.trim(); + } + } } try { - await syncUserLeaderboards(db, userId); + if (githubLogin || gitlabUsername) { + await setUserMetaFromProviders(userId, githubLogin, gitlabUsername); + } } catch (e) { - console.error('syncUserLeaderboards failed:', e); + console.error('[auth] setUserMetaFromProviders failed:', e); + } + + function backfill(body: unknown, label: string) { + return fetch(`${ORIGIN}/api/internal/leaderboard/backfill`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${env.CRON_SECRET}`, + }, + body: JSON.stringify(body), + }) + .then(async (r) => { + const text = await r.text().catch(() => ''); + console.log(`[auth] backfill ${label} ->`, r.status, text.slice(0, 200)); + return { ok: r.ok, status: r.status }; + }) + .catch((e) => { + console.warn(`[auth] backfill ${label} fetch failed:`, e); + return { ok: false, status: 0 }; + }); + } + + if (githubLogin || gitlabUsername) { + const body = { userId, githubLogin, gitlabUsername }; + + void backfill(body, 'rollups').then(async (res) => { + if (res.status === 409) { + setTimeout(() => { + void backfill(body, 'rollups retry'); + }, 60_000); + } + }); } }), }, diff --git a/packages/db/drizzle/0023_bent_strong_guy.sql b/packages/db/drizzle/0023_bent_strong_guy.sql new file mode 100644 index 00000000..abc6cfb1 --- /dev/null +++ b/packages/db/drizzle/0023_bent_strong_guy.sql @@ -0,0 +1,17 @@ +CREATE TYPE "public"."contrib_period" AS ENUM('all_time', 'last_30d', 'last_365d');--> statement-breakpoint +CREATE TABLE "contrib_rollups" ( + "user_id" text NOT NULL, + "period" "contrib_period" NOT NULL, + "commits" integer DEFAULT 0 NOT NULL, + "prs" integer DEFAULT 0 NOT NULL, + "issues" integer DEFAULT 0 NOT NULL, + "total" integer DEFAULT 0 NOT NULL, + "fetched_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +DROP TABLE "contrib_daily" CASCADE;--> statement-breakpoint +DROP TABLE "contrib_totals" CASCADE;--> statement-breakpoint +CREATE UNIQUE INDEX "contrib_rollups_user_period_uidx" ON "contrib_rollups" USING btree ("user_id","period");--> statement-breakpoint +CREATE INDEX "contrib_rollups_period_idx" ON "contrib_rollups" USING btree ("period");--> statement-breakpoint +CREATE INDEX "contrib_rollups_user_idx" ON "contrib_rollups" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0023_snapshot.json b/packages/db/drizzle/meta/0023_snapshot.json new file mode 100644 index 00000000..87cbf008 --- /dev/null +++ b/packages/db/drizzle/meta/0023_snapshot.json @@ -0,0 +1,2145 @@ +{ + "id": "29e18b6a-eb7c-443d-9d8f-952b4c66ebd3", + "prevId": "6fb6ddee-a1e3-4b3c-9278-7f9ce841f33d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "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": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor": { + "name": "competitor", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_repo_url": { + "name": "git_repo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_host": { + "name": "git_host", + "type": "git_host", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.competitor_tag_relations": { + "name": "competitor_tag_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "competitor_id": { + "name": "competitor_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "competitor_tag_relations_competitor_id_idx": { + "name": "competitor_tag_relations_competitor_id_idx", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "competitor_tag_relations_tag_id_idx": { + "name": "competitor_tag_relations_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_competitor_tag": { + "name": "unique_competitor_tag", + "columns": [ + { + "expression": "competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "competitor_tag_relations_competitor_id_competitor_id_fk": { + "name": "competitor_tag_relations_competitor_id_competitor_id_fk", + "tableFrom": "competitor_tag_relations", + "tableTo": "competitor", + "columnsFrom": [ + "competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "competitor_tag_relations_tag_id_category_tags_id_fk": { + "name": "competitor_tag_relations_tag_id_category_tags_id_fk", + "tableFrom": "competitor_tag_relations", + "tableTo": "category_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "git_repo_url": { + "name": "git_repo_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "git_host": { + "name": "git_host", + "type": "git_host", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "approval_status": { + "name": "approval_status", + "type": "project_approval_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "status_id": { + "name": "status_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type_id": { + "name": "type_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_looking_for_contributors": { + "name": "is_looking_for_contributors", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_looking_for_investors": { + "name": "is_looking_for_investors", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_hiring": { + "name": "is_hiring", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "has_been_acquired": { + "name": "has_been_acquired", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_repo_private": { + "name": "is_repo_private", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "acquired_by": { + "name": "acquired_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "stars_count": { + "name": "stars_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "stars_updated_at": { + "name": "stars_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "forks_count": { + "name": "forks_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "forks_updated_at": { + "name": "forks_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_status_id_idx": { + "name": "project_status_id_idx", + "columns": [ + { + "expression": "status_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_type_id_idx": { + "name": "project_type_id_idx", + "columns": [ + { + "expression": "type_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_stars_count_desc_idx": { + "name": "project_stars_count_desc_idx", + "columns": [ + { + "expression": "stars_count", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_forks_count_desc_idx": { + "name": "project_forks_count_desc_idx", + "columns": [ + { + "expression": "forks_count", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_owner_id_user_id_fk": { + "name": "project_owner_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "project_status_id_category_project_statuses_id_fk": { + "name": "project_status_id_category_project_statuses_id_fk", + "tableFrom": "project", + "tableTo": "category_project_statuses", + "columnsFrom": [ + "status_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_type_id_category_project_types_id_fk": { + "name": "project_type_id_category_project_types_id_fk", + "tableFrom": "project", + "tableTo": "category_project_types", + "columnsFrom": [ + "type_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "project_acquired_by_competitor_id_fk": { + "name": "project_acquired_by_competitor_id_fk", + "tableFrom": "project", + "tableTo": "competitor", + "columnsFrom": [ + "acquired_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_git_repo_url_unique": { + "name": "project_git_repo_url_unique", + "nullsNotDistinct": false, + "columns": [ + "git_repo_url" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_tag_relations": { + "name": "project_tag_relations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tag_id": { + "name": "tag_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_tag_relations_project_id_idx": { + "name": "project_tag_relations_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_tag_relations_tag_id_idx": { + "name": "project_tag_relations_tag_id_idx", + "columns": [ + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "unique_project_tag": { + "name": "unique_project_tag", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_tag_relations_project_id_project_id_fk": { + "name": "project_tag_relations_project_id_project_id_fk", + "tableFrom": "project_tag_relations", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_tag_relations_tag_id_category_tags_id_fk": { + "name": "project_tag_relations_tag_id_category_tags_id_fk", + "tableFrom": "project_tag_relations", + "tableTo": "category_tags", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_competitors": { + "name": "project_competitors", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "alternative_competitor_type": { + "name": "alternative_competitor_type", + "type": "alternative_competitor_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "alternative_project_id": { + "name": "alternative_project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "alternative_competitor_id": { + "name": "alternative_competitor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_competitors_project_id_idx": { + "name": "project_competitors_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_competitors_alt_project_id_idx": { + "name": "project_competitors_alt_project_id_idx", + "columns": [ + { + "expression": "alternative_project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_competitors_alt_competitor_id_idx": { + "name": "project_competitors_alt_competitor_id_idx", + "columns": [ + { + "expression": "alternative_competitor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_competitors_project_id_project_id_fk": { + "name": "project_competitors_project_id_project_id_fk", + "tableFrom": "project_competitors", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_competitors_alternative_project_id_project_id_fk": { + "name": "project_competitors_alternative_project_id_project_id_fk", + "tableFrom": "project_competitors", + "tableTo": "project", + "columnsFrom": [ + "alternative_project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_competitors_alternative_competitor_id_competitor_id_fk": { + "name": "project_competitors_alternative_competitor_id_competitor_id_fk", + "tableFrom": "project_competitors", + "tableTo": "competitor", + "columnsFrom": [ + "alternative_competitor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_claim": { + "name": "project_claim", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "verification_method": { + "name": "verification_method", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "verification_details": { + "name": "verification_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error_reason": { + "name": "error_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "project_claim_project_id_project_id_fk": { + "name": "project_claim_project_id_project_id_fk", + "tableFrom": "project_claim", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_claim_user_id_user_id_fk": { + "name": "project_claim_user_id_user_id_fk", + "tableFrom": "project_claim", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_project_statuses": { + "name": "category_project_statuses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_project_statuses_name_unique": { + "name": "category_project_statuses_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_project_types": { + "name": "category_project_types", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_project_types_name_unique": { + "name": "category_project_types_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.category_tags": { + "name": "category_tags", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "category_tags_name_unique": { + "name": "category_tags_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_launch": { + "name": "project_launch", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "tagline": { + "name": "tagline", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "detailed_description": { + "name": "detailed_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "launch_date": { + "name": "launch_date", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "status": { + "name": "status", + "type": "launch_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'scheduled'" + }, + "featured": { + "name": "featured", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_launch_project_id_idx": { + "name": "project_launch_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_launch_launch_date_idx": { + "name": "project_launch_launch_date_idx", + "columns": [ + { + "expression": "launch_date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_launch_status_idx": { + "name": "project_launch_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_launch_project_id_project_id_fk": { + "name": "project_launch_project_id_project_id_fk", + "tableFrom": "project_launch", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_launch_project_id_unique": { + "name": "project_launch_project_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_vote": { + "name": "project_vote", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_vote_project_id_idx": { + "name": "project_vote_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_vote_user_id_idx": { + "name": "project_vote_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_vote_project_id_project_id_fk": { + "name": "project_vote_project_id_project_id_fk", + "tableFrom": "project_vote", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_vote_user_id_user_id_fk": { + "name": "project_vote_user_id_user_id_fk", + "tableFrom": "project_vote", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_vote_project_id_user_id_unique": { + "name": "project_vote_project_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_comment": { + "name": "project_comment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_comment_project_id_idx": { + "name": "project_comment_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_comment_user_id_idx": { + "name": "project_comment_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_comment_parent_id_idx": { + "name": "project_comment_parent_id_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_comment_project_id_project_id_fk": { + "name": "project_comment_project_id_project_id_fk", + "tableFrom": "project_comment", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_comment_user_id_user_id_fk": { + "name": "project_comment_user_id_user_id_fk", + "tableFrom": "project_comment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_comment_parent_id_project_comment_id_fk": { + "name": "project_comment_parent_id_project_comment_id_fk", + "tableFrom": "project_comment", + "tableTo": "project_comment", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project_report": { + "name": "project_report", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_report_project_id_idx": { + "name": "project_report_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_report_user_id_idx": { + "name": "project_report_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_report_project_id_project_id_fk": { + "name": "project_report_project_id_project_id_fk", + "tableFrom": "project_report", + "tableTo": "project", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_report_user_id_user_id_fk": { + "name": "project_report_user_id_user_id_fk", + "tableFrom": "project_report", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "project_report_project_id_user_id_unique": { + "name": "project_report_project_id_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "project_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.contrib_rollups": { + "name": "contrib_rollups", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "period": { + "name": "period", + "type": "contrib_period", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "commits": { + "name": "commits", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prs": { + "name": "prs", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "issues": { + "name": "issues", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total": { + "name": "total", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "fetched_at": { + "name": "fetched_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "contrib_rollups_user_period_uidx": { + "name": "contrib_rollups_user_period_uidx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_rollups_period_idx": { + "name": "contrib_rollups_period_idx", + "columns": [ + { + "expression": "period", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "contrib_rollups_user_idx": { + "name": "contrib_rollups_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.acquisition_type": { + "name": "acquisition_type", + "schema": "public", + "values": [ + "ipo", + "acquisition", + "other" + ] + }, + "public.project_approval_status": { + "name": "project_approval_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.alternative_competitor_type": { + "name": "alternative_competitor_type", + "schema": "public", + "values": [ + "project", + "competitor" + ] + }, + "public.git_host": { + "name": "git_host", + "schema": "public", + "values": [ + "github", + "gitlab" + ] + }, + "public.project_provider": { + "name": "project_provider", + "schema": "public", + "values": [ + "github", + "gitlab" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "admin", + "user", + "moderator" + ] + }, + "public.launch_status": { + "name": "launch_status", + "schema": "public", + "values": [ + "scheduled", + "live", + "ended" + ] + }, + "public.contrib_period": { + "name": "contrib_period", + "schema": "public", + "values": [ + "all_time", + "last_30d", + "last_365d" + ] + }, + "public.contrib_provider": { + "name": "contrib_provider", + "schema": "public", + "values": [ + "github", + "gitlab" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index fab04242..7abe481f 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -162,6 +162,13 @@ "when": 1754906352408, "tag": "0022_faulty_gauntlet", "breakpoints": true + }, + { + "idx": 23, + "version": "7", + "when": 1756464780722, + "tag": "0023_bent_strong_guy", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/contributions.ts b/packages/db/src/schema/contributions.ts index 015f4c95..d8ed23ea 100644 --- a/packages/db/src/schema/contributions.ts +++ b/packages/db/src/schema/contributions.ts @@ -1,38 +1,34 @@ -// packages/db/src/schema/contributions.ts import { pgEnum, pgTable, - uuid, - date, + text, integer, timestamp, uniqueIndex, index, } from "drizzle-orm/pg-core"; -/** - * Keep provider values narrow and explicit. - * Name the enum specifically for this feature to avoid collisions. - */ export const contribProvider = pgEnum("contrib_provider", ["github", "gitlab"]); -/** - * Grain: 1 row per (user_id, provider, date_utc). - * Used for daily deltas and rolling-window math. - */ -export const contribDaily = pgTable( - "contrib_daily", +export const contribPeriod = pgEnum("contrib_period", [ + "all_time", + "last_30d", + "last_365d", +]); + +export const contribRollups = pgTable( + "contrib_rollups", { - userId: uuid("user_id").notNull(), // FK to users.id (kept loose here to avoid cross-package import loops) - provider: contribProvider("provider").notNull(), - dateUtc: date("date_utc").notNull(), // UTC calendar day + userId: text("user_id").notNull(), + + period: contribPeriod("period").notNull(), commits: integer("commits").notNull().default(0), prs: integer("prs").notNull().default(0), issues: integer("issues").notNull().default(0), + total: integer("total").notNull().default(0), - // Bookkeeping - createdAt: timestamp("created_at", { withTimezone: true }) + fetchedAt: timestamp("fetched_at", { withTimezone: true }) .notNull() .defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }) @@ -40,39 +36,8 @@ export const contribDaily = pgTable( .defaultNow(), }, (t) => [ - // Idempotency & fast upserts - uniqueIndex("contrib_daily_user_prov_day_uidx").on( - t.userId, - t.provider, - t.dateUtc, - ), - // Helpful for rebuilds or per-day scans - index("contrib_daily_provider_day_idx").on(t.provider, t.dateUtc), - index("contrib_daily_user_day_idx").on(t.userId, t.dateUtc), - ], -); - -/** - * Pre-aggregated, for fast reads: - * - allTime = lifetime public contributions - * - last30d, last365d maintained via rolling updates - */ -export const contribTotals = pgTable( - "contrib_totals", - { - userId: uuid("user_id").notNull(), - provider: contribProvider("provider").notNull(), - - allTime: integer("all_time").notNull().default(0), - last30d: integer("last_30d").notNull().default(0), - last365d: integer("last_365d").notNull().default(0), - - updatedAt: timestamp("updated_at", { withTimezone: true }) - .notNull() - .defaultNow(), - }, - (t) => [ - uniqueIndex("contrib_totals_user_prov_uidx").on(t.userId, t.provider), - index("contrib_totals_user_idx").on(t.userId), + uniqueIndex("contrib_rollups_user_period_uidx").on(t.userId, t.period), + index("contrib_rollups_period_idx").on(t.period), + index("contrib_rollups_user_idx").on(t.userId), ], ); diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx index ea2a0216..fa7ca2d2 100644 --- a/packages/ui/src/components/select.tsx +++ b/packages/ui/src/components/select.tsx @@ -37,7 +37,7 @@ function SelectTrigger({ data-slot="select-trigger" data-size={size} className={cn( - "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-input focus-visible:ring-ring/0 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-none border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", className )} {...props} @@ -64,7 +64,7 @@ function SelectContent({ ) + * - Reads provider handles from: lb:user: (githubLogin / gitlabUsername) + * - Calls POST /api/internal/leaderboard/backfill for each user + * - Bounded concurrency; retries on 409/429/5xx with backoff + * + * Usage: + * bun scripts/lb-backfill-365-all.ts --days=365 --batch=150 --concurrency=4 --origin=http://localhost:3000 + * + * Env: + * CRON_SECRET (required) + * DATABASE_URL (not used here, only Redis + HTTP) + * UPSTASH_REDIS_REST_URL (required by your redis client) + * UPSTASH_REDIS_REST_TOKEN (required by your redis client) + * + * Notes: + * - This script is idempotent thanks to "done" set tracking. + * - You can re-run until it prints "All users processed". + */ + +import { redis } from '../packages/api/src/redis/client'; + +type Flags = { + days: number; + batch: number; + concurrency: number; + jitterMinMs: number; + jitterMaxMs: number; + origin: string; + dry: boolean; +}; + +function parseFlags(): Flags { + const args = new Map(); + for (const a of process.argv.slice(2)) { + const m = a.match(/^--([^=]+)=(.*)$/); + if (m) args.set(m[1], m[2]); + else if (a === '--dry') args.set('dry', 'true'); + } + + const envOrigin = + process.env.ORIGIN || + (process.env.VERCEL_ENV === 'production' + ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}` + : `http://${process.env.VERCEL_PROJECT_PRODUCTION_URL || 'localhost:3000'}`); + + return { + days: Number(args.get('days') ?? 365), + batch: Number(args.get('batch') ?? 150), + concurrency: Number(args.get('concurrency') ?? 4), + jitterMinMs: Number(args.get('jitterMinMs') ?? 80), + jitterMaxMs: Number(args.get('jitterMaxMs') ?? 220), + origin: String(args.get('origin') ?? envOrigin), + dry: (args.get('dry') ?? 'false') === 'true', + }; +} + +const USER_SET = 'lb:users'; +const META = (id: string) => `lb:user:${id}`; +const DONE = (days: number) => `lb:backfill:done:${days}`; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); +const jitter = (min: number, max: number) => min + Math.floor(Math.random() * (max - min + 1)); + +function asTrimmed(v: unknown): string | undefined { + if (typeof v === 'string') return v.trim() || undefined; + if (v == null) return undefined; + if (typeof v === 'number' || typeof v === 'boolean') return String(v).trim() || undefined; + return undefined; +} + +async function fetchMetaFor( + ids: string[], +): Promise> { + const pipe = redis.pipeline(); + for (const id of ids) pipe.hgetall(META(id)); + const raw = await pipe.exec(); + + return raw.map((item: any, i: number) => { + const val = Array.isArray(item) ? item[1] : item; + const obj = (val && typeof val === 'object' ? val : {}) as Record; + return { + id: ids[i]!, + gh: asTrimmed(obj.githubLogin), + gl: asTrimmed(obj.gitlabUsername), + }; + }); +} + +async function filterUndone(ids: string[], days: number): Promise { + const pipe = redis.pipeline(); + for (const id of ids) pipe.sismember(DONE(days), id); + const res = await pipe.exec(); + return ids.filter((_, i) => { + const item = Array.isArray(res[i]) ? res[i][1] : res[i]; + return !item; + }); +} + +type BackfillResult = { ok: boolean; status: number; body?: any }; + +async function callBackfill( + origin: string, + cronSecret: string, + body: Record, +): Promise { + const url = `${origin}/api/internal/leaderboard/backfill`; + + let attempt = 0; + const maxAttempts = 4; + + while (true) { + attempt++; + try { + const r = await fetch(url, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${cronSecret}`, + }, + body: JSON.stringify(body), + }); + + const text = await r.text().catch(() => ''); + let parsed: any = undefined; + try { + parsed = text ? JSON.parse(text) : undefined; + } catch { + parsed = text; + } + + if (r.ok) return { ok: true, status: r.status, body: parsed }; + + if (r.status === 409 && attempt <= maxAttempts) { + console.warn(`[backfill] 409 conflict; retrying in 60s…`); + await sleep(60_000); + continue; + } + + if ((r.status === 429 || r.status === 403) && attempt <= maxAttempts) { + const backoff = 60_000 * attempt; + console.warn(`[backfill] ${r.status} rate-limited; retrying in ${backoff / 1000}s…`); + await sleep(backoff); + continue; + } + + if (r.status >= 500 && attempt <= maxAttempts) { + const backoff = 10_000 * attempt; + console.warn(`[backfill] ${r.status} server error; retrying in ${backoff / 1000}s…`); + await sleep(backoff); + continue; + } + + return { ok: false, status: r.status, body: parsed }; + } catch (e) { + if (attempt <= maxAttempts) { + const backoff = 5_000 * attempt; + console.warn( + `[backfill] fetch error (${(e as Error).message}); retrying in ${backoff / 1000}s…`, + ); + await sleep(backoff); + continue; + } + return { ok: false, status: 0, body: String(e) }; + } + } +} + +async function main() { + const flags = parseFlags(); + const cronSecret = process.env.CRON_SECRET; + if (!cronSecret) { + console.error('CRON_SECRET is required in env.'); + process.exit(1); + } + + console.log( + `Starting backfill: days=${flags.days} batch=${flags.batch} concurrency=${flags.concurrency} origin=${flags.origin} dry=${flags.dry}`, + ); + + await redis.ping(); + + const allIds = (await redis.smembers(USER_SET)).map(String); + if (allIds.length === 0) { + console.log(`No users found in Redis set ${USER_SET}. Seed it first.`); + return; + } + + while (true) { + const undone = await filterUndone(allIds, flags.days); + if (undone.length === 0) { + const totalDone = await redis.scard(DONE(flags.days)); + console.log(`✅ All users processed for ${flags.days}d. Done count = ${totalDone}.`); + break; + } + + const batch = undone.slice(0, flags.batch); + console.log(`Processing batch of ${batch.length} (remaining ~${undone.length})…`); + + const metas = await fetchMetaFor(batch); + const tasksQueue = metas.filter((m) => m.gh || m.gl); + const skipped = metas.length - tasksQueue.length; + if (skipped) console.log(`Skipping ${skipped} users without provider handles.`); + + let idx = 0; + const results: Array<{ id: string; ok: boolean; status: number }> = []; + + const workers = Array.from( + { length: Math.max(1, Math.min(flags.concurrency, 8)) }, + async () => { + while (true) { + const i = idx++; + if (i >= tasksQueue.length) break; + + const { id, gh, gl } = tasksQueue[i]!; + const body = { + userId: id, + githubLogin: gh, + gitlabUsername: gl, + days: flags.days, + concurrency: 4, + }; + + if (flags.dry) { + console.log(`[dry] would backfill ${id} (gh=${gh ?? '-'} gl=${gl ?? '-'})`); + results.push({ id, ok: true, status: 200 }); + continue; + } + + await sleep(jitter(flags.jitterMinMs, flags.jitterMaxMs)); + + const res = await callBackfill(flags.origin, cronSecret, body); + if (!res.ok) { + const msg = + typeof res.body === 'string' + ? res.body + : res.body?.error || res.body?.message || JSON.stringify(res.body ?? {}); + console.warn(`[backfill] user=${id} -> ${res.status} ${msg}`); + } else { + console.log(`[backfill] user=${id} -> OK (${res.status})`); + } + + results.push({ id, ok: res.ok, status: res.status }); + } + }, + ); + + await Promise.all(workers); + + const succeeded = results.filter((r) => r.ok).map((r) => r.id); + if (succeeded.length) { + const pipe = redis.pipeline(); + for (const id of succeeded) pipe.sadd(DONE(flags.days), id); + await pipe.exec(); + } + + const failed = results.filter((r) => !r.ok).map((r) => r.id); + console.log( + `Batch complete: ok=${succeeded.length}, failed=${failed.length}, marked done=${succeeded.length}.`, + ); + + if (succeeded.length === 0 && failed.length > 0) { + console.warn(`No successes in this batch; backing off 90s before next batch…`); + await sleep(90_000); + } + } + + const total = await redis.scard(USER_SET); + const done = await redis.scard(DONE(flags.days)); + console.log(`Finished. USER_SET=${total}, DONE(${flags.days})=${done}`); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/lb-seed-all-users.ts b/scripts/lb-seed-all-users.ts new file mode 100644 index 00000000..173c9174 --- /dev/null +++ b/scripts/lb-seed-all-users.ts @@ -0,0 +1,104 @@ +#!/usr/bin/env bun +import { drizzle } from 'drizzle-orm/postgres-js'; +import { inArray } from 'drizzle-orm'; + +import { user as userTable, account as accountTable } from '../packages/db/src/schema/auth'; +import { setUserMetaFromProviders } from '../packages/api/src/leaderboard/useMeta'; +import { redis } from '../packages/api/src/redis/client'; + +const GH = 'github'; +const GL = 'gitlab'; +const USER_SET = 'lb:users'; +const META = (id: string) => `lb:user:${id}`; + +async function getPostgres() { + const mod = (await import('postgres')) as any; + return (mod.default ?? mod) as (url: string, opts?: any) => any; +} + +async function makeDb() { + const url = process.env.DATABASE_URL; + if (!url) throw new Error('Missing DATABASE_URL'); + + const postgres = await getPostgres(); + const needsSSL = /neon\.tech/i.test(url) || /sslmode=require/i.test(url); + const pg = postgres(url, needsSSL ? { ssl: 'require' as const } : {}); + const db = drizzle(pg); + return { db, pg }; +} + +function asStr(x: unknown): string | undefined { + if (x == null) return undefined; + if (typeof x === 'string') return x.trim() || undefined; + return String(x).trim() || undefined; +} + +async function main() { + const { db, pg } = await makeDb(); + try { + const users = await db + .select({ userId: userTable.id, username: userTable.username }) + .from(userTable); + + const userIds = Array.from(new Set(users.map((u) => u.userId))); + console.log(`Found ${userIds.length} users`); + + if (userIds.length === 0) return; + + const usernameByUserId = new Map(); + for (const u of users) { + usernameByUserId.set(u.userId, asStr(u.username)); + } + + const links = await db + .select({ + userId: accountTable.userId, + providerId: accountTable.providerId, + }) + .from(accountTable) + .where(inArray(accountTable.userId, userIds)); + + const map = new Map(); + for (const id of userIds) map.set(id, {}); + + for (const l of links) { + const m = map.get(l.userId)!; + const uname = usernameByUserId.get(l.userId); + if (!uname) continue; + + if (l.providerId === GH && !m.gh) m.gh = uname; + if (l.providerId === GL && !m.gl) m.gl = uname; + } + + const BATCH = 100; + let done = 0; + + for (let i = 0; i < userIds.length; i += BATCH) { + const chunk = userIds.slice(i, i + BATCH); + + await Promise.all( + chunk.map(async (id) => { + const { gh, gl } = map.get(id)!; + await setUserMetaFromProviders(id, gh, gl); + + const meta = await redis.hgetall(META(id)); + }), + ); + + done += chunk.length; + console.log(`Seeded ${done}/${userIds.length}`); + } + + const count = await redis.scard(USER_SET); + console.log(`✅ Redis set "${USER_SET}" now has ${count} members`); + } finally { + if (typeof (pg as any)?.end === 'function') { + await (pg as any).end({ timeout: 5 }).catch(() => {}); + } + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/scripts/lb-seed-from-total.ts b/scripts/lb-seed-from-total.ts deleted file mode 100644 index 6824a9ff..00000000 --- a/scripts/lb-seed-from-total.ts +++ /dev/null @@ -1,52 +0,0 @@ -#!/usr/bin/env bun -/** - * Seed Redis set lb:users from contrib_totals (distinct userIds). - * Usage: - * bun scripts/lb-seed-from-totals.ts - * - * Requires: - * DATABASE_URL - * UPSTASH_REDIS_REST_URL - * UPSTASH_REDIS_REST_TOKEN - */ - -import { drizzle } from "drizzle-orm/postgres-js"; -import { contribTotals } from "../packages/db/src/schema/contributions"; -import { redis } from "../packages/api/src/redis/client"; - -async function makeDb() { - const url = process.env.DATABASE_URL; - if (!url) throw new Error("Missing DATABASE_URL"); - // dynamic import sidesteps TS "export ="/default issues without changing tsconfig - const { default: postgres } = await import("postgres"); - const needsSSL = /neon\.tech/i.test(url) || /sslmode=require/i.test(url); - const client = postgres(url, needsSSL ? { ssl: "require" as const } : {}); - return drizzle(client); -} - -async function main() { - const db = await makeDb(); - - const rows = await db - .select({ userId: contribTotals.userId }) - .from(contribTotals) - .groupBy(contribTotals.userId); - - const idArray: string[] = Array.from(new Set(rows.map((r) => r.userId))); - - if (idArray.length === 0) { - console.log("No users found in contrib_totals"); - return; - } - - const pipe = redis.pipeline(); - for (const id of idArray) pipe.sadd("lb:users", id); - await pipe.exec(); - - console.log(`Seeded ${idArray.length} userIds into Redis set lb:users`); -} - -main().catch((e) => { - console.error(e); - process.exit(1); -}); From 466e3e29a0457aae14fb92ebff987b0a903cedf7 Mon Sep 17 00:00:00 2001 From: Notoriousbrain Date: Sat, 30 Aug 2025 21:14:46 +0530 Subject: [PATCH 15/20] feat(leaderboard): add the final leaderboard --- apps/web/app/(public)/leaderboard/page.tsx | 35 ++-- .../internal/leaderboard/backfill/route.ts | 3 +- .../internal/leaderboard/cron/daily/route.ts | 9 - apps/web/app/api/leaderboard/details/route.ts | 8 - .../app/api/leaderboard/dev-backfill/route.ts | 8 +- apps/web/app/api/leaderboard/export/route.ts | 25 +-- apps/web/app/api/leaderboard/route.ts | 3 +- .../leaderboard/leaderboard-client.tsx | 53 +++--- packages/api/package.json | 3 +- packages/api/src/leaderboard/aggregator.ts | 64 +++---- packages/api/src/leaderboard/meta.ts | 4 - packages/api/src/leaderboard/read.ts | 64 +++---- packages/api/src/leaderboard/redis.ts | 38 ++--- packages/api/src/leaderboard/useMeta.ts | 1 - packages/api/src/providers/github.ts | 76 +++------ packages/api/src/providers/gitlab.ts | 42 +---- packages/db/drizzle/meta/0022_snapshot.json | 2 +- packages/db/drizzle/meta/0023_snapshot.json | 2 +- packages/ui/src/components/input.tsx | 2 +- scripts/ag-test.ts | 158 ------------------ scripts/agg-range-test.ts | 135 --------------- scripts/github-test.ts | 109 ++++++++++++ scripts/lb-sync-one.ts | 42 ----- 23 files changed, 263 insertions(+), 623 deletions(-) delete mode 100644 scripts/ag-test.ts delete mode 100644 scripts/agg-range-test.ts create mode 100644 scripts/github-test.ts delete mode 100644 scripts/lb-sync-one.ts diff --git a/apps/web/app/(public)/leaderboard/page.tsx b/apps/web/app/(public)/leaderboard/page.tsx index d0bc6834..ee48f242 100644 --- a/apps/web/app/(public)/leaderboard/page.tsx +++ b/apps/web/app/(public)/leaderboard/page.tsx @@ -1,31 +1,32 @@ -import LeaderboardClient from "@/components/leaderboard/leaderboard-client"; +import LeaderboardClient from '@/components/leaderboard/leaderboard-client'; -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; +export const runtime = 'nodejs'; +export const dynamic = 'force-dynamic'; +type SearchParams = Promise>; -type WindowKey = "all" | "30d" | "365d"; - -function getWindow(searchParams?: Record): WindowKey { - const w = (searchParams?.window as string) || "30d"; - return w === "all" || w === "30d" || w === "365d" ? (w as WindowKey) : "30d"; +function normalizeWindow(v: unknown): '30d' | '365d' { + if (v === '30d') return '30d'; + if (v === '365d') return '365d'; + return '365d'; } -export default async function LeaderboardPage({ - searchParams, -}: { - searchParams?: Record; -}) { - const window = getWindow(searchParams); +export default async function LeaderboardPage({ searchParams }: { searchParams?: SearchParams }) { + const sp = await searchParams; + const winParam = sp && 'window' in sp + ? Array.isArray(sp.window) ? sp.window[0] : sp.window + : undefined; + const initialWindow = normalizeWindow(winParam); + return (
-
+

Global Leaderboard

- Top contributors across GitHub and GitLab. + Top contributors across GitHub and GitLab based of open source contributions.

- +
); } diff --git a/apps/web/app/api/internal/leaderboard/backfill/route.ts b/apps/web/app/api/internal/leaderboard/backfill/route.ts index dc83ae74..e931d31d 100644 --- a/apps/web/app/api/internal/leaderboard/backfill/route.ts +++ b/apps/web/app/api/internal/leaderboard/backfill/route.ts @@ -1,4 +1,3 @@ -// routes/api/backfill-user/route.ts (updated for rollup snapshots) export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -10,7 +9,7 @@ import { isCronAuthorized } from '@workspace/env/verify-cron'; import { env } from '@workspace/env/server'; import { backfillLockKey, withLock, acquireLock, releaseLock } from '@workspace/api/locks'; -import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; // <- refactored to use contribRollups +import { syncUserLeaderboards } from '@workspace/api/leaderboard/redis'; import { setUserMetaFromProviders } from '@workspace/api/use-meta'; import { db } from '@workspace/db'; import { refreshUserRollups } from '@workspace/api/aggregator'; diff --git a/apps/web/app/api/internal/leaderboard/cron/daily/route.ts b/apps/web/app/api/internal/leaderboard/cron/daily/route.ts index 574c5557..0e894421 100644 --- a/apps/web/app/api/internal/leaderboard/cron/daily/route.ts +++ b/apps/web/app/api/internal/leaderboard/cron/daily/route.ts @@ -1,5 +1,3 @@ -// routes/api/cron/daily/route.ts — rollup snapshot refresh (no per-day backfill) - export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -45,10 +43,8 @@ export async function GET(req: NextRequest) { const snapshotDate = ymd(new Date()); try { - // Sanity check Redis await redis.ping(); - // Enumerate users to process const allIdsRaw = await redis.smembers(USER_SET); const userIds = (Array.isArray(allIdsRaw) ? allIdsRaw : []).map(String).slice(0, limit); @@ -64,7 +60,6 @@ export async function GET(req: NextRequest) { }); } - // Fetch provider metadata (githubLogin / gitlabUsername) in a pipeline const pipe = redis.pipeline(); for (const id of userIds) pipe.hgetall(META(id)); const rawResults = await pipe.exec(); @@ -98,7 +93,6 @@ export async function GET(req: NextRequest) { }); } - // Bounded parallelism const workers = Math.max(1, Math.min(concurrency, 8)); let idx = 0; let processed = 0; @@ -120,7 +114,6 @@ export async function GET(req: NextRequest) { continue; } - // Token checks per provider if (githubLogin && !env.GITHUB_TOKEN) { errors.push({ userId, error: 'Missing GITHUB_TOKEN' }); skipped++; @@ -133,7 +126,6 @@ export async function GET(req: NextRequest) { } try { - // One-shot snapshot for last_30d + last_365d await refreshUserRollups( { db }, { @@ -146,7 +138,6 @@ export async function GET(req: NextRequest) { }, ); - // Push to Redis leaderboards from contribRollups await syncUserLeaderboards(db, userId); processed++; } catch (err) { diff --git a/apps/web/app/api/leaderboard/details/route.ts b/apps/web/app/api/leaderboard/details/route.ts index e1d174ce..434fc5ae 100644 --- a/apps/web/app/api/leaderboard/details/route.ts +++ b/apps/web/app/api/leaderboard/details/route.ts @@ -1,4 +1,3 @@ - export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const revalidate = 0; @@ -14,13 +13,11 @@ const HAS_ALL_TIME = false as const; type WindowKey = '30d' | '365d' | 'all'; -// request body const Body = z.object({ window: z.enum(HAS_ALL_TIME ? (['all', '30d', '365d'] as const) : (['30d', '365d'] as const)), userIds: z.array(z.string().min(1)).max(2000), }); -// map window -> DB period const PERIOD_FROM_WINDOW: Record<'30d' | '365d', 'last_30d' | 'last_365d'> = { '30d': 'last_30d', '365d': 'last_365d', @@ -38,7 +35,6 @@ export async function POST(req: NextRequest) { return Response.json({ ok: true, window, entries: [] }); } - // guard if 'all' requested but enum not supported in DB if (window === 'all' && !HAS_ALL_TIME) { return new Response(`Bad Request: 'all' window not supported by current schema`, { status: 400, @@ -46,7 +42,6 @@ export async function POST(req: NextRequest) { } try { - // Build WHERE by window/period const where = and( inArray(contribRollups.userId, userIds), window === 'all' @@ -54,7 +49,6 @@ export async function POST(req: NextRequest) { : eq(contribRollups.period, PERIOD_FROM_WINDOW[window]), ); - // Fetch snapshot rows const rows = await db .select({ userId: contribRollups.userId, @@ -66,7 +60,6 @@ export async function POST(req: NextRequest) { .from(contribRollups) .where(where); - // Index by userId for quick lookup const byId = new Map( rows.map((r) => [ r.userId, @@ -79,7 +72,6 @@ export async function POST(req: NextRequest) { ]), ); - // Preserve requested order and fill zeros for missing const entries = userIds.map((id) => { const v = byId.get(id) ?? { commits: 0, prs: 0, issues: 0, total: 0 }; return { userId: id, ...v }; diff --git a/apps/web/app/api/leaderboard/dev-backfill/route.ts b/apps/web/app/api/leaderboard/dev-backfill/route.ts index b1853db9..ed359ed6 100644 --- a/apps/web/app/api/leaderboard/dev-backfill/route.ts +++ b/apps/web/app/api/leaderboard/dev-backfill/route.ts @@ -1,4 +1,3 @@ -// apps/web/app/api/internal/leaderboard/fanout/route.ts export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; @@ -24,7 +23,7 @@ const Body = z.object({ export async function POST(req: NextRequest) { const session = await auth.api.getSession({ headers: req.headers }).catch(() => null); - const role = (session?.user)?.role as string | undefined; + const role = session?.user?.role as string | undefined; if (!session || !role || !['admin', 'moderator'].includes(role)) { return new Response('Forbidden', { status: 403 }); } @@ -58,15 +57,12 @@ export async function POST(req: NextRequest) { method: 'POST', headers: { 'content-type': 'application/json', - authorization: `Bearer ${env.CRON_SECRET}`, // required by backfill route + authorization: `Bearer ${env.CRON_SECRET}`, }, body: JSON.stringify({ userId: u.userId, githubLogin: u.githubLogin, gitlabUsername: u.gitlabUsername, - // optional noise—backfill ignores these now; remove once you drop bwd-compat: - // days: 365, - // concurrency: limit, }), }); results.push({ userId: u.userId, status: r.status }); diff --git a/apps/web/app/api/leaderboard/export/route.ts b/apps/web/app/api/leaderboard/export/route.ts index 64c47458..08d57f36 100644 --- a/apps/web/app/api/leaderboard/export/route.ts +++ b/apps/web/app/api/leaderboard/export/route.ts @@ -1,26 +1,17 @@ -// apps/web/app/api/leaderboard/export/route.ts export const runtime = 'nodejs'; export const dynamic = 'force-dynamic'; export const revalidate = 0; import { NextRequest } from 'next/server'; -import { z } from 'zod/v4'; import { db } from '@workspace/db'; +import { z } from 'zod/v4'; -// NEW read helper that uses contribRollups internally -import { getLeaderboardPage } from '@workspace/api/read'; // your refactored reader - -// User meta gives us githubLogin / gitlabUsername to tag provider in CSV +import { getLeaderboardPage } from '@workspace/api/read'; import { getUserMetas } from '@workspace/api/use-meta'; -// If your contrib_period enum includes 'all_time', flip this to true -const HAS_ALL_TIME = false as const; - const Query = z.object({ - // provider is no longer used, but keep for backward-compat (ignored) provider: z.enum(['combined', 'github', 'gitlab']).default('combined'), - window: z.enum(HAS_ALL_TIME ? (['all', '30d', '365d'] as const) : (['30d', '365d'] as const)) - .default('30d'), + window: z.enum(['30d', '365d']).default('30d'), limit: z.coerce.number().int().min(1).max(2000).default(500), cursor: z.coerce.number().int().min(0).default(0), }); @@ -32,18 +23,12 @@ export async function GET(req: NextRequest) { } const { window, limit, cursor } = parsed.data; - // Guard: 'all' not supported unless you added all_time snapshots - if (window === 'all' && !HAS_ALL_TIME) { - return new Response(`Bad Request: 'all' window not supported by current schema`, { status: 400 }); - } - - // Page through leaderboard to collect up to `limit` rows let entries: Array<{ userId: string; score: number }> = []; let next = cursor; while (entries.length < limit) { const page = await getLeaderboardPage(db, { - window: window === 'all' ? ('365d' as '30d' | '365d') : window, // 'all' would be supported only if HAS_ALL_TIME=true in reader too + window, limit: Math.min(200, limit - entries.length), cursor: next, }); @@ -57,7 +42,6 @@ export async function GET(req: NextRequest) { const metas = await getUserMetas(userIds); const metaMap = new Map(metas.map((m) => [m.userId, m])); - // Build CSV; since each user has one provider, put score into the correct column const header = [ 'rank', 'userId', @@ -77,7 +61,6 @@ export async function GET(req: NextRequest) { const hasGithub = !!(m?.githubLogin && String(m.githubLogin).trim()); const hasGitlab = !!(m?.gitlabUsername && String(m.gitlabUsername).trim()); - // allocate score to provider column based on meta const githubScore = hasGithub ? e.score : 0; const gitlabScore = hasGitlab ? e.score : 0; diff --git a/apps/web/app/api/leaderboard/route.ts b/apps/web/app/api/leaderboard/route.ts index 01258551..ea6bafb2 100644 --- a/apps/web/app/api/leaderboard/route.ts +++ b/apps/web/app/api/leaderboard/route.ts @@ -26,11 +26,10 @@ export async function GET(req: NextRequest) { return new Response(`Bad Request: 'all' window not supported by current schema`, { status: 400 }); } - // If you add 'all' later, ensure your reader also supports it. const windowForReader = q.window === "all" ? ("365d" as "30d" | "365d") : q.window; const { entries, nextCursor, source } = await getLeaderboardPage(db, { - window: windowForReader, // '30d' | '365d' (or 'all' if you implement it) + window: windowForReader, limit: q.limit, cursor: q.cursor, }); diff --git a/apps/web/components/leaderboard/leaderboard-client.tsx b/apps/web/components/leaderboard/leaderboard-client.tsx index 537b4089..9e735621 100644 --- a/apps/web/components/leaderboard/leaderboard-client.tsx +++ b/apps/web/components/leaderboard/leaderboard-client.tsx @@ -3,13 +3,6 @@ import { useRouter, useSearchParams } from 'next/navigation'; import * as React from 'react'; -import { - Select, - SelectTrigger, - SelectContent, - SelectItem, - SelectValue, -} from '@workspace/ui/components/select'; import { Table, TableHeader, @@ -18,11 +11,18 @@ import { TableBody, TableCell, } from '@workspace/ui/components/table'; +import { + Select, + SelectTrigger, + SelectContent, + SelectItem, + SelectValue, +} from '@workspace/ui/components/select'; import { Card, CardContent } from '@workspace/ui/components/card'; import { Button } from '@workspace/ui/components/button'; import { Input } from '@workspace/ui/components/input'; -type WindowKey = '30d' | '365d'; +type UIWindow = '30d' | '365d'; type TopEntry = { userId: string; score: number }; @@ -54,7 +54,7 @@ type LeaderRow = { type SortKey = 'rank' | 'userId' | 'total' | 'commits' | 'prs' | 'issues'; type SortDir = 'asc' | 'desc'; -async function fetchTop(window: WindowKey, limit: number, cursor = 0) { +async function fetchTop(window: UIWindow, limit: number, cursor = 0) { const url = `/api/leaderboard?window=${window}&limit=${limit}&cursor=${cursor}`; const res = await fetch(url, { cache: 'no-store' }); if (!res.ok) throw new Error(`Failed to fetch leaderboard: ${await res.text()}`); @@ -66,7 +66,7 @@ async function fetchTop(window: WindowKey, limit: number, cursor = 0) { }; } -async function fetchDetails(window: WindowKey, userIds: string[]) { +async function fetchDetails(window: UIWindow, userIds: string[]) { if (userIds.length === 0) return { ok: true, window, entries: [] as DetailsEntry[] }; const res = await fetch(`/api/leaderboard/details`, { method: 'POST', @@ -75,7 +75,7 @@ async function fetchDetails(window: WindowKey, userIds: string[]) { body: JSON.stringify({ window, userIds }), }); if (!res.ok) throw new Error(`Failed to fetch details: ${await res.text()}`); - return (await res.json()) as { ok: true; window: WindowKey; entries: DetailsEntry[] }; + return (await res.json()) as { ok: true; window: UIWindow; entries: DetailsEntry[] }; } async function fetchProfiles(userIds: string[]): Promise { @@ -94,28 +94,32 @@ async function fetchProfiles(userIds: string[]): Promise { return data.entries; } -export default function LeaderboardClient({ initialWindow }: { initialWindow: WindowKey }) { +export default function LeaderboardClient({ + initialWindow, +}: { + initialWindow: 'all' | '30d' | '365d'; +}) { const router = useRouter(); const search = useSearchParams(); - const [window, setWindow] = React.useState(initialWindow); + const normalized: UIWindow = initialWindow === 'all' ? '365d' : initialWindow; + + const [window, setWindow] = React.useState(normalized); const [rows, setRows] = React.useState(null); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); const [limit, setLimit] = React.useState(25); const [cursor, setCursor] = React.useState(0); const [nextCursor, setNextCursor] = React.useState(null); - const [source, setSource] = React.useState<'redis' | 'db' | null>(null); const [sortKey, setSortKey] = React.useState('rank'); const [sortDir, setSortDir] = React.useState('asc'); - const doFetch = React.useCallback(async (w: WindowKey, lim: number, cur: number) => { + const doFetch = React.useCallback(async (w: UIWindow, lim: number, cur: number) => { setLoading(true); setError(null); try { const top = await fetchTop(w, lim, cur); - setSource(top.source); const ids = top.entries.map((e) => e.userId); const [details, profiles] = await Promise.all([fetchDetails(w, ids), fetchProfiles(ids)]); const detailMap = new Map(details.entries.map((d) => [d.userId, d])); @@ -141,11 +145,14 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi }); setRows(merged); setNextCursor(top.nextCursor); - } catch (err: any) { - setError(String(err?.message || err)); + } catch (err: unknown) { + if (err instanceof Error) { + setError(err.message); + } else { + setError(String(err)); + } setRows([]); setNextCursor(null); - setSource(null); } finally { setLoading(false); } @@ -219,7 +226,7 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi
@@ -260,7 +266,6 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi - {/* Table */}
@@ -359,13 +364,9 @@ export default function LeaderboardClient({ initialWindow }: { initialWindow: Wi
-
- Source: {source ?? '—'} {source === 'redis' ? '(live / warming)' : ''} -
- {/* Pagination */}
+
diff --git a/apps/web/app/(public)/(projects)/projects/[id]/project-page.tsx b/apps/web/app/(public)/(projects)/projects/[id]/project-page.tsx index d0291e41..bef59af0 100644 --- a/apps/web/app/(public)/(projects)/projects/[id]/project-page.tsx +++ b/apps/web/app/(public)/(projects)/projects/[id]/project-page.tsx @@ -33,12 +33,7 @@ import { useEffect, useState, useRef } from 'react'; import Link from '@workspace/ui/components/link'; import { useTRPC } from '@/hooks/use-trpc'; import { formatDate } from '@/lib/utils'; - -// Define types for repository data -interface RepoContent { - content: string; - encoding: 'base64' | 'utf8'; -} +import {isValidProvider, RepoContent} from '@/lib/constants' interface Label { id: string | number; @@ -149,12 +144,6 @@ interface RepoData { // }>; // } -const isValidProvider = ( - provider: string | null | undefined, -): provider is (typeof projectProviderEnum.enumValues)[number] => { - return provider === 'github' || provider === 'gitlab'; -}; - function useProject(id: string) { const trpc = useTRPC(); const query = useQuery(trpc.projects.getProject.queryOptions({ id }, { retry: false })); diff --git a/apps/web/app/(public)/(projects)/projects/project-card.tsx b/apps/web/app/(public)/(projects)/projects/project-card.tsx index 4ea77bfc..f99fec39 100644 --- a/apps/web/app/(public)/(projects)/projects/project-card.tsx +++ b/apps/web/app/(public)/(projects)/projects/project-card.tsx @@ -5,15 +5,10 @@ import { useQuery } from '@tanstack/react-query'; import { useTRPC } from '@/hooks/use-trpc'; import { formatDate } from '@/lib/utils'; import Image from 'next/image'; +import {isValidProvider} from '@/lib/constants' type Project = typeof projectSchema.$inferSelect; -const isValidProvider = ( - provider: string | null, -): provider is (typeof projectProviderEnum.enumValues)[number] => { - return provider === 'github' || provider === 'gitlab'; -}; - export default function ProjectCard({ project, isOwnProfile = false, @@ -22,7 +17,7 @@ export default function ProjectCard({ isOwnProfile?: boolean; }) { const trpc = useTRPC(); - const { data: repo, isError } = useQuery({ + const { data: repo, isError, isLoading } = useQuery({ ...trpc.repository.getRepo.queryOptions({ url: project.gitRepoUrl, provider: project.gitHost as (typeof projectProviderEnum.enumValues)[number], @@ -31,8 +26,6 @@ export default function ProjectCard({ staleTime: 1000 * 60 * 60 * 24, }); - if (isError || !repo) return null; - return (
View {project.name}
- {(repo && repo?.owner && repo?.owner?.avatar_url) || - (repo?.namespace && repo?.namespace?.avatar_url) ? ( + {isLoading || isError || !repo ? ( +
+ ) : (repo?.owner?.avatar_url || repo?.namespace?.avatar_url) ? ( project.ownerId ? ( - {repo?.stargazers_count || repo?.star_count || 0} + {isLoading ? ( + + ) : isError || !repo ? ( + project.starsCount ?? 0 + ) : ( + repo?.stargazers_count ?? repo?.star_count ?? project.starsCount ?? 0 + )}
- {repo?.forks_count || 0} + + {isLoading ? ( + + ) : isError || !repo ? ( + project.forksCount ?? 0 + ) : ( + repo?.forks_count ?? project.forksCount ?? 0 + )} +
- {repo?.created_at ? formatDate(new Date(repo.created_at)) : 'N/A'} + {isLoading ? ( + + ) : isError || !repo ? ( + formatDate(new Date(project.createdAt)) + ) : ( + repo?.created_at ? formatDate(new Date(repo.created_at)) : formatDate(new Date(project.createdAt)) + )}
diff --git a/apps/web/app/(public)/launches/[id]/components/launch-comments.tsx b/apps/web/app/(public)/launches/[id]/components/launch-comments.tsx index e86bb76b..abc8db8a 100644 --- a/apps/web/app/(public)/launches/[id]/components/launch-comments.tsx +++ b/apps/web/app/(public)/launches/[id]/components/launch-comments.tsx @@ -1,26 +1,14 @@ 'use client'; -import { Form, FormControl, FormField, FormItem, FormMessage } from '@workspace/ui/components/form'; -import { Avatar, AvatarFallback, AvatarImage } from '@workspace/ui/components/avatar'; -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { Textarea } from '@workspace/ui/components/textarea'; -import { MessageCircle, Send, Loader2 } from 'lucide-react'; +import { MessageCircle } from 'lucide-react'; import { Button } from '@workspace/ui/components/button'; -import { zodResolver } from '@hookform/resolvers/zod'; import { authClient } from '@workspace/auth/client'; import Link from '@workspace/ui/components/link'; -import { formatDistanceToNow } from 'date-fns'; -import { useTRPC } from '@/hooks/use-trpc'; -import { useForm } from 'react-hook-form'; -import type { Comment } from '../types'; -import { toast } from 'sonner'; -import { z } from 'zod/v4'; +import CommentThread from '@/components/comments/comment-thread'; +import ReplyForm from '@/components/comments/reply-form'; + -const commentSchema = z.object({ - content: z.string().min(1, 'Comment cannot be empty').max(1000, 'Comment is too long'), -}); -type CommentFormData = z.infer; interface LaunchCommentsProps { projectId: string; @@ -35,48 +23,6 @@ export default function LaunchComments({ commentsLoading, }: LaunchCommentsProps) { const { data: session } = authClient.useSession(); - const trpc = useTRPC(); - const queryClient = useQueryClient(); - const form = useForm({ - resolver: zodResolver(commentSchema), - defaultValues: { - content: '', - }, - }); - - // Comment mutation - const { mutate: addComment, isPending: isCommentPending } = useMutation( - trpc.launches.addComment.mutationOptions({ - onSuccess: () => { - toast.success('Comment added successfully!'); - form.reset(); - queryClient.invalidateQueries({ - queryKey: trpc.launches.getComments.queryKey({ projectId }), - }); - }, - onError: () => { - toast.error('Failed to add comment. Please try again.'); - }, - }), - ); - - const onSubmit = (data: CommentFormData) => { - if (!session?.user) { - toast.error('Please login to comment'); - return; - } - addComment({ - projectId, - content: data.content, - }); - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') { - e.preventDefault(); - void form.handleSubmit(onSubmit)(); - } - }; return (
@@ -85,83 +31,22 @@ export default function LaunchComments({ Comments ({comments?.length || 0}) - {/* Show empty state above input if no comments */} - {!commentsLoading && (!comments || comments.length === 0) && ( -
- -

No comments yet.

-

Be the first to comment!

-
- )} - - {/* Comments List */} -
- {commentsLoading ? ( -
- - Loading comments... -
- ) : comments && comments.length > 0 ? ( - comments.map((comment: Comment) => ( -
- - - {comment.user.name?.[0] || 'U'} - -
-
-

{comment.user.name}

- • -

- {formatDistanceToNow(new Date(comment.createdAt))} ago -

-
-

{comment.content}

-
-
- )) - ) : null} + {/* Comments Thread */} +
+
{/* Comment Form */} {session?.user && ( -
-
- - ( - - -