31 Commits
v1.0.0 ... main

Author SHA1 Message Date
5362ff7599 feat: switch back to meta llama
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m7s
2026-02-15 10:30:08 +00:00
21b3c3ba83 feat: switch to deepseek llama
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m52s
2026-02-08 10:22:08 +00:00
44b9793c60 feat: switch to umami
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m51s
2026-01-31 22:50:00 +01:00
35020f2499 feat: switch to sweego
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m19s
2026-01-30 18:12:29 +01:00
20b09849bc revert: restore lucide-react icons for emails
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m33s
2026-01-29 19:42:21 +01:00
dc3850ac4d fix: rename env var
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m23s
2026-01-29 18:19:00 +01:00
e73262b3b3 chore: update privacy
Some checks failed
Deploy / lint-build-deploy (push) Failing after 2m7s
2026-01-28 19:56:07 +01:00
7299a266f1 fix: correct infrastructure path
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m10s
2026-01-22 17:44:36 +00:00
e46cb018fd feat: replace icons and obfuscate email
Some checks failed
Deploy / lint-build-deploy (push) Failing after 1m18s
2026-01-22 18:36:52 +01:00
a8d3bf1b3b feat: migrate AI to llama and use local db
All checks were successful
Deploy / lint-build-deploy (push) Successful in 2m13s
2026-01-19 21:25:38 +01:00
dependabot[bot]
6e75be19ed build(deps): bump next from 15.5.7 to 15.5.9 in the npm_and_yarn group across 1 directory (#41)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-12 22:50:18 +01:00
dependabot[bot]
f7e9d5c494 build(deps): bump next from 15.5.4 to 15.5.7 in the npm_and_yarn group across 1 directory (#40)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-06 15:14:10 +01:00
dependabot[bot]
c0caedd6fa build(deps): bump js-yaml from 4.1.0 to 4.1.1 in the npm_and_yarn group across 1 directory (#39) 2025-11-16 20:48:22 +01:00
2afe8e39b9 feat: use vercel ai gateway (#38) 2025-10-11 16:37:36 +02:00
dependabot[bot]
8ed813dd53 build(deps): bump axios from 1.8.2 to 1.12.0 in the npm_and_yarn group across 1 directory (#37)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-28 17:16:54 +08:00
905054e3b5 fix: update claude version with response handling correction 2025-09-28 17:02:33 +08:00
271fa45937 fix: revert claude version 2025-09-28 16:53:18 +08:00
a38ddc727f chore: update claude version 2025-09-27 21:01:22 +08:00
dependabot[bot]
72019caca2 build(deps): bump next from 15.2.4 to 15.4.7 in the npm_and_yarn group across 1 directory (#36)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-14 20:44:32 +08:00
dependabot[bot]
449277be21 build(deps): bump form-data from 4.0.1 to 4.0.4 in the npm_and_yarn group across 1 directory (#35)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-04 20:22:10 +08:00
dependabot[bot]
77211d58cc build(deps): bump the npm_and_yarn group across 1 directory with 2 updates (#34)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 22:29:41 +02:00
dependabot[bot]
5673870649 build(deps): bump dompurify from 3.2.3 to 3.2.6 in the npm_and_yarn group across 1 directory (#33)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-05-29 22:26:07 +02:00
Riccardo Senica
f11299ed93 docs: update README.md 2025-01-18 16:18:53 +01:00
11c26df116 docs: update README.md 2025-01-18 12:41:26 +01:00
Riccardo Senica
2752045c16 chore: rename new created_at column (#31) 2025-01-18 11:48:40 +01:00
Riccardo Senica
2616728128 chore: resend cleaning (#28) 2025-01-07 20:12:24 +01:00
Riccardo Senica
a8de784981 chore: remove import email (#27) 2025-01-07 20:06:06 +01:00
867e6e65a5 chore: remove unused env variables 2024-12-30 07:10:18 +01:00
e39026c259 chore: some refactor and cleaning 2024-12-30 06:54:01 +01:00
dependabot[bot]
5c75e9390e build(deps): bump next from 14.2.13 to 14.2.15 in the npm_and_yarn group (#24)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-21 15:51:07 +01:00
dependabot[bot]
cc71d9be21 build(deps): bump nanoid from 3.3.7 to 3.3.8 in the npm_and_yarn group (#23)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-12-21 15:42:15 +01:00
35 changed files with 16321 additions and 7837 deletions

View File

@@ -1,39 +1,15 @@
ADMIN_EMAIL="" ADMIN_EMAIL=""
CRON_SECRET="" CRON_SECRET=""
HOME_URL="" NEXT_PUBLIC_HOME_URL=""
MAINTENANCE_MODE="" OVHCLOUD_API_KEY=""
NEWS_LIMIT="" MAINTENANCE_MODE="0"
NEWS_LIMIT="50"
NEWS_TO_USE="10"
NEXT_PUBLIC_BRAND_COUNTRY="" NEXT_PUBLIC_BRAND_COUNTRY=""
NEXT_PUBLIC_BRAND_EMAIL="" NEXT_PUBLIC_BRAND_EMAIL=""
NEXT_PUBLIC_BRAND_NAME="" NEXT_PUBLIC_BRAND_NAME=""
NX_DAEMON="" NEXT_PUBLIC_BRAND_OWNER_NAME=""
POSTGRES_DATABASE="" DATABASE_URL=""
POSTGRES_HOST="" SWEEGO_API_KEY=""
POSTGRES_PASSWORD="" SWEEGO_FROM=""
POSTGRES_PRISMA_URL=""
POSTGRES_URL=""
POSTGRES_URL_NON_POOLING=""
POSTGRES_USER=""
RESEND_AUDIENCE=""
RESEND_FROM=""
RESEND_KEY=""
SECRET_HASH="" SECRET_HASH=""
TURBO_REMOTE_ONLY=""
TURBO_RUN_SUMMARY=""
VERCEL="1"
VERCEL_ENV="development"
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
VERCEL_GIT_COMMIT_MESSAGE=""
VERCEL_GIT_COMMIT_REF=""
VERCEL_GIT_COMMIT_SHA=""
VERCEL_GIT_PREVIOUS_SHA=""
VERCEL_GIT_PROVIDER=""
VERCEL_GIT_PULL_REQUEST_ID=""
VERCEL_GIT_REPO_ID=""
VERCEL_GIT_REPO_OWNER=""
VERCEL_GIT_REPO_SLUG=""
VERCEL_URL=""
ANALYZE="false"
ANTHROPIC_API_KEY=""
NEWS_TO_USE=10

View File

@@ -0,0 +1,41 @@
name: Deploy
on:
push:
branches: [ main ]
jobs:
lint-build-deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
- name: Install dependencies
run: npm install --legacy-peer-deps
- name: Run linting
run: npm run lint
- name: Build
run: npm run build
- name: Deploy to VPS
env:
SSH_PRIVATE_KEY: ${{ secrets.SSH_PRIVATE_KEY }}
run: |
mkdir -p ~/.ssh
echo "$SSH_PRIVATE_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H 51.210.247.57 >> ~/.ssh/known_hosts
ssh debian@51.210.247.57 << 'EOF'
cd /home/debian/newsletter-hackernews
git pull origin main
cd /home/debian/infrastructure
docker compose up -d --build newsletter
EOF

86
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,86 @@
name: CI
on:
push:
branches: [main, develop]
pull_request:
branches: [main, develop]
jobs:
lint-and-typecheck:
name: Lint & Type Check
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Run ESLint
run: yarn lint
- name: Run TypeScript type check
run: yarn typecheck
- name: Run Prettier check
run: yarn format
build:
name: Build
runs-on: ubuntu-latest
needs: lint-and-typecheck
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Build application
run: yarn build
- name: Report deployment readiness
if: github.ref == 'refs/heads/main'
run: echo "✅ All checks passed - ready for deployment"
commitlint:
name: Commit Lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'yarn'
- name: Install dependencies
run: yarn install --frozen-lockfile
- name: Validate commit messages
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
yarn commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
else
yarn commitlint --from HEAD~1 --to HEAD --verbose
fi

View File

@@ -4,12 +4,24 @@ WORKDIR /app
COPY package*.json ./ COPY package*.json ./
RUN yarn RUN npm install
COPY . . COPY . .
RUN yarn build ARG NEXT_PUBLIC_BRAND_NAME
ARG NEXT_PUBLIC_BRAND_EMAIL
ARG NEXT_PUBLIC_BRAND_COUNTRY
ARG NEXT_PUBLIC_HOME_URL
ARG NEXT_PUBLIC_BRAND_OWNER_NAME
ENV NEXT_PUBLIC_BRAND_NAME=$NEXT_PUBLIC_BRAND_NAME
ENV NEXT_PUBLIC_BRAND_EMAIL=$NEXT_PUBLIC_BRAND_EMAIL
ENV NEXT_PUBLIC_BRAND_COUNTRY=$NEXT_PUBLIC_BRAND_COUNTRY
ENV NEXT_PUBLIC_HOME_URL=$NEXT_PUBLIC_HOME_URL
ENV NEXT_PUBLIC_BRAND_OWNER_NAME=$NEXT_PUBLIC_BRAND_OWNER_NAME
RUN npm run build
EXPOSE 3000 EXPOSE 3000
CMD [ "yarn", "start" ] CMD [ "npm", "start" ]

View File

@@ -1,54 +1,87 @@
# Hackernews newsletter # 📰 HackerNews Newsletter
## Future improvements A Next.js application that aggregates HackerNews stories and delivers them as personalized daily newsletters.
- Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue) ## ✨ Features
## Some resources used - 📧 Daily newsletter containing curated HackerNews stories
- ☁️ Vercel hosting integration
https://gradientbuttons.colorion.co/ ## 🚀 Future improvements
https://codepen.io/alphardex/pen/vYEYGzp
## Commands - ⏰ Cron every 10 minutes and reduce time delta to 10 minutes: people are more likely to open the newsletter if delivered around the time when they subscribed (if cron becomes not enough, then the cost of sending all the emails might be a bigger issue)
Install vercel cli ## 🛠️ Tech Stack
- ⚡ Next.js
- 🗄️ Prisma (Database ORM)
- 🚀 Vercel (Hosting)
- 📝 Custom email templates
## 🏁 Getting Started
### 📋 Prerequisites
- 📦 Node.js
- 🐳 Docker
- 🔧 Vercel CLI
- 🧶 Yarn package manager
### 💻 Installation
1. Clone the repository:
```bash ```bash
git clone https://github.com/RiccardoSenica/newsletter-hackernews
cd hackernews-newsletter
```
2. Install dependencies:
```bash
yarn install
```
3. Set up Vercel:
```bash
# Install Vercel CLI
yarn add -g vercel@latest yarn add -g vercel@latest
```
Link to vercel # Link to your Vercel project
```bash
yarn vercel:link yarn vercel:link
```
Pull env variables from vercel # Pull environment variables
```bash
yarn vercel:env yarn vercel:env
``` ```
Push Prisma schema to vercel 4. Set up the database:
```bash ```bash
# Push Prisma schema to database
yarn db:push yarn db:push
```
Generate Prisma client # Generate Prisma client
```bash
yarn prisma:generate yarn prisma:generate
``` ```
Reset Prisma database ### 🔧 Development
Run locally with Docker:
```bash
docker-compose up --build
```
### 🗄️ Database Management
Reset database (⚠️ caution: this will delete all data):
```bash ```bash
yarn db:reset yarn db:reset
``` ```
Run on Docker ## 🙏 Acknowledgments
```bash - 🎨 [Gradient Buttons](https://gradientbuttons.colorion.co/)
docker-compose up --build - ✨ [Custom Animation Effects](https://codepen.io/alphardex/pen/vYEYGzp)
```

View File

@@ -9,14 +9,13 @@ import {
} from '@utils/statusCodes'; } from '@utils/statusCodes';
import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas'; import { ConfirmationSchema, ResponseType } from '@utils/validationSchemas';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) { if (!process.env.SWEEGO_API_KEY) {
throw new Error('Resend variables not set'); throw new Error('SWEEGO_API_KEY is not set');
} }
const body = await request.json(); const body = await request.json();
const validation = ConfirmationSchema.safeParse(body); const validation = ConfirmationSchema.safeParse(body);
@@ -31,14 +30,6 @@ export async function POST(request: NextRequest) {
}); });
if (user) { if (user) {
const resend = new Resend(process.env.RESEND_KEY);
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: false
});
await prisma.user.update({ await prisma.user.update({
where: { where: {
code: validation.data.code code: validation.data.code

View File

@@ -1,4 +1,5 @@
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import axios from 'axios';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { import {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
@@ -9,7 +10,6 @@ import {
import { getSingleNews, getTopNews } from '@utils/urls'; import { getSingleNews, getTopNews } from '@utils/urls';
import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas'; import { NewsDatabaseSchema, NewsDatabaseType } from '@utils/validationSchemas';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
if ( if (
@@ -19,17 +19,22 @@ export async function GET(request: NextRequest) {
} }
try { try {
const topStories: number[] = await fetch(getTopNews, { const { data: topStories } = await axios.get<number[]>(getTopNews, {
cache: 'no-store' headers: {
}).then(res => res.json()); 'Cache-Control': 'no-store'
}
});
console.info(`Top stories ids: ${topStories}`); console.info(`Top stories ids: ${topStories}`);
const newsPromises = topStories const newsPromises = topStories
.slice(0, Number(process.env.NEWS_LIMIT)) .slice(0, Number(process.env.NEWS_LIMIT))
.map(id => fetch(getSingleNews(id)).then(res => res.json())); .map(id => axios.get<NewsDatabaseType>(getSingleNews(id)));
const news: NewsDatabaseType[] = await Promise.all(newsPromises); const newsResponses = await Promise.all(newsPromises);
const news: NewsDatabaseType[] = newsResponses.map(
response => response.data
);
const upsertPromises = news.map(async getSingleNews => { const upsertPromises = news.map(async getSingleNews => {
const validation = NewsDatabaseSchema.safeParse(getSingleNews); const validation = NewsDatabaseSchema.safeParse(getSingleNews);
@@ -65,23 +70,16 @@ export async function GET(request: NextRequest) {
console.info(`Imported ${result.length} news.`); console.info(`Imported ${result.length} news.`);
if (process.env.ADMIN_EMAIL && process.env.RESEND_FROM) {
const resend = new Resend(process.env.RESEND_KEY);
await resend.emails.send({
from: process.env.RESEND_FROM,
to: [process.env.ADMIN_EMAIL],
subject: 'Newsletter: import cron job',
text: `Imported ${result.length} news out of these ids: ${topStories.join(', ')}.`
});
}
return formatApiResponse( return formatApiResponse(
STATUS_OK, STATUS_OK,
`Imported ${newsPromises.length} news.` `Imported ${newsPromises.length} news.`
); );
} catch (error) { } catch (error) {
console.error(error); if (axios.isAxiosError(error)) {
console.error('Axios error:', error.response?.data || error.message);
} else {
console.error('Error:', error);
}
return formatApiResponse( return formatApiResponse(
STATUS_INTERNAL_SERVER_ERROR, STATUS_INTERNAL_SERVER_ERROR,
INTERNAL_SERVER_ERROR INTERNAL_SERVER_ERROR

View File

@@ -1,7 +1,7 @@
import { NewsletterTemplate } from '@components/email/Newsletter'; import { NewsletterTemplate } from '@components/email/Newsletter';
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient'; import { sender } from '@utils/sweego';
import { import {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
STATUS_INTERNAL_SERVER_ERROR, STATUS_INTERNAL_SERVER_ERROR,
@@ -25,9 +25,6 @@ export async function GET(request: NextRequest) {
} }
try { try {
// send newsletter to users who didn't get it in the last 23h 50m, assuming a cron job every 10 minutes
// this is to avoid sending the newsletter to the same users multiple times
// this is not a perfect solution, but it's good enough for now
const users = await prisma.user.findMany({ const users = await prisma.user.findMany({
where: { where: {
confirmed: true, confirmed: true,

View File

@@ -1,7 +1,7 @@
import { ConfirmationTemplate } from '@components/email/Confirmation'; import { ConfirmationTemplate } from '@components/email/Confirmation';
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient'; import { sender } from '@utils/sweego';
import { import {
BAD_REQUEST, BAD_REQUEST,
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
@@ -12,14 +12,9 @@ import {
import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas'; import { ResponseType, SubscribeFormSchema } from '@utils/validationSchemas';
import * as crypto from 'crypto'; import * as crypto from 'crypto';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_KEY is not set');
}
const body = await request.json(); const body = await request.json();
const validation = SubscribeFormSchema.safeParse(body); const validation = SubscribeFormSchema.safeParse(body);
@@ -36,8 +31,6 @@ export async function POST(request: NextRequest) {
} }
}); });
const resend = new Resend(process.env.RESEND_KEY);
const code = crypto const code = crypto
.createHash('sha256') .createHash('sha256')
.update(`${process.env.SECRET_HASH}${email}}`) .update(`${process.env.SECRET_HASH}${email}}`)
@@ -53,19 +46,6 @@ export async function POST(request: NextRequest) {
deleted: false deleted: false
} }
}); });
const contact = await resend.contacts.get({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE
});
if (!contact) {
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: true
});
}
} }
const message: ResponseType = { const message: ResponseType = {
@@ -84,21 +64,10 @@ export async function POST(request: NextRequest) {
} }
}); });
} else { } else {
const contact = await resend.contacts.create({
email: email,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: true
});
if (!contact.data?.id) {
throw new Error('Failed to create Resend contact');
}
await prisma.user.create({ await prisma.user.create({
data: { data: {
email, email,
code, code
resendId: contact.data.id
} }
}); });
} }

View File

@@ -1,7 +1,7 @@
import { UnsubscribeTemplate } from '@components/email/Unsubscribe'; import { UnsubscribeTemplate } from '@components/email/Unsubscribe';
import prisma from '@prisma/prisma'; import prisma from '@prisma/prisma';
import { formatApiResponse } from '@utils/formatApiResponse'; import { formatApiResponse } from '@utils/formatApiResponse';
import { sender } from '@utils/resendClient'; import { sender } from '@utils/sweego';
import { import {
BAD_REQUEST, BAD_REQUEST,
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
@@ -11,17 +11,13 @@ import {
} from '@utils/statusCodes'; } from '@utils/statusCodes';
import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas'; import { ResponseType, UnsubscribeFormSchema } from '@utils/validationSchemas';
import { NextRequest } from 'next/server'; import { NextRequest } from 'next/server';
import { Resend } from 'resend';
export const dynamic = 'force-dynamic'; // defaults to force-static
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { try {
if (!process.env.RESEND_KEY || !process.env.RESEND_AUDIENCE) {
throw new Error('RESEND_AUDIENCE is not set');
}
const body = await request.json(); const body = await request.json();
const validation = UnsubscribeFormSchema.safeParse(body); const validation = UnsubscribeFormSchema.safeParse(body);
if (!validation.success) { if (!validation.success) {
return formatApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST); return formatApiResponse(STATUS_BAD_REQUEST, BAD_REQUEST);
} }
@@ -34,24 +30,13 @@ export async function POST(request: NextRequest) {
} }
}); });
if (user && !user.deleted) { if (user) {
await prisma.user.update({ await prisma.user.delete({
where: { where: {
email email
},
data: {
deleted: true
} }
}); });
const resend = new Resend(process.env.RESEND_KEY);
await resend.contacts.update({
id: user.resendId,
audienceId: process.env.RESEND_AUDIENCE,
unsubscribed: true
});
const sent = await sender([email], UnsubscribeTemplate()); const sent = await sender([email], UnsubscribeTemplate());
if (!sent) { if (!sent) {

View File

@@ -4,6 +4,7 @@ import { CardDescription } from '@components/Card';
import { CustomCard } from '@components/CustomCard'; import { CustomCard } from '@components/CustomCard';
import { SchemaOrg } from '@components/SchemaOrg'; import { SchemaOrg } from '@components/SchemaOrg';
import { ResponseType } from '@utils/validationSchemas'; import { ResponseType } from '@utils/validationSchemas';
import axios from 'axios';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { Suspense, useEffect, useState } from 'react'; import { Suspense, useEffect, useState } from 'react';
@@ -23,32 +24,31 @@ const ConfirmationPage = () => {
} }
try { try {
const res = await fetch('/api/confirmation', { const { data } = await axios.post<ResponseType>(
method: 'POST', '/api/confirmation',
headers: { {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code: code code: code
}) },
}); {
headers: {
'Content-Type': 'application/json'
}
}
);
if (!res.ok) { if (!data.success) {
router.push('/'); router.push('/');
return; return;
} }
const response: ResponseType = await res.json(); setMessage(data.message);
if (!response.success) {
router.push('/');
return;
}
setMessage(response.message);
setLoading(false); setLoading(false);
} catch (error) { } catch (error) {
console.error(error); if (axios.isAxiosError(error)) {
console.error('Axios error:', error.response?.data || error.message);
} else {
console.error('Error:', error);
}
router.push('/'); router.push('/');
} }
}; };
@@ -86,7 +86,7 @@ const Confirmation = () => {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Hackernews Newsletter', name: 'Hackernews Newsletter',
title: 'Subscription Confirmation', title: 'Subscription Confirmation',
url: `${process.env.HOME_URL}/confirmation` url: `${process.env.NEXT_PUBLIC_HOME_URL}/confirmation`
}; };
return ( return (

View File

@@ -1,8 +1,8 @@
import { Tiles } from '@components/tiles/Tiles'; import { Tiles } from '@components/tiles/Tiles';
import { cn } from '@utils/cn'; import { cn } from '@utils/cn';
import { Analytics } from '@vercel/analytics/react';
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import { Inter as FontSans } from 'next/font/google'; import { Inter as FontSans } from 'next/font/google';
import Script from 'next/script';
import './globals.css'; import './globals.css';
export const metadata: Metadata = { export const metadata: Metadata = {
@@ -34,8 +34,12 @@ export default function RootLayout({
<Tiles> <Tiles>
<div className='z-10'>{children}</div> <div className='z-10'>{children}</div>
</Tiles> </Tiles>
<Analytics />
</body> </body>
<Script
defer
src='https://analytics.frompixels.com/script.js'
data-website-id='588e7b7d-e9cd-4b96-94bf-8269c499b0a2'
/>
</html> </html>
); );
} }

View File

@@ -18,6 +18,7 @@ import {
} from '@utils/validationSchemas'; } from '@utils/validationSchemas';
import { useState } from 'react'; import { useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
import axios from 'axios';
export const Home = () => { export const Home = () => {
const [completed, setCompleted] = useState(false); const [completed, setCompleted] = useState(false);
@@ -30,7 +31,7 @@ export const Home = () => {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Hackernews Newsletter', name: 'Hackernews Newsletter',
title: 'Home', title: 'Home',
url: process.env.HOME_URL url: process.env.NEXT_PUBLIC_HOME_URL
}; };
const form = useForm<SubscribeFormType>({ const form = useForm<SubscribeFormType>({
@@ -45,29 +46,28 @@ export const Home = () => {
async function handleSubmit(values: SubscribeFormType) { async function handleSubmit(values: SubscribeFormType) {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch('/api/subscribe', { const { data } = await axios.post<ResponseType>(
method: 'POST', '/api/subscribe',
headers: { {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.email email: values.email
}) },
}); {
headers: {
'Content-Type': 'application/json'
}
}
);
if (!response?.ok) { if (!data.success) {
throw new Error(`Invalid response: ${response.status}`); throw new Error(data.message);
} }
const formResponse: ResponseType = await response.json(); setMessage(data.message);
if (!formResponse.success) {
throw Error(formResponse.message);
}
setMessage(formResponse.message);
setCompleted(true); setCompleted(true);
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) {
console.error('Axios error:', error.response?.data || error.message);
}
setError(true); setError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@@ -9,434 +9,106 @@ const Privacy = () => {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Hackernews Newsletter', name: 'Hackernews Newsletter',
title: 'Privacy Policy', title: 'Privacy Policy',
url: `${process.env.HOME_URL}/privacy` url: `${process.env.NEXT_PUBLIC_HOME_URL}/privacy`
}; };
const body = ( const body = (
<div className='privacy-content my-2 max-h-[50vh] space-y-1 overflow-auto'> <div className='privacy-content my-2 max-h-[50vh] space-y-1 overflow-auto'>
<h2>Who We Are</h2>
<p className='leading-relaxed'> <p className='leading-relaxed'>
This Privacy Policy describes Our policies and procedures on the Data controller: {process.env.NEXT_PUBLIC_BRAND_OWNER_NAME}, an
collection, use and disclosure of Your information when You use the individual based in {process.env.NEXT_PUBLIC_BRAND_COUNTRY}.
Service and tells You about Your privacy rights and how the law protects
You.
</p> </p>
<p> <p>
We use Your Personal data to provide and improve the Service. By using Contact:{' '}
the Service, You agree to the collection and use of information in
accordance with this Privacy Policy.
</p>
<h2>Interpretation and Definitions</h2>
<h3>Interpretation</h3>
<p className='leading-relaxed'>
The words of which the initial letter is capitalized have meanings
defined under the following conditions. The following definitions shall
have the same meaning regardless of whether they appear in singular or
in plural.
</p>
<h3>Definitions</h3>
<p className='leading-relaxed'>
For the purposes of this Privacy Policy:
</p>
<ul className='list-disc space-y-4 pl-6'>
<li>
<p>
<strong>Account</strong> means a unique account created for You to
access our Service or parts of our Service.
</p>
</li>
<li>
<p>
<strong>Affiliate</strong> means an entity that controls, is
controlled by or is under common control with a party, where
&quot;control&quot; means ownership of 50% or more of the shares,
equity interest or other securities entitled to vote for election of
directors or other managing authority.
</p>
</li>
<li>
<p>
<strong>Application</strong> refers to{' '}
{process.env.NEXT_PUBLIC_BRAND_NAME}, the software program provided
by the Company.
</p>
</li>
<li>
<p>
<strong>Company</strong> (referred to as either &quot;the
Company&quot;, &quot;We&quot;, &quot;Us&quot; or &quot;Our&quot; in
this Agreement) refers to {process.env.NEXT_PUBLIC_BRAND_NAME}.
</p>
</li>
<li>
<p>
<strong>Country</strong> refers to:{' '}
{process.env.NEXT_PUBLIC_BRAND_COUNTRY}
</p>
</li>
<li>
<p>
<strong>Device</strong> means any device that can access the Service
such as a computer, a cellphone or a digital tablet.
</p>
</li>
<li>
<p>
<strong>Personal Data</strong> is any information that relates to an
identified or identifiable individual.
</p>
</li>
<li>
<p>
<strong>Service</strong> refers to the Application.
</p>
</li>
<li>
<p>
<strong>Service Provider</strong> means any natural or legal person
who processes the data on behalf of the Company. It refers to
third-party companies or individuals employed by the Company to
facilitate the Service, to provide the Service on behalf of the
Company, to perform services related to the Service or to assist the
Company in analyzing how the Service is used.
</p>
</li>
<li>
<p>
<strong>Usage Data</strong> refers to data collected automatically,
either generated by the use of the Service or from the Service
infrastructure itself (for example, the duration of a page visit).
</p>
</li>
<li>
<p>
<strong>You</strong> means the individual accessing or using the
Service, or the company, or other legal entity on behalf of which
such individual is accessing or using the Service, as applicable.
</p>
</li>
</ul>
<h2>Data Collection and Usage</h2>
<h3>Types of Data Collected</h3>
<h4>Personal Data</h4>
<p className='leading-relaxed'>
While using Our Service, We may ask You to provide Us with certain
personally identifiable information that can be used to contact or
identify You. Personally identifiable information may include, but is
not limited to:
</p>
<ul className='list-disc space-y-4 pl-6'>
<li>
<p>Email address</p>
</li>
<li>
<p>Usage Data</p>
</li>
</ul>
<h4>Usage Data</h4>
<p className='leading-relaxed'>
Usage Data is collected automatically when using the Service.
</p>
<p>
Usage Data may include information such as Your Device&apos;s Internet
Protocol address (e.g. IP address), browser type, browser version, the
pages of our Service that You visit, the time and date of Your visit,
the time spent on those pages, unique device identifiers and other
diagnostic data.
</p>
<p>
When You access the Service by or through a mobile device, We may
collect certain information automatically, including, but not limited
to, the type of mobile device You use, Your mobile device unique ID, the
IP address of Your mobile device, Your mobile operating system, the type
of mobile Internet browser You use, unique device identifiers and other
diagnostic data.
</p>
<p>
We may also collect information that Your browser sends whenever You
visit our Service or when You access the Service by or through a mobile
device.
</p>
<h2>Use of Your Personal Data</h2>
<p className='leading-relaxed'>
The Company may use Personal Data for the following purposes:
</p>
<ul className='list-disc space-y-4 pl-6'>
<li>
<p>
<strong>To provide and maintain our Service</strong>, including to
monitor the usage of our Service.
</p>
</li>
<li>
<p>
<strong>To manage Your Account:</strong> to manage Your registration
as a user of the Service. The Personal Data You provide can give You
access to different functionalities of the Service that are
available to You as a registered user.
</p>
</li>
<li>
<p>
<strong>For the performance of a contract:</strong> the development,
compliance and undertaking of the purchase contract for the
products, items or services You have purchased or of any other
contract with Us through the Service.
</p>
</li>
<li>
<p>
<strong>To contact You:</strong> To contact You by email, telephone
calls, SMS, or other equivalent forms of electronic communication,
such as a mobile application&apos;s push notifications regarding
updates or informative communications related to the
functionalities, products or contracted services, including the
security updates, when necessary or reasonable for their
implementation.
</p>
</li>
<li>
<p>
<strong>To provide You</strong> with news, special offers and
general information about other goods, services and events which we
offer that are similar to those that you have already purchased or
enquired about unless You have opted not to receive such
information.
</p>
</li>
<li>
<p>
<strong>To manage Your requests:</strong> To attend and manage Your
requests to Us.
</p>
</li>
<li>
<p>
<strong>For business transfers:</strong> We may use Your information
to evaluate or conduct a merger, divestiture, restructuring,
reorganization, dissolution, or other sale or transfer of some or
all of Our assets, whether as a going concern or as part of
bankruptcy, liquidation, or similar proceeding, in which Personal
Data held by Us about our Service users is among the assets
transferred.
</p>
</li>
<li>
<p>
<strong>For other purposes</strong>: We may use Your information for
other purposes, such as data analysis, identifying usage trends,
determining the effectiveness of our promotional campaigns and to
evaluate and improve our Service, products, services, marketing and
your experience.
</p>
</li>
</ul>
<p>We may share Your personal information in the following situations:</p>
<ul className='list-disc space-y-4 pl-6'>
<li>
<strong>With Service Providers:</strong> We may share Your personal
information with Service Providers to monitor and analyze the use of
our Service, to contact You.
</li>
<li>
<strong>For business transfers:</strong> We may share or transfer Your
personal information in connection with, or during negotiations of,
any merger, sale of Company assets, financing, or acquisition of all
or a portion of Our business to another company.
</li>
<li>
<strong>With Affiliates:</strong> We may share Your information with
Our affiliates, in which case we will require those affiliates to
honor this Privacy Policy. Affiliates include Our parent company and
any other subsidiaries, joint venture partners or other companies that
We control or that are under common control with Us.
</li>
<li>
<strong>With business partners:</strong> We may share Your information
with Our business partners to offer You certain products, services or
promotions.
</li>
<li>
<strong>With other users:</strong> when You share personal information
or otherwise interact in the public areas with other users, such
information may be viewed by all users and may be publicly distributed
outside.
</li>
<li>
<strong>With Your consent</strong>: We may disclose Your personal
information for any other purpose with Your consent.
</li>
</ul>
<h2>Data Handling and Security</h2>
<h3>Retention of Your Personal Data</h3>
<p className='leading-relaxed'>
The Company will retain Your Personal Data only for as long as is
necessary for the purposes set out in this Privacy Policy. We will
retain and use Your Personal Data to the extent necessary to comply with
our legal obligations (for example, if we are required to retain your
data to comply with applicable laws), resolve disputes, and enforce our
legal agreements and policies.
</p>
<p>
The Company will also retain Usage Data for internal analysis purposes.
Usage Data is generally retained for a shorter period of time, except
when this data is used to strengthen the security or to improve the
functionality of Our Service, or We are legally obligated to retain this
data for longer time periods.
</p>
<h3>Transfer of Your Personal Data</h3>
<p className='leading-relaxed'>
Your information, including Personal Data, is processed at the
Company&apos;s operating offices and in any other places where the
parties involved in the processing are located. It means that this
information may be transferred to and maintained on computers
located outside of Your state, province, country or other governmental
jurisdiction where the data protection laws may differ than those from
Your jurisdiction.
</p>
<h3>Security of Your Personal Data</h3>
<p className='leading-relaxed'>
Your consent to this Privacy Policy followed by Your submission of such
information represents Your agreement to that transfer.
</p>
<p>
The Company will take all steps reasonably necessary to ensure that Your
data is treated securely and in accordance with this Privacy Policy and
no transfer of Your Personal Data will take place to an organization or
a country unless there are adequate controls in place including the
security of Your data and other personal information.
</p>
<h4 className='text-lg font-medium text-gray-800'>
Delete Your Personal Data
</h4>
<p className='leading-relaxed'>
You have the right to delete or request that We assist in deleting the
Personal Data that We have collected about You.
</p>
<p>
Our Service may give You the ability to delete certain information about
You from within the Service.
</p>
<p>
You may update, amend, or delete Your information at any time by signing
in to Your Account, if you have one, and visiting the account settings
section that allows you to manage Your personal information. You may
also contact Us to request access to, correct, or delete any personal
information that You have provided to Us.
</p>
<p>
Please note, however, that We may need to retain certain information
when we have a legal obligation or lawful basis to do so.
</p>
<h2>Legal Disclosures</h2>
<h3>Business Transactions</h3>
<p className='leading-relaxed'>
If the Company is involved in a merger, acquisition or asset sale, Your
Personal Data may be transferred. We will provide notice before Your
Personal Data is transferred and becomes subject to a different Privacy
Policy.
</p>
<h3>Law Enforcement</h3>
<p className='leading-relaxed'>
Under certain circumstances, the Company may be required to disclose
Your Personal Data if required to do so by law or in response to valid
requests by public authorities (e.g. a court or a government agency).
</p>
<h3>Other Legal Requirements</h3>
<p className='leading-relaxed'>
The Company may disclose Your Personal Data in the good faith belief
that such action is necessary to:
</p>
<ul className='list-disc space-y-4 pl-6'>
<li>Comply with a legal obligation</li>
<li>Protect and defend the rights or property of the Company</li>
<li>
Prevent or investigate possible wrongdoing in connection with the
Service
</li>
<li>
Protect the personal safety of Users of the Service or the public
</li>
<li>Protect against legal liability</li>
</ul>
<h2>Additional Information</h2>
<p className='leading-relaxed'>
The security of Your Personal Data is important to Us, but remember that
no method of transmission over the Internet, or method of electronic
storage is 100% secure. While We strive to use commercially acceptable
means to protect Your Personal Data, We cannot guarantee its absolute
security.
</p>
<h3>Children&apos;s Privacy</h3>
<p className='leading-relaxed'>
Our Service does not address anyone under the age of 13. We do not
knowingly collect personally identifiable information from anyone under
the age of 13. If You are a parent or guardian and You are aware that
Your child has provided Us with Personal Data, please contact Us. If We
become aware that We have collected Personal Data from anyone under the
age of 13 without verification of parental consent, We take steps to
remove that information from Our servers.
</p>
<p>
If We need to rely on consent as a legal basis for processing Your
information and Your country requires consent from a parent, We may
require Your parent&apos;s consent before We collect and use that
information.
</p>
<h3>Links to Other Websites</h3>
<p className='leading-relaxed'>
Our Service may contain links to other websites that are not operated by
Us. If You click on a third party link, You will be directed to that
third party&apos;s site. We strongly advise You to review the Privacy
Policy of every site You visit.
</p>
<p>
We have no control over and assume no responsibility for the content,
privacy policies or practices of any third party sites or services.
</p>
<h3>Changes to this Privacy Policy</h3>
<p className='leading-relaxed'>
We may update Our Privacy Policy from time to time. We will notify You
of any changes by posting the new Privacy Policy on this page.
</p>
<p>
We will let You know via email and/or a prominent notice on Our Service,
prior to the change becoming effective and update the &quot;Last
updated&quot; date at the top of this Privacy Policy.
</p>
<p>
You are advised to review this Privacy Policy periodically for any
changes. Changes to this Privacy Policy are effective when they are
posted on this page.
</p>
<h2>Contact Information</h2>
<p className='leading-relaxed'>
If you have any questions about this Privacy Policy, You can contact us
by writing to{' '}
<a <a
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`} href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
className='text-purple-600 hover:text-purple-700' className='text-purple-600 hover:text-purple-700'
> >
{process.env.NEXT_PUBLIC_BRAND_EMAIL} {process.env.NEXT_PUBLIC_BRAND_EMAIL}
</a> </a>
</p>
<h2>What We Collect</h2>
<p className='leading-relaxed'>
Your email address (required to send the newsletter).
</p>
<h2>Why We Collect It</h2>
<p className='leading-relaxed'>
To deliver the daily {process.env.NEXT_PUBLIC_BRAND_NAME}{' '}
newsletter&mdash;a digest of top Hacker News stories with AI-generated
commentary.
</p>
<p>
We do not sell products, track your activity beyond essential delivery,
or share your data for marketing.
</p>
<h2>Legal Basis</h2>
<p className='leading-relaxed'>
Your explicit consent via double opt-in signup (you receive a
confirmation email with an activation link).
</p>
<h2>Third Parties</h2>
<ul className='list-disc space-y-4 pl-6'>
<li>
<strong>Email delivery:</strong> Resend (US). GDPR-compliant email
infrastructure.
</li>
<li>
<strong>Hosting &amp; analytics:</strong> Vercel (US). Analytics are
anonymized and aggregated&mdash;no personal data beyond your email is
collected.
</li>
<li>
<strong>Content source:</strong> Public Hacker News API (Y Combinator,
US).
</li>
</ul>
<h2>Your Rights</h2>
<ul className='list-disc space-y-4 pl-6'>
<li>Unsubscribe anytime via the link in every email</li>
<li>
Request deletion of your email by contacting{' '}
<a
href={`mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}`}
className='text-purple-600 hover:text-purple-700'
>
{process.env.NEXT_PUBLIC_BRAND_EMAIL}
</a>
</li>
<li>
Upon unsubscribe or deletion request, your email is permanently
deleted from our database
</li>
</ul>
<h2>Data Retention</h2>
<p className='leading-relaxed'>
We retain your email address only until you unsubscribe.
</p>
<h2>International Transfers</h2>
<p className='leading-relaxed'>
We rely on EU-approved safeguards including Standard Contractual Clauses
to ensure adequate protection when using US-based services.
</p>
<h2>Changes</h2>
<p className='leading-relaxed'>
We may update this policy occasionally. The latest version will always
be available at{' '}
<a
href={`${process.env.NEXT_PUBLIC_HOME_URL}/privacy`}
target='_blank'
rel='noopener noreferrer'
className='text-purple-600 hover:text-purple-700'
>
{process.env.NEXT_PUBLIC_HOME_URL}/privacy
</a>
. .
</p> </p>
</div> </div>
@@ -448,7 +120,7 @@ const Privacy = () => {
<CustomCard <CustomCard
className='max-90vh max-90vw' className='max-90vh max-90vw'
title='Privacy Policy' title='Privacy Policy'
description='Last updated: November 23, 2024' description='Last updated: January 28, 2026'
content={body} content={body}
/> />
</> </>

View File

@@ -6,6 +6,6 @@ export default function robots(): MetadataRoute.Robots {
userAgent: '*', userAgent: '*',
disallow: '' disallow: ''
}, },
sitemap: `${process.env.HOME_URL!}/sitemap.xml` sitemap: `${process.env.NEXT_PUBLIC_HOME_URL!}/sitemap.xml`
}; };
} }

View File

@@ -3,19 +3,19 @@ import { MetadataRoute } from 'next';
export default function sitemap(): MetadataRoute.Sitemap { export default function sitemap(): MetadataRoute.Sitemap {
return [ return [
{ {
url: process.env.HOME_URL!, url: process.env.NEXT_PUBLIC_HOME_URL!,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 1 priority: 1
}, },
{ {
url: `${process.env.HOME_URL!}/privacy`, url: `${process.env.NEXT_PUBLIC_HOME_URL!}/privacy`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.5 priority: 0.5
}, },
{ {
url: `${process.env.HOME_URL!}/unsubscribe`, url: `${process.env.NEXT_PUBLIC_HOME_URL!}/unsubscribe`,
lastModified: new Date(), lastModified: new Date(),
changeFrequency: 'yearly', changeFrequency: 'yearly',
priority: 0.2 priority: 0.2

View File

@@ -16,6 +16,8 @@ import {
UnsubscribeFormSchema, UnsubscribeFormSchema,
UnsubscribeFormType UnsubscribeFormType
} from '@utils/validationSchemas'; } from '@utils/validationSchemas';
import axios from 'axios';
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { FormProvider, useForm } from 'react-hook-form'; import { FormProvider, useForm } from 'react-hook-form';
@@ -31,7 +33,7 @@ const Unsubscribe = () => {
'@type': 'WebSite', '@type': 'WebSite',
name: 'Hackernews Newsletter', name: 'Hackernews Newsletter',
title: 'Unsubscribe', title: 'Unsubscribe',
url: `${process.env.HOME_URL}/unsubscribe` url: `${process.env.NEXT_PUBLIC_HOME_URL}/unsubscribe`
}; };
const form = useForm<UnsubscribeFormType>({ const form = useForm<UnsubscribeFormType>({
@@ -52,29 +54,28 @@ const Unsubscribe = () => {
async function handleSubmit(values: UnsubscribeFormType) { async function handleSubmit(values: UnsubscribeFormType) {
setIsLoading(true); setIsLoading(true);
try { try {
const response = await fetch('/api/unsubscribe', { const { data } = await axios.post<ResponseType>(
method: 'POST', '/api/unsubscribe',
headers: { {
'Content-Type': 'application/json'
},
body: JSON.stringify({
email: values.email email: values.email
}) },
}); {
headers: {
'Content-Type': 'application/json'
}
}
);
if (!response?.ok) { if (!data.success) {
throw new Error(`Invalid response: ${response.status}`); throw new Error(data.message);
} }
const formResponse: ResponseType = await response.json(); setMessage(data.message);
if (!formResponse.success) {
throw Error(formResponse.message);
}
setMessage(formResponse.message);
setCompleted(true); setCompleted(true);
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) {
console.error('Axios error:', error.response?.data || error.message);
}
setError(true); setError(true);
} finally { } finally {
setIsLoading(false); setIsLoading(false);

View File

@@ -22,7 +22,7 @@ export const ConfirmationTemplate = (code: string) => {
}} }}
> >
<a <a
href={`${process.env.HOME_URL}/confirmation?code=${code}`} href={`${process.env.NEXT_PUBLIC_HOME_URL}/confirmation?code=${code}`}
style={{ style={{
display: 'inline-block', display: 'inline-block',
padding: '12px 24px', padding: '12px 24px',

View File

@@ -1,5 +1,5 @@
import React from 'react'; import React from 'react';
import { summirize } from '@utils/anthropic/summarize'; import { summirize } from '@utils/ai/summarize';
import { NewsType } from '@utils/validationSchemas'; import { NewsType } from '@utils/validationSchemas';
import createDOMPurify from 'isomorphic-dompurify'; import createDOMPurify from 'isomorphic-dompurify';
import { Template } from './Template'; import { Template } from './Template';

View File

@@ -22,7 +22,7 @@ export const UnsubscribeTemplate = () => {
}} }}
> >
<a <a
href={`${process.env.HOME_URL}/`} href={`${process.env.NEXT_PUBLIC_HOME_URL}/`}
style={{ style={{
display: 'inline-block', display: 'inline-block',
padding: '12px 24px', padding: '12px 24px',

View File

@@ -92,7 +92,7 @@ export const Footer = () => {
<span> <span>
Click{' '} Click{' '}
<a <a
href={`${process.env.HOME_URL}/unsubscribe`} href={`${process.env.NEXT_PUBLIC_HOME_URL}/unsubscribe`}
style={{ color: '#386FA4', textDecoration: 'none' }} style={{ color: '#386FA4', textDecoration: 'none' }}
> >
here here
@@ -139,7 +139,7 @@ export const Footer = () => {
> >
<Shield size={14} color='#386FA4' /> <Shield size={14} color='#386FA4' />
<a <a
href={`${process.env.HOME_URL}/privacy`} href={`${process.env.NEXT_PUBLIC_HOME_URL}/privacy`}
style={{ color: '#386FA4', textDecoration: 'none' }} style={{ color: '#386FA4', textDecoration: 'none' }}
> >
Privacy Policy Privacy Policy
@@ -157,7 +157,7 @@ export const Footer = () => {
> >
<Home size={14} color='#386FA4' /> <Home size={14} color='#386FA4' />
<a <a
href={process.env.HOME_URL} href={process.env.NEXT_PUBLIC_HOME_URL}
style={{ color: '#386FA4', textDecoration: 'none' }} style={{ color: '#386FA4', textDecoration: 'none' }}
> >
Visit Website Visit Website

View File

@@ -4,6 +4,7 @@ import { NewsTileType } from '@utils/validationSchemas';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { Tile } from './components/Tile'; import { Tile } from './components/Tile';
import axios from 'axios';
interface TilesProps { interface TilesProps {
children: React.ReactNode; children: React.ReactNode;
@@ -22,11 +23,19 @@ export const Tiles = ({ children }: TilesProps) => {
useEffect(() => { useEffect(() => {
async function getNews() { async function getNews() {
const news: NewsTileType[] = await fetch('/api/news').then(res => try {
res.json() const { data: news } = await axios.get<NewsTileType[]>('/api/news');
); setNews(news);
} catch (error) {
setNews(news); if (axios.isAxiosError(error)) {
console.error(
'Failed to fetch news:',
error.response?.data || error.message
);
} else {
console.error('Failed to fetch news:', error);
}
}
} }
if (!news) { if (!news) {

View File

@@ -1,8 +1,16 @@
version: '3.8' version: '3.8'
services: services:
app: postgres:
build: image: postgres:16-alpine
context: . container_name: newsletter-db
dockerfile: Dockerfile environment:
- POSTGRES_USER=newsletter
- POSTGRES_PASSWORD=newsletter
- POSTGRES_DB=newsletter
ports: ports:
- '3000:3000' - '5432:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
volumes:
postgres-data:

10450
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -21,26 +21,25 @@
"prisma:reset": "npx prisma db push --force-reset" "prisma:reset": "npx prisma db push --force-reset"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.27.3",
"@hookform/resolvers": "^3.3.2", "@hookform/resolvers": "^3.3.2",
"@next/bundle-analyzer": "^14.2.5", "@next/bundle-analyzer": "^14.2.5",
"@prisma/client": "^5.6.0", "@prisma/client": "^5.6.0",
"@radix-ui/react-label": "^2.0.2", "@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@vercel/analytics": "^1.1.1", "axios": "^1.12.0",
"class-variance-authority": "^0.7.0", "class-variance-authority": "^0.7.0",
"clsx": "^2.0.0", "clsx": "^2.0.0",
"isomorphic-dompurify": "^2.15.0", "isomorphic-dompurify": "^2.15.0",
"lucide-react": "^0.460.0", "lucide-react": "^0.460.0",
"next": "^14.2.10", "next": "^15.5.9",
"openai": "^4.77.0",
"postcss-nesting": "^12.0.2", "postcss-nesting": "^12.0.2",
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-hook-form": "^7.48.2", "react-hook-form": "^7.48.2",
"resend": "^3.1.0",
"tailwind-merge": "^2.1.0", "tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "^18.4.3", "@commitlint/cli": "^18.4.3",

View File

@@ -0,0 +1,8 @@
/*
Warnings:
- You are about to drop the column `resendId` on the `users` table. All the data in the column will be lost.
*/
-- AlterTable
ALTER TABLE "users" DROP COLUMN "resendId";

View File

@@ -0,0 +1,2 @@
-- Rename the column without dropping it
ALTER TABLE news RENAME COLUMN created_at TO "createdAt";

View File

@@ -3,19 +3,17 @@ generator client {
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("POSTGRES_PRISMA_URL") // uses connection pooling url = env("DATABASE_URL")
directUrl = env("POSTGRES_URL_NON_POOLING") // uses a direct connection
} }
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
resendId String email String @unique
email String @unique code String @unique
code String @unique confirmed Boolean @default(false)
confirmed Boolean @default(false) deleted Boolean @default(false)
deleted Boolean @default(false) createdAt DateTime @default(now())
createdAt DateTime @default(now())
lastMail DateTime? lastMail DateTime?
@@map(name: "users") @@map(name: "users")
@@ -30,7 +28,7 @@ model News {
time Float time Float
url String? url String?
score Float score Float
createdAt DateTime @default(now()) @map(name: "created_at") createdAt DateTime @default(now())
@@map(name: "news") @@map(name: "news")
} }

103
utils/ai/client.ts Normal file
View File

@@ -0,0 +1,103 @@
import OpenAI from 'openai';
import { BaseTool } from './tool';
let ovhAI: OpenAI | null = null;
function getClient(): OpenAI {
if (!ovhAI) {
ovhAI = new OpenAI({
apiKey: process.env.OVHCLOUD_API_KEY,
baseURL: 'https://oai.endpoints.kepler.ai.cloud.ovh.net/v1'
});
}
return ovhAI;
}
const MAX_RETRIES = 3;
export async function getMessage<T>(
text: string,
baseTool: BaseTool
): Promise<T> {
const requiredFields = baseTool.input_schema.required
? [...baseTool.input_schema.required]
: Object.keys(baseTool.input_schema.properties);
let lastError: Error | null = null;
for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
try {
console.info(`OVH AI request attempt ${attempt}/${MAX_RETRIES}`);
const completion = await getClient().chat.completions.create({
model: 'Meta-Llama-3_3-70B-Instruct',
temperature: 0.7,
max_tokens: 16000,
tools: [
{
type: 'function',
function: {
name: baseTool.name,
description: baseTool.input_schema.description || '',
parameters: {
type: 'object',
properties: baseTool.input_schema.properties,
required: requiredFields
}
}
}
],
tool_choice: {
type: 'function',
function: { name: baseTool.name }
},
messages: [
{
role: 'system',
content: `You are a professional tech journalist creating newsletter content.
CRITICAL REQUIREMENTS:
1. You MUST call the function with ALL required fields populated
2. Required fields: ${requiredFields.join(', ')}
3. Every field must be fully populated with high-quality content
4. Do NOT leave any field as null, undefined, or empty`
},
{
role: 'user',
content: text
}
]
});
const message = completion.choices[0]?.message;
if (!message?.tool_calls || message.tool_calls.length === 0) {
throw new Error('No function call found in response');
}
const toolCall = message.tool_calls[0];
if (toolCall.function.name !== baseTool.name) {
throw new Error(
`Expected tool ${baseTool.name} but got ${toolCall.function.name}`
);
}
const result = JSON.parse(toolCall.function.arguments);
console.info('OVH AI response:', result);
return result as T;
} catch (error) {
console.error(`Attempt ${attempt} failed:`, error);
lastError = error as Error;
if (attempt < MAX_RETRIES) {
const delay = 1000 * attempt;
console.info(`Retrying in ${delay}ms...`);
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
console.error('All retry attempts failed');
throw lastError || new Error('OVH AI Endpoints client error.');
}

View File

@@ -8,6 +8,13 @@ export interface BaseTool {
}; };
} }
export interface ToolUseBlock {
type: 'tool_use';
id: string;
name: string;
input: Record<string, unknown>;
}
export interface NewsletterTool { export interface NewsletterTool {
title: string; title: string;
content: string; content: string;
@@ -18,20 +25,25 @@ export const newsletterTool: BaseTool = {
name: 'NewsletterTool' as const, name: 'NewsletterTool' as const,
input_schema: { input_schema: {
type: 'object' as const, type: 'object' as const,
description: 'Newsletter', description:
'Generate a tech newsletter with title, content, and focus sections',
properties: { properties: {
title: { title: {
type: 'string' as const, type: 'string' as const,
description: 'The title of the newsletter' description:
'The title of the newsletter (40-50 characters, capturing key themes)'
}, },
content: { content: {
type: 'string' as const, type: 'string' as const,
description: 'The main content of the newsletter' description:
'The main content of the newsletter (300-400 words, HTML formatted with paragraph tags and links)'
}, },
focus: { focus: {
type: 'string' as const, type: 'string' as const,
description: 'The text of the focus segment' description:
'Forward-looking assessment of key developments and trends'
} }
} },
required: ['title', 'content', 'focus'] as const
} }
} as const; } as const;

View File

@@ -1,30 +0,0 @@
import Anthropic from '@anthropic-ai/sdk';
import { BaseTool } from './tool';
export async function getMessage<T>(text: string, tool: BaseTool) {
const anthropic = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY
});
console.info('Anthropic request with text: ', text);
const response = await anthropic.messages.create({
model: 'claude-3-5-sonnet-20241022',
max_tokens: 2048,
messages: [{ role: 'user', content: text }],
tools: [tool]
});
console.info('Anthropic response: ', response);
try {
const data = response.content as [
{ type: string; text: string },
{ type: string; input: object } // object of type V
];
return data[1].input as T;
} catch (error) {
throw Error(JSON.stringify(error));
}
}

View File

@@ -1,61 +0,0 @@
import { Resend } from 'resend';
interface EmailTemplate {
subject: string;
template: JSX.Element;
}
export async function sender(
recipients: string[],
{ subject, template }: EmailTemplate
) {
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const resend = new Resend(process.env.RESEND_KEY);
try {
let response;
if (recipients.length == 1) {
response = await resend.emails.send({
from: process.env.RESEND_FROM!,
to: recipients[0],
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
});
} else {
response = await resend.batch.send(
recipients.map(recipient => {
return {
from: process.env.RESEND_FROM!,
to: recipient,
subject,
react: template,
headers: {
'List-Unsubscribe': `<${process.env.HOME_URL}/unsubscribe>`
}
};
})
);
}
const { error } = response;
if (error) {
console.error(error);
return false;
}
console.info(`${subject} email sent to ${recipients.length} recipients`);
return true;
} catch (error) {
console.error(error);
return false;
}
}

90
utils/sweego.ts Normal file
View File

@@ -0,0 +1,90 @@
interface EmailTemplate {
subject: string;
template: JSX.Element;
}
const SWEEGO_API_URL = 'https://api.sweego.io/send';
const renderTemplate = async (template: JSX.Element): Promise<string> => {
const { renderToStaticMarkup } = await import('react-dom/server');
const html = renderToStaticMarkup(template);
return `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
</head>
<body style="margin: 0; padding: 0; background-color: #f4f4f5;">
${html}
</body>
</html>`;
};
export async function sender(
recipients: string[],
{ subject, template }: EmailTemplate
): Promise<boolean> {
if (!process.env.SWEEGO_API_KEY) {
throw new Error('SWEEGO_API_KEY is not set');
}
if (!process.env.SWEEGO_FROM) {
throw new Error('SWEEGO_FROM is not set');
}
if (recipients.length === 0) {
console.info(`${subject} email skipped for having zero recipients`);
return true;
}
const htmlContent = await renderTemplate(template);
const fromName = process.env.NEXT_PUBLIC_BRAND_NAME || 'Newsletter';
let successCount = 0;
let failCount = 0;
for (const recipient of recipients) {
try {
const response = await fetch(SWEEGO_API_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Api-Key': process.env.SWEEGO_API_KEY
},
body: JSON.stringify({
channel: 'email',
provider: 'sweego',
recipients: [{ email: recipient }],
from: {
name: fromName,
email: process.env.SWEEGO_FROM
},
subject,
'message-html': htmlContent,
headers: {
'List-Unsubscribe': `<mailto:${process.env.NEXT_PUBLIC_BRAND_EMAIL}>`
}
})
});
if (!response.ok) {
const error = await response.text();
console.error(
`Failed to send to ${recipient}: ${response.status} ${error}`
);
failCount++;
continue;
}
successCount++;
} catch (error) {
console.error(`Failed to send to ${recipient}:`, error);
failCount++;
}
}
console.info(
`${subject} email: ${successCount} sent, ${failCount} failed out of ${recipients.length} recipients`
);
return successCount > 0;
}

12299
yarn.lock

File diff suppressed because it is too large Load Diff