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
51 changes: 51 additions & 0 deletions Dockerfile.page
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
COPY apps/page/package.json ./apps/page/
COPY packages/ ./packages/
RUN corepack enable pnpm && pnpm i --frozen-lockfile

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build the page application
RUN corepack enable pnpm && cd apps/page && pnpm run build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/apps/page/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/page/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/page/.next/static ./.next/static

USER nextjs

EXPOSE 3001

ENV PORT 3001
ENV HOSTNAME "0.0.0.0"

CMD ["node", "apps/page/server.js"]
51 changes: 51 additions & 0 deletions Dockerfile.web
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
FROM node:20-alpine AS base

# Install dependencies only when needed
FROM base AS deps
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
RUN apk add --no-cache libc6-compat
WORKDIR /app

# Install dependencies based on the preferred package manager
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml ./
COPY apps/web/package.json ./apps/web/
COPY packages/ ./packages/
RUN corepack enable pnpm && pnpm i --frozen-lockfile

# Rebuild the source code only when needed
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build the web application
RUN corepack enable pnpm && cd apps/web && pnpm run build

# Production image, copy all the files and run next
FROM base AS runner
WORKDIR /app

ENV NODE_ENV production

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --from=builder /app/apps/web/public ./public

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown nextjs:nodejs .next

# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./.next/static

USER nextjs

EXPOSE 3000

ENV PORT 3000
ENV HOSTNAME "0.0.0.0"

CMD ["node", "apps/web/server.js"]
5 changes: 4 additions & 1 deletion apps/page/components/post.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ const PostDateTime = dynamic(
{
ssr: false,
}
);
) as React.ComponentType<{
publishedAt: string;
startWithFullDate?: boolean;
}>;

export default function Post({
post,
Expand Down
2 changes: 1 addition & 1 deletion apps/page/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"private": true,
"scripts": {
"dev": "next dev --port 3001",
"build": "mkdir public/v1 && minify widget/widget.js > public/v1/widget.js && next build",
"build": "mkdir -p public/v1 && minify widget/widget.js > public/v1/widget.js && next build",
"start": "next start",
"lint": "next lint"
},
Expand Down
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 * * *"
}
]
}
}
23 changes: 23 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
services:
web:
build:
context: .
dockerfile: Dockerfile.web
ports:
- "3000:3000"
env_file:
- apps/web/.env
environment:
- NODE_ENV=production

page:
build:
context: .
dockerfile: Dockerfile.page
ports:
- "3001:3001"
env_file:
- apps/page/.env
environment:
- NODE_ENV=production

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