Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
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<CleanupResponse | IErrorResponse>
) => {
if (req.method !== "POST") {
return res
.status(405)
.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();

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;
19 changes: 19 additions & 0 deletions apps/web/pages/api/pages/settings/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion apps/web/vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
{
"path": "/api/billing/jobs/report-usage",
"schedule": "0 0 * * *"
},
{
"path": "/api/billing/jobs/cleanup-inactive-pages",
"schedule": "0 1 * * *"
}
Comment on lines +7 to 10
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Specify HTTP method to prevent side-effectful cleanup from defaulting to GET

Vercel cron invocations default to a GET request, which is semantically wrong for a mutation-type endpoint and can introduce caching or proxy side-effects. Declare the intended verb explicitly.

       "path": "/api/billing/jobs/cleanup-inactive-pages",
+      "method": "POST",
       "schedule": "0 1 * * *"

Ensure the handler in pages/api/billing/jobs/cleanup-inactive-pages.ts is prepared to accept POST requests before applying the change.


🏁 Script executed:

#!/bin/bash
set -e

# Locate the cleanup-inactive-pages API handler file
handler_file=$(fd cleanup-inactive-pages.ts)

echo "Handler file found at: $handler_file"

# Inspect HTTP method checks in the handler
echo -e "\nChecking for HTTP method conditions (req.method) in the handler:"
grep -n -C2 "req.method" "$handler_file" || echo "No HTTP method checks found"

# Show the handler export signature for context
echo -e "\nHandler export signature:"
grep -n "export default" "$handler_file" | head -n 1

Length of output: 864


Specify HTTP method for the cleanup-inactive-pages cron job

Your handler in apps/web/pages/api/billing/jobs/cleanup-inactive-pages.ts already enforces req.method === "POST" (returning 405 otherwise), so it’s safe—and recommended—to explicitly declare the HTTP verb in vercel.json to avoid a default GET invocation:

• File: apps/web/vercel.json
• Location: the block for /api/billing/jobs/cleanup-inactive-pages

     {
       "path": "/api/billing/jobs/cleanup-inactive-pages",
+      "method": "POST",
       "schedule": "0 1 * * *"
     }

This ensures Vercel’s cron will invoke the endpoint with POST, matching your mutation-style handler.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{
"path": "/api/billing/jobs/cleanup-inactive-pages",
"schedule": "0 1 * * *"
}
{
"path": "/api/billing/jobs/cleanup-inactive-pages",
"method": "POST",
"schedule": "0 1 * * *"
}
🤖 Prompt for AI Agents
In apps/web/vercel.json around lines 7 to 10, the cron job for
/api/billing/jobs/cleanup-inactive-pages does not specify the HTTP method,
causing Vercel to default to GET. Since your handler requires POST, update the
configuration to explicitly include "method": "POST" in the job definition to
ensure the cron triggers a POST request matching your handler's expectations.

]
}
}
49 changes: 49 additions & 0 deletions packages/supabase/migrations/17_handle_inactive_pages.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-- 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
JOIN
auth.users au ON u.id = au.id
WHERE
(
-- Users with canceled subscription
(u.stripe_subscription->>'status')::text = 'canceled'
-- OR users without any subscription
OR u.stripe_subscription IS NULL
)
-- not gifted pro
AND u.pro_gifted = false
-- 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;
$$ LANGUAGE plpgsql SECURITY DEFINER;
Loading