From 058ede9690557ddbaf6059ad4e14e4ff8c380bc9 Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Wed, 21 Jan 2026 00:56:24 +0530 Subject: [PATCH 01/15] feat(duel): add otp and settings columns - `public.gen_monkeytype_otp`: generates 4 digit numeric code - `monkeytype_duel_otp`: text - `monkeytype_duel_settings`: jsonb --- libs/types/src/lib/supabase.gen.ts | 117 ++---------------- ...r_profiles_otp_and_monkeytype_settings.sql | 32 +++++ 2 files changed, 39 insertions(+), 110 deletions(-) create mode 100644 supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql diff --git a/libs/types/src/lib/supabase.gen.ts b/libs/types/src/lib/supabase.gen.ts index 082b322..43418a7 100644 --- a/libs/types/src/lib/supabase.gen.ts +++ b/libs/types/src/lib/supabase.gen.ts @@ -272,116 +272,6 @@ export type Database = { }; Relationships: []; }; - email_jobs: { - Row: { - completed_at: string | null; - created_at: string; - created_by: string | null; - custom_body: string | null; - error_message: string | null; - failed_count: number; - id: string; - sent_count: number; - started_at: string | null; - status: string; - subject: string; - target_status: number[]; - template: string; - total_recipients: number; - }; - Insert: { - completed_at?: string | null; - created_at?: string; - created_by?: string | null; - custom_body?: string | null; - error_message?: string | null; - failed_count?: number; - id?: string; - sent_count?: number; - started_at?: string | null; - status?: string; - subject: string; - target_status: number[]; - template: string; - total_recipients?: number; - }; - Update: { - completed_at?: string | null; - created_at?: string; - created_by?: string | null; - custom_body?: string | null; - error_message?: string | null; - failed_count?: number; - id?: string; - sent_count?: number; - started_at?: string | null; - status?: string; - subject?: string; - target_status?: number[]; - template?: string; - total_recipients?: number; - }; - Relationships: [ - { - foreignKeyName: 'email_jobs_created_by_fkey'; - columns: ['created_by']; - isOneToOne: false; - referencedRelation: 'user_profiles'; - referencedColumns: ['user_id']; - } - ]; - }; - email_logs: { - Row: { - created_at: string; - error_message: string | null; - id: string; - job_id: string; - recipient_email: string; - recipient_id: string | null; - resend_id: string | null; - sent_at: string | null; - status: string; - }; - Insert: { - created_at?: string; - error_message?: string | null; - id?: string; - job_id: string; - recipient_email: string; - recipient_id?: string | null; - resend_id?: string | null; - sent_at?: string | null; - status?: string; - }; - Update: { - created_at?: string; - error_message?: string | null; - id?: string; - job_id?: string; - recipient_email?: string; - recipient_id?: string | null; - resend_id?: string | null; - sent_at?: string | null; - status?: string; - }; - Relationships: [ - { - foreignKeyName: 'email_logs_job_id_fkey'; - columns: ['job_id']; - isOneToOne: false; - referencedRelation: 'email_jobs'; - referencedColumns: ['id']; - }, - { - foreignKeyName: 'email_logs_recipient_id_fkey'; - columns: ['recipient_id']; - isOneToOne: false; - referencedRelation: 'user_profiles'; - referencedColumns: ['user_id']; - } - ]; - }; event_log: { Row: { check_in_time: string; @@ -1020,6 +910,8 @@ export type Database = { email: string | null; first_name: string; last_name: string; + monkeytype_duel_otp: string | null; + monkeytype_duel_settings: Json; referral_code: string | null; referred_by: string | null; role: number | null; @@ -1038,6 +930,8 @@ export type Database = { email?: string | null; first_name: string; last_name: string; + monkeytype_duel_otp?: string | null; + monkeytype_duel_settings?: Json; referral_code?: string | null; referred_by?: string | null; role?: number | null; @@ -1056,6 +950,8 @@ export type Database = { email?: string | null; first_name?: string; last_name?: string; + monkeytype_duel_otp?: string | null; + monkeytype_duel_settings?: Json; referral_code?: string | null; referred_by?: string | null; role?: number | null; @@ -1163,6 +1059,7 @@ export type Database = { [_ in never]: never; }; Functions: { + gen_monkeytype_otp: { Args: never; Returns: string }; get_sponsors: { Args: never; Returns: string[] }; get_volunteers: { Args: never; Returns: string[] }; is_valid_url: { Args: { url: string }; Returns: boolean }; diff --git a/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql b/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql new file mode 100644 index 0000000..e5f4659 --- /dev/null +++ b/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql @@ -0,0 +1,32 @@ +-- Migration: 20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql +-- Adds: +-- - otp: 4-digit numeric code (stored as text to preserve leading zeros) +-- - monkeytype_duel_settings: jsonb blob for Monkeytype duel settings + +CREATE OR REPLACE FUNCTION public.gen_monkeytype_otp() +RETURNS text +LANGUAGE sql +VOLATILE +AS $$ + SELECT lpad((floor(random() * 10000))::int::text, 4, '0'); +$$; + + +ALTER TABLE user_profiles +ADD COLUMN IF NOT EXISTS monkeytype_duel_otp text; + +-- Default: random 4-digit code (0000-9999) with leading zeros +ALTER TABLE user_profiles +ALTER COLUMN monkeytype_duel_otp +SET DEFAULT public.gen_monkeytype_otp(); + +-- Backfill existing rows that don't yet have an OTP +UPDATE user_profiles +SET monkeytype_duel_otp = lpad((floor(random() * 10000))::int::text, 4, '0') +WHERE monkeytype_duel_otp IS NULL; + +ALTER TABLE user_profiles +ADD COLUMN IF NOT EXISTS monkeytype_duel_settings jsonb NOT NULL DEFAULT '{}'::jsonb; + +COMMENT ON COLUMN user_profiles.monkeytype_duel_otp IS '[Monkeytype Duel] Authentication OTP (regenerated on request)'; +COMMENT ON COLUMN user_profiles.monkeytype_duel_settings IS '[Monkeytype Duel] Settings & customization'; \ No newline at end of file From 21eec1c55240d9c709d0f9114f1234e04bdbeed5 Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Wed, 21 Jan 2026 00:56:42 +0530 Subject: [PATCH 02/15] feat(duel): backend route to edit monkeytype_duel_settings --- .../api/dashboard/monkeytype-settings.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/dashboard/pages/api/dashboard/monkeytype-settings.ts diff --git a/apps/dashboard/pages/api/dashboard/monkeytype-settings.ts b/apps/dashboard/pages/api/dashboard/monkeytype-settings.ts new file mode 100644 index 0000000..10342da --- /dev/null +++ b/apps/dashboard/pages/api/dashboard/monkeytype-settings.ts @@ -0,0 +1,61 @@ +import 'reflect-metadata'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; + +type ResponseBody = + | { message: string } + | { settings: unknown; updatedAt?: string | null }; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const settings = (req.body as { settings?: unknown } | undefined)?.settings; + // TODO: add validation once schema is known + if ( + settings == null || + typeof settings !== 'object' || + Array.isArray(settings) + ) { + return res.status(400).json({ + message: '`settings` must be a JSON object', + }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + + const { data, error } = await hbc + .getClient() + .from('user_profiles') + .update({ monkeytype_duel_settings: settings }) + .eq('user_id', user.user_id) + .select('monkeytype_duel_settings,created_at') + .single(); + + if (error) { + console.error('[dashboard/monkeytype-settings] Update error:', error); + return res.status(500).json({ message: 'Failed to update settings' }); + } + + return res.status(200).json({ + settings: data.monkeytype_duel_settings, + updatedAt: data.created_at, + }); + } catch (e) { + console.error('[dashboard/monkeytype-settings] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} From b747b9405f91f379c2944e98f4e24ec03e5e4467 Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Wed, 21 Jan 2026 01:21:10 +0530 Subject: [PATCH 03/15] chore(duel): move routes around --- .../pages/api/monkeytype-duel/reset-otp.ts | 56 +++++++++++++++++ .../pages/api/monkeytype-duel/settings.ts | 61 +++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 apps/dashboard/pages/api/monkeytype-duel/reset-otp.ts create mode 100644 apps/dashboard/pages/api/monkeytype-duel/settings.ts diff --git a/apps/dashboard/pages/api/monkeytype-duel/reset-otp.ts b/apps/dashboard/pages/api/monkeytype-duel/reset-otp.ts new file mode 100644 index 0000000..247cc7f --- /dev/null +++ b/apps/dashboard/pages/api/monkeytype-duel/reset-otp.ts @@ -0,0 +1,56 @@ +import 'reflect-metadata'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; + +type ResponseBody = { message: string } | { otp: string }; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + + const { data: otp, error: otpError } = await hbc + .getClient() + .rpc('gen_monkeytype_otp'); + + if (otpError || typeof otp !== 'string') { + console.error( + '[dashboard/monkeytype-otp/reset] OTP gen error:', + otpError + ); + return res.status(500).json({ message: 'Failed to generate OTP' }); + } + + const { data, error } = await hbc + .getClient() + .from('user_profiles') + .update({ monkeytype_duel_otp: otp }) + .eq('user_id', user.user_id) + .select('monkeytype_duel_otp') + .single(); + + if (error) { + console.error('[dashboard/monkeytype-otp/reset] Update error:', error); + return res.status(500).json({ message: 'Failed to reset OTP' }); + } + + return res.status(200).json({ otp: data.monkeytype_duel_otp }); + } catch (e) { + console.error('[dashboard/monkeytype-otp/reset] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} diff --git a/apps/dashboard/pages/api/monkeytype-duel/settings.ts b/apps/dashboard/pages/api/monkeytype-duel/settings.ts new file mode 100644 index 0000000..10342da --- /dev/null +++ b/apps/dashboard/pages/api/monkeytype-duel/settings.ts @@ -0,0 +1,61 @@ +import 'reflect-metadata'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; +import { getAuthenticatedUser } from '../../../common/auth'; + +type ResponseBody = + | { message: string } + | { settings: unknown; updatedAt?: string | null }; + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const user = await getAuthenticatedUser(req); + if (!user) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const settings = (req.body as { settings?: unknown } | undefined)?.settings; + // TODO: add validation once schema is known + if ( + settings == null || + typeof settings !== 'object' || + Array.isArray(settings) + ) { + return res.status(400).json({ + message: '`settings` must be a JSON object', + }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + + const { data, error } = await hbc + .getClient() + .from('user_profiles') + .update({ monkeytype_duel_settings: settings }) + .eq('user_id', user.user_id) + .select('monkeytype_duel_settings,created_at') + .single(); + + if (error) { + console.error('[dashboard/monkeytype-settings] Update error:', error); + return res.status(500).json({ message: 'Failed to update settings' }); + } + + return res.status(200).json({ + settings: data.monkeytype_duel_settings, + updatedAt: data.created_at, + }); + } catch (e) { + console.error('[dashboard/monkeytype-settings] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} From e1fd0f3f57b809ac628c5ce15e74ae193fc44a96 Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Wed, 21 Jan 2026 01:23:06 +0530 Subject: [PATCH 04/15] feat(duel): otp exchange route --- .../api/dashboard/monkeytype-settings.ts | 61 ---------- .../pages/api/monkeytype-duel/authenticate.ts | 113 ++++++++++++++++++ 2 files changed, 113 insertions(+), 61 deletions(-) delete mode 100644 apps/dashboard/pages/api/dashboard/monkeytype-settings.ts create mode 100644 apps/dashboard/pages/api/monkeytype-duel/authenticate.ts diff --git a/apps/dashboard/pages/api/dashboard/monkeytype-settings.ts b/apps/dashboard/pages/api/dashboard/monkeytype-settings.ts deleted file mode 100644 index 10342da..0000000 --- a/apps/dashboard/pages/api/dashboard/monkeytype-settings.ts +++ /dev/null @@ -1,61 +0,0 @@ -import 'reflect-metadata'; -import type { NextApiRequest, NextApiResponse } from 'next'; -import { container } from 'tsyringe'; -import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; -import { getAuthenticatedUser } from '../../../common/auth'; - -type ResponseBody = - | { message: string } - | { settings: unknown; updatedAt?: string | null }; - -export default async function handler( - req: NextApiRequest, - res: NextApiResponse -) { - if (req.method !== 'POST') { - return res.status(405).json({ message: 'Method not allowed' }); - } - - const user = await getAuthenticatedUser(req); - if (!user) { - return res.status(401).json({ message: 'Unauthorized' }); - } - - const settings = (req.body as { settings?: unknown } | undefined)?.settings; - // TODO: add validation once schema is known - if ( - settings == null || - typeof settings !== 'object' || - Array.isArray(settings) - ) { - return res.status(400).json({ - message: '`settings` must be a JSON object', - }); - } - - try { - const hbc = container.resolve(HibiscusSupabaseClient); - hbc.setOptions({ useServiceKey: true }); - - const { data, error } = await hbc - .getClient() - .from('user_profiles') - .update({ monkeytype_duel_settings: settings }) - .eq('user_id', user.user_id) - .select('monkeytype_duel_settings,created_at') - .single(); - - if (error) { - console.error('[dashboard/monkeytype-settings] Update error:', error); - return res.status(500).json({ message: 'Failed to update settings' }); - } - - return res.status(200).json({ - settings: data.monkeytype_duel_settings, - updatedAt: data.created_at, - }); - } catch (e) { - console.error('[dashboard/monkeytype-settings] Error:', e); - return res.status(500).json({ message: 'Internal server error' }); - } -} diff --git a/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts b/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts new file mode 100644 index 0000000..0f32f1d --- /dev/null +++ b/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts @@ -0,0 +1,113 @@ +import 'reflect-metadata'; +import type { NextApiRequest, NextApiResponse } from 'next'; +import { container } from 'tsyringe'; +import { HibiscusSupabaseClient } from '@hibiscus/hibiscus-supabase-client'; + +type TeamBasicInfo = { + team_id: string; + name: string; + created_at: string | null; + description: string | null; + organizer_id: string | null; +}; + +type ResponseBody = + | { message: string } + | { + first_name: string; + last_name: string; + team: TeamBasicInfo | null; + monkeytype_duel_settings: unknown; + }; + +function getBearerToken(req: NextApiRequest): string | null { + const header = req.headers.authorization; + if (!header) return null; + const prefix = 'Bearer '; + if (!header.startsWith(prefix)) return null; + const token = header.slice(prefix.length).trim(); + if (!token) return null; + return token; +} + +export default async function handler( + req: NextApiRequest, + res: NextApiResponse +) { + if (req.method !== 'POST') { + return res.status(405).json({ message: 'Method not allowed' }); + } + + const expectedSecret = process.env.MONKEYTYPE_DUEL_SECRET; + if (!expectedSecret) { + console.error( + '[monkeytype-duel/authenticate] MONKEYTYPE_DUEL_SECRET is not set' + ); + return res.status(500).json({ message: 'Server misconfigured' }); + } + + const token = getBearerToken(req); + if (!token || token !== expectedSecret) { + return res.status(401).json({ message: 'Unauthorized' }); + } + + const otp = (req.body as { otp?: unknown } | undefined)?.otp; + if (typeof otp !== 'string' || otp.trim().length === 0) { + return res.status(400).json({ message: '`otp` is required' }); + } + + try { + const hbc = container.resolve(HibiscusSupabaseClient); + hbc.setOptions({ useServiceKey: true }); + const supabase = hbc.getClient(); + + const { data: userProfile, error: userError } = await supabase + .from('user_profiles') + .select( + 'user_id,first_name,last_name,team_id,monkeytype_duel_settings,monkeytype_duel_otp' + ) + .eq('monkeytype_duel_otp', otp) + .maybeSingle(); + + if (userError) { + console.error( + '[monkeytype-duel/authenticate] Profile query error:', + userError + ); + return res.status(500).json({ message: 'Failed to authenticate' }); + } + + if (!userProfile) { + return res.status(401).json({ message: 'Invalid OTP' }); + } + + let team: TeamBasicInfo | null = null; + if (userProfile.team_id) { + const { data: teamData, error: teamError } = await supabase + .from('teams') + .select('team_id,name,created_at,description,organizer_id') + .eq('team_id', userProfile.team_id) + .maybeSingle(); + + if (teamError) { + console.error( + '[monkeytype-duel/authenticate] Team query error:', + teamError + ); + return res.status(500).json({ message: 'Failed to authenticate' }); + } + + team = teamData ?? null; + } + + return res.status(200).json({ + first_name: userProfile.first_name, + last_name: userProfile.last_name, + team, + monkeytype_duel_settings: userProfile.monkeytype_duel_settings ?? {}, + }); + } catch (e) { + console.error('[monkeytype-duel/authenticate] Error:', e); + return res.status(500).json({ message: 'Internal server error' }); + } +} From 83bb9af932efb86d79b175e8028f46a2734fe5e2 Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Fri, 23 Jan 2026 17:01:18 +0530 Subject: [PATCH 05/15] fix: copilot suggestions - remove created_at from settings api route output - add unique constraint on user_profiles.monkeytype_duel_otp --- .../pages/api/monkeytype-duel/settings.ts | 3 +- ...r_profiles_otp_and_monkeytype_settings.sql | 2 +- ...000001_make_monkeytype_duel_otp_unique.sql | 106 ++++++++++++++++++ 3 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260123000001_make_monkeytype_duel_otp_unique.sql diff --git a/apps/dashboard/pages/api/monkeytype-duel/settings.ts b/apps/dashboard/pages/api/monkeytype-duel/settings.ts index 10342da..3c6b9be 100644 --- a/apps/dashboard/pages/api/monkeytype-duel/settings.ts +++ b/apps/dashboard/pages/api/monkeytype-duel/settings.ts @@ -42,7 +42,7 @@ export default async function handler( .from('user_profiles') .update({ monkeytype_duel_settings: settings }) .eq('user_id', user.user_id) - .select('monkeytype_duel_settings,created_at') + .select('monkeytype_duel_settings') .single(); if (error) { @@ -52,7 +52,6 @@ export default async function handler( return res.status(200).json({ settings: data.monkeytype_duel_settings, - updatedAt: data.created_at, }); } catch (e) { console.error('[dashboard/monkeytype-settings] Error:', e); diff --git a/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql b/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql index e5f4659..5881716 100644 --- a/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql +++ b/supabase/migrations/20260120000001_add_user_profiles_otp_and_monkeytype_settings.sql @@ -22,7 +22,7 @@ SET DEFAULT public.gen_monkeytype_otp(); -- Backfill existing rows that don't yet have an OTP UPDATE user_profiles -SET monkeytype_duel_otp = lpad((floor(random() * 10000))::int::text, 4, '0') +SET monkeytype_duel_otp = public.gen_monkeytype_otp() WHERE monkeytype_duel_otp IS NULL; ALTER TABLE user_profiles diff --git a/supabase/migrations/20260123000001_make_monkeytype_duel_otp_unique.sql b/supabase/migrations/20260123000001_make_monkeytype_duel_otp_unique.sql new file mode 100644 index 0000000..b45c20e --- /dev/null +++ b/supabase/migrations/20260123000001_make_monkeytype_duel_otp_unique.sql @@ -0,0 +1,106 @@ +-- Migration: 20260123000001_make_monkeytype_duel_otp_unique.sql +-- Purpose: +-- - Ensure monkeytype_duel_otp is unique (used for authentication) +-- - De-dupe any existing collisions +-- - Prevent future collisions on INSERT/UPDATE by regenerating OTP + +-- 1) Generate a unique 4-digit OTP (serialized via advisory lock to avoid races) +CREATE OR REPLACE FUNCTION public.gen_unique_monkeytype_otp() +RETURNS text +LANGUAGE plpgsql +VOLATILE +AS $$ +DECLARE + candidate text; +BEGIN + -- Serialize OTP generation to avoid concurrent collisions. + PERFORM pg_advisory_xact_lock(hashtext('user_profiles.monkeytype_duel_otp')); + + LOOP + candidate := public.gen_monkeytype_otp(); + EXIT WHEN NOT EXISTS ( + SELECT 1 + FROM public.user_profiles up + WHERE up.monkeytype_duel_otp = candidate + ); + END LOOP; + + RETURN candidate; +END; +$$; + +-- 2) Trigger: if INSERT/UPDATE would result in a collision (or NULL/empty), regenerate +CREATE OR REPLACE FUNCTION public.ensure_unique_monkeytype_duel_otp() +RETURNS trigger +LANGUAGE plpgsql +AS $$ +BEGIN + IF NEW.monkeytype_duel_otp IS NULL OR NEW.monkeytype_duel_otp = '' THEN + NEW.monkeytype_duel_otp := public.gen_unique_monkeytype_otp(); + RETURN NEW; + END IF; + + IF EXISTS ( + SELECT 1 + FROM public.user_profiles up + WHERE up.monkeytype_duel_otp = NEW.monkeytype_duel_otp + AND up.user_id <> NEW.user_id + ) THEN + NEW.monkeytype_duel_otp := public.gen_unique_monkeytype_otp(); + END IF; + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS trg_ensure_unique_monkeytype_duel_otp ON public.user_profiles; +CREATE TRIGGER trg_ensure_unique_monkeytype_duel_otp +BEFORE INSERT OR UPDATE ON public.user_profiles +FOR EACH ROW +EXECUTE FUNCTION public.ensure_unique_monkeytype_duel_otp(); + +-- 3) Clean up existing data: +-- - Backfill NULL/empty OTPs +-- - Resolve collisions by regenerating for all but one row per duplicated OTP +DO $$ +DECLARE + r record; +BEGIN + -- Backfill NULL/empty + UPDATE public.user_profiles + SET monkeytype_duel_otp = public.gen_unique_monkeytype_otp() + WHERE monkeytype_duel_otp IS NULL OR monkeytype_duel_otp = ''; + + -- De-dupe collisions (keep first by user_id) + FOR r IN + SELECT user_id + FROM ( + SELECT + user_id, + monkeytype_duel_otp, + row_number() OVER ( + PARTITION BY monkeytype_duel_otp + ORDER BY user_id + ) AS rn + FROM public.user_profiles + WHERE monkeytype_duel_otp IS NOT NULL AND monkeytype_duel_otp <> '' + ) t + WHERE t.rn > 1 + LOOP + UPDATE public.user_profiles + SET monkeytype_duel_otp = public.gen_unique_monkeytype_otp() + WHERE user_id = r.user_id; + END LOOP; +END $$; + +-- 4) Enforce uniqueness at the database level +CREATE UNIQUE INDEX IF NOT EXISTS user_profiles_monkeytype_duel_otp_unique_idx + ON public.user_profiles (monkeytype_duel_otp); + +-- 5) Make it non-nullable and set a safe default going forward +ALTER TABLE public.user_profiles + ALTER COLUMN monkeytype_duel_otp SET DEFAULT public.gen_unique_monkeytype_otp(); + +ALTER TABLE public.user_profiles + ALTER COLUMN monkeytype_duel_otp SET NOT NULL; + From b0f5bc5fd6c6e283ef3cf610cfd6fc52b7310ee0 Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Fri, 23 Jan 2026 17:06:40 +0530 Subject: [PATCH 06/15] fix: copilot suggestions --- apps/dashboard/pages/api/monkeytype-duel/authenticate.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts b/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts index 0f32f1d..22bf777 100644 --- a/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts +++ b/apps/dashboard/pages/api/monkeytype-duel/authenticate.ts @@ -51,8 +51,11 @@ export default async function handler( return res.status(401).json({ message: 'Unauthorized' }); } - const otp = (req.body as { otp?: unknown } | undefined)?.otp; - if (typeof otp !== 'string' || otp.trim().length === 0) { + if (typeof req.body['otp'] !== 'string') { + return res.status(400).json({ message: '`otp` is required' }); + } + const otp = (req.body as { otp: string })?.otp.trim(); + if (otp.length !== 0) { return res.status(400).json({ message: '`otp` is required' }); } From d0c516fc75dc1c0e579914af67f36cc8653f614b Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Wed, 28 Jan 2026 01:36:39 +0530 Subject: [PATCH 07/15] feat(finalist-dashboard): create finalist role --- apps/supabase-auth/src/index.ts | 6 +++--- libs/styles/src/lib/colors.ts | 4 ++++ libs/types/src/lib/roles.ts | 1 + supabase/migrations/20260127000001_add_finalist_role.sql | 6 ++++++ 4 files changed, 14 insertions(+), 3 deletions(-) create mode 100644 supabase/migrations/20260127000001_add_finalist_role.sql diff --git a/apps/supabase-auth/src/index.ts b/apps/supabase-auth/src/index.ts index 9d1ff37..9a36d0c 100644 --- a/apps/supabase-auth/src/index.ts +++ b/apps/supabase-auth/src/index.ts @@ -37,9 +37,9 @@ app.post('/api/invite/:role/:email', async (c) => { INTERNAL_SERVER_ERROR ); } - // role should be between 1 and 7 + // role should be between 1 and 8 // maybe we shouldn't hardcode this, but I'm not sure how to change this for now - if (role > 0 && role < 8) { + if (role > 0 && role < 9) { const result = await supabase.from('user_invites').insert({ role: role, email: email, @@ -52,7 +52,7 @@ app.post('/api/invite/:role/:email', async (c) => { return c.json( { error: 'PARAMETER_ERROR', - message: 'Role number not recognized should be between 1 and 7', + message: 'Role number not recognized should be between 1 and 8', }, INTERNAL_SERVER_ERROR ); diff --git a/libs/styles/src/lib/colors.ts b/libs/styles/src/lib/colors.ts index a960d66..ca41cb5 100644 --- a/libs/styles/src/lib/colors.ts +++ b/libs/styles/src/lib/colors.ts @@ -67,5 +67,9 @@ export namespace Colors2023 { light: BLUE.LIGHT, standard: BLUE.STANDARD, }, + [HibiscusRole.FINALIST]: { + light: PURPLE.LIGHT, + standard: PURPLE.STANDARD, + }, }; } diff --git a/libs/types/src/lib/roles.ts b/libs/types/src/lib/roles.ts index 42667c0..4db9ed2 100644 --- a/libs/types/src/lib/roles.ts +++ b/libs/types/src/lib/roles.ts @@ -6,4 +6,5 @@ export enum HibiscusRole { HACKER = 'HACKER', APPLICANT = 'APPLICANT', JUDGE = 'JUDGE', + FINALIST = 'FINALIST', } diff --git a/supabase/migrations/20260127000001_add_finalist_role.sql b/supabase/migrations/20260127000001_add_finalist_role.sql new file mode 100644 index 0000000..935aa84 --- /dev/null +++ b/supabase/migrations/20260127000001_add_finalist_role.sql @@ -0,0 +1,6 @@ +-- Add FINALIST role (id=8). +-- NOTE: Some dashboard code maps role ids to the HibiscusRole enum by index, +-- so this role must be appended as the next sequential id. +INSERT INTO public.roles (id, name) +VALUES (8, 'FINALIST') +ON CONFLICT DO NOTHING; From be58f463b4217f5d2f1e9ca716373a33a9b9b6cd Mon Sep 17 00:00:00 2001 From: Angad Singh Date: Wed, 28 Jan 2026 01:37:04 +0530 Subject: [PATCH 08/15] feat(finalist-dashboard): create empty dashboard page, redirect user based on role --- .../components/neo-ui/NeoSidebar.tsx | 33 ++++++++++++++----- apps/dashboard/layouts/portal-layout.tsx | 2 +- apps/dashboard/pages/finalist/index.tsx | 29 ++++++++++++++++ apps/dashboard/pages/index.tsx | 3 ++ 4 files changed, 57 insertions(+), 10 deletions(-) create mode 100644 apps/dashboard/pages/finalist/index.tsx diff --git a/apps/dashboard/components/neo-ui/NeoSidebar.tsx b/apps/dashboard/components/neo-ui/NeoSidebar.tsx index 87bd226..b953b1f 100644 --- a/apps/dashboard/components/neo-ui/NeoSidebar.tsx +++ b/apps/dashboard/components/neo-ui/NeoSidebar.tsx @@ -1,7 +1,8 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; import Link from 'next/link'; import { useRouter } from 'next/router'; +import { HibiscusRole, HibiscusUser } from '@hibiscus/types'; import { FaHouse, FaUsers, @@ -17,26 +18,40 @@ const CONTACT = { phone: '+91 90500 14105', }; -const NAV_ITEMS = [ - { path: '/', label: 'Home', icon: FaHouse }, - { path: '/team', label: 'Team', icon: FaUsers }, - { path: '/submit', label: 'Submit', icon: FaCloudArrowUp }, - { path: '/apply', label: 'Profile', icon: FaUser }, -]; +const NAV_ITEMS = (user: HibiscusUser) => + user.role === HibiscusRole.FINALIST + ? [ + { path: '/finalist', label: 'Home', icon: FaHouse }, + { path: '/team', label: 'Team', icon: FaUsers }, + { path: '/submit', label: 'Submit', icon: FaCloudArrowUp }, + { path: '/apply', label: 'Profile', icon: FaUser }, + ] + : [ + { path: '/', label: 'Home', icon: FaHouse }, + { path: '/team', label: 'Team', icon: FaUsers }, + { path: '/submit', label: 'Submit', icon: FaCloudArrowUp }, + { path: '/apply', label: 'Profile', icon: FaUser }, + ]; function isActiveRoute(itemPath: string, currentPath: string): boolean { if (itemPath === '/') return currentPath === '/'; return currentPath.startsWith(itemPath); } -export function NeoSidebar() { +export interface NeoSidebarProps { + user: HibiscusUser; +} + +export function NeoSidebar({ user }: NeoSidebarProps) { const router = useRouter(); const currentPath = router.pathname; + const navItems = useMemo(() => NAV_ITEMS(user), [user]); + return (