From e1ff8a5f112bb2b0b951f2fb836c1ecd1211529f Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 27 Jul 2025 10:08:52 +1000 Subject: [PATCH 1/5] feat: Handle inactive users --- .../billing/jobs/cleanup-inactive-pages.ts | 93 +++++++++ .../migrations/17_handle_inactive_pages.sql | 45 +++++ packages/supabase/types/index.ts | 177 ++++++++++-------- 3 files changed, 240 insertions(+), 75 deletions(-) create mode 100644 apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts create mode 100644 packages/supabase/migrations/17_handle_inactive_pages.sql diff --git a/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts b/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts new file mode 100644 index 0000000..e57ad1e --- /dev/null +++ b/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts @@ -0,0 +1,93 @@ +import { supabaseAdmin } from "@changes-page/supabase/admin"; +import { IErrorResponse } from "@changes-page/supabase/types/api"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { v4 } from "uuid"; + +interface CleanupResponse { + status: string; + deletedPages: number; + jobId: string; +} + +const cleanupInactivePagesJob = async ( + req: NextApiRequest, + res: NextApiResponse +) => { + if (req.method !== "POST") { + return res + .status(405) + .json({ error: { statusCode: 405, message: "Method not allowed" } }); + } + + try { + const jobId = v4(); + + console.log(`[${jobId}] Starting cleanup inactive pages job`); + + const { data: inactivePages, error: fetchError } = await supabaseAdmin.rpc( + "get_pages_with_inactive_subscriptions" + ); + + if (fetchError) { + console.error(`[${jobId}] Error fetching inactive pages:`, fetchError); + throw fetchError; + } + + const allInactivePages = inactivePages || []; + + console.log( + `[${jobId}] Found ${allInactivePages.length} pages from inactive users to delete` + ); + + if (allInactivePages.length === 0) { + console.log(`[${jobId}] No pages to delete`); + return res.status(200).json({ + status: "ok", + deletedPages: 0, + jobId, + }); + } + + const pageIdsToDelete = allInactivePages.map((page) => page.page_id); + + console.log(`[${jobId}] Deleting ${pageIdsToDelete.length} pages`); + const { error: pagesDeleteError } = await supabaseAdmin + .from("pages") + .delete() + .in("id", pageIdsToDelete); + + if (pagesDeleteError) { + console.error(`[${jobId}] Error deleting pages:`, pagesDeleteError); + throw pagesDeleteError; + } + + console.log( + `[${jobId}] Successfully deleted ${pageIdsToDelete.length} pages and related data` + ); + + // Log deleted pages for audit purposes + allInactivePages.forEach((page) => { + console.log( + `[${jobId}] Deleted page: ${page.page_title} (ID: ${page.page_id}) from user: ${page.user_id}` + ); + }); + + console.log(`[${jobId}] Cleanup job finished successfully`); + + return res.status(200).json({ + status: "ok", + deletedPages: pageIdsToDelete.length, + jobId, + }); + } catch (err) { + console.error("cleanupInactivePagesJob error:", err); + res.status(500).json({ + error: { + statusCode: 500, + message: err.message || "Internal server error", + }, + }); + } +}; + +export default cleanupInactivePagesJob; diff --git a/packages/supabase/migrations/17_handle_inactive_pages.sql b/packages/supabase/migrations/17_handle_inactive_pages.sql new file mode 100644 index 0000000..f5c46b1 --- /dev/null +++ b/packages/supabase/migrations/17_handle_inactive_pages.sql @@ -0,0 +1,45 @@ +-- Drop the existing foreign key constraint +ALTER TABLE page_audit_logs +DROP CONSTRAINT page_audit_logs_page_id_fkey; + +-- Recreate the constraint with CASCADE DELETE +ALTER TABLE page_audit_logs +ADD CONSTRAINT page_audit_logs_page_id_fkey +FOREIGN KEY (page_id) +REFERENCES pages(id) +ON DELETE CASCADE; + +-- Function to get pages with inactive subscriptions +CREATE OR REPLACE FUNCTION get_pages_with_inactive_subscriptions() +RETURNS TABLE ( + page_id uuid, + page_title text, + page_created_at timestamptz, + url text, + user_id uuid +) AS $$ +BEGIN + RETURN QUERY + SELECT + p.id AS page_id, + p.title AS page_title, + p.created_at AS page_created_at, + p.url_slug AS url, + u.id AS user_id + FROM + pages p + JOIN + users u ON p.user_id = u.id + WHERE + ( + -- Users with canceled subscription + (u.stripe_subscription->>'status')::text = 'canceled' + -- OR users without any subscription + OR u.stripe_subscription IS NULL + ) + AND u.pro_gifted = false + AND p.created_at < NOW() - INTERVAL '90 days' + ORDER BY + p.created_at ASC; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; \ No newline at end of file diff --git a/packages/supabase/types/index.ts b/packages/supabase/types/index.ts index 6b653b2..4034a99 100644 --- a/packages/supabase/types/index.ts +++ b/packages/supabase/types/index.ts @@ -7,6 +7,11 @@ export type Json = | Json[] export type Database = { + // Allows to automatically instanciate createClient with right options + // instead of createClient(URL, KEY) + __InternalSupabase: { + PostgrestVersion: "10.2.0 (e07807d)" + } public: { Tables: { page_audit_logs: { @@ -553,60 +558,51 @@ export type Database = { [_ in never]: never } Functions: { - is_subscription_active: { - Args: { + get_pages_with_inactive_subscriptions: { + Args: Record + Returns: { + page_id: string + page_title: string + page_created_at: string + url: string user_id: string - } + }[] + } + is_subscription_active: { + Args: { user_id: string } Returns: boolean } is_team_member: { - Args: { - tid: string - uid: string - } + Args: { tid: string; uid: string } Returns: boolean } page_view_browsers: { - Args: { - pageid: string - date: string - } + Args: { pageid: string; date: string } Returns: { data_name: string data_count: number }[] } page_view_os: { - Args: { - pageid: string - date: string - } + Args: { pageid: string; date: string } Returns: { data_name: string data_count: number }[] } page_view_referrers: { - Args: { - pageid: string - date: string - } + Args: { pageid: string; date: string } Returns: { data_name: string data_count: number }[] } page_view_stats: { - Args: { - pageid: string - date: string - } + Args: { pageid: string; date: string } Returns: Record } post_reactions_aggregate: { - Args: { - postid: string - } + Args: { postid: string } Returns: { thumbs_up_count: number thumbs_down_count: number @@ -628,27 +624,33 @@ export type Database = { } } -type PublicSchema = Database[Extract] +type DatabaseWithoutInternals = Omit + +type DefaultSchema = DatabaseWithoutInternals[Extract] export type Tables< - PublicTableNameOrOptions extends - | keyof (PublicSchema["Tables"] & PublicSchema["Views"]) - | { schema: keyof Database }, - TableName extends PublicTableNameOrOptions extends { schema: keyof Database } - ? keyof (Database[PublicTableNameOrOptions["schema"]]["Tables"] & - Database[PublicTableNameOrOptions["schema"]]["Views"]) + DefaultSchemaTableNameOrOptions extends + | keyof (DefaultSchema["Tables"] & DefaultSchema["Views"]) + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"]) : never = never, -> = PublicTableNameOrOptions extends { schema: keyof Database } - ? (Database[PublicTableNameOrOptions["schema"]]["Tables"] & - Database[PublicTableNameOrOptions["schema"]]["Views"])[TableName] extends { +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? (DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] & + DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Views"])[TableName] extends { Row: infer R } ? R : never - : PublicTableNameOrOptions extends keyof (PublicSchema["Tables"] & - PublicSchema["Views"]) - ? (PublicSchema["Tables"] & - PublicSchema["Views"])[PublicTableNameOrOptions] extends { + : DefaultSchemaTableNameOrOptions extends keyof (DefaultSchema["Tables"] & + DefaultSchema["Views"]) + ? (DefaultSchema["Tables"] & + DefaultSchema["Views"])[DefaultSchemaTableNameOrOptions] extends { Row: infer R } ? R @@ -656,20 +658,24 @@ export type Tables< : never export type TablesInsert< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: keyof Database }, - TableName extends PublicTableNameOrOptions extends { schema: keyof Database } - ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, -> = PublicTableNameOrOptions extends { schema: keyof Database } - ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { Insert: infer I } ? I : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { Insert: infer I } ? I @@ -677,20 +683,24 @@ export type TablesInsert< : never export type TablesUpdate< - PublicTableNameOrOptions extends - | keyof PublicSchema["Tables"] - | { schema: keyof Database }, - TableName extends PublicTableNameOrOptions extends { schema: keyof Database } - ? keyof Database[PublicTableNameOrOptions["schema"]]["Tables"] + DefaultSchemaTableNameOrOptions extends + | keyof DefaultSchema["Tables"] + | { schema: keyof DatabaseWithoutInternals }, + TableName extends DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"] : never = never, -> = PublicTableNameOrOptions extends { schema: keyof Database } - ? Database[PublicTableNameOrOptions["schema"]]["Tables"][TableName] extends { +> = DefaultSchemaTableNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaTableNameOrOptions["schema"]]["Tables"][TableName] extends { Update: infer U } ? U : never - : PublicTableNameOrOptions extends keyof PublicSchema["Tables"] - ? PublicSchema["Tables"][PublicTableNameOrOptions] extends { + : DefaultSchemaTableNameOrOptions extends keyof DefaultSchema["Tables"] + ? DefaultSchema["Tables"][DefaultSchemaTableNameOrOptions] extends { Update: infer U } ? U @@ -698,29 +708,46 @@ export type TablesUpdate< : never export type Enums< - PublicEnumNameOrOptions extends - | keyof PublicSchema["Enums"] - | { schema: keyof Database }, - EnumName extends PublicEnumNameOrOptions extends { schema: keyof Database } - ? keyof Database[PublicEnumNameOrOptions["schema"]]["Enums"] + DefaultSchemaEnumNameOrOptions extends + | keyof DefaultSchema["Enums"] + | { schema: keyof DatabaseWithoutInternals }, + EnumName extends DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals + } + ? keyof DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"] : never = never, -> = PublicEnumNameOrOptions extends { schema: keyof Database } - ? Database[PublicEnumNameOrOptions["schema"]]["Enums"][EnumName] - : PublicEnumNameOrOptions extends keyof PublicSchema["Enums"] - ? PublicSchema["Enums"][PublicEnumNameOrOptions] +> = DefaultSchemaEnumNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[DefaultSchemaEnumNameOrOptions["schema"]]["Enums"][EnumName] + : DefaultSchemaEnumNameOrOptions extends keyof DefaultSchema["Enums"] + ? DefaultSchema["Enums"][DefaultSchemaEnumNameOrOptions] : never export type CompositeTypes< PublicCompositeTypeNameOrOptions extends - | keyof PublicSchema["CompositeTypes"] - | { schema: keyof Database }, + | keyof DefaultSchema["CompositeTypes"] + | { schema: keyof DatabaseWithoutInternals }, CompositeTypeName extends PublicCompositeTypeNameOrOptions extends { - schema: keyof Database + schema: keyof DatabaseWithoutInternals } - ? keyof Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] + ? keyof DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"] : never = never, -> = PublicCompositeTypeNameOrOptions extends { schema: keyof Database } - ? Database[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] - : PublicCompositeTypeNameOrOptions extends keyof PublicSchema["CompositeTypes"] - ? PublicSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] +> = PublicCompositeTypeNameOrOptions extends { + schema: keyof DatabaseWithoutInternals +} + ? DatabaseWithoutInternals[PublicCompositeTypeNameOrOptions["schema"]]["CompositeTypes"][CompositeTypeName] + : PublicCompositeTypeNameOrOptions extends keyof DefaultSchema["CompositeTypes"] + ? DefaultSchema["CompositeTypes"][PublicCompositeTypeNameOrOptions] : never + +export const Constants = { + public: { + Enums: { + page_color_scheme: ["auto", "dark", "light"], + page_type: ["changelogs", "updates", "releases", "announcements"], + post_status: ["draft", "published", "archived", "publish_later"], + post_type: ["fix", "new", "improvement", "announcement", "alert"], + }, + }, +} as const From a86b2cc15bcc10589d79e919b121ae10c81a4baf Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 27 Jul 2025 11:03:35 +1000 Subject: [PATCH 2/5] Handle custom domain removal --- apps/web/pages/api/pages/settings/webhook.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/web/pages/api/pages/settings/webhook.ts b/apps/web/pages/api/pages/settings/webhook.ts index 9661552..c69a57b 100644 --- a/apps/web/pages/api/pages/settings/webhook.ts +++ b/apps/web/pages/api/pages/settings/webhook.ts @@ -21,6 +21,25 @@ const databaseWebhook = async (req: NextApiRequest, res: NextApiResponse) => { page_id ); + if (type === "DELETE") { + if (page_settings?.custom_domain) { + try { + const response = await fetch( + `https://api.vercel.com/v8/projects/${process.env.VERCEL_PAGES_PROJECT_ID}/domains/${page_settings.custom_domain}?teamId=${process.env.VERCEL_TEAM_ID}`, + { + headers: { + Authorization: `Bearer ${process.env.VERCEL_AUTH_TOKEN}`, + }, + method: "DELETE", + } + ).then((res) => res.json()); + console.log("Response from Vercel API:", response); + } catch (error) { + console.error("Error deleting custom domain:", error); + } + } + } + return res.status(200).json({ ok: true }); } catch (err) { console.log("Trigger databaseWebhook [Page Settings]: Error:", err); From 926c48741ea6c1ba1e66b2da06dcef61b42b511e Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 27 Jul 2025 14:34:46 +1000 Subject: [PATCH 3/5] Update inactive user query --- packages/supabase/migrations/17_handle_inactive_pages.sql | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/supabase/migrations/17_handle_inactive_pages.sql b/packages/supabase/migrations/17_handle_inactive_pages.sql index f5c46b1..bea2083 100644 --- a/packages/supabase/migrations/17_handle_inactive_pages.sql +++ b/packages/supabase/migrations/17_handle_inactive_pages.sql @@ -30,6 +30,8 @@ BEGIN pages p JOIN users u ON p.user_id = u.id + JOIN + auth.users au ON u.id = au.id WHERE ( -- Users with canceled subscription @@ -37,8 +39,10 @@ BEGIN -- OR users without any subscription OR u.stripe_subscription IS NULL ) + -- not gifted pro AND u.pro_gifted = false - AND p.created_at < NOW() - INTERVAL '90 days' + -- User hasn't been active in the last 180 days + AND (au.last_sign_in_at IS NULL OR au.last_sign_in_at < NOW() - INTERVAL '180 days') ORDER BY p.created_at ASC; END; From 213e13d45ff9c91c5efdec640c0276f0c4653e73 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 27 Jul 2025 14:54:51 +1000 Subject: [PATCH 4/5] Add cron auth --- .../pages/api/billing/jobs/cleanup-inactive-pages.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts b/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts index e57ad1e..6a46de7 100644 --- a/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts +++ b/apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts @@ -19,6 +19,16 @@ const cleanupInactivePagesJob = async ( .json({ error: { statusCode: 405, message: "Method not allowed" } }); } + const authHeader = req.headers["authorization"]; + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return res.status(401).json({ + error: { + statusCode: 401, + message: "Unauthorized", + }, + }); + } + try { const jobId = v4(); From 068159d37e9ce65212afa1ff648f0c6944a62ed2 Mon Sep 17 00:00:00 2001 From: Arjun Komath Date: Sun, 27 Jul 2025 14:59:30 +1000 Subject: [PATCH 5/5] Setup cronjob --- apps/web/vercel.json | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/vercel.json b/apps/web/vercel.json index 9353095..5f34883 100644 --- a/apps/web/vercel.json +++ b/apps/web/vercel.json @@ -3,6 +3,10 @@ { "path": "/api/billing/jobs/report-usage", "schedule": "0 0 * * *" + }, + { + "path": "/api/billing/jobs/cleanup-inactive-pages", + "schedule": "0 1 * * *" } ] -} +} \ No newline at end of file