0% found this document useful (0 votes)
2 views

Project Implementation plan

The document outlines a comprehensive migration plan for transitioning a PHP application to NextJS, divided into five phases: Foundation & Architecture, User Features, Admin Panel, Testing & Optimization, and Launch & Migration. Each phase includes specific tasks, such as setting up project structure, implementing user and admin features, integrating payment systems, and ensuring thorough testing before deployment. Key technical considerations are also highlighted, including authentication flow, database schema, API structure, and error handling.

Uploaded by

chotu.agrawal181
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as XLSX, PDF, TXT or read online on Scribd
0% found this document useful (0 votes)
2 views

Project Implementation plan

The document outlines a comprehensive migration plan for transitioning a PHP application to NextJS, divided into five phases: Foundation & Architecture, User Features, Admin Panel, Testing & Optimization, and Launch & Migration. Each phase includes specific tasks, such as setting up project structure, implementing user and admin features, integrating payment systems, and ensuring thorough testing before deployment. Key technical considerations are also highlighted, including authentication flow, database schema, API structure, and error handling.

Uploaded by

chotu.agrawal181
Copyright
© © All Rights Reserved
We take content rights seriously. If you suspect this is your content, claim it here.
Available Formats
Download as XLSX, PDF, TXT or read online on Scribd
You are on page 1/ 147

# OnlyLinks: PHP to NextJS Migration Project Plan

## Phase 1: Foundation & Architecture (2 weeks)

### Week 1: Project Setup & Authentication


1. **Project Structure Setup**
- Initialize NextJS app with TypeScript
- Configure ESLint, Prettier, and Husky for code quality
- Set up folder structure following NextJS 13+ App Router conventions

2. **Database Integration**(Use Drizzle ORM instead of prisma)


- Install and configure Prisma ORM
- Create schema.prisma file based on existing PHP database structure
- Set up database connection and environment variables

3. **Authentication System**
- Implement NextAuth.js with the following providers:
- Credentials provider (email/password)
- OAuth providers if needed
- Create login, register, and password reset pages
- Implement session management and protected routes

Core API Routes & Models


1. **User API Routes**
- `/api/auth/[...nextauth].js` - Authentication endpoints
- `/api/users` - User management endpoints
- `/api/users/profile` - Profile management

2. **Admin Authentication**
- Create separate admin authentication flow
- Implement admin session validation middleware
- Set up admin-only protected routes

3. **File Upload System**


- Implement file upload functionality using AWS S3 or similar
- Create API routes for file management
- Set up image optimization and processing

## Phase 2: User Features (3 weeks)

### Week 3: User Dashboard


1. **Dashboard UI**
- Create responsive dashboard layout
- Implement sidebar navigation
- Build dashboard overview components

2. **Profile Management**
- Create profile editing interface
- Implement avatar upload and management
- Add account settings functionality

3. **Link Management**
- Build link creation and editing interface
- Implement link analytics display
- Create link organization features

### Week 4: Analytics & Customization


1. **Analytics System**
- Implement link click tracking
- Create visitor analytics collection
- Build analytics dashboard with charts

2. **Theme Customization**
- Create theme selection interface
- Implement font management
- Build color scheme customization

3. **Mobile Responsiveness**
- Ensure all user interfaces work on mobile
- Implement responsive design patterns
- Test across various device sizes

### Week 5: Billing & Subscriptions


1. **Stripe Integration**
- Set up Stripe API connection
- Implement payment processing
- Create webhook handlers for payment events

2. **Subscription Management**
- Build subscription plan selection interface
- Implement subscription lifecycle management
- Create billing history and invoice display

3. **Plan Features**
- Implement feature access based on subscription level
- Create upgrade/downgrade flows
- Add trial period functionality

## Phase 3: Admin Panel (3 weeks)


### Week 6: Admin Dashboard
1. **Admin Layout**
- Create admin dashboard layout
- Implement admin navigation
- Build admin overview components

2. **User Management**
- Create user listing with search and filters
- Implement user editing and management
- Add user activity monitoring

3. **Content Management**
- Build page editor for static content
- Implement language management
- Create theme and font management interfaces

### Week 7: System Management


1. **Settings Management**
- Create system settings interface
- Implement configuration management
- Add environment variable management

2. **Plan Management**
- Build subscription plan editor
- Implement feature management for plans
- Create pricing configuration

3. **Agency Features**
- Implement agency dashboard
- Create client management interface
- Build agency-specific analytics

### Week 8: Advanced Admin Features


1. **Reporting**
- Create financial reports
- Implement user growth analytics
- Build custom report generation

2. **Bulk Operations**
- Add bulk user management
- Implement batch operations for links
- Create import/export functionality

3. **Admin API**
- Complete remaining admin API endpoints
- Implement comprehensive error handling
- Add rate limiting and security features

## Phase 4: Testing & Optimization (2 weeks)

### Week 9: Testing


1. **Unit Testing**
- Write tests for core functionality
- Implement API route testing
- Create component tests

2. **Integration Testing**
- Test end-to-end user flows
- Verify admin functionality
- Test payment processing

3. **Performance Testing**
- Analyze and optimize page load times
- Implement code splitting and lazy loading
- Optimize database queries

### Week 10: Deployment Preparation


1. **SEO Optimization**
- Implement metadata management
- Add sitemap generation
- Configure robots.txt

2. **Accessibility**
- Audit and fix accessibility issues
- Implement keyboard navigation
- Add screen reader support

3. **Documentation**
- Create user documentation
- Write admin guide
- Document API endpoints

## Phase 5: Launch & Migration (2 weeks)

### Week 11: Deployment


1. **Staging Deployment**
- Deploy to staging environment
- Perform final testing
- Fix any discovered issues

2. **Data Migration**
- Create data migration scripts
- Test migration process
- Prepare rollback plan

3. **CI/CD Setup**
- Configure continuous integration
- Set up automated testing
- Implement deployment pipeline

### Week 12: Launch & Monitoring


1. **Production Deployment**
- Deploy to production environment
- Implement monitoring tools
- Set up error tracking

2. **User Migration**
- Migrate existing users
- Send communication about the new platform
- Provide support for transition

3. **Post-Launch Support**
- Monitor system performance
- Address user feedback
- Fix any critical issues

## Key Technical Considerations

### Authentication Flow


- Implement JWT-based authentication
- Create secure password reset flow
- Add two-factor authentication option

### Database Schema


- Maintain compatibility with existing data
- Implement proper relations between tables
- Use migrations for schema changes

### API Structure


```
/api
/auth
/[...nextauth].js
/admin-login
/password-reset
/users
/[id]
/profile
/links
/admin
/users
/settings
/plans
/themes
/billing
/subscriptions
/invoices
/webhooks
/analytics
/links
/visitors
/reports
```

### Scheduled Tasks


- Replace cron.php with Vercel Cron Jobs or similar
- Implement the following scheduled tasks:
- Subscription status checking
- Session cleanup
- Analytics aggregation

### Error Handling


- Implement global error boundary
- Create custom error pages
- Add comprehensive logging

This plan provides a structured approach to migrating your PHP application to NextJS
# OnlyLinks: PHP to NextJS Migration Technical Implementation Guide

This guide provides detailed technical instructions for migrating the Only

## Phase 1: Foundation & Architecture

### Week 1: Project Setup & Authentication

uter conventions #### 1.1 Project Structure Setup

```bash
# Initialize NextJS app with TypeScript
abase structure npx create-next-app@latest onlylinks-next --typescript --eslint --app --src

# Install additional dependencies


cd onlylinks-next
npm install drizzle-orm @vercel/postgres drizzle-kit
npm install next-auth bcrypt jsonwebtoken
npm install zod react-hook-form @hookform/resolvers
npm install tailwindcss postcss autoprefixer
npm install lucide-react
npm install -D prettier prettier-plugin-tailwindcss husky lint-staged

# Set up Husky
npx husky init
```

Create the following folder structure:


```
src/
├── app/
│ ├── (auth)/
│ │ ├── login/
│ │ ├── signup/
│ │ └── forgot-password/
│ ├── (dashboard)/
│ │ ├── dashboard/
│ │ ├── links/
│ │ └── settings/
│ ├── (admin)/
│ │ ├── admin/
│ │ └── admin-dashboard/
│ ├── api/
│ │ ├── auth/
│ │ ├── users/
│ │ ├── links/
│ │ └── admin/
│ └── layout.js
├── components/
│ ├── ui/
│ ├── forms/
│ ├── dashboard/
│ └── admin/
├── lib/
│ ├── db/
│ │ ├── schema/
│ │ ├── index.js
│ │ └── migrations/
│ ├── auth/
│ └── utils/
├── middleware.js
└── types/
```

#### 1.2 Database Integration with Drizzle ORM

Create the database schema files:

```javascript
// src/lib/db/index.js
import { drizzle } from 'drizzle-orm/vercel-postgres';
import { sql } from '@vercel/postgres';

export const db = drizzle(sql);


```

```javascript
// src/lib/db/schema/users.js
import { pgTable, serial, varchar, boolean, timestamp, text } from 'drizzle
import { relations } from 'drizzle-orm';
import { links } from './links';
import { subscriptions } from './subscriptions';

export const users = pgTable('users', {


id: serial('id').primaryKey(),
email: varchar('email', { length: 255 }).notNull().unique(),
password: varchar('password', { length: 255 }).notNull(),
name: varchar('name', { length: 255 }),
username: varchar('username', { length: 100 }).unique(),
active: boolean('active').default(true),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
role: varchar('role', { length: 20 }).default('user'),
profileImage: text('profile_image'),
pageStatus: boolean('page_status').default(false),
});

export const usersRelations = relations(users, ({ many }) => ({


links: many(links),
subscriptions: many(subscriptions),
}));
```

Create similar schema files for other tables (links, subscriptions, plans, e

Set up Drizzle migrations:

```javascript
// drizzle.config.js
export default {
schema: './src/lib/db/schema',
out: './src/lib/db/migrations',
driver: 'pg',
dbCredentials: {
connectionString: process.env.DATABASE_URL,
}
};
```

```bash
# Generate migrations
npx drizzle-kit generate

# Apply migrations
npx drizzle-kit push
```

#### 1.3 Authentication System with NextAuth.js

```javascript
// src/lib/auth/auth-options.js
import { NextAuthOptions } from "next-auth";
import CredentialsProvider from "next-auth/providers/credentials";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";

export const authOptions = {


session: {
strategy: "jwt",
},
pages: {
signIn: "/login",
signOut: "/",
error: "/login",
},
providers: [
CredentialsProvider({
name: "Credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" }
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}

const user = await db.query.users.findFirst({


where: eq(users.email, credentials.email),
});

if (!user || !user.active) {
return null;
}

const passwordMatch = await bcrypt.compare(


credentials.password,
user.password
);

if (!passwordMatch) {
return null;
}

return {
id: user.id.toString(),
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.role = user.role;
}
return token;
},
async session({ session, token }) {
if (token) {
session.user.id = token.id;
session.user.role = token.role;
}
return session;
},
},
};
```

```javascript
// src/app/api/auth/[...nextauth]/route.js
import NextAuth from "next-auth";
import { authOptions } from "@/lib/auth/auth-options";

const handler = NextAuth(authOptions);

export { handler as GET, handler as POST };


```

Create login page:

```jsx
// src/app/(auth)/login/page.js
use client;

import { useState } from "react";


import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import * as z from "zod";
import { signIn } from "next-auth/react";
import { useRouter } from "next/navigation";
import Link from "next/link";
const loginSchema = z.object({
email: z.string().email("Please enter a valid email"),
password: z.string().min(6, "Password must be at least 6 characters"),
});

export default function LoginPage() {


const router = useRouter();
const [error, setError] = useState("");
const [isLoading, setIsLoading] = useState(false);

const { register, handleSubmit, formState: { errors } } = useForm({


resolver: zodResolver(loginSchema),
});

const onSubmit = async (data) => {


setIsLoading(true);
setError("");

try {
const result = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});

if (result?.error) {
setError("Invalid email or password");
setIsLoading(false);
ur PHP application to NextJS while ensuring all fu return;
}

router.push("/dashboard");
router.refresh();
} catch (error) {
setError("An error occurred. Please try again.");
setIsLoading(false);
}
};

return (
<div className="flex min-h-screen flex-col items-center justify-center
<div className="w-full max-w-md space-y-8 rounded-lg border p-6 s
<div className="text-center">
<h1 className="text-2xl font-bold">Login to OnlyLinks</h1>
<p className="mt-2 text-gray-600">Enter your credentials to acce
</div>

{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700"
{error}
</div>
)}

<form onSubmit={handleSubmit(onSubmit)} className="mt-8 spac


<div className="space-y-4">
<div>
<label htmlFor="email" className="block text-sm font-medium"
Email
</label>
<input
id="email"
type="email"
{...register("email")}
className="mt-1 block w-full rounded-md border border-gray-
/>
{errors.email && (
<p className="mt-1 text-sm text-red-600">{errors.email.mess
)}
</div>

<div>
<label htmlFor="password" className="block text-sm font-med
Password
</label>
<input
id="password"
type="password"
{...register("password")}
className="mt-1 block w-full rounded-md border border-gray-
/>
{errors.password && (
<p className="mt-1 text-sm text-red-600">{errors.password.m
)}
</div>
</div>

<div className="flex items-center justify-between">


<div className="flex items-center">
<input
id="remember-me"
name="remember-me"
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-blue-600 fo
/>
<label htmlFor="remember-me" className="ml-2 block text-sm
Remember me
</label>
</div>

<div className="text-sm">
<Link href="/forgot-password" className="font-medium text-bl
Forgot your password?
</Link>
</div>
</div>

<div>
<button
type="submit"
disabled={isLoading}
className="flex w-full justify-center rounded-md bg-blue-600 p
>
{isLoading ? "Logging in..." : "Sign in"}
</button>
</div>

<div className="text-center text-sm">


Don't have an account?{" "}
<Link href="/signup" className="font-medium text-blue-600 hov
Sign up
</Link>
</div>
</form>
</div>
</div>
);
}
```

Create signup page with similar structure.

### Week 2: Core API Routes & Models

#### 2.1 User API Routes

```javascript
// src/app/api/users/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import bcrypt from "bcrypt";
import * as z from "zod";

const userSchema = z.object({


name: z.string().min(2, "Name must be at least 2 characters"),
email: z.string().email("Please enter a valid email"),
password: z.string().min(6, "Password must be at least 6 characters"),
username: z.string().min(3, "Username must be at least 3 characters"),
});

export async function POST(request) {


try {
const body = await request.json();

const validation = userSchema.safeParse(body);


if (!validation.success) {
return NextResponse.json(
{ error: validation.error.errors[0].message },
{ status: 400 }
);
}

const { name, email, password, username } = body;

// Check if email already exists


const existingUserByEmail = await db.query.users.findFirst({
where: eq(users.email, email),
});

if (existingUserByEmail) {
return NextResponse.json(
{ error: "Email already in use" },
{ status: 400 }
);
}

// Check if username already exists


const existingUserByUsername = await db.query.users.findFirst({
where: eq(users.username, username),
});

if (existingUserByUsername) {
return NextResponse.json(
{ error: "Username already taken" },
{ status: 400 }
);
}

// Hash password
const hashedPassword = await bcrypt.hash(password, 10);

// Create user
const newUser = await db.insert(users).values({
name,
email,
password: hashedPassword,
username,
active: true,
role: "user",
createdAt: new Date(),
updatedAt: new Date(),
}).returning();

const user = newUser[0];

return NextResponse.json({
id: user.id,
name: user.name,
email: user.email,
username: user.username,
}, { status: 201 });
} catch (error) {
console.error("Error creating user:", error);
return NextResponse.json(
{ error: "Failed to create user" },
{ status: 500 }
);
}
}
```

```javascript
// src/app/api/users/[id]/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { eq } from "drizzle-orm";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
export async function GET(request, { params }) {
try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

// Only allow users to access their own data or admins to access any d
if (session.user.id !== params.id && session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}

const user = await db.query.users.findFirst({


where: eq(users.id, parseInt(params.id)),
});

if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

// Don't return password


const { password, ...userWithoutPassword } = user;

return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}

export async function PATCH(request, { params }) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

// Only allow users to update their own data or admins to update any
if (session.user.id !== params.id && session.user.role !== "admin") {
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}

const body = await request.json();

// Update user
const updatedUser = await db.update(users)
.set({
...body,
updatedAt: new Date(),
})
.where(eq(users.id, parseInt(params.id)))
.returning();

if (!updatedUser.length) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

// Don't return password


const { password, ...userWithoutPassword } = updatedUser[0];

return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
}
}
```

#### 2.2 Admin Authentication

```javascript
// src/middleware.js
import { NextResponse } from "next/server";
import { getToken } from "next-auth/jwt";

export async function middleware(request) {


const token = await getToken({ req: request });

// Check if user is authenticated


if (!token) {
return NextResponse.redirect(new URL("/login", request.url));
}

// Admin routes protection


if (request.nextUrl.pathname.startsWith("/admin") && token.role !== "
return NextResponse.redirect(new URL("/dashboard", request.url));
}

return NextResponse.next();
}

export const config = {


matcher: [
"/dashboard/:path*",
"/admin/:path*",
"/api/admin/:path*",
],
};
```

```javascript
// src/app/api/admin/login/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { eq, and } from "drizzle-orm";
import bcrypt from "bcrypt";
import jwt from "jsonwebtoken";

export async function POST(request) {


try {
const body = await request.json();
const { username, password } = body;

if (!username || !password) {
return NextResponse.json(
{ error: "Username and password are required" },
{ status: 400 }
);
}

// Find admin user


const user = await db.query.users.findFirst({
where: and(
eq(users.username, username),
eq(users.role, "admin")
),
});

if (!user || !user.active) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}

// Verify password
const passwordMatch = await bcrypt.compare(password, user.passwo

if (!passwordMatch) {
return NextResponse.json(
{ error: "Invalid credentials" },
{ status: 401 }
);
}

// Create admin token


const adminToken = jwt.sign(
{
id: user.id,
role: user.role,
},
process.env.ADMIN_JWT_SECRET,
{ expiresIn: "1d" }
);

// Set cookie with admin token


const response = NextResponse.json({ success: true });
response.cookies.set("admin_token", adminToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "strict",
maxAge: 86400, // 1 day
path: "/",
});

return response;
} catch (error) {
console.error("Admin login error:", error);
return NextResponse.json(
{ error: "Authentication failed" },
{ status: 500 }
);
}
}
```

#### 2.3 File Upload System

```javascript
// src/app/api/upload/route.js
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { writeFile } from "fs/promises";
import { join } from "path";
import { v4 as uuidv4 } from "uuid";

export async function POST(request) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const formData = await request.formData();


const file = formData.get("file");

if (!file) {
return NextResponse.json(
{ error: "No file uploaded" },
{ status: 400 }
);
}

// Validate file type


const allowedTypes = ["image/jpeg", "image/png", "image/gif"];
if (!allowedTypes.includes(file.type)) {
return NextResponse.json(
{ error: "File type not allowed" },
{ status: 400 }
);
}

// Validate file size (max 5MB)


if (file.size > 5 * 1024 * 1024) {
return NextResponse.json(
{ error: "File too large (max 5MB)" },
{ status: 400 }
);
}

const bytes = await file.arrayBuffer();


const buffer = Buffer.from(bytes);

// Generate unique filename


const fileName = `${uuidv4()}-${file.name.replace(/\s/g, "-")}`;
const uploadDir = join(process.cwd(), "public", "uploads");
const filePath = join(uploadDir, fileName);

// Save file
await writeFile(filePath, buffer);

// Return file URL


const fileUrl = `/uploads/${fileName}`;

return NextResponse.json({ url: fileUrl });


} catch (error) {
console.error("File upload error:", error);
return NextResponse.json(
{ error: "Failed to upload file" },
{ status: 500 }
);
}
}
```

## Phase 2: User Features

### Week 3: User Dashboard

#### 3.1 Dashboard UI

Create a dashboard layout:

```jsx
// src/app/(dashboard)/layout.js
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { redirect } from "next/navigation";
import Sidebar from "@/components/dashboard/Sidebar";
import Header from "@/components/dashboard/Header";

export default async function DashboardLayout({ children }) {


const session = await getServerSession(authOptions);

if (!session) {
redirect("/login");
}

return (
<div className="flex h-screen bg-gray-50">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header user={session.user} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</
</div>
</div>
);
}
```

Create the dashboard components:

```jsx
// src/components/dashboard/Sidebar.jsx
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Link as LinkIcon,
Settings,
BarChart,
LogOut
} from "lucide-react";
import { signOut } from "next-auth/react";

export default function Sidebar() {


const pathname = usePathname();

const navItems = [
{
name: "Dashboard",
href: "/dashboard",
icon: LayoutDashboard,
},
{
name: "Links",
href: "/links",
icon: LinkIcon,
},
{
name: "Analytics",
href: "/analytics",
icon: BarChart,
},
{
name: "Settings",
href: "/settings",
icon: Settings,
},
];

return (
<aside className="hidden w-64 flex-shrink-0 bg-white shadow-md m
<div className="flex h-16 items-center justify-center border-b">
<Link href="/dashboard" className="text-xl font-bold">OnlyLinks<
</div>
<nav className="flex flex-1 flex-col p-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.name}>
<Link
href={item.href}
className={`flex items-center rounded-md px-4 py-2 text-sm
isActive
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
</li>
);
})}
</ul>
<div className="mt-auto">
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="flex w-full items-center rounded-md px-4 py-2 text-s
>
<LogOut className="mr-3 h-5 w-5" />
Sign out
</button>
</div>
</nav>
</aside>
);
}
```

```jsx
// src/components/dashboard/Header.jsx
import { useState } from "react";
import { Menu, Bell, User } from "lucide-react";
import Link from "next/link";
import Image from "next/image";

export default function Header({ user }) {


const [isProfileOpen, setIsProfileOpen] = useState(false);

return (
<header className="flex h-16 items-center justify-between border-b
<button className="rounded-md p-2 text-gray-500 hover:bg-gray-10
<Menu className="h-6 w-6" />
</button>

<div className="ml-auto flex items-center space-x-4">


<button className="rounded-md p-2 text-gray-500 hover:bg-gray-1
<Bell className="h-5 w-5" />
</button>

<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-2 rounded-md p-2 text-gra
>
<div className="relative h-8 w-8 overflow-hidden rounded-full b
{user.image ? (
<Image
src={user.image}
alt={user.name || "User"}
fill
className="object-cover"
/>
):(
<User className="h-full w-full p-1" />
)}
</div>
<span className="hidden text-sm font-medium md:block">
{user.name || user.email}
</span>
</button>

{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md bg-whit
<Link
href="/settings/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Your Profile
</Link>
<Link
href="/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Settings
</Link>
<button
onClick={() => {
setIsProfileOpen(false);
signOut({ callbackUrl: "/" });
}}
className="block w-full px-4 py-2 text-left text-sm text-gray-70
>
Sign out
</button>
</div>
)}
</div>
</div>
</header>
);
}
```

#### 3.2 Link Management

Create the links schema:

```javascript
// src/lib/db/schema/links.js
import { pgTable, serial, varchar, text, timestamp, integer, boolean } from
import { relations } from 'drizzle-orm';
import { users } from './users';

export const links = pgTable('links', {


id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
title: varchar('title', { length: 255 }).notNull(),
url: text('url').notNull(),
active: boolean('active').default(true),
clicks: integer('clicks').default(0),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
position: integer('position').default(0),
customIcon: text('custom_icon'),
});

export const linksRelations = relations(links, ({ one }) => ({


user: one(users, {
fields: [links.userId],
references: [users.id],
}),
}));
```

Create the links API:


// src/app/api/links/route.js (continued)
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { links } from "@/lib/db/schema/links";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import * as z from "zod";

const linkSchema = z.object({


title: z.string().min(1, "Title is required"),
url: z.string().url("Please enter a valid URL"),
active: z.boolean().optional(),
customIcon: z.string().optional(),
});

export async function GET(request) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const userLinks = await db.query.links.findMany({


where: eq(links.userId, parseInt(session.user.id)),
orderBy: [asc(links.position)],
});

return NextResponse.json(userLinks);
} catch (error) {
console.error("Error fetching links:", error);
return NextResponse.json(
{ error: "Failed to fetch links" },
{ status: 500 }
);
}
}

export async function POST(request) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const body = await request.json();

const validation = linkSchema.safeParse(body);


if (!validation.success) {
return NextResponse.json(
{ error: validation.error.errors[0].message },
{ status: 400 }
);
}

// Get highest position for user's links


const highestPositionLink = await db.query.links.findFirst({
where: eq(links.userId, parseInt(session.user.id)),
orderBy: [desc(links.position)],
});

const newPosition = highestPositionLink ? highestPositionLink.position

// Create link
const newLink = await db.insert(links).values({
userId: parseInt(session.user.id),
title: body.title,
url: body.url,
active: body.active ?? true,
customIcon: body.customIcon,
position: newPosition,
createdAt: new Date(),
updatedAt: new Date(),
}).returning();

return NextResponse.json(newLink[0], { status: 201 });


} catch (error) {
console.error("Error creating link:", error);
return NextResponse.json(
{ error: "Failed to create link" },
{ status: 500 }
);
}
}

Create the links page:


// src/app/(dashboard)/links/page.js
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Plus, Edit, Trash2, ExternalLink, Move } from "lucide-react";
import { DragDropContext, Droppable, Draggable } from "react-beautifu
export default function LinksPage() {
const router = useRouter();
const [links, setLinks] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
const fetchLinks = async () => {
try {
const response = await fetch("/api/links");

if (!response.ok) {
throw new Error("Failed to fetch links");
}

const data = await response.json();


setLinks(data);
} catch (error) {
setError(error.message);
} finally {
setIsLoading(false);
}
};

fetchLinks();
}, []);

const handleDragEnd = async (result) => {


if (!result.destination) return;

const items = Array.from(links);


const [reorderedItem] = items.splice(result.source.index, 1);
items.splice(result.destination.index, 0, reorderedItem);

// Update positions
const updatedItems = items.map((item, index) => ({
...item,
position: index,
}));

setLinks(updatedItems);
// Update positions in database
try {
const movedItem = updatedItems[result.destination.index];
await fetch(`/api/links/${movedItem.id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ position: result.destination.index }),
});
} catch (error) {
console.error("Error updating position:", error);
}
};

const handleDeleteLink = async (id) => {


if (!confirm("Are you sure you want to delete this link?")) return;

try {
const response = await fetch(`/api/links/${id}`, {
method: "DELETE",
});

if (!response.ok) {
throw new Error("Failed to delete link");
}

setLinks(links.filter((link) => link.id !== id));


} catch (error) {
console.error("Error deleting link:", error);
}
};

const handleToggleActive = async (id, active) => {


try {
const response = await fetch(`/api/links/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ active: !active }),
});

if (!response.ok) {
throw new Error("Failed to update link");
}

setLinks(
links.map((link) =>
link.id === id ? { ...link, active: !active } : link
)
);
} catch (error) {
console.error("Error updating link:", error);
}
};

if (isLoading) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 bord
<p className="mt-2 text-gray-600">Loading links...</p>
</div>
</div>
);
}

if (error) {
return (
<div className="flex h-full items-center justify-center">
<div className="text-center">
<p className="text-red-600">{error}</p>
<button
onClick={() => router.refresh()}
className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white h
>
Try again
</button>
</div>
</div>
);
}

return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Your Links</h1>
<button
onClick={() => router.push("/links/new")}
className="flex items-center rounded-md bg-blue-600 px-4 py-2 t
>
<Plus className="mr-2 h-4 w-4" />
Add Link
</button>
</div>

{links.length === 0 ? (
<div className="rounded-md bg-gray-50 p-8 text-center">
<p className="text-gray-600">You don't have any links yet.</p>
<button
onClick={() => router.push("/links/new")}
className="mt-4 rounded-md bg-blue-600 px-4 py-2 text-white h
>
Create your first link
</button>
</div>
):(
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="links">
{(provided) => (
<div
{...provided.droppableProps}
ref={provided.innerRef}
className="space-y-4"
>
{links.map((link, index) => (
<Draggable
key={link.id}
draggableId={link.id.toString()}
index={index}
>
{(provided) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
className={`rounded-md border p-4 ${
link.active ? "bg-white" : "bg-gray-50"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center">
<div
{...provided.dragHandleProps}
className="mr-3 cursor-move text-gray-400 hover:text
>
<Move className="h-5 w-5" />
</div>
<div>
<h3 className="font-medium">{link.title}</h3>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="flex items-center text-sm text-gray-500 ho
>
{link.url}
<ExternalLink className="ml-1 h-3 w-3" />
</a>
</div>
</div>
<div className="flex items-center space-x-2">
<div className="flex items-center">
<span className="mr-2 text-sm text-gray-500">
{link.clicks} clicks
</span>
<label className="relative inline-flex cursor-pointer ite
<input
type="checkbox"
checked={link.active}
onChange={() =>
handleToggleActive(link.id, link.active)
}
className="peer sr-only"
/>
<div className="peer h-6 w-11 rounded-full bg-gray-2
</label>
</div>
<button
onClick={() => router.push(`/links/${link.id}`)}
className="rounded-md p-2 text-gray-500 hover:bg-gr
>
<Edit className="h-5 w-5" />
</button>
<button
onClick={() => handleDeleteLink(link.id)}
className="rounded-md p-2 text-gray-500 hover:bg-gr
>
<Trash2 className="h-5 w-5" />
</button>
</div>
</div>
</div>
)}
</Draggable>
))}
{provided.placeholder}
</div>
)}
</Droppable>
</DragDropContext>
)}
</div>
);
}

Week 4: Analytics & Customization


4.1 Analytics System
Create the analytics schema:
// src/lib/db/schema/analytics.js
import { pgTable, serial, integer, timestamp, varchar, text } from 'drizzle-
import { relations } from 'drizzle-orm';
import { links } from './links';
import { users } from './users';

export const linkClicks = pgTable('link_clicks', {


id: serial('id').primaryKey(),
linkId: integer('link_id').notNull().references(() => links.id),
userId: integer('user_id').notNull().references(() => users.id),
clickedAt: timestamp('clicked_at').defaultNow(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: text('user_agent'),
referrer: text('referrer'),
country: varchar('country', { length: 2 }),
city: varchar('city', { length: 100 }),
});

export const linkClicksRelations = relations(linkClicks, ({ one }) => ({


link: one(links, {
fields: [linkClicks.linkId],
references: [links.id],
}),
user: one(users, {
fields: [linkClicks.userId],
references: [users.id],
}),
}));

export const pageViews = pgTable('page_views', {


id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
viewedAt: timestamp('viewed_at').defaultNow(),
ipAddress: varchar('ip_address', { length: 45 }),
userAgent: text('user_agent'),
referrer: text('referrer'),
country: varchar('country', { length: 2 }),
city: varchar('city', { length: 100 }),
});

export const pageViewsRelations = relations(pageViews, ({ one }) => ({


user: one(users, {
fields: [pageViews.userId],
references: [users.id],
}),
}));

Create the analytics tracking API:


// src/app/api/analytics/track-click/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { linkClicks } from "@/lib/db/schema/analytics";
import { links } from "@/lib/db/schema/links";
import { eq } from "drizzle-orm";
import { getClientIp } from "@/lib/utils/ip";
import { getCountryAndCity } from "@/lib/utils/geo";

export async function POST(request) {


try {
const body = await request.json();
const { linkId } = body;

if (!linkId) {
return NextResponse.json(
{ error: "Link ID is required" },
{ status: 400 }
);
}

// Get link
const link = await db.query.links.findFirst({
where: eq(links.id, parseInt(linkId)),
});

if (!link) {
return NextResponse.json(
{ error: "Link not found" },
{ status: 404 }
);
}

// Get IP and location


const ipAddress = getClientIp(request);
const { country, city } = await getCountryAndCity(ipAddress);

// Record click
await db.insert(linkClicks).values({
linkId: parseInt(linkId),
userId: link.userId,
clickedAt: new Date(),
ipAddress,
userAgent: request.headers.get("user-agent") || "",
referrer: request.headers.get("referer") || "",
country,
city,
});

// Increment link clicks


await db.update(links)
.set({
clicks: link.clicks + 1,
})
.where(eq(links.id, parseInt(linkId)));

return NextResponse.json({ success: true });


} catch (error) {
console.error("Error tracking click:", error);
return NextResponse.json(
{ error: "Failed to track click" },
{ status: 500 }
);
}
}

// src/app/api/analytics/track-view/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { pageViews } from "@/lib/db/schema/analytics";
import { getClientIp } from "@/lib/utils/ip";
import { getCountryAndCity } from "@/lib/utils/geo";

export async function POST(request) {


try {
const body = await request.json();
const { userId } = body;

if (!userId) {
return NextResponse.json(
{ error: "User ID is required" },
{ status: 400 }
);
}

// Get IP and location


const ipAddress = getClientIp(request);
const { country, city } = await getCountryAndCity(ipAddress);

// Record page view


await db.insert(pageViews).values({
userId: parseInt(userId),
viewedAt: new Date(),
ipAddress,
userAgent: request.headers.get("user-agent") || "",
referrer: request.headers.get("referer") || "",
country,
city,
});

return NextResponse.json({ success: true });


} catch (error) {
console.error("Error tracking page view:", error);
return NextResponse.json(
{ error: "Failed to track page view" },
{ status: 500 }
);
}
}

Create utility functions for IP and geolocation:


// src/lib/utils/ip.js
export function getClientIp(request) {
const forwarded = request.headers.get("x-forwarded-for");

if (forwarded) {
return forwarded.split(",")[0].trim();
}

return "127.0.0.1"; // Default for local development


}

// src/lib/utils/geo.js
export async function getCountryAndCity(ip) {
// For production, use a geolocation service like ipinfo.io or maxmind
// This is a simplified example
try {
if (ip === "127.0.0.1") {
return { country: "US", city: "Local" };
}

const response = await fetch(`https://ipinfo.io/${ip}/json?token=${pro


const data = await response.json();

return {
country: data.country || "",
city: data.city || "",
};
} catch (error) {
console.error("Error getting location:", error);
return { country: "", city: "" };
}
}

4.2 Theme Customization


Create the themes schema:
// src/lib/db/schema/themes.js
import { pgTable, serial, varchar, text, integer, boolean, jsonb } from 'driz
import { relations } from 'drizzle-orm';
import { users } from './users';

export const themes = pgTable('themes', {


id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
isDefault: boolean('is_default').default(false),
isPublic: boolean('is_public').default(true),
createdBy: integer('created_by').references(() => users.id),
styles: jsonb('styles'),
});

export const themesRelations = relations(themes, ({ one, many }) => ({


creator: one(users, {
fields: [themes.createdBy],
references: [users.id],
}),
users: many(users),
}));

export const fonts = pgTable('fonts', {


id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
family: varchar('family', { length: 255 }).notNull(),
url: text('url'),
isDefault: boolean('is_default').default(false),
});

Create the theme API:


// src/app/api/themes/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { themes } from "@/lib/db/schema/themes";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { eq } from "drizzle-orm";

export async function GET(request) {


try {
const session = await getServerSession(authOptions);

if (!session) {
// For public themes, no authentication required
const publicThemes = await db.query.themes.findMany({
where: eq(themes.isPublic, true),
});

return NextResponse.json(publicThemes);
}

// For authenticated users, include private themes created by them


const userThemes = await db.query.themes.findMany({
where: (themes) =>
eq(themes.isPublic, true).or(
eq(themes.createdBy, parseInt(session.user.id))
),
});

return NextResponse.json(userThemes);
} catch (error) {
console.error("Error fetching themes:", error);
return NextResponse.json(
{ error: "Failed to fetch themes" },
{ status: 500 }
);
}
}

export async function POST(request) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const body = await request.json();

// Create theme
const newTheme = await db.insert(themes).values({
name: body.name,
description: body.description,
isPublic: body.isPublic ?? false,
createdBy: parseInt(session.user.id),
styles: body.styles || {},
}).returning();

return NextResponse.json(newTheme[0], { status: 201 });


} catch (error) {
console.error("Error creating theme:", error);
return NextResponse.json(
{ error: "Failed to create theme" },
{ status: 500 }
);
}
}

// src/app/api/themes/[id]/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { themes } from "@/lib/db/schema/themes";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { eq, and } from "drizzle-orm";
export async function GET(request, { params }) {
try {
const theme = await db.query.themes.findFirst({
where: eq(themes.id, parseInt(params.id)),
});

if (!theme) {
return NextResponse.json(
{ error: "Theme not found" },
{ status: 404 }
);
}

// If theme is not public, check if user is creator


if (!theme.isPublic) {
const session = await getServerSession(authOptions);

if (!session || theme.createdBy !== parseInt(session.user.id)) {


return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
}

return NextResponse.json(theme);
} catch (error) {
console.error("Error fetching theme:", error);
return NextResponse.json(
{ error: "Failed to fetch theme" },
{ status: 500 }
);
}
}

export async function PATCH(request, { params }) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}
const theme = await db.query.themes.findFirst({
where: eq(themes.id, parseInt(params.id)),
});

if (!theme) {
return NextResponse.json(
{ error: "Theme not found" },
{ status: 404 }
);
}

// Only creator or admin can update


if (theme.createdBy !== parseInt(session.user.id) && session.user.role
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}

const body = await request.json();

// Update theme
const updatedTheme = await db.update(themes)
.set({
name: body.name ?? theme.name,
description: body.description ?? theme.description,
isPublic: body.isPublic ?? theme.isPublic,
styles: body.styles ?? theme.styles,
})
.where(eq(themes.id, parseInt(params.id)))
.returning();

return NextResponse.json(updatedTheme[0]);
} catch (error) {
console.error("Error updating theme:", error);
return NextResponse.json(
{ error: "Failed to update theme" },
{ status: 500 }
);
}
}

export async function DELETE(request, { params }) {


try {
const session = await getServerSession(authOptions);
if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const theme = await db.query.themes.findFirst({


where: eq(themes.id, parseInt(params.id)),
});

if (!theme) {
return NextResponse.json(
{ error: "Theme not found" },
{ status: 404 }
);
}

// Only creator or admin can delete


if (theme.createdBy !== parseInt(session.user.id) && session.user.role
return NextResponse.json(
{ error: "Forbidden" },
{ status: 403 }
);
}

// Check if theme is default


if (theme.isDefault) {
return NextResponse.json(
{ error: "Cannot delete default theme" },
{ status: 400 }
);
}

// Delete theme
await db.delete(themes)
.where(eq(themes.id, parseInt(params.id)));

return NextResponse.json({ success: true });


} catch (error) {
console.error("Error deleting theme:", error);
return NextResponse.json(
{ error: "Failed to delete theme" },
{ status: 500 }
);
}
}

Week 5: Billing & Subscriptions


5.1 Stripe Integration
First, install the Stripe package:

npm install stripe

Create the Stripe client:


// src/lib/stripe.js
import Stripe from 'stripe';

export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY, {


apiVersion: '2023-10-16', // Use the latest API version
});

Create the subscription schema:


// src/lib/db/schema/subscriptions.js
import { pgTable, serial, integer, varchar, timestamp, boolean, text, decim
import { relations } from 'drizzle-orm';
import { users } from './users';

export const plans = pgTable('plans', {


id: serial('id').primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
price: decimal('price', { precision: 10, scale: 2 }).notNull(),
interval: varchar('interval', { length: 20 }).notNull(), // monthly, yearly
stripePriceId: varchar('stripe_price_id', { length: 255 }),
features: text('features'),
isActive: boolean('is_active').default(true),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});

export const subscriptions = pgTable('subscriptions', {


id: serial('id').primaryKey(),
userId: integer('user_id').notNull().references(() => users.id),
planId: integer('plan_id').notNull().references(() => plans.id),
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
stripeSubscriptionId: varchar('stripe_subscription_id', { length: 255 }),
status: varchar('status', { length: 50 }).notNull(), // active, canceled, pas
currentPeriodStart: timestamp('current_period_start'),
currentPeriodEnd: timestamp('current_period_end'),
cancelAtPeriodEnd: boolean('cancel_at_period_end').default(false),
createdAt: timestamp('created_at').defaultNow(),
updatedAt: timestamp('updated_at').defaultNow(),
});

export const plansRelations = relations(plans, ({ many }) => ({


subscriptions: many(subscriptions),
}));

export const subscriptionsRelations = relations(subscriptions, ({ one }) =>


user: one(users, {
fields: [subscriptions.userId],
references: [users.id],
}),
plan: one(plans, {
fields: [subscriptions.planId],
references: [plans.id],
}),
}));

Create the checkout API:


// src/app/api/billing/checkout/route.js
import { NextResponse } from "next/server";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { db } from "@/lib/db";
import { plans } from "@/lib/db/schema/subscriptions";
import { users } from "@/lib/db/schema/users";
import { eq } from "drizzle-orm";
import { stripe } from "@/lib/stripe";

export async function POST(request) {


try {
const session = await getServerSession(authOptions);

if (!session) {
return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const body = await request.json();


const { planId } = body;

if (!planId) {
return NextResponse.json(
{ error: "Plan ID is required" },
{ status: 400 }
);
}

// Get plan
const plan = await db.query.plans.findFirst({
where: eq(plans.id, parseInt(planId)),
});

if (!plan || !plan.isActive) {
return NextResponse.json(
{ error: "Plan not found or inactive" },
{ status: 404 }
);
}

// Get user
const user = await db.query.users.findFirst({
where: eq(users.id, parseInt(session.user.id)),
});

if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

// Create or get Stripe customer


let customerId = user.stripeCustomerId;

if (!customerId) {
const customer = await stripe.customers.create({
email: user.email,
name: user.name,
metadata: {
userId: user.id,
},
});

customerId = customer.id;

// Update user with Stripe customer ID


await db.update(users)
.set({ stripeCustomerId: customerId })
.where(eq(users.id, parseInt(session.user.id)));
}

// Create checkout session


const checkoutSession = await stripe.checkout.sessions.create({
customer: customerId,
line_items: [
{
price: plan.stripePriceId,
quantity: 1,
},
],
mode: "subscription",
success_url: `${process.env.NEXT_PUBLIC_APP_URL}/dashboard?che
cancel_url: `${process.env.NEXT_PUBLIC_APP_URL}/plans?checkout=
metadata: {
userId: user.id,
planId: plan.id,
},
});

return NextResponse.json({ url: checkoutSession.url });


} catch (error) {
console.error("Checkout error:", error);
return NextResponse.json(
{ error: "Failed to create checkout session" },
{ status: 500 }
);
}
}

Create the webhook handler for Stripe events:


// src/app/api/billing/webhook/route.js
import { NextResponse } from "next/server";
import { headers } from "next/headers";
import { stripe } from "@/lib/stripe";
import { db } from "@/lib/db";
import { subscriptions } from "@/lib/db/schema/subscriptions";
import { users } from "@/lib/db/schema/users";
import { eq } from "drizzle-orm";

export async function POST(request) {


const body = await request.text();
const signature = headers().get("Stripe-Signature");
let event;

try {
event = stripe.webhooks.constructEvent(
body,
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (error) {
console.error("Webhook signature verification failed:", error);
return NextResponse.json(
{ error: "Webhook signature verification failed" },
{ status: 400 }
);
}

try {
switch (event.type) {
case "checkout.session.completed": {
const session = event.data.object;

// Handle successful checkout


await handleCheckoutCompleted(session);
break;
}

case "invoice.payment_succeeded": {
const invoice = event.data.object;

// Handle successful payment


await handlePaymentSucceeded(invoice);
break;
}

case "invoice.payment_failed": {
const invoice = event.data.object;

// Handle failed payment


await handlePaymentFailed(invoice);
break;
}

case "customer.subscription.updated": {
const subscription = event.data.object;

// Handle subscription update


await handleSubscriptionUpdated(subscription);
break;
}

case "customer.subscription.deleted": {
const subscription = event.data.object;

// Handle subscription cancellation


await handleSubscriptionDeleted(subscription);
break;
}
}

return NextResponse.json({ received: true });


} catch (error) {
console.error("Webhook error:", error);
return NextResponse.json(
{ error: "Webhook handler failed" },
{ status: 500 }
);
}
}

async function handleCheckoutCompleted(session) {


const { userId, planId } = session.metadata;

if (!userId || !planId) {
console.error("Missing metadata in checkout session");
return;
}

// Get subscription from Stripe


const stripeSubscription = await stripe.subscriptions.retrieve(
session.subscription
);

// Create subscription record


await db.insert(subscriptions).values({
userId: parseInt(userId),
planId: parseInt(planId),
stripeCustomerId: session.customer,
stripeSubscriptionId: session.subscription,
status: "active",
currentPeriodStart: new Date(stripeSubscription.current_period_start
currentPeriodEnd: new Date(stripeSubscription.current_period_end *
createdAt: new Date(),
updatedAt: new Date(),
});

// Update user's page status


await db.update(users)
.set({ pageStatus: true })
.where(eq(users.id, parseInt(userId)));
}

async function handlePaymentSucceeded(invoice) {


const subscriptionId = invoice.subscription;

// Get subscription from database


const subscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.stripeSubscriptionId, subscriptionId),
});

if (!subscription) {
console.error("Subscription not found:", subscriptionId);
return;
}

// Get subscription from Stripe


const stripeSubscription = await stripe.subscriptions.retrieve(
subscriptionId
);

// Update subscription
await db.update(subscriptions)
.set({
status: "active",
currentPeriodStart: new Date(stripeSubscription.current_period_star
currentPeriodEnd: new Date(stripeSubscription.current_period_end
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscriptionId));

// Update user's page status


await db.update(users)
.set({ pageStatus: true })
.where(eq(users.id, subscription.userId));
}

async function handlePaymentFailed(invoice) {


const subscriptionId = invoice.subscription;
// Get subscription from database
const subscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.stripeSubscriptionId, subscriptionId),
});

if (!subscription) {
console.error("Subscription not found:", subscriptionId);
return;
}

// Update subscription
await db.update(subscriptions)
.set({
status: "past_due",
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscriptionId));
}

async function handleSubscriptionUpdated(subscription) {


// Get subscription from database
const dbSubscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.stripeSubscriptionId, subscription.id),
});

if (!dbSubscription) {
console.error("Subscription not found:", subscription.id);
return;
}

// Update subscription
await db.update(subscriptions)
.set({
status: subscription.status,
currentPeriodStart: new Date(subscription.current_period_start * 10
currentPeriodEnd: new Date(subscription.current_period_end * 1000
cancelAtPeriodEnd: subscription.cancel_at_period_end,
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));
}

async function handleSubscriptionDeleted(subscription) {


// Get subscription from database
const dbSubscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.stripeSubscriptionId, subscription.id),
});

if (!dbSubscription) {
console.error("Subscription not found:", subscription.id);
return;
}

// Update subscription
await db.update(subscriptions)
.set({
status: "canceled",
updatedAt: new Date(),
})
.where(eq(subscriptions.stripeSubscriptionId, subscription.id));

// Update user's page status after grace period (3 days)


setTimeout(async () => {
await db.update(users)
.set({ pageStatus: false })
.where(eq(users.id, dbSubscription.userId));
}, 3 * 24 * 60 * 60 * 1000); // 3 days in milliseconds
}

Phase 3: Admin Panel


Week 6: Admin Dashboard
6.1 Admin Layout
Create the admin layout:

// src/app/(admin)/layout.js
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { redirect } from "next/navigation";
import AdminSidebar from "@/components/admin/AdminSidebar";
import AdminHeader from "@/components/admin/AdminHeader";

export default async function AdminLayout({ children }) {


const session = await getServerSession(authOptions);

if (!session || session.user.role !== "admin") {


redirect("/login");
}

return (
<div className="flex h-screen bg-gray-50">
<AdminSidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<AdminHeader user={session.user} />
<main className="flex-1 overflow-y-auto p-4 md:p-6">{children}</
</div>
</div>
);
}

Create the admin components:


// src/components/admin/AdminSidebar.jsx
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
Settings,
CreditCard,
Palette,
FileText,
Globe,
LogOut
} from "lucide-react";
import { signOut } from "next-auth/react";

export default function AdminSidebar() {


const pathname = usePathname();

const navItems = [
{
name: "Dashboard",
href: "/admin/dashboard",
icon: LayoutDashboard,
},
{
name: "Users",
href: "/admin/users",
icon: Users,
},
{
name: "Plans",
href: "/admin/plans",
icon: CreditCard,
},
{
name: "Subscriptions",
href: "/admin/subscriptions",
icon: CreditCard,
},
{
name: "Themes",
href: "/admin/themes",
icon: Palette,
},
{
name: "Pages",
href: "/admin/pages",
icon: FileText,
},
{
name: "Languages",
href: "/admin/languages",
icon: Globe,
},
{
name: "Settings",
href: "/admin/settings",
icon: Settings,
},
];

return (
<aside className="hidden w-64 flex-shrink-0 bg-white shadow-md m
<div className="flex h-16 items-center justify-center border-b">
<Link href="/admin/dashboard" className="text-xl font-bold">
OnlyLinks Admin
</Link>
</div>
<nav className="flex flex-1 flex-col p-4">
<ul className="space-y-1">
{navItems.map((item) => {
const isActive = pathname === item.href;
return (
<li key={item.name}>
<Link
href={item.href}
className={`flex items-center rounded-md px-4 py-2 text-sm
isActive
? "bg-blue-50 text-blue-700"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<item.icon className="mr-3 h-5 w-5" />
{item.name}
</Link>
</li>
);
})}
</ul>
<div className="mt-auto">
<button
onClick={() => signOut({ callbackUrl: "/" })}
className="flex w-full items-center rounded-md px-4 py-2 text-s
>
<LogOut className="mr-3 h-5 w-5" />
Sign out
</button>
</div>
</nav>
</aside>
);
}

// src/components/admin/AdminHeader.jsx
import { useState } from "react";
import { Menu, Bell, User } from "lucide-react";
import Link from "next/link";
import Image from "next/image";
import { signOut } from "next-auth/react";

export default function AdminHeader({ user }) {


const [isProfileOpen, setIsProfileOpen] = useState(false);

return (
<header className="flex h-16 items-center justify-between border-b
<button className="rounded-md p-2 text-gray-500 hover:bg-gray-10
<Menu className="h-6 w-6" />
</button>

<div className="ml-auto flex items-center space-x-4">


<button className="rounded-md p-2 text-gray-500 hover:bg-gray-1
<Bell className="h-5 w-5" />
</button>

<div className="relative">
<button
onClick={() => setIsProfileOpen(!isProfileOpen)}
className="flex items-center space-x-2 rounded-md p-2 text-gra
>
<div className="relative h-8 w-8 overflow-hidden rounded-full b
{user.image ? (
<Image
src={user.image}
alt={user.name || "Admin"}
fill
className="object-cover"
/>
):(
<User className="h-full w-full p-1" />
)}
</div>
<span className="hidden text-sm font-medium md:block">
{user.name || user.email}
</span>
</button>

{isProfileOpen && (
<div className="absolute right-0 mt-2 w-48 rounded-md bg-whit
<Link
href="/admin/settings/profile"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Your Profile
</Link>
<Link
href="/admin/settings"
className="block px-4 py-2 text-sm text-gray-700 hover:bg-gra
onClick={() => setIsProfileOpen(false)}
>
Settings
</Link>
<button
onClick={() => {
setIsProfileOpen(false);
signOut({ callbackUrl: "/" });
}}
className="block w-full px-4 py-2 text-left text-sm text-gray-70
>
Sign out
</button>
</div>
)}
</div>
</div>
</header>
);
}

6.2 User Management


Create the admin users API:
// src/app/api/admin/users/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { desc, asc, sql } from "drizzle-orm";

export async function GET(request) {


try {
const session = await getServerSession(authOptions);

if (!session || session.user.role !== "admin") {


return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const { searchParams } = new URL(request.url);


const page = parseInt(searchParams.get("page") || "1");
const limit = parseInt(searchParams.get("limit") || "10");
const search = searchParams.get("search") || "";
const sortBy = searchParams.get("sortBy") || "createdAt";
const sortOrder = searchParams.get("sortOrder") || "desc";

const offset = (page - 1) * limit;

// Build query
let query = db.select({
id: users.id,
name: users.name,
email: users.email,
username: users.username,
active: users.active,
role: users.role,
createdAt: users.createdAt,
pageStatus: users.pageStatus,
}).from(users);
// Add search condition
if (search) {
query = query.where(
sql`${users.name} ILIKE ${'%' + search + '%'} OR ${users.email} ILIKE
);
}

// Add sorting
if (sortOrder === "desc") {
query = query.orderBy(desc(users[sortBy]));
} else {
query = query.orderBy(asc(users[sortBy]));
}

// Add pagination
query = query.limit(limit).offset(offset);

// Execute query
const usersList = await query;

// Get total count


const countQuery = db.select({ count: sql`COUNT(*)` }).from(users);

if (search) {
countQuery.where(
sql`${users.name} ILIKE ${'%' + search + '%'} OR ${users.email} ILIKE
);
}

const [{ count }] = await countQuery;


const totalPages = Math.ceil(count / limit);

return NextResponse.json({
users: usersList,
pagination: {
page,
limit,
totalItems: count,
totalPages,
},
});
} catch (error) {
console.error("Error fetching users:", error);
return NextResponse.json(
{ error: "Failed to fetch users" },
{ status: 500 }
);
}
}

Create the admin user detail API:


// src/app/api/admin/users/[id]/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { users } from "@/lib/db/schema/users";
import { subscriptions } from "@/lib/db/schema/subscriptions";
import { links } from "@/lib/db/schema/links";
import { getServerSession } from "next-auth/next";
import { authOptions } from "@/lib/auth/auth-options";
import { eq } from "drizzle-orm";
import bcrypt from "bcrypt";

export async function GET(request, { params }) {


try {
const session = await getServerSession(authOptions);

if (!session || session.user.role !== "admin") {


return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

// Get user
const user = await db.query.users.findFirst({
where: eq(users.id, parseInt(params.id)),
});

if (!user) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

// Don't return password


const { password, ...userWithoutPassword } = user;

// Get user's subscription


const subscription = await db.query.subscriptions.findFirst({
where: eq(subscriptions.userId, parseInt(params.id)),
with: {
plan: true,
},
});

// Get user's links count


const linksCount = await db.select({ count: sql`COUNT(*)` })
.from(links)
.where(eq(links.userId, parseInt(params.id)));

return NextResponse.json({
user: userWithoutPassword,
subscription,
linksCount: linksCount[0].count,
});
} catch (error) {
console.error("Error fetching user:", error);
return NextResponse.json(
{ error: "Failed to fetch user" },
{ status: 500 }
);
}
}

export async function PATCH(request, { params }) {


try {
const session = await getServerSession(authOptions);

if (!session || session.user.role !== "admin") {


return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

const body = await request.json();

// Check if user exists


const existingUser = await db.query.users.findFirst({
where: eq(users.id, parseInt(params.id)),
});

if (!existingUser) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

// Prepare update data


const updateData = { ...body };

// Hash password if provided


if (updateData.password) {
updateData.password = await bcrypt.hash(updateData.password, 10
}

// Update user
const updatedUser = await db.update(users)
.set({
...updateData,
updatedAt: new Date(),
})
.where(eq(users.id, parseInt(params.id)))
.returning();

// Don't return password


const { password, ...userWithoutPassword } = updatedUser[0];

return NextResponse.json(userWithoutPassword);
} catch (error) {
console.error("Error updating user:", error);
return NextResponse.json(
{ error: "Failed to update user" },
{ status: 500 }
);
}
}

export async function DELETE(request, { params }) {


try {
const session = await getServerSession(authOptions);

if (!session || session.user.role !== "admin") {


return NextResponse.json(
{ error: "Unauthorized" },
{ status: 401 }
);
}

// Check if user exists


const existingUser = await db.query.users.findFirst({
where: eq(users.id, parseInt(params.id)),
});

if (!existingUser) {
return NextResponse.json(
{ error: "User not found" },
{ status: 404 }
);
}

// Don't allow deleting admin users


if (existingUser.role === "admin") {
return NextResponse.json(
{ error: "Cannot delete admin users" },
{ status: 400 }
);
}

// Delete user
await db.delete(users)
.where(eq(users.id, parseInt(params.id)));

return NextResponse.json({ success: true });


} catch (error) {
console.error("Error deleting user:", error);
return NextResponse.json(
{ error: "Failed to delete user" },
{ status: 500 }
);
}
}

Phase 4: Testing & Optimization


Week 9: Testing
9.1 Unit Testing
Install testing libraries:

npm install -D jest @testing-library/react @testing-library/jest-dom jest-

Configure Jest:
// jest.config.js
const nextJest = require('next/jest');

const createJestConfig = nextJest({


dir: './',
});
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
testEnvironment: 'jest-environment-jsdom',
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
},
};

module.exports = createJestConfig(customJestConfig);

// jest.setup.js
import '@testing-library/jest-dom';

Create a sample test:


// src/components/ui/Button.test.jsx
import { render, screen } from '@testing-library/react';
import Button from './Button';

describe('Button component', () => {


it('renders correctly', () => {
render(<Button>Click me</Button>);
expect(screen.getByText('Click me')).toBeInTheDocument();
});

it('applies the correct variant class', () => {


render(<Button variant="primary">Primary Button</Button>);
const button = screen.getByText('Primary Button');
expect(button).toHaveClass('bg-blue-600');
expect(button).toHaveClass('text-white');
});

it('handles onClick events', () => {


const handleClick = jest.fn();
render(<Button onClick={handleClick}>Click me</Button>);
screen.getByText('Click me').click();
expect(handleClick).toHaveBeenCalledTimes(1);
});

it('can be disabled', () => {


render(<Button disabled>Disabled Button</Button>);
expect(screen.getByText('Disabled Button')).toBeDisabled();
});
});

9.2 API Testing


Create API tests:
// src/app/api/users/users.test.js
import { createMocks } from 'node-mocks-http';
import { POST } from './route';
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema/users';
import bcrypt from 'bcrypt';

// Mock dependencies
jest.mock('@/lib/db', () => ({
insert: jest.fn(),
query: {
users: {
findFirst: jest.fn(),
},
},
}));

jest.mock('bcrypt', () => ({
hash: jest.fn(() => 'hashed_password'),
}));

describe('Users API', () => {


beforeEach(() => {
jest.clearAllMocks();
});

describe('POST /api/users', () => {


it('should create a new user', async () => {
// Mock database responses
db.query.users.findFirst.mockResolvedValueOnce(null); // No existing
db.insert.mockImplementationOnce(() => ({
values: () => ({
returning: () => Promise.resolve([{
id: 1,
name: 'Test User',
email: '[email protected]',
username: 'testuser',
password: 'hashed_password',
}]),
}),
}));

// Create mock request


const { req, res } = createMocks({
method: 'POST',
body: {
name: 'Test User',
email: '[email protected]',
password: 'password123',
username: 'testuser',
},
});

// Call API handler


await POST(req, res);

// Assertions
expect(db.query.users.findFirst).toHaveBeenCalledTimes(2); // Check
expect(bcrypt.hash).toHaveBeenCalledWith('password123', 10);
expect(db.insert).toHaveBeenCalledTimes(1);
expect(res._getStatusCode()).toBe(201);

const responseData = JSON.parse(res._getData());


expect(responseData).toEqual({
id: 1,
name: 'Test User',
email: '[email protected]',
username: 'testuser',
});
});

it('should return 400 if email already exists', async () => {


// Mock existing user with same email
db.query.users.findFirst.mockResolvedValueOnce({
id: 1,
email: '[email protected]',
});

// Create mock request


const { req, res } = createMocks({
method: 'POST',
body: {
name: 'Test User',
email: '[email protected]',
password: 'password123',
username: 'testuser',
},
});

// Call API handler


await POST(req, res);
// Assertions
expect(res._getStatusCode()).toBe(400);
expect(JSON.parse(res._getData())).toEqual({
error: 'Email already in use',
});
});
});
});

Week 10: Deployment Preparation


10.1 SEO Optimization
Create a metadata utility:
// src/lib/utils/metadata.js
export function generateMetadata({
title,
description,
keywords,
image,
url,
type = 'website',
}) {
const baseUrl = process.env.NEXT_PUBLIC_APP_URL;

return {
title: title ? `${title} | OnlyLinks` : 'OnlyLinks - Manage Your Links',
description: description || 'Create and manage your links in one place
keywords: keywords || 'links, bio links, link management, social media
openGraph: {
title: title ? `${title} | OnlyLinks` : 'OnlyLinks - Manage Your Links',
description: description || 'Create and manage your links in one plac
url: url ? `${baseUrl}${url}` : baseUrl,
siteName: 'OnlyLinks',
images: [
{
url: image || `${baseUrl}/images/og-image.jpg`,
width: 1200,
height: 630,
alt: title || 'OnlyLinks',
},
],
locale: 'en_US',
type,
},
twitter: {
card: 'summary_large_image',
title: title ? `${title} | OnlyLinks` : 'OnlyLinks - Manage Your Links',
description: description || 'Create and manage your links in one plac
images: [image || `${baseUrl}/images/og-image.jpg`],
},
};
}

Create a sitemap generator:


// src/app/sitemap.js
import { db } from '@/lib/db';
import { users } from '@/lib/db/schema/users';
import { eq } from 'drizzle-orm';

export default async function sitemap() {


const baseUrl = process.env.NEXT_PUBLIC_APP_URL;

// Static routes
const routes = [
{
url: `${baseUrl}`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 1,
},
{
url: `${baseUrl}/login`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/signup`,
lastModified: new Date(),
changeFrequency: 'monthly',
priority: 0.8,
},
{
url: `${baseUrl}/plans`,
lastModified: new Date(),
changeFrequency: 'weekly',
priority: 0.9,
},
];

// Get public user profiles


const publicUsers = await db.query.users.findMany({
where: eq(users.pageStatus, true),
select: {
username: true,
updatedAt: true,
},
});

// Add user profiles to sitemap


const userRoutes = publicUsers.map((user) => ({
url: `${baseUrl}/${user.username}`,
lastModified: user.updatedAt,
changeFrequency: 'daily',
priority: 0.7,
}));

return [...routes, ...userRoutes];


}

Create a robots.txt file:


// src/app/robots.js
export default function robots() {
return {
rules: {
userAgent: '*',
allow: '/',
disallow: ['/admin/', '/api/', '/dashboard/'],
},
sitemap: `${process.env.NEXT_PUBLIC_APP_URL}/sitemap.xml`,
};
}

10.2 Accessibility
Install accessibility testing tools:
npm install -D axe-core @axe-core/react

Set up accessibility testing:


// src/lib/utils/a11y.js
import React from 'react';
import ReactDOM from 'react-dom';
import { axe, toHaveNoViolations } from 'jest-axe';

expect.extend(toHaveNoViolations);

export async function checkA11y(component) {


const container = document.createElement('div');
document.body.appendChild(container);
ReactDOM.render(component, container);

const results = await axe(container);


expect(results).toHaveNoViolations();

ReactDOM.unmountComponentAtNode(container);
container.remove();
}

Create an accessibility test:


// src/components/ui/Button.a11y.test.jsx
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
import Button from './Button';

expect.extend(toHaveNoViolations);

describe('Button accessibility', () => {


it('should not have accessibility violations', async () => {
const { container } = render(<Button>Accessible Button</Button>);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

it('should have proper ARIA attributes when loading', async () => {


const { container, getByText } = render(<Button isLoading>Loading</Bu
expect(getByText('Loading')).toHaveAttribute('aria-busy', 'true');
const results = await axe(container);
expect(results).toHaveNoViolations();
});
});

Phase 5: Launch & Migration


Week 11: Deployment
11.1 Vercel Deployment Configuration
Create a Vercel configuration file:

// vercel.json
{
"version": 2,
"buildCommand": "npm run build",
"installCommand": "npm install",
"framework": "nextjs",
"regions": ["iad1"],
"env": {
"NEXT_PUBLIC_APP_URL": "https://onlylinks.vercel.app"
},
"crons": [
{
"path": "/api/cron/subscriptions",
"schedule": "0 0 * * *"
},
{
"path": "/api/cron/sessions",
"schedule": "0 */12 * * *"
}
]
}

Create cron job handlers:


// src/app/api/cron/subscriptions/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { subscriptions } from "@/lib/db/schema/subscriptions";
import { users } from "@/lib/db/schema/users";
import { eq, and, lte } from "drizzle-orm";

export async function GET(request) {


try {
// Verify cron secret to prevent unauthorized access
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Get expired subscriptions


const expiredSubscriptions = await db.query.subscriptions.findMany({
where: and(
eq(subscriptions.status, "active"),
lte(subscriptions.currentPeriodEnd, new Date())
),
});

// Process each expired subscription


for (const subscription of expiredSubscriptions) {
// Update subscription status
await db.update(subscriptions)
.set({
status: "expired",
updatedAt: new Date(),
})
.where(eq(subscriptions.id, subscription.id));

// Update user page status after grace period (3 days)


const gracePeriod = new Date();
gracePeriod.setDate(gracePeriod.getDate() - 3);

if (subscription.currentPeriodEnd <= gracePeriod) {


await db.update(users)
.set({ pageStatus: false })
.where(eq(users.id, subscription.userId));
}
}

return NextResponse.json({
success: true,
processed: expiredSubscriptions.length,
});
} catch (error) {
console.error("Subscription cron error:", error);
return NextResponse.json(
{ error: "Failed to process subscriptions" },
{ status: 500 }
);
}
}

// src/app/api/cron/sessions/route.js
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
import { sessions } from "@/lib/db/schema/sessions";
import { lte } from "drizzle-orm";

export async function GET(request) {


try {
// Verify cron secret to prevent unauthorized access
const authHeader = request.headers.get("authorization");
if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

// Delete expired sessions


const result = await db.delete(sessions)
.where(lte(sessions.expires, new Date()));
return NextResponse.json({
success: true,
deleted: result.count,
});
} catch (error) {
console.error("Sessions cleanup error:", error);
return NextResponse.json(
{ error: "Failed to clean up sessions" },
{ status: 500 }
);
}
}

11.2 Data Migration Scripts


Create a migration script for users:
// scripts/migrate-users.js
require('dotenv').config();
const { Pool } = require('pg');
const bcrypt = require('bcrypt');

// Source database (PHP app)


const sourcePool = new Pool({
connectionString: process.env.SOURCE_DATABASE_URL,
});

// Target database (NextJS app)


const targetPool = new Pool({
connectionString: process.env.DATABASE_URL,
});

async function migrateUsers() {


console.log('Starting user migration...');

try {
// Get users from source database
const { rows: sourceUsers } = await sourcePool.query('SELECT * FROM
console.log(`Found ${sourceUsers.length} users to migrate`);

// Migrate each user


for (const user of sourceUsers) {
// Check if user already exists in target database
const { rows: existingUsers } = await targetPool.query(
'SELECT * FROM users WHERE email = $1',
[user.email]
);
if (existingUsers.length > 0) {
console.log(`User ${user.email} already exists, skipping`);
continue;
}

// Insert user into target database


await targetPool.query(
`INSERT INTO users (
name, email, password, username, active, created_at, updated_at,
role, profile_image, page_status, stripe_customer_id
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
[
user.name,
user.email,
user.password, // Assuming passwords are already hashed
user.username,
user.active,
user.created_at,
user.updated_at,
user.role || 'user',
user.profile_image,
user.page_status,
user.stripe_customer_id,
]
);

console.log(`Migrated user: ${user.email}`);


}

console.log('User migration completed successfully');


} catch (error) {
console.error('Error migrating users:', error);
} finally {
// Close database connections
await sourcePool.end();
await targetPool.end();
}
}

migrateUsers();
nical Implementation Guide

ructions for migrating the OnlyLinks PHP application to NextJS using Drizzle ORM. Each phase is broken down into specific im

--typescript --eslint --app --src-dir --import-alias "@/*"

rm/resolvers

windcss husky lint-staged


timestamp, text } from 'drizzle-orm/pg-core';

Null().unique(),
55 }).notNull(),

100 }).unique(),
ers, ({ many }) => ({

s (links, subscriptions, plans, etc.).

h/providers/credentials";
password" }

.password) {
uth-options";

resolvers/zod";
st be at least 6 characters"),

: { errors } } = useForm({

col items-center justify-center px-4">


ce-y-8 rounded-lg border p-6 shadow-md">

Login to OnlyLinks</h1>
Enter your credentials to access your account</p>
d-50 p-4 text-sm text-red-700">

bmit)} className="mt-8 space-y-6">

="block text-sm font-medium">

unded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"

-red-600">{errors.email.message}</p>

ame="block text-sm font-medium">

unded-md border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-blue-500"

-red-600">{errors.password.message}</p>

stify-between">
der-gray-300 text-blue-600 focus:ring-blue-500"

assName="ml-2 block text-sm text-gray-900">

ssName="font-medium text-blue-600 hover:text-blue-500">

er rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-blue-700 focus:outline-none fo

ont-medium text-blue-600 hover:text-blue-500">


least 2 characters"),

st be at least 6 characters"),
ust be at least 3 characters"),

e } = body;

ery.users.findFirst({

db.query.users.findFirst({
ash(password, 10);

uth-options";
authOptions);

data or admins to access any data


ion.user.role !== "admin") {

rd } = user;
authOptions);

data or admins to update any data


ion.user.role !== "admin") {

rd } = updatedUser[0];
"/login", request.url));

"/admin") && token.role !== "admin") {


"/dashboard", request.url));
required" },

mpare(password, user.password);
uccess: true });
dminToken, {

roduction",

uth-options";

authOptions);
mage/png", "image/gif"];

e.replace(/\s/g, "-")}`;
ublic", "uploads");
uth-options";

hboard/Sidebar";
hboard/Header";

ayout({ children }) {
uthOptions);

erflow-hidden">

auto p-4 md:p-6">{children}</main>


rink-0 bg-white shadow-md md:flex md:flex-col">
r justify-center border-b">
text-xl font-bold">OnlyLinks</Link>

ounded-md px-4 py-2 text-sm font-medium ${


r rounded-md px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"

useState(false);

nter justify-between border-b bg-white px-4 md:px-6">


ext-gray-500 hover:bg-gray-100 md:hidden">

nter space-x-4">
text-gray-500 hover:bg-gray-100">
rofileOpen)}
e-x-2 rounded-md p-2 text-gray-700 hover:bg-gray-100"

verflow-hidden rounded-full bg-gray-200">

ont-medium md:block">

mt-2 w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">

sm text-gray-700 hover:bg-gray-100"

sm text-gray-700 hover:bg-gray-100"

2 text-left text-sm text-gray-700 hover:bg-gray-100"


stamp, integer, boolean } from 'drizzle-orm/pg-core';

nces(() => users.id),

s, ({ one }) => ({
uth-options";

authOptions);

authOptions);
ery.links.findFirst({

? highestPositionLink.position + 1 : 0;

status: 201 });


ove } from "lucide-react";
aggable } from "react-beautiful-dnd";

ult.source.index, 1);
reorderedItem);

index) => ({
lt.destination.index];

destination.index }),

elete this link?")) return;


er justify-center">

pin rounded-full border-4 border-gray-300 border-t-blue-600 mx-auto"></div>


Loading links...</p>

er justify-center">

lue-600 px-4 py-2 text-white hover:bg-blue-700"

tify-between">
our Links</h1>

ed-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700"


y-50 p-8 text-center">
don't have any links yet.</p>

lue-600 px-4 py-2 text-white hover:bg-blue-700"

eDragEnd}>

rder p-4 ${

enter justify-between">

move text-gray-400 hover:text-gray-600"


dium">{link.title}</h3>

enter text-sm text-gray-500 hover:text-blue-600"

="ml-1 h-3 w-3" />

center space-x-2">

ext-sm text-gray-500">

e inline-flex cursor-pointer items-center">

nk.id, link.active)

6 w-11 rounded-full bg-gray-200 after:absolute after:left-[2px] after:top-[2px] after:h-5 after:w-5 after:rounded-full after:b

h(`/links/${link.id}`)}
p-2 text-gray-500 hover:bg-gray-100 hover:text-blue-600"

teLink(link.id)}
p-2 text-gray-500 hover:bg-gray-100 hover:text-red-600"
p, varchar, text } from 'drizzle-orm/pg-core';

ces(() => links.id),


nces(() => users.id),

(linkClicks, ({ one }) => ({


nces(() => users.id),

ns(pageViews, ({ one }) => ({

b/schema/analytics";
hema/links";

m "@/lib/utils/geo";
yAndCity(ipAddress);

agent") || "",

ma/analytics";

utils/geo";
yAndCity(ipAddress);

agent") || "",

x-forwarded-for");

velopment
ce like ipinfo.io or maxmind

nfo.io/${ip}/json?token=${process.env.IPINFO_TOKEN}`);

ger, boolean, jsonb } from 'drizzle-orm/pg-core';

es(() => users.id),

hemes, ({ one, many }) => ({


uth-options";

authOptions);

hemes.findMany({

te themes created by them


mes.findMany({

on.user.id))
authOptions);

mes).values({

], { status: 201 });

uth-options";
(authOptions);

seInt(session.user.id)) {

authOptions);
n.user.id) && session.user.role !== "admin") {

e.description,

authOptions);
n.user.id) && session.user.role !== "admin") {
nv.STRIPE_SECRET_KEY, {
t API version

timestamp, boolean, text, decimal } from 'drizzle-orm/pg-core';

e: 2 }).notNull(),
notNull(), // monthly, yearly
ength: 255 }),

criptions', {

nces(() => users.id),


nces(() => plans.id),
mer_id', { length: 255 }),
cription_id', { length: 255 }),
Null(), // active, canceled, past_due
period_start'),
eriod_end'),
period_end').default(false),
ns, ({ many }) => ({

tions(subscriptions, ({ one }) => ({

uth-options";

ubscriptions";

authOptions);
eckout.sessions.create({

LIC_APP_URL}/dashboard?checkout=success`,
C_APP_URL}/plans?checkout=canceled`,

tSession.url });

hema/subscriptions";
cation failed:", error);
bscription);

ckout session");

bscriptions.retrieve(

scription.current_period_start * 1000),
cription.current_period_end * 1000),
criptions.findFirst({
onId, subscriptionId),

subscriptionId);

bscriptions.retrieve(

bscription.current_period_start * 1000),
scription.current_period_end * 1000),

onId, subscriptionId));
criptions.findFirst({
onId, subscriptionId),

subscriptionId);

onId, subscriptionId));

d(subscription) {

bscriptions.findFirst({
onId, subscription.id),

subscription.id);

tion.current_period_start * 1000),
on.current_period_end * 1000),
_at_period_end,

onId, subscription.id));

(subscription) {

bscriptions.findFirst({
onId, subscription.id),
subscription.id);

onId, subscription.id));

eriod (3 days)

uth-options";

ts/admin/AdminSidebar";
ts/admin/AdminHeader";

t({ children }) {
uthOptions);
erflow-hidden">

auto p-4 md:p-6">{children}</main>


rink-0 bg-white shadow-md md:flex md:flex-col">
r justify-center border-b">
Name="text-xl font-bold">

ounded-md px-4 py-2 text-sm font-medium ${


r rounded-md px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-100"

useState(false);

nter justify-between border-b bg-white px-4 md:px-6">


ext-gray-500 hover:bg-gray-100 md:hidden">

nter space-x-4">
text-gray-500 hover:bg-gray-100">

rofileOpen)}
e-x-2 rounded-md p-2 text-gray-700 hover:bg-gray-100"
verflow-hidden rounded-full bg-gray-200">

ont-medium md:block">

mt-2 w-48 rounded-md bg-white py-1 shadow-lg ring-1 ring-black ring-opacity-5">

sm text-gray-700 hover:bg-gray-100"

sm text-gray-700 hover:bg-gray-100"

2 text-left text-sm text-gray-700 hover:bg-gray-100"


uth-options";

authOptions);

("page") || "1");
"limit") || "10");

y") || "createdAt";
rtOrder") || "desc";
+ '%'} OR ${users.email} ILIKE ${'%' + search + '%'} OR ${users.username} ILIKE ${'%' + search + '%'}`

ql`COUNT(*)` }).from(users);

+ '%'} OR ${users.email} ILIKE ${'%' + search + '%'} OR ${users.username} ILIKE ${'%' + search + '%'}`
hema/subscriptions";

uth-options";

authOptions);

rd } = user;

scriptions.findFirst({
nt(params.id)),
nt: sql`COUNT(*)` })

authOptions);

rs.findFirst({
ash(updateData.password, 10);

rd } = updatedUser[0];

authOptions);

rs.findFirst({
@testing-library/jest-dom jest-environment-jsdom
mJestConfig);

brary/react';

eInTheDocument();

ary Button</Button>);
y Button');

lick me</Button>);

n</Button>);
on')).toBeDisabled();
ValueOnce(null); // No existing user
eBeenCalledTimes(2); // Check email and username
With('password123', 10);

getData());

sts', async () => {

ValueOnce({
C_APP_URL;

nks - Manage Your Links',


manage your links in one place with OnlyLinks.',
ink management, social media',

Links - Manage Your Links',


manage your links in one place with OnlyLinks.',

image.jpg`,
Links - Manage Your Links',
manage your links in one place with OnlyLinks.',
og-image.jpg`],

C_APP_URL;

.findMany({
APP_URL}/sitemap.xml`,
container);

', async () => {


cessible Button</Button>);

en loading', async () => {


Button isLoading>Loading</Button>);
ibute('aria-busy', 'true');
links.vercel.app"

hema/subscriptions";

rized access
("authorization");
v.CRON_SECRET}`) {
authorized" }, { status: 401 });

uery.subscriptions.findMany({
period (3 days)

acePeriod) {

a/sessions";

rized access
("authorization");
v.CRON_SECRET}`) {
authorized" }, { status: 401 });
DATABASE_URL,

cePool.query('SELECT * FROM users');


h} users to migrate`);

getPool.query(
y exists, skipping`);

ctive, created_at, updated_at,


pe_customer_id
8, $9, $10, $11)`,

ds are already hashed

uccessfully');
is broken down into specific implementation steps with code examples.
none focus:ring-blue-500"

none focus:ring-blue-500"
blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:opacity-50"
w-5 after:rounded-full after:border after:border-gray-300 after:bg-white after:transition-all after:content-[''] peer-checked
fter:content-[''] peer-checked:bg-blue-600 peer-checked:after:translate-x-full peer-checked:after:border-white peer-focus:
after:border-white peer-focus:outline-none"></div>

You might also like