first commit

This commit is contained in:
2026-07-22 13:48:46 +08:00
commit c87751c3dc
2820 changed files with 726976 additions and 0 deletions
@@ -0,0 +1,213 @@
create table "public"."conversations" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone default now(),
"updated_at" timestamp with time zone default now(),
"user_id" uuid not null,
"title" text not null,
"current_message_leaf_id" uuid
);
alter table "public"."conversations" enable row level security;
create table "public"."messages" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone not null default now(),
"conversation_id" uuid not null,
"role" text not null,
"content" jsonb not null,
"parent_message_id" uuid
);
alter table "public"."messages" enable row level security;
CREATE INDEX conversations_created_at_idx ON public.conversations USING btree (created_at);
CREATE UNIQUE INDEX conversations_pkey ON public.conversations USING btree (id);
CREATE INDEX conversations_updated_at_idx ON public.conversations USING btree (updated_at);
CREATE INDEX conversations_user_id_idx ON public.conversations USING btree (user_id);
CREATE INDEX messages_conversation_id_idx ON public.messages USING btree (conversation_id);
CREATE UNIQUE INDEX messages_pkey ON public.messages USING btree (id);
alter table "public"."conversations" add constraint "conversations_pkey" PRIMARY KEY using index "conversations_pkey";
alter table "public"."messages" add constraint "messages_pkey" PRIMARY KEY using index "messages_pkey";
alter table "public"."conversations" add constraint "conversations_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."conversations" validate constraint "conversations_user_id_fkey";
alter table "public"."messages" add constraint "messages_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE not valid;
alter table "public"."messages" validate constraint "messages_conversation_id_fkey";
alter table "public"."messages" add constraint "messages_role_check" CHECK ((role = ANY (ARRAY['user'::text, 'assistant'::text]))) not valid;
alter table "public"."messages" validate constraint "messages_role_check";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.update_conversation_leaf()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
begin
update conversations set
current_message_leaf_id = new.id,
updated_at = now()
where id = new.conversation_id;
return new;
end;
$function$
;
grant delete on table "public"."conversations" to "anon";
grant insert on table "public"."conversations" to "anon";
grant references on table "public"."conversations" to "anon";
grant select on table "public"."conversations" to "anon";
grant trigger on table "public"."conversations" to "anon";
grant truncate on table "public"."conversations" to "anon";
grant update on table "public"."conversations" to "anon";
grant delete on table "public"."conversations" to "authenticated";
grant insert on table "public"."conversations" to "authenticated";
grant references on table "public"."conversations" to "authenticated";
grant select on table "public"."conversations" to "authenticated";
grant trigger on table "public"."conversations" to "authenticated";
grant truncate on table "public"."conversations" to "authenticated";
grant update on table "public"."conversations" to "authenticated";
grant delete on table "public"."conversations" to "service_role";
grant insert on table "public"."conversations" to "service_role";
grant references on table "public"."conversations" to "service_role";
grant select on table "public"."conversations" to "service_role";
grant trigger on table "public"."conversations" to "service_role";
grant truncate on table "public"."conversations" to "service_role";
grant update on table "public"."conversations" to "service_role";
grant delete on table "public"."messages" to "anon";
grant insert on table "public"."messages" to "anon";
grant references on table "public"."messages" to "anon";
grant select on table "public"."messages" to "anon";
grant trigger on table "public"."messages" to "anon";
grant truncate on table "public"."messages" to "anon";
grant update on table "public"."messages" to "anon";
grant delete on table "public"."messages" to "authenticated";
grant insert on table "public"."messages" to "authenticated";
grant references on table "public"."messages" to "authenticated";
grant select on table "public"."messages" to "authenticated";
grant trigger on table "public"."messages" to "authenticated";
grant truncate on table "public"."messages" to "authenticated";
grant update on table "public"."messages" to "authenticated";
grant delete on table "public"."messages" to "service_role";
grant insert on table "public"."messages" to "service_role";
grant references on table "public"."messages" to "service_role";
grant select on table "public"."messages" to "service_role";
grant trigger on table "public"."messages" to "service_role";
grant truncate on table "public"."messages" to "service_role";
grant update on table "public"."messages" to "service_role";
create policy "Users can manage their own conversations"
on "public"."conversations"
as permissive
for all
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Users can manage messages in their conversations"
on "public"."messages"
as permissive
for all
to public
using ((( SELECT auth.uid() AS uid) IN ( SELECT conversations.user_id
FROM conversations
WHERE (conversations.id = messages.conversation_id))));
CREATE TRIGGER update_leaf_trigger AFTER INSERT ON public.messages FOR EACH ROW EXECUTE FUNCTION update_conversation_leaf();
create policy "Give users access to own folder images_delete"
on "storage"."objects"
as permissive
for delete
to public
using (((bucket_id = 'images'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder images_insert"
on "storage"."objects"
as permissive
for insert
to public
with check (((bucket_id = 'images'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder images_select"
on "storage"."objects"
as permissive
for select
to public
using (((bucket_id = 'images'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder images_update"
on "storage"."objects"
as permissive
for update
to public
using (((bucket_id = 'images'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
@@ -0,0 +1,890 @@
create type "public"."conversation-type" as enum ('parametric', 'creative');
create type "public"."generation-status" as enum ('pending', 'success', 'failure');
create type "public"."mesh_file_type" as enum ('glb', 'stl', 'obj', 'fbx');
create type "public"."mesh_model_type" as enum ('quality', 'fast');
create type "public"."privacy_type" as enum ('public', 'private');
create type "public"."prompt_type" as enum ('mesh', 'image', 'chat');
create type "public"."stripe-level" as enum ('pro', 'standard');
create type "public"."subscription_level" as enum ('pro', 'standard', 'free');
create table "public"."images" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone not null default now(),
"status" public."generation-status" not null default 'pending'::public."generation-status",
"user_id" uuid not null,
"conversation_id" uuid not null,
"image_generation_call_id" text,
"prompt" jsonb not null default '{}'::jsonb
);
alter table "public"."images" enable row level security;
create table "public"."meshes" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone not null default now(),
"status" public."generation-status" not null default 'pending'::public."generation-status",
"user_id" uuid not null,
"images" uuid[],
"conversation_id" uuid not null,
"prompt" jsonb not null default '{}'::jsonb,
"file_type" public.mesh_file_type not null default 'glb'::public.mesh_file_type
);
alter table "public"."meshes" enable row level security;
create table "public"."previews" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now(),
"status" public."generation-status" not null default 'pending'::public."generation-status",
"user_id" uuid not null,
"conversation_id" uuid not null,
"mesh_id" uuid not null
);
alter table "public"."previews" enable row level security;
create table "public"."profiles" (
"id" uuid not null default gen_random_uuid(),
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now(),
"user_id" uuid not null,
"full_name" text not null,
"notifications_enabled" boolean not null default false,
"avatar_path" text
);
alter table "public"."profiles" enable row level security;
create table "public"."prompts" (
"id" bigint generated by default as identity not null,
"created_at" timestamp with time zone not null default now(),
"user_id" uuid not null,
"type" public.prompt_type not null default 'chat'::public.prompt_type
);
alter table "public"."prompts" enable row level security;
create table "public"."subscriptions" (
"id" uuid not null default gen_random_uuid(),
"user_id" uuid not null,
"stripe_customer_id" text,
"stripe_subscription_id" text,
"status" text,
"created_at" timestamp with time zone default now(),
"level" public."stripe-level" not null default 'pro'::public."stripe-level"
);
alter table "public"."subscriptions" enable row level security;
create table "public"."trial_users" (
"id" uuid not null default gen_random_uuid(),
"user_id" uuid not null
);
alter table "public"."trial_users" enable row level security;
alter table "public"."conversations" add column "privacy" public.privacy_type not null default 'private'::public.privacy_type;
alter table "public"."conversations" add column "settings" jsonb not null default '{}'::jsonb;
alter table "public"."conversations" add column "type" public."conversation-type" not null default 'parametric'::public."conversation-type";
alter table "public"."messages" add column "rating" smallint not null default '0'::smallint;
CREATE INDEX idx_images_image_generation_call_id ON public.images USING btree (image_generation_call_id);
CREATE INDEX idx_subscriptions_stripe_customer_id ON public.subscriptions USING btree (stripe_customer_id);
CREATE INDEX idx_subscriptions_stripe_subscription_id ON public.subscriptions USING btree (stripe_subscription_id);
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions USING btree (user_id);
CREATE UNIQUE INDEX images_pkey ON public.images USING btree (id);
CREATE UNIQUE INDEX meshes_pkey ON public.meshes USING btree (id);
CREATE UNIQUE INDEX previews_pkey ON public.previews USING btree (id);
CREATE UNIQUE INDEX profiles_pkey ON public.profiles USING btree (id);
CREATE UNIQUE INDEX prompts_pkey ON public.prompts USING btree (id);
CREATE UNIQUE INDEX subscriptions_pkey ON public.subscriptions USING btree (id);
CREATE UNIQUE INDEX trial_users_pkey ON public.trial_users USING btree (id);
CREATE UNIQUE INDEX trial_users_user_id_key ON public.trial_users USING btree (user_id);
alter table "public"."images" add constraint "images_pkey" PRIMARY KEY using index "images_pkey";
alter table "public"."meshes" add constraint "meshes_pkey" PRIMARY KEY using index "meshes_pkey";
alter table "public"."previews" add constraint "previews_pkey" PRIMARY KEY using index "previews_pkey";
alter table "public"."profiles" add constraint "profiles_pkey" PRIMARY KEY using index "profiles_pkey";
alter table "public"."prompts" add constraint "prompts_pkey" PRIMARY KEY using index "prompts_pkey";
alter table "public"."subscriptions" add constraint "subscriptions_pkey" PRIMARY KEY using index "subscriptions_pkey";
alter table "public"."trial_users" add constraint "trial_users_pkey" PRIMARY KEY using index "trial_users_pkey";
alter table "public"."images" add constraint "images_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."images" validate constraint "images_conversation_id_fkey";
alter table "public"."images" add constraint "images_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."images" validate constraint "images_user_id_fkey";
alter table "public"."meshes" add constraint "meshes_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."meshes" validate constraint "meshes_conversation_id_fkey";
alter table "public"."meshes" add constraint "meshes_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."meshes" validate constraint "meshes_user_id_fkey";
alter table "public"."previews" add constraint "previews_conversation_id_fkey" FOREIGN KEY (conversation_id) REFERENCES public.conversations(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."previews" validate constraint "previews_conversation_id_fkey";
alter table "public"."previews" add constraint "previews_mesh_id_fkey" FOREIGN KEY (mesh_id) REFERENCES public.meshes(id) ON DELETE CASCADE not valid;
alter table "public"."previews" validate constraint "previews_mesh_id_fkey";
alter table "public"."previews" add constraint "previews_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."previews" validate constraint "previews_user_id_fkey";
alter table "public"."profiles" add constraint "profiles_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."profiles" validate constraint "profiles_user_id_fkey";
alter table "public"."prompts" add constraint "prompts_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."prompts" validate constraint "prompts_user_id_fkey";
alter table "public"."subscriptions" add constraint "subscriptions_status_check" CHECK ((status = ANY (ARRAY['active'::text, 'canceled'::text, 'incomplete'::text, 'incomplete_expired'::text, 'past_due'::text, 'trialing'::text, 'unpaid'::text]))) not valid;
alter table "public"."subscriptions" validate constraint "subscriptions_status_check";
alter table "public"."subscriptions" add constraint "subscriptions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) not valid;
alter table "public"."subscriptions" validate constraint "subscriptions_user_id_fkey";
alter table "public"."trial_users" add constraint "trial_users_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."trial_users" validate constraint "trial_users_user_id_fkey";
alter table "public"."trial_users" add constraint "trial_users_user_id_key" UNIQUE using index "trial_users_user_id_key";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.handle_mesh_insert()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
-- Insert a prompt entry immediately when a mesh is created
INSERT INTO public.prompts (user_id, type)
VALUES (NEW.user_id, 'mesh');
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_mesh_status_update()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
-- If mesh status changed to 'failure', remove the most recent mesh prompt for this user
IF OLD.status != 'failure' AND NEW.status = 'failure' THEN
DELETE FROM public.prompts
WHERE user_id = NEW.user_id
AND type = 'mesh'
AND id = (
SELECT id FROM public.prompts
WHERE user_id = NEW.user_id
AND type = 'mesh'
ORDER BY created_at DESC
LIMIT 1
);
END IF;
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
INSERT INTO public.profiles (user_id, full_name)
VALUES (
NEW.id,
COALESCE(
NEW.raw_user_meta_data->>'full_name',
split_part(NEW.email, '@', 1)
)
);
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.update_updated_at_column()
RETURNS trigger
LANGUAGE plpgsql
AS $function$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$function$
;
create type "public"."user_data" as ("hasTrialed" boolean, "sublevel" public.subscription_level, "generationsRemaining" integer);
CREATE OR REPLACE FUNCTION public.user_extradata(user_id_input uuid)
RETURNS public.user_data
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
hasTrialed boolean;
userlevel public.subscriptions.level%TYPE;
userstatus public.subscriptions.status%TYPE;
daily_generations_used integer;
monthly_generations_used integer;
ret user_data;
BEGIN
-- Get trial status
SELECT (
(SELECT count(*) FROM public.trial_users WHERE user_id = user_id_input) > 0
) INTO hasTrialed;
-- Get subscription info
SELECT STATUS, LEVEL INTO userstatus, userlevel
FROM public.subscriptions
WHERE user_id = user_id_input;
-- Get daily generation count (for free tier)
SELECT COUNT(*) INTO daily_generations_used
FROM public.prompts
WHERE user_id = user_id_input
AND type != 'image'
AND type != 'chat' -- Exclude parametric generations
AND created_at >= NOW() - INTERVAL '1 day';
-- Get monthly generation count (for standard tier)
SELECT COUNT(*) INTO monthly_generations_used
FROM public.prompts
WHERE user_id = user_id_input
AND type != 'image'
AND type != 'chat' -- Exclude parametric generations
AND created_at >= date_trunc('month', NOW());
-- Set return values
ret."hasTrialed" = hasTrialed;
-- Set subscription level
IF (userstatus = 'active') THEN
ret."sublevel" = userlevel;
ELSIF (userstatus = 'trialing') THEN
ret."sublevel" = 'pro';
ELSE
ret."sublevel" = 'free';
END IF;
-- Calculate remaining generations based on subscription level
IF ret."sublevel" = 'free' THEN
-- Free tier: 3 generations per day
ret."generationsRemaining" = GREATEST(3 - daily_generations_used, 0);
ELSIF ret."sublevel" = 'standard' THEN
-- Standard tier: 100 generations per month
ret."generationsRemaining" = GREATEST(100 - monthly_generations_used, 0);
ELSE
-- Pro or enterprise: unlimited
ret."generationsRemaining" = 999999; -- effectively unlimited
END IF;
RETURN ret;
EXCEPTION
WHEN others THEN
RAISE EXCEPTION 'An error occurred in function user_extradata(): %', SQLERRM;
END;
$function$
;
grant delete on table "public"."images" to "anon";
grant insert on table "public"."images" to "anon";
grant references on table "public"."images" to "anon";
grant select on table "public"."images" to "anon";
grant trigger on table "public"."images" to "anon";
grant truncate on table "public"."images" to "anon";
grant update on table "public"."images" to "anon";
grant delete on table "public"."images" to "authenticated";
grant insert on table "public"."images" to "authenticated";
grant references on table "public"."images" to "authenticated";
grant select on table "public"."images" to "authenticated";
grant trigger on table "public"."images" to "authenticated";
grant truncate on table "public"."images" to "authenticated";
grant update on table "public"."images" to "authenticated";
grant delete on table "public"."images" to "service_role";
grant insert on table "public"."images" to "service_role";
grant references on table "public"."images" to "service_role";
grant select on table "public"."images" to "service_role";
grant trigger on table "public"."images" to "service_role";
grant truncate on table "public"."images" to "service_role";
grant update on table "public"."images" to "service_role";
grant delete on table "public"."meshes" to "anon";
grant insert on table "public"."meshes" to "anon";
grant references on table "public"."meshes" to "anon";
grant select on table "public"."meshes" to "anon";
grant trigger on table "public"."meshes" to "anon";
grant truncate on table "public"."meshes" to "anon";
grant update on table "public"."meshes" to "anon";
grant delete on table "public"."meshes" to "authenticated";
grant insert on table "public"."meshes" to "authenticated";
grant references on table "public"."meshes" to "authenticated";
grant select on table "public"."meshes" to "authenticated";
grant trigger on table "public"."meshes" to "authenticated";
grant truncate on table "public"."meshes" to "authenticated";
grant update on table "public"."meshes" to "authenticated";
grant delete on table "public"."meshes" to "service_role";
grant insert on table "public"."meshes" to "service_role";
grant references on table "public"."meshes" to "service_role";
grant select on table "public"."meshes" to "service_role";
grant trigger on table "public"."meshes" to "service_role";
grant truncate on table "public"."meshes" to "service_role";
grant update on table "public"."meshes" to "service_role";
grant delete on table "public"."previews" to "anon";
grant insert on table "public"."previews" to "anon";
grant references on table "public"."previews" to "anon";
grant select on table "public"."previews" to "anon";
grant trigger on table "public"."previews" to "anon";
grant truncate on table "public"."previews" to "anon";
grant update on table "public"."previews" to "anon";
grant delete on table "public"."previews" to "authenticated";
grant insert on table "public"."previews" to "authenticated";
grant references on table "public"."previews" to "authenticated";
grant select on table "public"."previews" to "authenticated";
grant trigger on table "public"."previews" to "authenticated";
grant truncate on table "public"."previews" to "authenticated";
grant update on table "public"."previews" to "authenticated";
grant delete on table "public"."previews" to "service_role";
grant insert on table "public"."previews" to "service_role";
grant references on table "public"."previews" to "service_role";
grant select on table "public"."previews" to "service_role";
grant trigger on table "public"."previews" to "service_role";
grant truncate on table "public"."previews" to "service_role";
grant update on table "public"."previews" to "service_role";
grant delete on table "public"."profiles" to "anon";
grant insert on table "public"."profiles" to "anon";
grant references on table "public"."profiles" to "anon";
grant select on table "public"."profiles" to "anon";
grant trigger on table "public"."profiles" to "anon";
grant truncate on table "public"."profiles" to "anon";
grant update on table "public"."profiles" to "anon";
grant delete on table "public"."profiles" to "authenticated";
grant insert on table "public"."profiles" to "authenticated";
grant references on table "public"."profiles" to "authenticated";
grant select on table "public"."profiles" to "authenticated";
grant trigger on table "public"."profiles" to "authenticated";
grant truncate on table "public"."profiles" to "authenticated";
grant update on table "public"."profiles" to "authenticated";
grant delete on table "public"."profiles" to "service_role";
grant insert on table "public"."profiles" to "service_role";
grant references on table "public"."profiles" to "service_role";
grant select on table "public"."profiles" to "service_role";
grant trigger on table "public"."profiles" to "service_role";
grant truncate on table "public"."profiles" to "service_role";
grant update on table "public"."profiles" to "service_role";
grant delete on table "public"."prompts" to "anon";
grant insert on table "public"."prompts" to "anon";
grant references on table "public"."prompts" to "anon";
grant select on table "public"."prompts" to "anon";
grant trigger on table "public"."prompts" to "anon";
grant truncate on table "public"."prompts" to "anon";
grant update on table "public"."prompts" to "anon";
grant delete on table "public"."prompts" to "authenticated";
grant insert on table "public"."prompts" to "authenticated";
grant references on table "public"."prompts" to "authenticated";
grant select on table "public"."prompts" to "authenticated";
grant trigger on table "public"."prompts" to "authenticated";
grant truncate on table "public"."prompts" to "authenticated";
grant update on table "public"."prompts" to "authenticated";
grant delete on table "public"."prompts" to "service_role";
grant insert on table "public"."prompts" to "service_role";
grant references on table "public"."prompts" to "service_role";
grant select on table "public"."prompts" to "service_role";
grant trigger on table "public"."prompts" to "service_role";
grant truncate on table "public"."prompts" to "service_role";
grant update on table "public"."prompts" to "service_role";
grant delete on table "public"."subscriptions" to "anon";
grant insert on table "public"."subscriptions" to "anon";
grant references on table "public"."subscriptions" to "anon";
grant select on table "public"."subscriptions" to "anon";
grant trigger on table "public"."subscriptions" to "anon";
grant truncate on table "public"."subscriptions" to "anon";
grant update on table "public"."subscriptions" to "anon";
grant delete on table "public"."subscriptions" to "authenticated";
grant insert on table "public"."subscriptions" to "authenticated";
grant references on table "public"."subscriptions" to "authenticated";
grant select on table "public"."subscriptions" to "authenticated";
grant trigger on table "public"."subscriptions" to "authenticated";
grant truncate on table "public"."subscriptions" to "authenticated";
grant update on table "public"."subscriptions" to "authenticated";
grant delete on table "public"."subscriptions" to "service_role";
grant insert on table "public"."subscriptions" to "service_role";
grant references on table "public"."subscriptions" to "service_role";
grant select on table "public"."subscriptions" to "service_role";
grant trigger on table "public"."subscriptions" to "service_role";
grant truncate on table "public"."subscriptions" to "service_role";
grant update on table "public"."subscriptions" to "service_role";
grant delete on table "public"."trial_users" to "anon";
grant insert on table "public"."trial_users" to "anon";
grant references on table "public"."trial_users" to "anon";
grant select on table "public"."trial_users" to "anon";
grant trigger on table "public"."trial_users" to "anon";
grant truncate on table "public"."trial_users" to "anon";
grant update on table "public"."trial_users" to "anon";
grant delete on table "public"."trial_users" to "authenticated";
grant insert on table "public"."trial_users" to "authenticated";
grant references on table "public"."trial_users" to "authenticated";
grant select on table "public"."trial_users" to "authenticated";
grant trigger on table "public"."trial_users" to "authenticated";
grant truncate on table "public"."trial_users" to "authenticated";
grant update on table "public"."trial_users" to "authenticated";
grant delete on table "public"."trial_users" to "service_role";
grant insert on table "public"."trial_users" to "service_role";
grant references on table "public"."trial_users" to "service_role";
grant select on table "public"."trial_users" to "service_role";
grant trigger on table "public"."trial_users" to "service_role";
grant truncate on table "public"."trial_users" to "service_role";
grant update on table "public"."trial_users" to "service_role";
create policy "Anyone can view a public conversation"
on "public"."conversations"
as permissive
for select
to authenticated, anon
using ((privacy = 'public'::public.privacy_type));
create policy "Public conversations images"
on "public"."images"
as permissive
for select
to authenticated, anon
using ((EXISTS ( SELECT 1
FROM public.conversations
WHERE ((conversations.id = images.conversation_id) AND (conversations.privacy = 'public'::public.privacy_type)))));
create policy "User can manage their data"
on "public"."images"
as permissive
for all
to authenticated
using ((( SELECT auth.uid() AS uid) = user_id))
with check ((( SELECT auth.uid() AS uid) = user_id));
create policy "Everyone can view meshes associated with public conversations"
on "public"."meshes"
as permissive
for select
to authenticated, anon
using ((EXISTS ( SELECT 1
FROM public.conversations
WHERE ((conversations.id = meshes.conversation_id) AND (conversations.privacy = 'public'::public.privacy_type)))));
create policy "Users can manage their meshes"
on "public"."meshes"
as permissive
for all
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Public conversations messages"
on "public"."messages"
as permissive
for select
to authenticated, anon
using ((EXISTS ( SELECT 1
FROM public.conversations
WHERE ((conversations.id = messages.conversation_id) AND (conversations.privacy = 'public'::public.privacy_type)))));
create policy "Users can manage their own previews"
on "public"."previews"
as permissive
for all
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Users can manage their own profile"
on "public"."profiles"
as permissive
for all
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Users can view their own prompts"
on "public"."prompts"
as permissive
for select
to public
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Service role can manage all subscriptions"
on "public"."subscriptions"
as permissive
for all
to service_role
using (true)
with check (true);
create policy "Users can read their own subscriptions"
on "public"."subscriptions"
as permissive
for select
to authenticated
using ((( SELECT auth.uid() AS uid) = user_id));
create policy "Enable users to view their own data only"
on "public"."trial_users"
as permissive
for select
to authenticated
using ((( SELECT auth.uid() AS uid) = user_id));
CREATE TRIGGER mesh_insert_prompt_trigger AFTER INSERT ON public.meshes FOR EACH ROW EXECUTE FUNCTION public.handle_mesh_insert();
CREATE TRIGGER mesh_status_update_trigger AFTER UPDATE ON public.meshes FOR EACH ROW WHEN ((old.status IS DISTINCT FROM new.status)) EXECUTE FUNCTION public.handle_mesh_status_update();
CREATE TRIGGER update_previews_updated_at BEFORE UPDATE ON public.previews FOR EACH ROW EXECUTE FUNCTION public.update_updated_at_column();
CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
create policy "Allow public read access to temp multiview images"
on "storage"."objects"
as permissive
for select
to public
using ((bucket_id = 'temp-multiview'::text));
create policy "Allow service role to delete temp multiview images"
on "storage"."objects"
as permissive
for delete
to service_role
using ((bucket_id = 'temp-multiview'::text));
create policy "Allow service role to upload temp multiview images"
on "storage"."objects"
as permissive
for insert
to service_role
with check ((bucket_id = 'temp-multiview'::text));
create policy "Give users access to own folder meshes_delete"
on "storage"."objects"
as permissive
for delete
to public
using (((bucket_id = 'meshes'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder meshes_insert"
on "storage"."objects"
as permissive
for insert
to public
with check (((bucket_id = 'meshes'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder meshes_select"
on "storage"."objects"
as permissive
for select
to public
using (((bucket_id = 'meshes'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder meshes_update"
on "storage"."objects"
as permissive
for update
to public
using (((bucket_id = 'meshes'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder previews_delete"
on "storage"."objects"
as permissive
for delete
to public
using (((bucket_id = 'previews'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder previews_insert"
on "storage"."objects"
as permissive
for insert
to public
with check (((bucket_id = 'previews'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder previews_select"
on "storage"."objects"
as permissive
for select
to public
using (((bucket_id = 'previews'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Give users access to own folder previews_update"
on "storage"."objects"
as permissive
for update
to public
using (((bucket_id = 'previews'::text) AND (( SELECT (auth.uid())::text AS uid) = (storage.foldername(name))[1])));
create policy "Public conversations allow anyone to view images_select"
on "storage"."objects"
as permissive
for select
to anon, authenticated
using (((bucket_id = 'images'::text) AND (EXISTS ( SELECT 1
FROM public.conversations
WHERE ((conversations.privacy = 'public'::public.privacy_type) AND ((conversations.id)::text = (storage.foldername(objects.name))[2]))))));
create policy "Public conversations allow anyone to view meshes_select"
on "storage"."objects"
as permissive
for select
to anon, authenticated
using (((bucket_id = 'meshes'::text) AND (EXISTS ( SELECT 1
FROM public.conversations
WHERE ((conversations.privacy = 'public'::public.privacy_type) AND ((conversations.id)::text = (storage.foldername(objects.name))[2]))))));
@@ -0,0 +1,723 @@
create extension if not exists "pg_cron" with schema "pg_catalog";
create type "public"."token_operation_type" as enum ('mesh', 'parametric', 'chat', 'refund');
create type "public"."token_source_type" as enum ('subscription', 'purchased');
drop trigger if exists "mesh_insert_prompt_trigger" on "public"."meshes";
drop function if exists "public"."handle_mesh_insert"();
drop function if exists "public"."user_extradata"(uuid);
drop type "public"."user_data";
create table "public"."token_balances" (
"id" uuid not null default gen_random_uuid(),
"user_id" uuid not null,
"source" public.token_source_type not null,
"balance" integer not null default 0,
"expires_at" timestamp with time zone,
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now()
);
alter table "public"."token_balances" enable row level security;
create table "public"."token_costs" (
"operation" public.token_operation_type not null,
"cost" integer not null,
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now()
);
alter table "public"."token_costs" enable row level security;
create table "public"."token_pack_products" (
"id" uuid not null default gen_random_uuid(),
"stripe_lookup_key" text not null,
"token_amount" integer not null,
"name" text not null,
"price_cents" integer not null,
"active" boolean not null default true,
"created_at" timestamp with time zone not null default now()
);
-- Default costs
INSERT INTO "public"."token_costs" ("operation", "cost") VALUES
('mesh', 30),
('parametric', 5),
('chat', 1);
alter table "public"."token_pack_products" enable row level security;
create table "public"."token_transactions" (
"id" bigint generated always as identity not null,
"user_id" uuid not null,
"operation" public.token_operation_type not null,
"amount" integer not null,
"source" public.token_source_type not null,
"reference_id" text,
"subscription_balance_after" integer not null,
"purchased_balance_after" integer not null,
"created_at" timestamp with time zone not null default now()
);
alter table "public"."token_transactions" enable row level security;
CREATE UNIQUE INDEX token_balances_pkey ON public.token_balances USING btree (id);
CREATE UNIQUE INDEX token_balances_user_id_source_key ON public.token_balances USING btree (user_id, source);
CREATE UNIQUE INDEX token_costs_pkey ON public.token_costs USING btree (operation);
CREATE UNIQUE INDEX token_pack_products_pkey ON public.token_pack_products USING btree (id);
CREATE UNIQUE INDEX token_pack_products_stripe_lookup_key_key ON public.token_pack_products USING btree (stripe_lookup_key);
CREATE UNIQUE INDEX token_transactions_pkey ON public.token_transactions USING btree (id);
alter table "public"."token_balances" add constraint "token_balances_pkey" PRIMARY KEY using index "token_balances_pkey";
alter table "public"."token_costs" add constraint "token_costs_pkey" PRIMARY KEY using index "token_costs_pkey";
alter table "public"."token_pack_products" add constraint "token_pack_products_pkey" PRIMARY KEY using index "token_pack_products_pkey";
alter table "public"."token_transactions" add constraint "token_transactions_pkey" PRIMARY KEY using index "token_transactions_pkey";
alter table "public"."token_balances" add constraint "token_balances_balance_check" CHECK ((balance >= 0)) not valid;
alter table "public"."token_balances" validate constraint "token_balances_balance_check";
alter table "public"."token_balances" add constraint "token_balances_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE not valid;
alter table "public"."token_balances" validate constraint "token_balances_user_id_fkey";
alter table "public"."token_balances" add constraint "token_balances_user_id_source_key" UNIQUE using index "token_balances_user_id_source_key";
alter table "public"."token_pack_products" add constraint "token_pack_products_stripe_lookup_key_key" UNIQUE using index "token_pack_products_stripe_lookup_key_key";
alter table "public"."token_transactions" add constraint "token_transactions_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) ON DELETE CASCADE not valid;
alter table "public"."token_transactions" validate constraint "token_transactions_user_id_fkey";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.credit_purchased_tokens(p_user_id uuid, p_amount integer, p_reference_id text DEFAULT NULL::text)
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
v_new_balance integer;
v_sub_balance integer;
BEGIN
INSERT INTO public.token_balances (user_id, source, balance)
VALUES (p_user_id, 'purchased'::public.token_source_type, p_amount)
ON CONFLICT (user_id, source) DO UPDATE
SET balance = token_balances.balance + p_amount, updated_at = now()
RETURNING balance INTO v_new_balance;
SELECT COALESCE(balance, 0) INTO v_sub_balance
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'subscription'::public.token_source_type;
v_sub_balance := COALESCE(v_sub_balance, 0);
INSERT INTO public.token_transactions (
user_id, operation, amount, source, reference_id,
subscription_balance_after, purchased_balance_after
) VALUES (
p_user_id, 'chat'::public.token_operation_type, p_amount, 'purchased'::public.token_source_type, p_reference_id,
v_sub_balance, v_new_balance
);
RETURN jsonb_build_object(
'success', true,
'tokensAdded', p_amount,
'purchasedBalance', v_new_balance
);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.deduct_tokens(p_user_id uuid, p_operation public.token_operation_type, p_reference_id text DEFAULT NULL::text)
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
v_cost integer;
v_sub_balance integer;
v_pur_balance integer;
v_sub_expires timestamptz;
v_remaining integer;
v_sub_deduct integer;
v_pur_deduct integer;
BEGIN
-- Get cost for operation
SELECT cost INTO v_cost FROM public.token_costs WHERE operation = p_operation;
IF v_cost IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'unknown_operation');
END IF;
-- Lock and read subscription balance
SELECT balance, expires_at INTO v_sub_balance, v_sub_expires
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'subscription'
FOR UPDATE;
-- If subscription tokens have expired, treat as 0
IF v_sub_expires IS NOT NULL AND v_sub_expires < now() THEN
v_sub_balance := 0;
END IF;
-- Lock and read purchased balance
SELECT balance INTO v_pur_balance
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'purchased'
FOR UPDATE;
-- Default to 0 if no rows exist
v_sub_balance := COALESCE(v_sub_balance, 0);
v_pur_balance := COALESCE(v_pur_balance, 0);
-- Check total available
IF (v_sub_balance + v_pur_balance) < v_cost THEN
RETURN jsonb_build_object(
'success', false,
'error', 'insufficient_tokens',
'tokensRequired', v_cost,
'tokensAvailable', v_sub_balance + v_pur_balance
);
END IF;
-- Consume subscription tokens first (they expire anyway)
v_sub_deduct := LEAST(v_cost, v_sub_balance);
v_pur_deduct := v_cost - v_sub_deduct;
-- Update subscription balance
IF v_sub_deduct > 0 THEN
UPDATE public.token_balances
SET balance = balance - v_sub_deduct, updated_at = now()
WHERE user_id = p_user_id AND source = 'subscription';
END IF;
-- Update purchased balance
IF v_pur_deduct > 0 THEN
UPDATE public.token_balances
SET balance = balance - v_pur_deduct, updated_at = now()
WHERE user_id = p_user_id AND source = 'purchased';
END IF;
-- Record transaction
INSERT INTO public.token_transactions (
user_id, operation, amount, source, reference_id,
subscription_balance_after, purchased_balance_after
) VALUES (
p_user_id, p_operation, -v_cost,
CASE WHEN v_sub_deduct > 0 THEN 'subscription'::public.token_source_type ELSE 'purchased'::public.token_source_type END,
p_reference_id,
v_sub_balance - v_sub_deduct,
v_pur_balance - v_pur_deduct
);
RETURN jsonb_build_object(
'success', true,
'tokensDeducted', v_cost,
'subscriptionBalance', v_sub_balance - v_sub_deduct,
'purchasedBalance', v_pur_balance - v_pur_deduct,
'totalBalance', (v_sub_balance - v_sub_deduct) + (v_pur_balance - v_pur_deduct)
);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.get_subscription_token_limit(p_user_id uuid)
RETURNS integer
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
userlevel public.subscriptions.level%TYPE;
userstatus public.subscriptions.status%TYPE;
BEGIN
SELECT status, level INTO userstatus, userlevel
FROM public.subscriptions
WHERE user_id = p_user_id;
IF userstatus = 'active' OR userstatus = 'trialing' THEN
IF userlevel = 'pro' THEN
RETURN 5000;
ELSIF userlevel = 'standard' THEN
RETURN 1000;
END IF;
END IF;
-- Free tier
RETURN 50;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.grant_subscription_tokens(p_user_id uuid, p_token_amount integer, p_expires_at timestamp with time zone)
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
v_pur_balance integer;
BEGIN
INSERT INTO public.token_balances (user_id, source, balance, expires_at)
VALUES (p_user_id, 'subscription'::public.token_source_type, p_token_amount, p_expires_at)
ON CONFLICT (user_id, source) DO UPDATE
SET balance = p_token_amount, expires_at = p_expires_at, updated_at = now();
SELECT COALESCE(balance, 0) INTO v_pur_balance
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'purchased'::public.token_source_type;
v_pur_balance := COALESCE(v_pur_balance, 0);
INSERT INTO public.token_transactions (
user_id, operation, amount, source, reference_id,
subscription_balance_after, purchased_balance_after
) VALUES (
p_user_id, 'chat'::public.token_operation_type, p_token_amount, 'subscription'::public.token_source_type, 'subscription_grant',
p_token_amount, v_pur_balance
);
RETURN jsonb_build_object(
'success', true,
'tokensGranted', p_token_amount,
'subscriptionBalance', p_token_amount,
'expiresAt', p_expires_at
);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.refund_tokens(p_user_id uuid, p_operation public.token_operation_type, p_reference_id text DEFAULT NULL::text)
RETURNS jsonb
LANGUAGE plpgsql
AS $function$
DECLARE
v_cost integer;
v_sub_balance integer;
v_pur_balance integer;
v_sub_limit integer;
v_sub_refund integer;
v_pur_refund integer;
BEGIN
-- Get cost for operation
SELECT cost INTO v_cost FROM public.token_costs WHERE operation = p_operation;
IF v_cost IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'unknown_operation');
END IF;
-- Get current balances with lock
SELECT balance INTO v_sub_balance
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'subscription'
FOR UPDATE;
SELECT balance INTO v_pur_balance
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'purchased'
FOR UPDATE;
v_sub_balance := COALESCE(v_sub_balance, 0);
v_pur_balance := COALESCE(v_pur_balance, 0);
-- Get subscription tier limit
v_sub_limit := public.get_subscription_token_limit(p_user_id);
-- Refund to subscription first (up to tier limit), remainder to purchased
v_sub_refund := LEAST(v_cost, v_sub_limit - v_sub_balance);
v_sub_refund := GREATEST(v_sub_refund, 0);
v_pur_refund := v_cost - v_sub_refund;
-- Update balances
IF v_sub_refund > 0 THEN
UPDATE public.token_balances
SET balance = balance + v_sub_refund, updated_at = now()
WHERE user_id = p_user_id AND source = 'subscription';
END IF;
IF v_pur_refund > 0 THEN
UPDATE public.token_balances
SET balance = balance + v_pur_refund, updated_at = now()
WHERE user_id = p_user_id AND source = 'purchased';
END IF;
-- Record refund transaction
INSERT INTO public.token_transactions (
user_id, operation, amount, source, reference_id,
subscription_balance_after, purchased_balance_after
) VALUES (
p_user_id, 'refund'::public.token_operation_type, v_cost,
CASE WHEN v_sub_refund > 0 THEN 'subscription'::public.token_source_type ELSE 'purchased'::public.token_source_type END,
p_reference_id,
v_sub_balance + v_sub_refund,
v_pur_balance + v_pur_refund
);
RETURN jsonb_build_object(
'success', true,
'tokensRefunded', v_cost,
'subscriptionBalance', v_sub_balance + v_sub_refund,
'purchasedBalance', v_pur_balance + v_pur_refund
);
END;
$function$
;
CREATE OR REPLACE FUNCTION public.reset_free_tier_tokens()
RETURNS void
LANGUAGE plpgsql
AS $function$
BEGIN
UPDATE public.token_balances tb
SET balance = 50,
expires_at = now() + interval '1 day',
updated_at = now()
WHERE tb.source = 'subscription'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tb.user_id
AND s.status IN ('active', 'trialing')
)
AND (tb.expires_at IS NULL OR tb.expires_at < now());
END;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_mesh_status_update()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
BEGIN
-- If mesh status changed to 'failure', refund the tokens
IF OLD.status != 'failure' AND NEW.status = 'failure' THEN
PERFORM public.refund_tokens(NEW.user_id, 'mesh'::public.token_operation_type, NEW.id::text);
END IF;
RETURN NEW;
END;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
INSERT INTO public.profiles (user_id, full_name)
VALUES (
NEW.id,
COALESCE(
NEW.raw_user_meta_data->>'full_name',
split_part(NEW.email, '@', 1)
)
);
-- Initialize subscription token balance (free tier: 50 tokens, 1-day expiry)
INSERT INTO public.token_balances (user_id, source, balance, expires_at)
VALUES (NEW.id, 'subscription'::public.token_source_type, 50, now() + interval '1 day');
-- Initialize purchased token balance (0)
INSERT INTO public.token_balances (user_id, source, balance)
VALUES (NEW.id, 'purchased'::public.token_source_type, 0);
RETURN NEW;
END;
$function$
;
create type "public"."user_data" as ("hasTrialed" boolean, "sublevel" public.subscription_level, "subscriptionTokens" integer, "purchasedTokens" integer, "totalTokens" integer, "subscriptionTokenLimit" integer, "subscriptionExpiresAt" timestamp with time zone);
CREATE OR REPLACE FUNCTION public.user_extradata(user_id_input uuid)
RETURNS public.user_data
LANGUAGE plpgsql
STABLE
AS $function$
DECLARE
hasTrialed boolean;
userlevel public.subscriptions.level%TYPE;
userstatus public.subscriptions.status%TYPE;
v_sub_balance integer;
v_pur_balance integer;
v_sub_expires timestamptz;
v_sub_limit integer;
ret user_data;
BEGIN
-- Get trial status
SELECT (
(SELECT count(*) FROM public.trial_users WHERE user_id = user_id_input) > 0
) INTO hasTrialed;
-- Get subscription info
SELECT STATUS, LEVEL INTO userstatus, userlevel
FROM public.subscriptions
WHERE user_id = user_id_input;
-- Get token balances
SELECT balance, expires_at INTO v_sub_balance, v_sub_expires
FROM public.token_balances
WHERE user_id = user_id_input AND source = 'subscription';
SELECT balance INTO v_pur_balance
FROM public.token_balances
WHERE user_id = user_id_input AND source = 'purchased';
v_sub_balance := COALESCE(v_sub_balance, 0);
v_pur_balance := COALESCE(v_pur_balance, 0);
-- If subscription tokens have expired, treat as 0
IF v_sub_expires IS NOT NULL AND v_sub_expires < now() THEN
v_sub_balance := 0;
END IF;
-- Set return values
ret."hasTrialed" = hasTrialed;
-- Set subscription level
IF (userstatus = 'active') THEN
ret."sublevel" = userlevel;
ELSIF (userstatus = 'trialing') THEN
ret."sublevel" = 'pro';
ELSE
ret."sublevel" = 'free';
END IF;
-- Get token limit for tier
v_sub_limit := public.get_subscription_token_limit(user_id_input);
-- Set token values
ret."subscriptionTokens" = v_sub_balance;
ret."purchasedTokens" = v_pur_balance;
ret."totalTokens" = v_sub_balance + v_pur_balance;
ret."subscriptionTokenLimit" = v_sub_limit;
ret."subscriptionExpiresAt" = v_sub_expires;
RETURN ret;
EXCEPTION
WHEN others THEN
RAISE EXCEPTION 'An error occurred in function user_extradata(): %', SQLERRM;
END;
$function$
;
grant delete on table "public"."token_balances" to "anon";
grant insert on table "public"."token_balances" to "anon";
grant references on table "public"."token_balances" to "anon";
grant select on table "public"."token_balances" to "anon";
grant trigger on table "public"."token_balances" to "anon";
grant truncate on table "public"."token_balances" to "anon";
grant update on table "public"."token_balances" to "anon";
grant delete on table "public"."token_balances" to "authenticated";
grant insert on table "public"."token_balances" to "authenticated";
grant references on table "public"."token_balances" to "authenticated";
grant select on table "public"."token_balances" to "authenticated";
grant trigger on table "public"."token_balances" to "authenticated";
grant truncate on table "public"."token_balances" to "authenticated";
grant update on table "public"."token_balances" to "authenticated";
grant delete on table "public"."token_balances" to "service_role";
grant insert on table "public"."token_balances" to "service_role";
grant references on table "public"."token_balances" to "service_role";
grant select on table "public"."token_balances" to "service_role";
grant trigger on table "public"."token_balances" to "service_role";
grant truncate on table "public"."token_balances" to "service_role";
grant update on table "public"."token_balances" to "service_role";
grant delete on table "public"."token_costs" to "anon";
grant insert on table "public"."token_costs" to "anon";
grant references on table "public"."token_costs" to "anon";
grant select on table "public"."token_costs" to "anon";
grant trigger on table "public"."token_costs" to "anon";
grant truncate on table "public"."token_costs" to "anon";
grant update on table "public"."token_costs" to "anon";
grant delete on table "public"."token_costs" to "authenticated";
grant insert on table "public"."token_costs" to "authenticated";
grant references on table "public"."token_costs" to "authenticated";
grant select on table "public"."token_costs" to "authenticated";
grant trigger on table "public"."token_costs" to "authenticated";
grant truncate on table "public"."token_costs" to "authenticated";
grant update on table "public"."token_costs" to "authenticated";
grant delete on table "public"."token_costs" to "service_role";
grant insert on table "public"."token_costs" to "service_role";
grant references on table "public"."token_costs" to "service_role";
grant select on table "public"."token_costs" to "service_role";
grant trigger on table "public"."token_costs" to "service_role";
grant truncate on table "public"."token_costs" to "service_role";
grant update on table "public"."token_costs" to "service_role";
grant delete on table "public"."token_pack_products" to "anon";
grant insert on table "public"."token_pack_products" to "anon";
grant references on table "public"."token_pack_products" to "anon";
grant select on table "public"."token_pack_products" to "anon";
grant trigger on table "public"."token_pack_products" to "anon";
grant truncate on table "public"."token_pack_products" to "anon";
grant update on table "public"."token_pack_products" to "anon";
grant delete on table "public"."token_pack_products" to "authenticated";
grant insert on table "public"."token_pack_products" to "authenticated";
grant references on table "public"."token_pack_products" to "authenticated";
grant select on table "public"."token_pack_products" to "authenticated";
grant trigger on table "public"."token_pack_products" to "authenticated";
grant truncate on table "public"."token_pack_products" to "authenticated";
grant update on table "public"."token_pack_products" to "authenticated";
grant delete on table "public"."token_pack_products" to "service_role";
grant insert on table "public"."token_pack_products" to "service_role";
grant references on table "public"."token_pack_products" to "service_role";
grant select on table "public"."token_pack_products" to "service_role";
grant trigger on table "public"."token_pack_products" to "service_role";
grant truncate on table "public"."token_pack_products" to "service_role";
grant update on table "public"."token_pack_products" to "service_role";
grant delete on table "public"."token_transactions" to "anon";
grant insert on table "public"."token_transactions" to "anon";
grant references on table "public"."token_transactions" to "anon";
grant select on table "public"."token_transactions" to "anon";
grant trigger on table "public"."token_transactions" to "anon";
grant truncate on table "public"."token_transactions" to "anon";
grant update on table "public"."token_transactions" to "anon";
grant delete on table "public"."token_transactions" to "authenticated";
grant insert on table "public"."token_transactions" to "authenticated";
grant references on table "public"."token_transactions" to "authenticated";
grant select on table "public"."token_transactions" to "authenticated";
grant trigger on table "public"."token_transactions" to "authenticated";
grant truncate on table "public"."token_transactions" to "authenticated";
grant update on table "public"."token_transactions" to "authenticated";
grant delete on table "public"."token_transactions" to "service_role";
grant insert on table "public"."token_transactions" to "service_role";
grant references on table "public"."token_transactions" to "service_role";
grant select on table "public"."token_transactions" to "service_role";
grant trigger on table "public"."token_transactions" to "service_role";
grant truncate on table "public"."token_transactions" to "service_role";
grant update on table "public"."token_transactions" to "service_role";
create policy "token_balances_read_own"
on "public"."token_balances"
as permissive
for select
to authenticated
using ((auth.uid() = user_id));
create policy "token_costs_read"
on "public"."token_costs"
as permissive
for select
to authenticated
using (true);
create policy "token_pack_products_read"
on "public"."token_pack_products"
as permissive
for select
to authenticated
using ((active = true));
create policy "token_transactions_read_own"
on "public"."token_transactions"
as permissive
for select
to authenticated
using ((auth.uid() = user_id));
@@ -0,0 +1,243 @@
-- Fix free-tier daily token reset.
--
-- Bug 1: reset_free_tier_tokens() set expires_at = now() + 1 day. Because pg_cron
-- fires with small execution jitter, each day's stored expires_at crept
-- slightly ahead of the next cron's now(), and the gate
-- "expires_at < now()" made the cron skip its own prior resets.
--
-- Bug 2: user_extradata() and deduct_tokens() zero the displayed balance the
-- instant expires_at < now(). So any window between expiry and the next
-- (possibly skipped) cron caused users to see "You've used all your tokens"
-- with their real balance still sitting unchanged in the DB.
--
-- Fix: add a just-in-time reset on every read/deduct. The cron stays as a
-- backstop and is rewritten to pin expires_at to the next UTC day boundary so
-- it no longer drifts or skips.
CREATE OR REPLACE FUNCTION "public"."ensure_free_tier_fresh"("p_user_id" uuid)
RETURNS void
LANGUAGE "plpgsql"
SECURITY DEFINER
SET search_path = public
AS $$
DECLARE
v_is_free boolean;
v_caller uuid;
BEGIN
-- Only allow service role (server-side edge functions) or the user acting
-- on themselves. auth.role() = 'anon' with uid = NULL would otherwise slip
-- past a uid-only check. Silent return, not an exception, so that a
-- legitimate caller's outer flow (user_extradata, deduct_tokens) continues
-- on its own fallback path rather than erroring to the client.
v_caller := auth.uid();
IF auth.role() IS DISTINCT FROM 'service_role' AND v_caller IS DISTINCT FROM p_user_id THEN
RETURN;
END IF;
SELECT NOT EXISTS (
SELECT 1 FROM public.subscriptions
WHERE user_id = p_user_id
AND status IN ('active', 'trialing')
) INTO v_is_free;
IF NOT v_is_free THEN
RETURN;
END IF;
INSERT INTO public.token_balances (user_id, source, balance, expires_at, updated_at)
VALUES (
p_user_id,
'subscription',
50,
date_trunc('day', now()) + interval '1 day',
now()
)
ON CONFLICT (user_id, source) DO UPDATE
SET balance = 50,
expires_at = date_trunc('day', now()) + interval '1 day',
updated_at = now()
WHERE token_balances.expires_at IS NULL
OR token_balances.expires_at <= now();
END;
$$;
CREATE OR REPLACE FUNCTION "public"."deduct_tokens"(
"p_user_id" uuid,
"p_operation" "public"."token_operation_type",
"p_reference_id" text DEFAULT NULL
)
RETURNS jsonb
LANGUAGE "plpgsql"
AS $$
DECLARE
v_cost integer;
v_sub_balance integer;
v_pur_balance integer;
v_sub_expires timestamptz;
v_remaining integer;
v_sub_deduct integer;
v_pur_deduct integer;
BEGIN
SELECT cost INTO v_cost FROM public.token_costs WHERE operation = p_operation;
IF v_cost IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'unknown_operation');
END IF;
PERFORM public.ensure_free_tier_fresh(p_user_id);
SELECT balance, expires_at INTO v_sub_balance, v_sub_expires
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'subscription'
FOR UPDATE;
-- Expired subscription tokens count as 0. For free-tier users this is
-- unreachable after ensure_free_tier_fresh (expires_at is always future).
-- It is load-bearing for paid users whose billing-cycle sub has lapsed,
-- and for the edge case where ensure_free_tier_fresh silently bailed
-- (auth-mismatch). Don't remove without preserving both cases.
IF v_sub_expires IS NOT NULL AND v_sub_expires < now() THEN
v_sub_balance := 0;
END IF;
SELECT balance INTO v_pur_balance
FROM public.token_balances
WHERE user_id = p_user_id AND source = 'purchased'
FOR UPDATE;
v_sub_balance := COALESCE(v_sub_balance, 0);
v_pur_balance := COALESCE(v_pur_balance, 0);
IF (v_sub_balance + v_pur_balance) < v_cost THEN
RETURN jsonb_build_object(
'success', false,
'error', 'insufficient_tokens',
'tokensRequired', v_cost,
'tokensAvailable', v_sub_balance + v_pur_balance
);
END IF;
v_sub_deduct := LEAST(v_cost, v_sub_balance);
v_pur_deduct := v_cost - v_sub_deduct;
IF v_sub_deduct > 0 THEN
UPDATE public.token_balances
SET balance = balance - v_sub_deduct, updated_at = now()
WHERE user_id = p_user_id AND source = 'subscription';
END IF;
IF v_pur_deduct > 0 THEN
UPDATE public.token_balances
SET balance = balance - v_pur_deduct, updated_at = now()
WHERE user_id = p_user_id AND source = 'purchased';
END IF;
INSERT INTO public.token_transactions (
user_id, operation, amount, source, reference_id,
subscription_balance_after, purchased_balance_after
) VALUES (
p_user_id, p_operation, -v_cost,
CASE WHEN v_sub_deduct > 0 THEN 'subscription'::public.token_source_type ELSE 'purchased'::public.token_source_type END,
p_reference_id,
v_sub_balance - v_sub_deduct,
v_pur_balance - v_pur_deduct
);
RETURN jsonb_build_object(
'success', true,
'tokensDeducted', v_cost,
'subscriptionBalance', v_sub_balance - v_sub_deduct,
'purchasedBalance', v_pur_balance - v_pur_deduct,
'totalBalance', (v_sub_balance - v_sub_deduct) + (v_pur_balance - v_pur_deduct)
);
END;
$$;
CREATE OR REPLACE FUNCTION "public"."reset_free_tier_tokens"()
RETURNS void
LANGUAGE "plpgsql"
AS $$
BEGIN
UPDATE public.token_balances tb
SET balance = 50,
expires_at = date_trunc('day', now()) + interval '1 day',
updated_at = now()
WHERE tb.source = 'subscription'
AND NOT EXISTS (
SELECT 1 FROM public.subscriptions s
WHERE s.user_id = tb.user_id
AND s.status IN ('active', 'trialing')
)
-- Idempotency guard: don't re-credit users whose period is still live.
-- With expires_at pinned to the day boundary, the scheduled midnight run
-- satisfies `<=` exactly; manual or retried mid-day runs are safely no-ops.
AND (tb.expires_at IS NULL OR tb.expires_at <= now());
END;
$$;
CREATE OR REPLACE FUNCTION "public"."user_extradata"("user_id_input" "uuid")
RETURNS "public"."user_data"
LANGUAGE "plpgsql"
AS $$
DECLARE
hasTrialed boolean;
userlevel public.subscriptions.level%TYPE;
userstatus public.subscriptions.status%TYPE;
v_sub_balance integer;
v_pur_balance integer;
v_sub_expires timestamptz;
v_sub_limit integer;
ret user_data;
BEGIN
PERFORM public.ensure_free_tier_fresh(user_id_input);
SELECT (
(SELECT count(*) FROM public.trial_users WHERE user_id = user_id_input) > 0
) INTO hasTrialed;
SELECT STATUS, LEVEL INTO userstatus, userlevel
FROM public.subscriptions
WHERE user_id = user_id_input;
SELECT balance, expires_at INTO v_sub_balance, v_sub_expires
FROM public.token_balances
WHERE user_id = user_id_input AND source = 'subscription';
SELECT balance INTO v_pur_balance
FROM public.token_balances
WHERE user_id = user_id_input AND source = 'purchased';
v_sub_balance := COALESCE(v_sub_balance, 0);
v_pur_balance := COALESCE(v_pur_balance, 0);
-- See matching comment in deduct_tokens: this branch is load-bearing for
-- paid users whose billing-cycle sub has lapsed and as a fallback if
-- ensure_free_tier_fresh silently bailed. Do not remove.
IF v_sub_expires IS NOT NULL AND v_sub_expires < now() THEN
v_sub_balance := 0;
END IF;
ret."hasTrialed" = hasTrialed;
IF (userstatus = 'active') THEN
ret."sublevel" = userlevel;
ELSIF (userstatus = 'trialing') THEN
ret."sublevel" = 'pro';
ELSE
ret."sublevel" = 'free';
END IF;
v_sub_limit := public.get_subscription_token_limit(user_id_input);
ret."subscriptionTokens" = v_sub_balance;
ret."purchasedTokens" = v_pur_balance;
ret."totalTokens" = v_sub_balance + v_pur_balance;
ret."subscriptionTokenLimit" = v_sub_limit;
ret."subscriptionExpiresAt" = v_sub_expires;
RETURN ret;
EXCEPTION
WHEN others THEN
RAISE EXCEPTION 'An error occurred in function user_extradata(): %', SQLERRM;
END;
$$;
@@ -0,0 +1,14 @@
-- Unschedule the free-tier cron. adam-billing now owns free-tier refreshes
-- (it runs its own /v1/cron/refresh-free-tier against its own DB), so CADAM's
-- reset_free_tier_tokens() no longer needs to fire. The function itself is
-- preserved for now as part of the deferred-drop plan; a follow-up migration
-- will drop the billing tables/functions after adam-billing is stable.
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM cron.job WHERE jobname = 'reset-free-tier-tokens'
) THEN
PERFORM cron.unschedule('reset-free-tier-tokens');
END IF;
END;
$$;
@@ -0,0 +1,385 @@
drop trigger if exists "mesh_status_update_trigger" on "public"."meshes";
drop policy "Service role can manage all subscriptions" on "public"."subscriptions";
drop policy "Users can read their own subscriptions" on "public"."subscriptions";
drop policy "token_balances_read_own" on "public"."token_balances";
drop policy "token_costs_read" on "public"."token_costs";
drop policy "token_pack_products_read" on "public"."token_pack_products";
drop policy "token_transactions_read_own" on "public"."token_transactions";
drop policy "Enable users to view their own data only" on "public"."trial_users";
revoke delete on table "public"."subscriptions" from "anon";
revoke insert on table "public"."subscriptions" from "anon";
revoke references on table "public"."subscriptions" from "anon";
revoke select on table "public"."subscriptions" from "anon";
revoke trigger on table "public"."subscriptions" from "anon";
revoke truncate on table "public"."subscriptions" from "anon";
revoke update on table "public"."subscriptions" from "anon";
revoke delete on table "public"."subscriptions" from "authenticated";
revoke insert on table "public"."subscriptions" from "authenticated";
revoke references on table "public"."subscriptions" from "authenticated";
revoke select on table "public"."subscriptions" from "authenticated";
revoke trigger on table "public"."subscriptions" from "authenticated";
revoke truncate on table "public"."subscriptions" from "authenticated";
revoke update on table "public"."subscriptions" from "authenticated";
revoke delete on table "public"."subscriptions" from "service_role";
revoke insert on table "public"."subscriptions" from "service_role";
revoke references on table "public"."subscriptions" from "service_role";
revoke select on table "public"."subscriptions" from "service_role";
revoke trigger on table "public"."subscriptions" from "service_role";
revoke truncate on table "public"."subscriptions" from "service_role";
revoke update on table "public"."subscriptions" from "service_role";
revoke delete on table "public"."token_balances" from "anon";
revoke insert on table "public"."token_balances" from "anon";
revoke references on table "public"."token_balances" from "anon";
revoke select on table "public"."token_balances" from "anon";
revoke trigger on table "public"."token_balances" from "anon";
revoke truncate on table "public"."token_balances" from "anon";
revoke update on table "public"."token_balances" from "anon";
revoke delete on table "public"."token_balances" from "authenticated";
revoke insert on table "public"."token_balances" from "authenticated";
revoke references on table "public"."token_balances" from "authenticated";
revoke select on table "public"."token_balances" from "authenticated";
revoke trigger on table "public"."token_balances" from "authenticated";
revoke truncate on table "public"."token_balances" from "authenticated";
revoke update on table "public"."token_balances" from "authenticated";
revoke delete on table "public"."token_balances" from "service_role";
revoke insert on table "public"."token_balances" from "service_role";
revoke references on table "public"."token_balances" from "service_role";
revoke select on table "public"."token_balances" from "service_role";
revoke trigger on table "public"."token_balances" from "service_role";
revoke truncate on table "public"."token_balances" from "service_role";
revoke update on table "public"."token_balances" from "service_role";
revoke delete on table "public"."token_costs" from "anon";
revoke insert on table "public"."token_costs" from "anon";
revoke references on table "public"."token_costs" from "anon";
revoke select on table "public"."token_costs" from "anon";
revoke trigger on table "public"."token_costs" from "anon";
revoke truncate on table "public"."token_costs" from "anon";
revoke update on table "public"."token_costs" from "anon";
revoke delete on table "public"."token_costs" from "authenticated";
revoke insert on table "public"."token_costs" from "authenticated";
revoke references on table "public"."token_costs" from "authenticated";
revoke select on table "public"."token_costs" from "authenticated";
revoke trigger on table "public"."token_costs" from "authenticated";
revoke truncate on table "public"."token_costs" from "authenticated";
revoke update on table "public"."token_costs" from "authenticated";
revoke delete on table "public"."token_costs" from "service_role";
revoke insert on table "public"."token_costs" from "service_role";
revoke references on table "public"."token_costs" from "service_role";
revoke select on table "public"."token_costs" from "service_role";
revoke trigger on table "public"."token_costs" from "service_role";
revoke truncate on table "public"."token_costs" from "service_role";
revoke update on table "public"."token_costs" from "service_role";
revoke delete on table "public"."token_pack_products" from "anon";
revoke insert on table "public"."token_pack_products" from "anon";
revoke references on table "public"."token_pack_products" from "anon";
revoke select on table "public"."token_pack_products" from "anon";
revoke trigger on table "public"."token_pack_products" from "anon";
revoke truncate on table "public"."token_pack_products" from "anon";
revoke update on table "public"."token_pack_products" from "anon";
revoke delete on table "public"."token_pack_products" from "authenticated";
revoke insert on table "public"."token_pack_products" from "authenticated";
revoke references on table "public"."token_pack_products" from "authenticated";
revoke select on table "public"."token_pack_products" from "authenticated";
revoke trigger on table "public"."token_pack_products" from "authenticated";
revoke truncate on table "public"."token_pack_products" from "authenticated";
revoke update on table "public"."token_pack_products" from "authenticated";
revoke delete on table "public"."token_pack_products" from "service_role";
revoke insert on table "public"."token_pack_products" from "service_role";
revoke references on table "public"."token_pack_products" from "service_role";
revoke select on table "public"."token_pack_products" from "service_role";
revoke trigger on table "public"."token_pack_products" from "service_role";
revoke truncate on table "public"."token_pack_products" from "service_role";
revoke update on table "public"."token_pack_products" from "service_role";
revoke delete on table "public"."token_transactions" from "anon";
revoke insert on table "public"."token_transactions" from "anon";
revoke references on table "public"."token_transactions" from "anon";
revoke select on table "public"."token_transactions" from "anon";
revoke trigger on table "public"."token_transactions" from "anon";
revoke truncate on table "public"."token_transactions" from "anon";
revoke update on table "public"."token_transactions" from "anon";
revoke delete on table "public"."token_transactions" from "authenticated";
revoke insert on table "public"."token_transactions" from "authenticated";
revoke references on table "public"."token_transactions" from "authenticated";
revoke select on table "public"."token_transactions" from "authenticated";
revoke trigger on table "public"."token_transactions" from "authenticated";
revoke truncate on table "public"."token_transactions" from "authenticated";
revoke update on table "public"."token_transactions" from "authenticated";
revoke delete on table "public"."token_transactions" from "service_role";
revoke insert on table "public"."token_transactions" from "service_role";
revoke references on table "public"."token_transactions" from "service_role";
revoke select on table "public"."token_transactions" from "service_role";
revoke trigger on table "public"."token_transactions" from "service_role";
revoke truncate on table "public"."token_transactions" from "service_role";
revoke update on table "public"."token_transactions" from "service_role";
revoke delete on table "public"."trial_users" from "anon";
revoke insert on table "public"."trial_users" from "anon";
revoke references on table "public"."trial_users" from "anon";
revoke select on table "public"."trial_users" from "anon";
revoke trigger on table "public"."trial_users" from "anon";
revoke truncate on table "public"."trial_users" from "anon";
revoke update on table "public"."trial_users" from "anon";
revoke delete on table "public"."trial_users" from "authenticated";
revoke insert on table "public"."trial_users" from "authenticated";
revoke references on table "public"."trial_users" from "authenticated";
revoke select on table "public"."trial_users" from "authenticated";
revoke trigger on table "public"."trial_users" from "authenticated";
revoke truncate on table "public"."trial_users" from "authenticated";
revoke update on table "public"."trial_users" from "authenticated";
revoke delete on table "public"."trial_users" from "service_role";
revoke insert on table "public"."trial_users" from "service_role";
revoke references on table "public"."trial_users" from "service_role";
revoke select on table "public"."trial_users" from "service_role";
revoke trigger on table "public"."trial_users" from "service_role";
revoke truncate on table "public"."trial_users" from "service_role";
revoke update on table "public"."trial_users" from "service_role";
alter table "public"."subscriptions" drop constraint "subscriptions_status_check";
alter table "public"."subscriptions" drop constraint "subscriptions_user_id_fkey";
alter table "public"."token_balances" drop constraint "token_balances_balance_check";
alter table "public"."token_balances" drop constraint "token_balances_user_id_fkey";
alter table "public"."token_balances" drop constraint "token_balances_user_id_source_key";
alter table "public"."token_pack_products" drop constraint "token_pack_products_stripe_lookup_key_key";
alter table "public"."token_transactions" drop constraint "token_transactions_user_id_fkey";
alter table "public"."trial_users" drop constraint "trial_users_user_id_fkey";
alter table "public"."trial_users" drop constraint "trial_users_user_id_key";
drop function if exists "public"."credit_purchased_tokens"(p_user_id uuid, p_amount integer, p_reference_id text);
drop function if exists "public"."deduct_tokens"(p_user_id uuid, p_operation public.token_operation_type, p_reference_id text);
drop function if exists "public"."ensure_free_tier_fresh"(p_user_id uuid);
drop function if exists "public"."get_subscription_token_limit"(p_user_id uuid);
drop function if exists "public"."grant_subscription_tokens"(p_user_id uuid, p_token_amount integer, p_expires_at timestamp with time zone);
drop function if exists "public"."handle_mesh_status_update"();
drop function if exists "public"."refund_tokens"(p_user_id uuid, p_operation public.token_operation_type, p_reference_id text);
drop function if exists "public"."reset_free_tier_tokens"();
drop function if exists "public"."user_extradata"(user_id_input uuid);
drop type "public"."user_data";
alter table "public"."subscriptions" drop constraint "subscriptions_pkey";
alter table "public"."token_balances" drop constraint "token_balances_pkey";
alter table "public"."token_costs" drop constraint "token_costs_pkey";
alter table "public"."token_pack_products" drop constraint "token_pack_products_pkey";
alter table "public"."token_transactions" drop constraint "token_transactions_pkey";
alter table "public"."trial_users" drop constraint "trial_users_pkey";
drop index if exists "public"."idx_subscriptions_stripe_customer_id";
drop index if exists "public"."idx_subscriptions_stripe_subscription_id";
drop index if exists "public"."idx_subscriptions_user_id";
drop index if exists "public"."subscriptions_pkey";
drop index if exists "public"."token_balances_pkey";
drop index if exists "public"."token_balances_user_id_source_key";
drop index if exists "public"."token_costs_pkey";
drop index if exists "public"."token_pack_products_pkey";
drop index if exists "public"."token_pack_products_stripe_lookup_key_key";
drop index if exists "public"."token_transactions_pkey";
drop index if exists "public"."trial_users_pkey";
drop index if exists "public"."trial_users_user_id_key";
drop table "public"."subscriptions";
drop table "public"."token_balances";
drop table "public"."token_costs";
drop table "public"."token_pack_products";
drop table "public"."token_transactions";
drop table "public"."trial_users";
drop type "public"."stripe-level";
drop type "public"."subscription_level";
drop type "public"."token_operation_type";
drop type "public"."token_source_type";
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
BEGIN
INSERT INTO public.profiles (user_id, full_name)
VALUES (
NEW.id,
COALESCE(
NEW.raw_user_meta_data->>'full_name',
split_part(NEW.email, '@', 1)
)
);
RETURN NEW;
END;
$function$
;
@@ -0,0 +1,256 @@
-- Add the new AI-SDK-shaped columns to messages. `parts` is a discriminated
-- union of AI SDK UI parts (`{type:'text',...}`, `{type:'tool-...'}`, etc.);
-- `metadata` carries per-message bookkeeping like the `model` that generated
-- the response.
--
-- The migration uses a CREATE-TABLE-and-swap pattern instead of an in-place
-- UPDATE. Bench on 745k rows of prod data: the compute (CASE + jsonb_build)
-- takes 1.3s but rewriting every row in place takes 80s+ because of MVCC,
-- per-row WAL, and TOAST writes on the 968 MB of artifact code being copied
-- into `parts.input`. A bulk INSERT into a fresh table writes page-level
-- WAL, has no dead tuples to clean up, and builds the indexes once at the
-- end — total runtime drops well under a minute.
-- Tune for bulk work. Plain SET (not SET LOCAL) so this file works whether
-- run inside a transaction (supabase db push wraps it implicitly) or in
-- autocommit mode (psql on the direct connection); both forms scope to the
-- current session and don't leak.
SET statement_timeout = 0;
SET maintenance_work_mem = '512MB';
SET max_parallel_maintenance_workers = 4;
-- ============================================================================
-- 1. Build a new messages table with the parts/metadata columns pre-populated.
-- No indexes, constraints, or policies yet — those slow down bulk INSERT.
-- ============================================================================
CREATE TABLE public.messages_new AS
SELECT
m.id,
m.created_at,
m.conversation_id,
m.role,
m.content,
m.parent_message_id,
m.rating,
CASE
WHEN m.content IS NULL OR jsonb_typeof(m.content) <> 'object' THEN '[]'::jsonb
WHEN m.role = 'user' THEN
-- text part
(CASE WHEN COALESCE(m.content->>'text','') <> ''
THEN jsonb_build_array(jsonb_build_object('type','text','text', m.content->>'text'))
ELSE '[]'::jsonb END)
-- file parts from images. Storage path is `${user}/${conv}/${imageId}`
-- with no extension; .png suffix is just a display label.
|| (CASE WHEN jsonb_typeof(m.content->'images') = 'array'
THEN COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'type','file',
'mediaType','image/png',
'filename', img || '.png',
'url', '/storage/v1/object/public/images/' || c.user_id::text || '/' || c.id::text || '/' || img
))
FROM jsonb_array_elements_text(m.content->'images') AS img
), '[]'::jsonb)
ELSE '[]'::jsonb END)
-- data-mesh-context
|| (CASE WHEN jsonb_typeof(m.content->'mesh') = 'object'
AND (m.content->'mesh'->>'id') IS NOT NULL
THEN jsonb_build_array(jsonb_build_object(
'type','data-mesh-context',
'data', jsonb_strip_nulls(jsonb_build_object(
'meshId', m.content->'mesh'->>'id',
'fileType', COALESCE(m.content->'mesh'->>'fileType','glb'),
'filename', m.content->>'meshFilename',
'boundingBox', m.content->'meshBoundingBox'
))
))
ELSE '[]'::jsonb END)
-- data-mesh-preferences
|| (CASE WHEN (m.content ? 'meshTopology') OR (m.content ? 'polygonCount')
THEN jsonb_build_array(jsonb_build_object(
'type','data-mesh-preferences',
'data', jsonb_build_object(
'topology', COALESCE(m.content->>'meshTopology','polys'),
'polygonCount', COALESCE(NULLIF(m.content->>'polygonCount','')::int, 100000)
)
))
ELSE '[]'::jsonb END)
WHEN m.role = 'assistant' THEN
-- text part (assistant text lands with state:'done')
(CASE WHEN COALESCE(m.content->>'text','') <> ''
THEN jsonb_build_array(jsonb_build_object(
'type','text','text', m.content->>'text','state','done'))
ELSE '[]'::jsonb END)
-- tool-build_parametric_model. The legacy toolCalls array was the
-- source of the original tool call id, but ~98% of assistant rows have
-- toolCalls=[], so we synthesize a deterministic id keyed on the
-- message uuid instead. Re-runs are byte-identical for the same row.
-- The artifact object is referenced directly rather than rebuilt
-- field-by-field — same data, no re-serialization cost.
|| (CASE WHEN jsonb_typeof(m.content->'artifact') = 'object'
THEN jsonb_build_array(jsonb_build_object(
'type','tool-build_parametric_model',
'toolCallId', 'tool_legacy_bp_' || m.id::text,
'state','output-available',
'input', m.content->'artifact',
'output', jsonb_build_object('status','success','message','')
))
ELSE '[]'::jsonb END)
-- tool-create_mesh
|| (CASE WHEN jsonb_typeof(m.content->'mesh') = 'object'
AND (m.content->'mesh'->>'id') IS NOT NULL
THEN jsonb_build_array(jsonb_build_object(
'type','tool-create_mesh',
'toolCallId', m.content->'mesh'->>'id',
'state','output-available',
'input', '{}'::jsonb,
'output', jsonb_build_object(
'id', m.content->'mesh'->>'id',
'fileType', COALESCE(m.content->'mesh'->>'fileType','glb')
)
))
ELSE '[]'::jsonb END)
-- assistant-side file parts (legacy create_image tool outputs)
|| (CASE WHEN jsonb_typeof(m.content->'images') = 'array'
THEN COALESCE((
SELECT jsonb_agg(jsonb_build_object(
'type','file',
'mediaType','image/png',
'filename', img || '.png',
'url', '/storage/v1/object/public/images/' || c.user_id::text || '/' || c.id::text || '/' || img
))
FROM jsonb_array_elements_text(m.content->'images') AS img
), '[]'::jsonb)
ELSE '[]'::jsonb END)
ELSE '[]'::jsonb
END AS parts,
jsonb_strip_nulls(jsonb_build_object('model', m.content->>'model')) AS metadata
FROM public.messages m
LEFT JOIN public.conversations c ON c.id = m.conversation_id;
-- ============================================================================
-- 2. Restore the column constraints CTAS strips out. `content` becomes
-- nullable (new code writes parts only); `parts` and `metadata` get the
-- same defaults as the original ADD COLUMN form.
-- ============================================================================
ALTER TABLE public.messages_new
ALTER COLUMN id SET DEFAULT gen_random_uuid(),
ALTER COLUMN id SET NOT NULL,
ALTER COLUMN created_at SET DEFAULT now(),
ALTER COLUMN created_at SET NOT NULL,
ALTER COLUMN conversation_id SET NOT NULL,
ALTER COLUMN role SET NOT NULL,
ALTER COLUMN rating SET DEFAULT 0::smallint,
ALTER COLUMN rating SET NOT NULL,
ALTER COLUMN parts SET DEFAULT '[]'::jsonb,
ALTER COLUMN parts SET NOT NULL,
ALTER COLUMN metadata SET DEFAULT '{}'::jsonb,
ALTER COLUMN metadata SET NOT NULL;
-- ============================================================================
-- 3. Indexes. Built once at the end of the bulk load — far faster than
-- maintaining them per-row during INSERT.
-- ============================================================================
ALTER TABLE public.messages_new
ADD CONSTRAINT messages_new_pkey PRIMARY KEY (id);
CREATE INDEX messages_new_conversation_id_idx
ON public.messages_new USING btree (conversation_id);
-- ============================================================================
-- 4. Constraints. The role check matches the original schema. The
-- payload-present check is new and added NOT VALID to skip the full
-- table scan — every row trivially satisfies it (content was NOT NULL
-- in the legacy schema and every row carries either parts or content).
-- ============================================================================
ALTER TABLE public.messages_new
ADD CONSTRAINT messages_role_check
CHECK (role = ANY (ARRAY['user'::text, 'assistant'::text])) NOT VALID;
ALTER TABLE public.messages_new
ADD CONSTRAINT messages_payload_present
CHECK (jsonb_array_length(parts) > 0 OR content IS NOT NULL) NOT VALID;
ALTER TABLE public.messages_new
ADD CONSTRAINT messages_new_conversation_id_fkey
FOREIGN KEY (conversation_id) REFERENCES public.conversations(id)
ON DELETE CASCADE NOT VALID;
-- ============================================================================
-- 5. RLS — same policies as the legacy table.
-- ============================================================================
ALTER TABLE public.messages_new ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Public conversations messages"
ON public.messages_new
FOR SELECT
TO anon, authenticated
USING (EXISTS (
SELECT 1 FROM public.conversations
WHERE conversations.id = messages_new.conversation_id
AND conversations.privacy = 'public'::privacy_type
));
CREATE POLICY "Users can manage messages in their conversations"
ON public.messages_new
USING ((SELECT auth.uid() AS uid) IN (
SELECT conversations.user_id FROM public.conversations
WHERE conversations.id = messages_new.conversation_id
));
-- ============================================================================
-- 6. Grants — mirror the legacy table's permissions for anon/authenticated/
-- service_role/postgres.
-- ============================================================================
GRANT ALL ON TABLE public.messages_new TO anon;
GRANT ALL ON TABLE public.messages_new TO authenticated;
GRANT ALL ON TABLE public.messages_new TO service_role;
GRANT ALL ON TABLE public.messages_new TO postgres;
-- ============================================================================
-- 7. Trigger — keep conversations.current_message_leaf_id pointed at the
-- latest inserted message (function is unchanged, defined in the initial
-- schema migration).
-- ============================================================================
CREATE TRIGGER update_leaf_trigger
AFTER INSERT ON public.messages_new
FOR EACH ROW EXECUTE FUNCTION public.update_conversation_leaf();
-- ============================================================================
-- 8. Atomic swap. Drop the old table, rename the new one into its place,
-- rename the indexes/constraints so future migrations and ORMs see the
-- canonical names. This whole step holds an ACCESS EXCLUSIVE lock on
-- `messages` for milliseconds.
-- ============================================================================
DROP TABLE public.messages;
ALTER TABLE public.messages_new RENAME TO messages;
ALTER INDEX public.messages_new_pkey RENAME TO messages_pkey;
ALTER INDEX public.messages_new_conversation_id_idx RENAME TO messages_conversation_id_idx;
ALTER TABLE public.messages RENAME CONSTRAINT messages_new_conversation_id_fkey TO messages_conversation_id_fkey;
-- ============================================================================
-- 9. The legacy `conversations.legacy` column is no longer needed.
-- ============================================================================
ALTER TABLE public.conversations DROP COLUMN IF EXISTS legacy;
-- ============================================================================
-- 10. Atomic write for `conversations.settings.suggestions`. The chat server
-- previously read settings, merged the new suggestions array client-side,
-- and wrote the whole object back — which under concurrent assistant
-- turns could silently drop the other update. `jsonb_set` runs in one
-- statement so only the `suggestions` key is touched.
-- ============================================================================
CREATE OR REPLACE FUNCTION public.set_conversation_suggestions(
p_conversation_id uuid,
p_suggestions jsonb
) RETURNS void LANGUAGE sql VOLATILE SECURITY INVOKER AS $$
UPDATE public.conversations
SET settings = jsonb_set(
COALESCE(settings, '{}'::jsonb),
'{suggestions}',
p_suggestions,
true
)
WHERE id = p_conversation_id;
$$;