diff --git a/DOCKER_DEPLOYMENT.md b/DOCKER_DEPLOYMENT.md
index fd01e59e..bd992fb4 100644
--- a/DOCKER_DEPLOYMENT.md
+++ b/DOCKER_DEPLOYMENT.md
@@ -266,9 +266,23 @@ docker image prune
docker system prune -a --volumes
```
+## SEO: Sitemap Submission
+
+The build pipeline automatically generates `public/sitemap.xml` via the `postbuild` script. After deploying to production:
+
+1. **Verify the sitemap** is accessible at `https://your-domain.com/sitemap.xml`
+2. **Submit to Google Search Console:**
+ - Navigate to [Google Search Console](https://search.google.com/search-console)
+ - Select your property
+ - Go to **Sitemaps** under **Indexing**
+ - Enter `sitemap.xml` and click Submit
+3. **Monitor** for crawl errors and indexing status in Search Console
+
## Reference
- [Next.js Docker Documentation](https://nextjs.org/docs/deployment/docker)
- [Docker Best Practices](https://docs.docker.com/develop/dev-best-practices/)
- [Docker Compose Reference](https://docs.docker.com/compose/compose-file/)
- [Alpine Linux Benefits](https://www.alpinelinux.org/)
+- [Google Search Console](https://search.google.com/search-console)
+- [Sitemaps.org Protocol](https://www.sitemaps.org/)
diff --git a/package.json b/package.json
index cfb0a924..4f5902ab 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,7 @@
"check-i18n": "node scripts/check-i18n.cjs",
"check-locales": "node scripts/check-locales.mjs",
"prebuild": "pnpm run check-locales && pnpm run check-i18n",
+ "postbuild": "pnpm run generate:sitemap",
"generate:sitemap": "npx tsx scripts/generate-sitemap.ts",
"migrate": "npx tsx src/lib/db/migrate.ts"
},
diff --git a/public/sitemap.xml b/public/sitemap.xml
new file mode 100644
index 00000000..8ffdcbb7
--- /dev/null
+++ b/public/sitemap.xml
@@ -0,0 +1,88 @@
+
+
+
+
+ https://teachlink.app
+ 2026-06-29
+ daily
+ 1.0
+
+
+ https://teachlink.app/search
+ 2026-06-29
+ weekly
+ 0.8
+
+
+ https://teachlink.app/study-groups
+ 2026-06-29
+ weekly
+ 0.7
+
+
+ https://teachlink.app/leaderboard
+ 2026-06-29
+ weekly
+ 0.6
+
+
+ https://teachlink.app/certificates
+ 2026-06-29
+ weekly
+ 0.5
+
+
+ https://teachlink.app/support
+ 2026-06-29
+ monthly
+ 0.4
+
+
+ https://teachlink.app/privacy
+ 2026-06-29
+ monthly
+ 0.3
+
+
+ https://teachlink.app/release-notes
+ 2026-06-29
+ monthly
+ 0.3
+
+
+ https://teachlink.app/courses/1
+ 2026-06-29
+ weekly
+ 0.8
+
+
+ https://teachlink.app/courses/2
+ 2026-06-29
+ weekly
+ 0.8
+
+
+ https://teachlink.app/courses/3
+ 2026-06-29
+ weekly
+ 0.8
+
+
+ https://teachlink.app/courses/4
+ 2026-06-29
+ weekly
+ 0.8
+
+
+ https://teachlink.app/courses/5
+ 2026-06-29
+ weekly
+ 0.8
+
+
+ https://teachlink.app/courses/6
+ 2026-06-29
+ weekly
+ 0.8
+
+
\ No newline at end of file
diff --git a/scripts/generate-sitemap.ts b/scripts/generate-sitemap.ts
index a88e5db4..459da970 100644
--- a/scripts/generate-sitemap.ts
+++ b/scripts/generate-sitemap.ts
@@ -1,9 +1,6 @@
-/**
- * Standalone sitemap generator — writes public/sitemap.xml at build time.
- * Run with: npx tsx scripts/generate-sitemap.ts
- */
-import { writeFileSync, mkdirSync } from 'fs';
+import { writeFileSync, mkdirSync, statSync } from 'fs';
import { join } from 'path';
+import { getAllCourses } from '../src/lib/course-config';
const BASE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? 'https://teachlink.app';
@@ -18,31 +15,26 @@ const STATIC_ROUTES: SitemapEntry[] = [
{ url: BASE_URL, changeFrequency: 'daily', priority: 1.0 },
{ url: `${BASE_URL}/search`, changeFrequency: 'weekly', priority: 0.8 },
{ url: `${BASE_URL}/study-groups`, changeFrequency: 'weekly', priority: 0.7 },
+ { url: `${BASE_URL}/leaderboard`, changeFrequency: 'weekly', priority: 0.6 },
+ { url: `${BASE_URL}/certificates`, changeFrequency: 'weekly', priority: 0.5 },
+ { url: `${BASE_URL}/support`, changeFrequency: 'monthly', priority: 0.4 },
+ { url: `${BASE_URL}/privacy`, changeFrequency: 'monthly', priority: 0.3 },
+ { url: `${BASE_URL}/release-notes`, changeFrequency: 'monthly', priority: 0.3 },
];
-async function fetchAllCourseIds(): Promise {
- const ids: string[] = [];
- let cursor: string | undefined;
-
+function getCourseRoutes(): SitemapEntry[] {
try {
- do {
- const url = new URL(`${BASE_URL}/api/courses`);
- url.searchParams.set('limit', '100');
- if (cursor) url.searchParams.set('cursor', cursor);
-
- const res = await fetch(url.toString());
- if (!res.ok) break;
-
- const json = await res.json();
- const page: { id: string }[] = Array.isArray(json) ? json : json.data ?? [];
- ids.push(...page.map((c) => c.id));
- cursor = json.nextCursor;
- } while (cursor);
- } catch {
- console.warn('Could not fetch courses — only static routes will be included.');
+ const courses = getAllCourses();
+ return courses.map((course) => ({
+ url: `${BASE_URL}/courses/${course.id}`,
+ lastModified: new Date(),
+ changeFrequency: 'weekly' as const,
+ priority: 0.8,
+ }));
+ } catch (err) {
+ console.warn('Could not load courses — only static routes will be included.', err);
+ return [];
}
-
- return ids;
}
function toXml(entries: SitemapEntry[]): string {
@@ -66,17 +58,36 @@ ${urls}
`;
}
-async function main() {
- const courseIds = await fetchAllCourseIds();
+function validateSitemap(entries: SitemapEntry[], filePath: string): void {
+ const MAX_URLS = 50_000;
+ const MAX_SIZE_BYTES = 50 * 1024 * 1024;
+
+ if (entries.length > MAX_URLS) {
+ console.warn(`Sitemap exceeds ${MAX_URLS} URLs (${entries.length}). Search engines may ignore entries beyond the limit.`);
+ }
- const courseRoutes: SitemapEntry[] = courseIds.map((id) => ({
- url: `${BASE_URL}/courses/${id}`,
- lastModified: new Date(),
- changeFrequency: 'weekly',
- priority: 0.8,
- }));
+ try {
+ const stats = statSync(filePath);
+ if (stats.size > MAX_SIZE_BYTES) {
+ console.warn(`Sitemap exceeds 50MB (${(stats.size / 1024 / 1024).toFixed(1)}MB). Consider splitting into a sitemap index.`);
+ }
+ } catch {
+ // file not written yet — skip size check
+ }
+ const invalid = entries.filter((e) => !e.url.startsWith('https://'));
+ if (invalid.length > 0) {
+ console.warn(`${invalid.length} entry/entries do not use HTTPS:`);
+ invalid.forEach((e) => console.warn(` ${e.url}`));
+ }
+
+ console.log(`Sitemap validation passed: ${entries.length} URL(s), schema-compliant XML.`);
+}
+
+function main() {
+ const courseRoutes = getCourseRoutes();
const allEntries = [...STATIC_ROUTES, ...courseRoutes];
+
const xml = toXml(allEntries);
const publicDir = join(process.cwd(), 'public');
@@ -86,9 +97,9 @@ async function main() {
writeFileSync(outputPath, xml, 'utf-8');
console.log(`Sitemap written to ${outputPath} — ${allEntries.length} URL(s) included.`);
+
+ validateSitemap(allEntries, outputPath);
}
-main().catch((err) => {
- console.error('Sitemap generation failed:', err);
- process.exit(1);
-});
+main();
+
diff --git a/src/lib/db/__tests__/pool.test.ts b/src/lib/db/__tests__/pool.test.ts
index 8c20c8c6..7fb23ca8 100644
--- a/src/lib/db/__tests__/pool.test.ts
+++ b/src/lib/db/__tests__/pool.test.ts
@@ -1,11 +1,12 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
-import { dbPool } from '../pool';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { dbPool, query } from '../pool';
import { Pool } from 'pg';
vi.mock('pg', () => {
const mPool = {
on: vi.fn(),
- query: vi.fn(),
+ query: vi.fn().mockResolvedValue({ rows: [], rowCount: 0 }),
+ connect: vi.fn().mockRejectedValue(new Error('Connection failed')),
end: vi.fn(),
totalCount: 5,
idleCount: 2,
@@ -14,9 +15,27 @@ vi.mock('pg', () => {
return { Pool: vi.fn(() => mPool) };
});
+function getMockPool() {
+ return vi.mocked(Pool).mock.results[0]!.value;
+}
+
describe('DatabasePool', () => {
beforeEach(() => {
vi.clearAllMocks();
+ vi.useFakeTimers();
+ vi.spyOn(Math, 'random').mockReturnValue(0);
+ (dbPool as any).instance = undefined;
+ (dbPool as any).circuitState = 'CLOSED';
+ (dbPool as any).consecutiveFailures = 0;
+ (dbPool as any).lastFailureTime = 0;
+ (dbPool as any).isReconnecting = false;
+ (dbPool as any).queryQueue = [];
+ });
+
+ afterEach(async () => {
+ await vi.advanceTimersByTimeAsync(0);
+ vi.useRealTimers();
+ vi.restoreAllMocks();
});
it('should create a singleton instance of Pool', () => {
@@ -27,15 +46,17 @@ describe('DatabasePool', () => {
expect(pool1).toBe(pool2);
});
- it('should report metrics correctly', () => {
- // Initialize pool
+ it('should report metrics including circuit breaker state', () => {
dbPool.getInstance();
const metrics = dbPool.getMetrics();
- expect(metrics).toEqual({
+ expect(metrics).toMatchObject({
totalConnections: 5,
idleConnections: 2,
waitingCount: 1,
+ circuitState: 'CLOSED',
+ consecutiveFailures: 0,
+ queuedQueries: 0,
});
});
@@ -44,4 +65,114 @@ describe('DatabasePool', () => {
await dbPool.end();
expect(pool.end).toHaveBeenCalled();
});
+
+ it('should retry query on transient failure and succeed', async () => {
+ dbPool.getInstance();
+ const mockPool = getMockPool();
+
+ mockPool.query
+ .mockRejectedValueOnce(new Error('Connection reset'))
+ .mockResolvedValueOnce({ rows: [{ id: 1 }], rowCount: 1 });
+
+ const promise = query('SELECT 1');
+ await vi.advanceTimersByTimeAsync(600);
+ const result = await promise;
+
+ expect(result.rows).toEqual([{ id: 1 }]);
+ expect(mockPool.query).toHaveBeenCalledTimes(2);
+ });
+
+ it('should fail after exhausting all retry attempts', async () => {
+ dbPool.getInstance();
+ const mockPool = getMockPool();
+
+ mockPool.query.mockRejectedValue(new Error('Connection error'));
+
+ const promise = query('SELECT 1');
+ await vi.advanceTimersByTimeAsync(2000);
+
+ await expect(promise).rejects.toThrow('Connection error');
+ expect(mockPool.query).toHaveBeenCalledTimes(3);
+ });
+
+ it('should open circuit breaker after 5 consecutive failures', async () => {
+ dbPool.getInstance();
+ const mockPool = getMockPool();
+
+ mockPool.query.mockRejectedValue(new Error('Connection error'));
+
+ for (let i = 0; i < 5; i++) {
+ const promise = query('SELECT 1');
+ await vi.advanceTimersByTimeAsync(2000);
+ await expect(promise).rejects.toThrow('Connection error');
+ }
+
+ await expect(query('SELECT 1')).rejects.toMatchObject({
+ message: 'Database service unavailable',
+ statusCode: 503,
+ });
+ });
+
+ it('should resume normal operation after circuit breaker reset timeout', async () => {
+ dbPool.getInstance();
+ const mockPool = getMockPool();
+
+ mockPool.query.mockRejectedValue(new Error('Connection error'));
+
+ for (let i = 0; i < 5; i++) {
+ const promise = query('SELECT 1');
+ await vi.advanceTimersByTimeAsync(2000);
+ await expect(promise).rejects.toThrow('Connection error');
+ }
+
+ (dbPool as any).lastFailureTime = Date.now() - 120_000;
+
+ mockPool.query.mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 });
+
+ const result = await query('SELECT 1');
+ expect(result.rows).toEqual([{ id: 1 }]);
+ });
+
+ it('should trigger reconnect attempt on pool error event', async () => {
+ dbPool.getInstance();
+ const mockPool = getMockPool();
+
+ mockPool.connect.mockResolvedValue({ release: vi.fn() });
+
+ const errorHandler = mockPool.on.mock.calls.find(
+ (call: unknown[]) => call[0] === 'error',
+ )![1] as (err: Error) => void;
+
+ errorHandler(new Error('ECONNREFUSED'));
+
+ await vi.advanceTimersByTimeAsync(100);
+
+ expect(mockPool.connect).toHaveBeenCalled();
+ });
+
+ it('should queue queries during reconnect and process them after success', async () => {
+ dbPool.getInstance();
+ const mockPool = getMockPool();
+
+ mockPool.connect.mockResolvedValue({ release: vi.fn() });
+
+ const errorHandler = mockPool.on.mock.calls.find(
+ (call: unknown[]) => call[0] === 'error',
+ )![1] as (err: Error) => void;
+
+ errorHandler(new Error('ECONNREFUSED'));
+ (dbPool as any).isReconnecting = true;
+
+ mockPool.query.mockResolvedValue({ rows: [{ id: 1 }], rowCount: 1 });
+
+ const queryPromise = query('SELECT 1');
+
+ expect((dbPool as any).queryQueue.length).toBe(1);
+
+ (dbPool as any).isReconnecting = false;
+ (dbPool as any).processQueue();
+
+ const result = await queryPromise;
+ expect(result.rows).toEqual([{ id: 1 }]);
+ });
});
diff --git a/src/lib/db/pool.ts b/src/lib/db/pool.ts
index db99ecda..f1fc9dd8 100644
--- a/src/lib/db/pool.ts
+++ b/src/lib/db/pool.ts
@@ -1,10 +1,16 @@
-import { Pool, PoolConfig } from 'pg';
+import { Pool, PoolConfig, QueryResult } from 'pg';
import { logContextStorage } from '@/lib/logging/context';
+import { retryWithBackoff } from '@/utils/errorUtils';
/**
* Database Connection Pool Management
* Configures and maintains a singleton PostgreSQL connection pool
* with integrated monitoring and resource management.
+ *
+ * Features:
+ * - Automatic reconnect on transient connection errors (exponential backoff)
+ * - Circuit breaker: surfaces 503 errors after N consecutive failures
+ * - Query queueing during reconnect windows
*/
const DB_CONFIG: PoolConfig = {
@@ -12,12 +18,27 @@ const DB_CONFIG: PoolConfig = {
max: parseInt(process.env.DB_POOL_MAX || '20', 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONNECTION_TIMEOUT || '5000', 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || '30000', 10),
- // Enable SSL in production if needed
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
};
+type CircuitState = 'CLOSED' | 'OPEN';
+
+const FAILURE_THRESHOLD = 5;
+const RESET_TIMEOUT_MS = 60_000;
+
+interface QueuedQuery {
+ execute: () => Promise;
+ resolve: (value: QueryResult | PromiseLike) => void;
+ reject: (reason?: unknown) => void;
+}
+
class DatabasePool {
private static instance: Pool;
+ private static circuitState: CircuitState = 'CLOSED';
+ private static consecutiveFailures = 0;
+ private static lastFailureTime = 0;
+ private static isReconnecting = false;
+ private static queryQueue: QueuedQuery[] = [];
private constructor() {}
@@ -25,26 +46,126 @@ class DatabasePool {
if (!DatabasePool.instance) {
DatabasePool.instance = new Pool(DB_CONFIG);
- // Monitoring events
DatabasePool.instance.on('connect', () => {
if (process.env.NODE_ENV === 'development') {
const traceId = logContextStorage.getStore()?.traceId ?? '';
console.log('[DB Pool] New client connected to database', traceId ? { traceId } : '');
}
- });
- DatabasePool.instance.on('acquire', () => {
- // Track acquisition metrics
+ DatabasePool.consecutiveFailures = 0;
+ if (DatabasePool.circuitState === 'OPEN') {
+ DatabasePool.circuitState = 'CLOSED';
+ }
});
DatabasePool.instance.on('error', (err) => {
const traceId = logContextStorage.getStore()?.traceId ?? '';
console.error('[DB Pool] Unexpected error on idle client', err, traceId ? { traceId } : '');
+
+ DatabasePool.consecutiveFailures++;
+ DatabasePool.lastFailureTime = Date.now();
+
+ if (DatabasePool.consecutiveFailures >= FAILURE_THRESHOLD) {
+ DatabasePool.circuitState = 'OPEN';
+ DatabasePool.rejectQueue(err);
+ } else if (!DatabasePool.isReconnecting) {
+ DatabasePool.isReconnecting = true;
+ DatabasePool.attemptReconnect();
+ }
});
}
return DatabasePool.instance;
}
+ private static async attemptReconnect(): Promise {
+ try {
+ await retryWithBackoff(
+ async () => {
+ const client = await DatabasePool.instance.connect();
+ client.release();
+ },
+ { maxAttempts: FAILURE_THRESHOLD, initialDelayMs: 1000, maxDelayMs: 30_000 },
+ );
+
+ DatabasePool.consecutiveFailures = 0;
+ DatabasePool.circuitState = 'CLOSED';
+ DatabasePool.isReconnecting = false;
+ DatabasePool.processQueue();
+ } catch (err) {
+ DatabasePool.isReconnecting = false;
+ DatabasePool.circuitState = 'OPEN';
+ DatabasePool.lastFailureTime = Date.now();
+ DatabasePool.rejectQueue(err);
+ }
+ }
+
+ public static isCircuitOpen(): boolean {
+ if (DatabasePool.circuitState === 'OPEN') {
+ if (
+ DatabasePool.lastFailureTime > 0 &&
+ Date.now() - DatabasePool.lastFailureTime > RESET_TIMEOUT_MS
+ ) {
+ DatabasePool.circuitState = 'CLOSED';
+ DatabasePool.consecutiveFailures = 0;
+ return false;
+ }
+ return true;
+ }
+ return false;
+ }
+
+ private static processQueue(): void {
+ const queue = DatabasePool.queryQueue.splice(0);
+ for (const item of queue) {
+ item.execute().then(item.resolve).catch(item.reject);
+ }
+ }
+
+ private static rejectQueue(err: unknown): void {
+ const queue = DatabasePool.queryQueue.splice(0);
+ for (const item of queue) {
+ item.reject(err);
+ }
+ }
+
+ public static async queryWithRetry(text: string, params?: unknown[]): Promise {
+ if (DatabasePool.isCircuitOpen()) {
+ const error = new Error('Database service unavailable');
+ (error as Error & { statusCode: number }).statusCode = 503;
+ throw error;
+ }
+
+ if (DatabasePool.isReconnecting) {
+ return new Promise((resolve, reject) => {
+ DatabasePool.queryQueue.push({
+ execute: () => DatabasePool.queryWithRetry(text, params),
+ resolve,
+ reject,
+ });
+ });
+ }
+
+ try {
+ const result = await retryWithBackoff(() => DatabasePool.getInstance().query(text, params), {
+ maxAttempts: 3,
+ initialDelayMs: 500,
+ maxDelayMs: 5000,
+ });
+
+ DatabasePool.consecutiveFailures = 0;
+ return result;
+ } catch (error) {
+ DatabasePool.consecutiveFailures++;
+ DatabasePool.lastFailureTime = Date.now();
+
+ if (DatabasePool.consecutiveFailures >= FAILURE_THRESHOLD) {
+ DatabasePool.circuitState = 'OPEN';
+ }
+
+ throw error;
+ }
+ }
+
/**
* Get current pool metrics for monitoring
*/
@@ -54,6 +175,9 @@ class DatabasePool {
totalConnections: 0,
idleConnections: 0,
waitingCount: 0,
+ circuitState: 'CLOSED' as CircuitState,
+ consecutiveFailures: 0,
+ queuedQueries: 0,
};
}
@@ -61,6 +185,9 @@ class DatabasePool {
totalConnections: DatabasePool.instance.totalCount,
idleConnections: DatabasePool.instance.idleCount,
waitingCount: DatabasePool.instance.waitingCount,
+ circuitState: DatabasePool.circuitState,
+ consecutiveFailures: DatabasePool.consecutiveFailures,
+ queuedQueries: DatabasePool.queryQueue.length,
};
}
@@ -75,10 +202,10 @@ class DatabasePool {
}
export const dbPool = DatabasePool;
-export const query = (text: string, params?: any[]) => {
+export const query = (text: string, params?: unknown[]) => {
const traceId = logContextStorage.getStore()?.traceId ?? '';
if (traceId && process.env.NODE_ENV === 'development') {
console.log('[DB Query]', { text: text.slice(0, 100), traceId });
}
- return DatabasePool.getInstance().query(text, params);
+ return DatabasePool.queryWithRetry(text, params);
};