create extension if not exists pgcrypto; create table if not exists public.wordle_words ( position integer primary key, word text not null unique check (word ~ '^[a-z]{5}$') ); insert into public.wordle_words (position, word) values (1, 'cigar'), (2, 'rebut'), (3, 'sissy'), (4, 'humph'), (5, 'awake'), (6, 'blush'), (7, 'focal'), (8, 'evade'), (9, 'naval'), (10, 'serve'), (11, 'heath'), (12, 'dwarf'), (13, 'model'), (14, 'karma'), (15, 'stink'), (16, 'grade'), (17, 'quiet'), (18, 'bench'), (19, 'abate'), (20, 'feign'), (21, 'major'), (22, 'death'), (23, 'fresh'), (24, 'crust'), (25, 'stool'), (26, 'colon'), (27, 'abase'), (28, 'marry'), (29, 'react'), (30, 'batty'), (31, 'pride'), (32, 'floss'), (33, 'helix'), (34, 'croak'), (35, 'staff'), (36, 'paper'), (37, 'unfed'), (38, 'whelp'), (39, 'trawl'), (40, 'outdo'), (41, 'adobe'), (42, 'crazy'), (43, 'sower'), (44, 'repay'), (45, 'digit'), (46, 'crate'), (47, 'cluck'), (48, 'spike'), (49, 'mimic'), (50, 'pound'), (51, 'maxim'), (52, 'linen'), (53, 'unmet'), (54, 'flesh'), (55, 'booby'), (56, 'forth'), (57, 'first'), (58, 'stand'), (59, 'belly'), (60, 'ivory'), (61, 'seedy'), (62, 'print'), (63, 'yearn'), (64, 'drain'), (65, 'bribe'), (66, 'stout'), (67, 'panel'), (68, 'crass'), (69, 'flume'), (70, 'offal'), (71, 'agree'), (72, 'error'), (73, 'swirl'), (74, 'argue'), (75, 'bleed'), (76, 'delta'), (77, 'flick'), (78, 'totem'), (79, 'wooer'), (80, 'front'), (81, 'shrub'), (82, 'parry'), (83, 'biome'), (84, 'lapel'), (85, 'start'), (86, 'greet'), (87, 'goner'), (88, 'golem'), (89, 'lusty'), (90, 'loopy'), (91, 'round'), (92, 'audit'), (93, 'lying'), (94, 'gamma'), (95, 'labor'), (96, 'islet'), (97, 'civic'), (98, 'forge'), (99, 'corny'), (100, 'moult'), (101, 'basic'), (102, 'salad'), (103, 'agate'), (104, 'spicy'), (105, 'spray'), (106, 'essay'), (107, 'fjord'), (108, 'spend'), (109, 'kebab'), (110, 'guild'), (111, 'aback'), (112, 'motor'), (113, 'alone'), (114, 'hatch'), (115, 'hyper'), (116, 'thumb'), (117, 'dowry'), (118, 'ought'), (119, 'belch'), (120, 'dutch') on conflict (position) do nothing; alter table public.wordle_words enable row level security; create table if not exists public.wordle_rounds ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, word text not null check (word ~ '^[a-z]{5}$'), hour_start timestamptz, started_at timestamptz not null default now(), next_playable_at timestamptz not null default (now() + interval '1 hour'), completed_at timestamptz, won boolean, guess_count integer check (guess_count between 1 and 6), created_at timestamptz not null default now() ); alter table public.wordle_rounds add column if not exists hour_start timestamptz; update public.wordle_rounds set hour_start = date_trunc('hour', started_at) where hour_start is null; alter table public.wordle_rounds alter column hour_start set not null; create index if not exists wordle_rounds_user_started_idx on public.wordle_rounds (user_id, started_at desc); create unique index if not exists wordle_rounds_user_hour_idx on public.wordle_rounds (user_id, hour_start); alter table public.wordle_rounds enable row level security; drop policy if exists "Users can read their own rounds" on public.wordle_rounds; create policy "Users can read their own rounds" on public.wordle_rounds for select using (auth.uid() = user_id); drop function if exists public.start_hourly_round(text); create or replace function public.start_hourly_round() returns table ( round_id uuid, word text, hour_start timestamptz, started_at timestamptz, next_playable_at timestamptz, completed_at timestamptz, won boolean, guess_count integer, server_now timestamptz, is_existing boolean ) language plpgsql security definer set search_path = public as $$ declare existing_round public.wordle_rounds%rowtype; new_round public.wordle_rounds%rowtype; current_hour timestamptz := date_trunc('hour', now()); word_count integer; word_offset integer; hourly_word text; begin if auth.uid() is null then raise exception 'Authentication required'; end if; select count(*) into word_count from public.wordle_words; if word_count = 0 then raise exception 'No hourly words are configured'; end if; word_offset := (floor(extract(epoch from current_hour) / 3600)::bigint % word_count)::integer; select public.wordle_words.word into hourly_word from public.wordle_words order by position limit 1 offset word_offset; select * into existing_round from public.wordle_rounds where user_id = auth.uid() and public.wordle_rounds.hour_start = current_hour order by started_at desc limit 1; if found then return query select existing_round.id, existing_round.word, existing_round.hour_start, existing_round.started_at, existing_round.next_playable_at, existing_round.completed_at, existing_round.won, existing_round.guess_count, now(), true; return; end if; insert into public.wordle_rounds (user_id, word, hour_start, next_playable_at) values (auth.uid(), hourly_word, current_hour, current_hour + interval '1 hour') returning * into new_round; return query select new_round.id, new_round.word, new_round.hour_start, new_round.started_at, new_round.next_playable_at, new_round.completed_at, new_round.won, new_round.guess_count, now(), false; end; $$; create or replace function public.complete_hourly_round( round_id uuid, did_win boolean, guess_total integer ) returns void language plpgsql security definer set search_path = public as $$ begin if auth.uid() is null then raise exception 'Authentication required'; end if; if guess_total < 1 or guess_total > 6 then raise exception 'Guess total must be between 1 and 6'; end if; update public.wordle_rounds set completed_at = coalesce(completed_at, now()), won = did_win, guess_count = guess_total where id = round_id and user_id = auth.uid(); end; $$; revoke all on public.wordle_rounds from anon, authenticated; revoke all on public.wordle_words from anon, authenticated; grant select on public.wordle_rounds to authenticated; grant execute on function public.start_hourly_round() to authenticated; grant execute on function public.complete_hourly_round(uuid, boolean, integer) to authenticated;