From 112d4c9223c81596d80e763b95177af799e65a9a Mon Sep 17 00:00:00 2001 From: Sakariyah Abdulhazeem <150973162+zeemscript@users.noreply.github.com> Date: Sun, 28 Jun 2026 13:02:35 +0100 Subject: [PATCH] feat(api): persist notes, bookmarks, approvals, analytics to PostgreSQL Replaces in-memory Map storage with PostgreSQL database persistence for the notes, bookmarks, approvals, and video-analytics API routes. Changes: - Add SQL migrations for notes, bookmarks, content_approvals, video_events tables - Create repository layer with parameterized SQL queries - Update route handlers to use repositories instead of Maps - Remove edge runtime (using Node.js runtime for pg driver compatibility) - Add integration tests with mocked database queries Closes #749 --- src/app/api/approvals/__tests__/route.test.ts | 306 ++++++++++++++++++ src/app/api/approvals/route.ts | 134 ++++---- src/app/api/bookmarks/__tests__/route.test.ts | 286 ++++++++++++++++ src/app/api/bookmarks/route.ts | 196 +++++------ src/app/api/notes/__tests__/route.test.ts | 256 +++++++++++++++ src/app/api/notes/route.ts | 190 +++++------ .../video-analytics/__tests__/route.test.ts | 188 +++++++++++ src/app/api/video-analytics/route.ts | 41 +-- .../db/migrations/001_create_notes_table.sql | 18 ++ .../migrations/002_create_bookmarks_table.sql | 19 ++ .../003_create_content_approvals_table.sql | 24 ++ .../004_create_video_events_table.sql | 20 ++ .../db/repositories/approvals.repository.ts | 129 ++++++++ .../db/repositories/bookmarks.repository.ts | 101 ++++++ src/lib/db/repositories/notes.repository.ts | 98 ++++++ .../repositories/video-events.repository.ts | 19 ++ 16 files changed, 1749 insertions(+), 276 deletions(-) create mode 100644 src/app/api/approvals/__tests__/route.test.ts create mode 100644 src/app/api/bookmarks/__tests__/route.test.ts create mode 100644 src/app/api/notes/__tests__/route.test.ts create mode 100644 src/app/api/video-analytics/__tests__/route.test.ts create mode 100644 src/lib/db/migrations/001_create_notes_table.sql create mode 100644 src/lib/db/migrations/002_create_bookmarks_table.sql create mode 100644 src/lib/db/migrations/003_create_content_approvals_table.sql create mode 100644 src/lib/db/migrations/004_create_video_events_table.sql create mode 100644 src/lib/db/repositories/approvals.repository.ts create mode 100644 src/lib/db/repositories/bookmarks.repository.ts create mode 100644 src/lib/db/repositories/notes.repository.ts create mode 100644 src/lib/db/repositories/video-events.repository.ts diff --git a/src/app/api/approvals/__tests__/route.test.ts b/src/app/api/approvals/__tests__/route.test.ts new file mode 100644 index 00000000..7a8dd6c6 --- /dev/null +++ b/src/app/api/approvals/__tests__/route.test.ts @@ -0,0 +1,306 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET, POST, PATCH } from '../route'; + +// Mock the repository +vi.mock('@/lib/db/repositories/approvals.repository', () => ({ + findAll: vi.fn(), + findById: vi.fn(), + create: vi.fn(), + review: vi.fn(), +})); + +// Mock rate limiting +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +// Mock audit logging +vi.mock('@/middleware/audit', () => ({ + logAuditMutation: vi.fn(), +})); + +import * as approvalsRepo from '@/lib/db/repositories/approvals.repository'; + +describe('/api/approvals', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET', () => { + it('should return all approvals', async () => { + const mockApprovals = [ + { + id: 'approval-1', + contentId: 'course-1', + contentType: 'COURSE', + title: 'Test Course', + submittedBy: 'user-1', + submittedAt: '2024-01-01T00:00:00.000Z', + status: 'PENDING', + }, + ]; + + vi.mocked(approvalsRepo.findAll).mockResolvedValue(mockApprovals); + + const request = new Request('http://localhost/api/approvals'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(mockApprovals); + expect(approvalsRepo.findAll).toHaveBeenCalledWith(undefined); + }); + + it('should filter approvals by status', async () => { + const mockApprovals = [ + { + id: 'approval-1', + contentId: 'course-1', + contentType: 'COURSE', + title: 'Test Course', + submittedBy: 'user-1', + submittedAt: '2024-01-01T00:00:00.000Z', + status: 'PENDING', + }, + ]; + + vi.mocked(approvalsRepo.findAll).mockResolvedValue(mockApprovals); + + const request = new Request('http://localhost/api/approvals?status=PENDING'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(approvalsRepo.findAll).toHaveBeenCalledWith('PENDING'); + }); + + it('should return 400 for invalid status', async () => { + const request = new Request('http://localhost/api/approvals?status=INVALID'); + const response = await GET(request); + + expect(response.status).toBe(400); + }); + + it('should return 500 on database error', async () => { + vi.mocked(approvalsRepo.findAll).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/approvals'); + const response = await GET(request); + + expect(response.status).toBe(500); + }); + }); + + describe('POST', () => { + it('should create a new approval request', async () => { + const mockApproval = { + id: 'approval-123', + contentId: 'course-1', + contentType: 'COURSE' as const, + title: 'New Course', + submittedBy: 'user-1', + submittedAt: '2024-01-01T00:00:00.000Z', + status: 'PENDING' as const, + }; + + vi.mocked(approvalsRepo.create).mockResolvedValue(mockApproval); + + const request = new Request('http://localhost/api/approvals', { + method: 'POST', + body: JSON.stringify({ + contentId: 'course-1', + contentType: 'COURSE', + title: 'New Course', + submittedBy: 'user-1', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(201); + expect(json.success).toBe(true); + expect(json.data.status).toBe('PENDING'); + }); + + it('should return 400 for missing required fields', async () => { + const request = new Request('http://localhost/api/approvals', { + method: 'POST', + body: JSON.stringify({ + contentId: 'course-1', + // missing contentType, title, submittedBy + }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + }); + + it('should return 500 on database error', async () => { + vi.mocked(approvalsRepo.create).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/approvals', { + method: 'POST', + body: JSON.stringify({ + contentId: 'course-1', + contentType: 'COURSE', + title: 'New Course', + submittedBy: 'user-1', + }), + }); + + const response = await POST(request); + expect(response.status).toBe(500); + }); + }); + + describe('PATCH', () => { + it('should approve a pending approval', async () => { + const existingApproval = { + id: 'approval-123', + contentId: 'course-1', + contentType: 'COURSE' as const, + title: 'Test Course', + submittedBy: 'user-1', + submittedAt: '2024-01-01T00:00:00.000Z', + status: 'PENDING' as const, + }; + + const updatedApproval = { + ...existingApproval, + status: 'APPROVED' as const, + reviewedBy: 'admin-1', + reviewedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(approvalsRepo.findById).mockResolvedValue(existingApproval); + vi.mocked(approvalsRepo.review).mockResolvedValue(updatedApproval); + + const request = new Request('http://localhost/api/approvals', { + method: 'PATCH', + body: JSON.stringify({ + id: 'approval-123', + status: 'APPROVED', + reviewedBy: 'admin-1', + }), + }); + + const response = await PATCH(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.status).toBe('APPROVED'); + }); + + it('should reject a pending approval with note', async () => { + const existingApproval = { + id: 'approval-123', + contentId: 'course-1', + contentType: 'COURSE' as const, + title: 'Test Course', + submittedBy: 'user-1', + submittedAt: '2024-01-01T00:00:00.000Z', + status: 'PENDING' as const, + }; + + const updatedApproval = { + ...existingApproval, + status: 'REJECTED' as const, + reviewedBy: 'admin-1', + reviewedAt: '2024-01-02T00:00:00.000Z', + reviewNote: 'Content does not meet guidelines', + }; + + vi.mocked(approvalsRepo.findById).mockResolvedValue(existingApproval); + vi.mocked(approvalsRepo.review).mockResolvedValue(updatedApproval); + + const request = new Request('http://localhost/api/approvals', { + method: 'PATCH', + body: JSON.stringify({ + id: 'approval-123', + status: 'REJECTED', + reviewedBy: 'admin-1', + reviewNote: 'Content does not meet guidelines', + }), + }); + + const response = await PATCH(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.data.reviewNote).toBe('Content does not meet guidelines'); + }); + + it('should return 404 for non-existent approval', async () => { + vi.mocked(approvalsRepo.findById).mockResolvedValue(null); + + const request = new Request('http://localhost/api/approvals', { + method: 'PATCH', + body: JSON.stringify({ + id: 'non-existent', + status: 'APPROVED', + reviewedBy: 'admin-1', + }), + }); + + const response = await PATCH(request); + const json = await response.json(); + + expect(response.status).toBe(404); + expect(json.message).toBe('Approval not found'); + }); + + it('should return 409 for already reviewed approval', async () => { + const existingApproval = { + id: 'approval-123', + contentId: 'course-1', + contentType: 'COURSE' as const, + title: 'Test Course', + submittedBy: 'user-1', + submittedAt: '2024-01-01T00:00:00.000Z', + status: 'APPROVED' as const, + reviewedBy: 'admin-1', + reviewedAt: '2024-01-02T00:00:00.000Z', + }; + + vi.mocked(approvalsRepo.findById).mockResolvedValue(existingApproval); + + const request = new Request('http://localhost/api/approvals', { + method: 'PATCH', + body: JSON.stringify({ + id: 'approval-123', + status: 'REJECTED', + reviewedBy: 'admin-2', + }), + }); + + const response = await PATCH(request); + const json = await response.json(); + + expect(response.status).toBe(409); + expect(json.message).toBe('Only PENDING approvals can be reviewed'); + }); + + it('should return 500 on database error', async () => { + vi.mocked(approvalsRepo.findById).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/approvals', { + method: 'PATCH', + body: JSON.stringify({ + id: 'approval-123', + status: 'APPROVED', + reviewedBy: 'admin-1', + }), + }); + + const response = await PATCH(request); + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/app/api/approvals/route.ts b/src/app/api/approvals/route.ts index 901e6158..c86f7784 100644 --- a/src/app/api/approvals/route.ts +++ b/src/app/api/approvals/route.ts @@ -4,15 +4,9 @@ import { withRateLimit } from '@/lib/ratelimit'; import { logAuditMutation } from '@/middleware/audit'; import { validateBody, validateQuery } from '@/lib/validation'; import { ApprovalStatus } from '@/types/approvals'; -import type { ApprovalItem, ReviewDecision } from '@/types/api'; +import type { ApprovalItem } from '@/types/api'; -export const runtime = 'edge'; - -// --------------------------------------------------------------------------- -// In-memory store (replace with DB in production) -// --------------------------------------------------------------------------- - -const approvalsStore = new Map(); +import * as approvalsRepo from '@/lib/db/repositories/approvals.repository'; // --------------------------------------------------------------------------- // Schemas @@ -50,12 +44,14 @@ export async function GET(request: Request): Promise { const result = validateQuery(ListQuerySchema, searchParams); if (!result.ok) return addHeaders(result.error); - let items = Array.from(approvalsStore.values()); - if (result.data.status) { - items = items.filter((item) => item.status === result.data.status); + try { + const items = await approvalsRepo.findAll(result.data.status); + return addHeaders(NextResponse.json({ success: true, data: items })); + } catch (error) { + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); } - - return addHeaders(NextResponse.json({ success: true, data: items })); } // --------------------------------------------------------------------------- @@ -69,28 +65,30 @@ export async function POST(request: Request): Promise { const result = validateBody(SubmitSchema, await request.json()); if (!result.ok) return addHeaders(result.error); - const item: ApprovalItem = { - id: `approval-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, - contentId: result.data.contentId, - contentType: result.data.contentType, - title: result.data.title, - submittedBy: result.data.submittedBy, - submittedAt: new Date().toISOString(), - status: ApprovalStatus.PENDING, - }; - - approvalsStore.set(item.id, item); - - const response = addHeaders(NextResponse.json({ success: true, data: item }, { status: 201 })); - logAuditMutation(request, { - action: 'create', - targetType: 'approval', - targetId: item.id, - statusCode: response.status, - metadata: { contentId: item.contentId, contentType: item.contentType }, - }); - - return response; + try { + const item = await approvalsRepo.create({ + id: `approval-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + contentId: result.data.contentId, + contentType: result.data.contentType, + title: result.data.title, + submittedBy: result.data.submittedBy, + }); + + const response = addHeaders(NextResponse.json({ success: true, data: item }, { status: 201 })); + logAuditMutation(request, { + action: 'create', + targetType: 'approval', + targetId: item.id, + statusCode: response.status, + metadata: { contentId: item.contentId, contentType: item.contentType }, + }); + + return response; + } catch (error) { + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); + } } // --------------------------------------------------------------------------- @@ -104,40 +102,44 @@ export async function PATCH(request: Request): Promise { const result = validateBody(ReviewSchema, await request.json()); if (!result.ok) return addHeaders(result.error); - const existing = approvalsStore.get(result.data.id); - if (!existing) { - return addHeaders( - NextResponse.json({ success: false, message: 'Approval not found' }, { status: 404 }), + try { + // Check if approval exists and is in PENDING status + const existing = await approvalsRepo.findById(result.data.id); + if (!existing) { + return addHeaders( + NextResponse.json({ success: false, message: 'Approval not found' }, { status: 404 }), + ); + } + + if (existing.status !== ApprovalStatus.PENDING) { + return addHeaders( + NextResponse.json( + { success: false, message: 'Only PENDING approvals can be reviewed' }, + { status: 409 }, + ), + ); + } + + const updated = await approvalsRepo.review( + result.data.id, + result.data.status, + result.data.reviewedBy, + result.data.reviewNote, ); - } - if (existing.status !== ApprovalStatus.PENDING) { + const response = addHeaders(NextResponse.json({ success: true, data: updated })); + logAuditMutation(request, { + action: 'update', + targetType: 'approval', + targetId: result.data.id, + statusCode: response.status, + metadata: { status: result.data.status, reviewedBy: result.data.reviewedBy }, + }); + + return response; + } catch (error) { return addHeaders( - NextResponse.json( - { success: false, message: 'Only PENDING approvals can be reviewed' }, - { status: 409 }, - ), + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), ); } - - const updated: ApprovalItem = { - ...existing, - status: result.data.status, - reviewedBy: result.data.reviewedBy, - reviewedAt: new Date().toISOString(), - reviewNote: result.data.reviewNote, - }; - - approvalsStore.set(updated.id, updated); - - const response = addHeaders(NextResponse.json({ success: true, data: updated })); - logAuditMutation(request, { - action: 'update', - targetType: 'approval', - targetId: updated.id, - statusCode: response.status, - metadata: { status: updated.status, reviewedBy: updated.reviewedBy }, - }); - - return response; } diff --git a/src/app/api/bookmarks/__tests__/route.test.ts b/src/app/api/bookmarks/__tests__/route.test.ts new file mode 100644 index 00000000..4884ab9b --- /dev/null +++ b/src/app/api/bookmarks/__tests__/route.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET, POST, PATCH, DELETE } from '../route'; + +// Mock the repository +vi.mock('@/lib/db/repositories/bookmarks.repository', () => ({ + findByUserAndLesson: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})); + +// Mock rate limiting +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +// Mock audit logging +vi.mock('@/middleware/audit', () => ({ + logAuditMutation: vi.fn(), +})); + +// Mock edge logging +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +import * as bookmarksRepo from '@/lib/db/repositories/bookmarks.repository'; + +describe('/api/bookmarks', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET', () => { + it('should return bookmarks for a user and lesson', async () => { + const mockBookmarks = [ + { + id: 'bookmark-1', + time: 30.5, + title: 'Important part', + note: 'Review this', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ]; + + vi.mocked(bookmarksRepo.findByUserAndLesson).mockResolvedValue(mockBookmarks); + + const request = new Request('http://localhost/api/bookmarks?lessonId=lesson-1&userId=user-1'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(mockBookmarks); + expect(bookmarksRepo.findByUserAndLesson).toHaveBeenCalledWith('user-1', 'lesson-1'); + }); + + it('should return 400 for missing lessonId', async () => { + const request = new Request('http://localhost/api/bookmarks?userId=user-1'); + const response = await GET(request); + + expect(response.status).toBe(400); + }); + + it('should return empty array when no bookmarks exist', async () => { + vi.mocked(bookmarksRepo.findByUserAndLesson).mockResolvedValue([]); + + const request = new Request('http://localhost/api/bookmarks?lessonId=lesson-1&userId=user-1'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.data).toEqual([]); + }); + + it('should return 500 on database error', async () => { + vi.mocked(bookmarksRepo.findByUserAndLesson).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/bookmarks?lessonId=lesson-1&userId=user-1'); + const response = await GET(request); + + expect(response.status).toBe(500); + }); + }); + + describe('POST', () => { + it('should create a new bookmark', async () => { + const mockBookmark = { + id: 'bookmark-123', + time: 45.0, + title: 'Key concept', + note: 'Remember this', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(bookmarksRepo.create).mockResolvedValue(mockBookmark); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + bookmark: { time: 45.0, title: 'Key concept', note: 'Remember this' }, + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.title).toBe('Key concept'); + }); + + it('should create a bookmark without optional note', async () => { + const mockBookmark = { + id: 'bookmark-123', + time: 45.0, + title: 'Key concept', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(bookmarksRepo.create).mockResolvedValue(mockBookmark); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + bookmark: { time: 45.0, title: 'Key concept' }, + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + }); + + it('should return 400 for invalid payload', async () => { + const request = new Request('http://localhost/api/bookmarks', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + bookmark: { time: 45.0 }, // missing title + }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + }); + + it('should return 500 on database error', async () => { + vi.mocked(bookmarksRepo.create).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + bookmark: { time: 45.0, title: 'Key concept' }, + }), + }); + + const response = await POST(request); + expect(response.status).toBe(500); + }); + }); + + describe('PATCH', () => { + it('should update an existing bookmark', async () => { + vi.mocked(bookmarksRepo.update).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'PATCH', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'bookmark-123', + title: 'Updated title', + note: 'Updated note', + }), + }); + + const response = await PATCH(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(bookmarksRepo.update).toHaveBeenCalledWith( + 'bookmark-123', + 'user-1', + 'lesson-1', + { title: 'Updated title', note: 'Updated note', time: undefined }, + ); + }); + + it('should update bookmark with new time', async () => { + vi.mocked(bookmarksRepo.update).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'PATCH', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'bookmark-123', + title: 'Updated title', + time: 60.0, + }), + }); + + const response = await PATCH(request); + + expect(response.status).toBe(200); + expect(bookmarksRepo.update).toHaveBeenCalledWith( + 'bookmark-123', + 'user-1', + 'lesson-1', + { title: 'Updated title', note: undefined, time: 60.0 }, + ); + }); + + it('should return 500 on database error', async () => { + vi.mocked(bookmarksRepo.update).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'PATCH', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'bookmark-123', + title: 'Updated title', + }), + }); + + const response = await PATCH(request); + expect(response.status).toBe(500); + }); + }); + + describe('DELETE', () => { + it('should delete a bookmark', async () => { + vi.mocked(bookmarksRepo.remove).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'DELETE', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'bookmark-123', + }), + }); + + const response = await DELETE(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(bookmarksRepo.remove).toHaveBeenCalledWith('bookmark-123', 'user-1', 'lesson-1'); + }); + + it('should return 500 on database error', async () => { + vi.mocked(bookmarksRepo.remove).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/bookmarks', { + method: 'DELETE', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'bookmark-123', + }), + }); + + const response = await DELETE(request); + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/app/api/bookmarks/route.ts b/src/app/api/bookmarks/route.ts index 0ea8031b..86631c18 100644 --- a/src/app/api/bookmarks/route.ts +++ b/src/app/api/bookmarks/route.ts @@ -17,14 +17,7 @@ import type { BookmarksSuccessResponseDTO, } from '@/types/api/bookmarks.dto'; -export const runtime = 'edge'; - -const bookmarksStore = new Map(); - -const keyFor = (userId: string | undefined, lessonId: string): string => { - const safeUserId = encodeURIComponent(userId ?? 'anon'); - return `${safeUserId}::${encodeURIComponent(lessonId)}`; -}; +import * as bookmarksRepo from '@/lib/db/repositories/bookmarks.repository'; // --------------------------------------------------------------------------- // GET /api/bookmarks @@ -40,12 +33,26 @@ export async function GET(request: Request): Promise b.id !== persisted.id)]); - - const response = addHeaders( - NextResponse.json({ - success: true, - data: persisted, - }), - ); - - logAuditMutation(request, { - action: 'create', - targetType: 'video-bookmark', - targetId: persisted.id, - statusCode: response.status, - metadata: { lessonId: result.data.lessonId }, - }); - - return response; + try { + const bookmarkData = { + id: result.data.bookmark.id ?? `bookmark-${Date.now()}`, + time: result.data.bookmark.time, + title: result.data.bookmark.title, + note: result.data.bookmark.note, + }; + + const persisted = await bookmarksRepo.create( + result.data.userId, + result.data.lessonId, + bookmarkData, + ); + + const response = addHeaders( + NextResponse.json({ + success: true, + data: persisted, + }), + ); + + logAuditMutation(request, { + action: 'create', + targetType: 'video-bookmark', + targetId: persisted.id, + statusCode: response.status, + metadata: { lessonId: result.data.lessonId }, + }); + + return response; + } catch (error) { + edgeLog('error', '/api/bookmarks', 'Database error in POST', { error }); + return addHeaders( + NextResponse.json( + { success: false, message: 'Internal server error' } as unknown as BookmarkResponseDTO, + { status: 500 }, + ), + ) as NextResponse; + } } // --------------------------------------------------------------------------- @@ -108,35 +122,30 @@ export async function PATCH(request: Request): Promise - b.id === result.data.id - ? { - ...b, - title: result.data.title, - note: result.data.note, - time: result.data.time ?? b.time, - updatedAt: now, - } - : b, - ), - ); - - const response = addHeaders(NextResponse.json({ success: true })); - logAuditMutation(request, { - action: 'update', - targetType: 'video-bookmark', - targetId: result.data.id, - statusCode: response.status, - metadata: { lessonId: result.data.lessonId }, - }); - - return response; + try { + await bookmarksRepo.update( + result.data.id, + result.data.userId, + result.data.lessonId, + { title: result.data.title, note: result.data.note, time: result.data.time }, + ); + + const response = addHeaders(NextResponse.json({ success: true })); + logAuditMutation(request, { + action: 'update', + targetType: 'video-bookmark', + targetId: result.data.id, + statusCode: response.status, + metadata: { lessonId: result.data.lessonId }, + }); + + return response; + } catch (error) { + edgeLog('error', '/api/bookmarks', 'Database error in PATCH', { error }); + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); + } } // --------------------------------------------------------------------------- @@ -152,22 +161,23 @@ export async function DELETE(request: Request): Promise b.id !== result.data.id), - ); - - const response = addHeaders(NextResponse.json({ success: true })); - logAuditMutation(request, { - action: 'delete', - targetType: 'video-bookmark', - targetId: result.data.id, - statusCode: response.status, - metadata: { lessonId: result.data.lessonId }, - }); - - return response; + try { + await bookmarksRepo.remove(result.data.id, result.data.userId, result.data.lessonId); + + const response = addHeaders(NextResponse.json({ success: true })); + logAuditMutation(request, { + action: 'delete', + targetType: 'video-bookmark', + targetId: result.data.id, + statusCode: response.status, + metadata: { lessonId: result.data.lessonId }, + }); + + return response; + } catch (error) { + edgeLog('error', '/api/bookmarks', 'Database error in DELETE', { error }); + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); + } } diff --git a/src/app/api/notes/__tests__/route.test.ts b/src/app/api/notes/__tests__/route.test.ts new file mode 100644 index 00000000..6cac65f5 --- /dev/null +++ b/src/app/api/notes/__tests__/route.test.ts @@ -0,0 +1,256 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GET, POST, PATCH, DELETE } from '../route'; + +// Mock the repository +vi.mock('@/lib/db/repositories/notes.repository', () => ({ + findByUserAndLesson: vi.fn(), + create: vi.fn(), + update: vi.fn(), + remove: vi.fn(), +})); + +// Mock rate limiting +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +// Mock audit logging +vi.mock('@/middleware/audit', () => ({ + logAuditMutation: vi.fn(), +})); + +// Mock edge logging +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +import * as notesRepo from '@/lib/db/repositories/notes.repository'; + +describe('/api/notes', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('GET', () => { + it('should return notes for a user and lesson', async () => { + const mockNotes = [ + { + id: 'note-1', + time: 30.5, + text: 'Test note', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }, + ]; + + vi.mocked(notesRepo.findByUserAndLesson).mockResolvedValue(mockNotes); + + const request = new Request('http://localhost/api/notes?lessonId=lesson-1&userId=user-1'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data).toEqual(mockNotes); + expect(notesRepo.findByUserAndLesson).toHaveBeenCalledWith('user-1', 'lesson-1'); + }); + + it('should return 400 for missing lessonId', async () => { + const request = new Request('http://localhost/api/notes?userId=user-1'); + const response = await GET(request); + + expect(response.status).toBe(400); + }); + + it('should return empty array when no notes exist', async () => { + vi.mocked(notesRepo.findByUserAndLesson).mockResolvedValue([]); + + const request = new Request('http://localhost/api/notes?lessonId=lesson-1&userId=user-1'); + const response = await GET(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.data).toEqual([]); + }); + + it('should return 500 on database error', async () => { + vi.mocked(notesRepo.findByUserAndLesson).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/notes?lessonId=lesson-1&userId=user-1'); + const response = await GET(request); + + expect(response.status).toBe(500); + }); + }); + + describe('POST', () => { + it('should create a new note', async () => { + const mockNote = { + id: 'note-123', + time: 45.0, + text: 'New note', + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-01T00:00:00.000Z', + }; + + vi.mocked(notesRepo.create).mockResolvedValue(mockNote); + + const request = new Request('http://localhost/api/notes', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + note: { time: 45.0, text: 'New note' }, + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(json.data.text).toBe('New note'); + }); + + it('should return 400 for invalid payload', async () => { + const request = new Request('http://localhost/api/notes', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + note: { time: 'invalid' }, // missing text, invalid time + }), + }); + + const response = await POST(request); + expect(response.status).toBe(400); + }); + + it('should return 500 on database error', async () => { + vi.mocked(notesRepo.create).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/notes', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + note: { time: 45.0, text: 'New note' }, + }), + }); + + const response = await POST(request); + expect(response.status).toBe(500); + }); + }); + + describe('PATCH', () => { + it('should update an existing note', async () => { + vi.mocked(notesRepo.update).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/notes', { + method: 'PATCH', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'note-123', + text: 'Updated text', + }), + }); + + const response = await PATCH(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(notesRepo.update).toHaveBeenCalledWith( + 'note-123', + 'user-1', + 'lesson-1', + { text: 'Updated text', time: undefined }, + ); + }); + + it('should update note with new time', async () => { + vi.mocked(notesRepo.update).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/notes', { + method: 'PATCH', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'note-123', + text: 'Updated text', + time: 60.0, + }), + }); + + const response = await PATCH(request); + + expect(response.status).toBe(200); + expect(notesRepo.update).toHaveBeenCalledWith( + 'note-123', + 'user-1', + 'lesson-1', + { text: 'Updated text', time: 60.0 }, + ); + }); + + it('should return 500 on database error', async () => { + vi.mocked(notesRepo.update).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/notes', { + method: 'PATCH', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'note-123', + text: 'Updated text', + }), + }); + + const response = await PATCH(request); + expect(response.status).toBe(500); + }); + }); + + describe('DELETE', () => { + it('should delete a note', async () => { + vi.mocked(notesRepo.remove).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/notes', { + method: 'DELETE', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'note-123', + }), + }); + + const response = await DELETE(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(notesRepo.remove).toHaveBeenCalledWith('note-123', 'user-1', 'lesson-1'); + }); + + it('should return 500 on database error', async () => { + vi.mocked(notesRepo.remove).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/notes', { + method: 'DELETE', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + id: 'note-123', + }), + }); + + const response = await DELETE(request); + expect(response.status).toBe(500); + }); + }); +}); diff --git a/src/app/api/notes/route.ts b/src/app/api/notes/route.ts index 1f7de51d..7c8e83d9 100644 --- a/src/app/api/notes/route.ts +++ b/src/app/api/notes/route.ts @@ -17,14 +17,7 @@ import type { NotesSuccessResponseDTO, } from '@/types/api/notes.dto'; -export const runtime = 'edge'; - -const notesStore = new Map(); - -const keyFor = (userId: string | undefined, lessonId: string): string => { - const safeUserId = encodeURIComponent(userId ?? 'anon'); - return `${safeUserId}::${encodeURIComponent(lessonId)}`; -}; +import * as notesRepo from '@/lib/db/repositories/notes.repository'; // --------------------------------------------------------------------------- // GET /api/notes @@ -40,12 +33,23 @@ export async function GET(request: Request): Promise n.id !== persisted.id)]); - - const response = addHeaders( - NextResponse.json({ - success: true, - data: persisted, - }), - ); - - logAuditMutation(request, { - action: 'create', - targetType: 'video-note', - targetId: persisted.id, - statusCode: response.status, - metadata: { lessonId: result.data.lessonId }, - }); - - return response; + try { + const noteData = { + id: result.data.note.id ?? `note-${Date.now()}`, + time: result.data.note.time, + text: result.data.note.text, + }; + + const persisted = await notesRepo.create( + result.data.userId, + result.data.lessonId, + noteData, + ); + + const response = addHeaders( + NextResponse.json({ + success: true, + data: persisted, + }), + ); + + logAuditMutation(request, { + action: 'create', + targetType: 'video-note', + targetId: persisted.id, + statusCode: response.status, + metadata: { lessonId: result.data.lessonId }, + }); + + return response; + } catch (error) { + edgeLog('error', '/api/notes', 'Database error in POST', { error }); + return addHeaders( + NextResponse.json( + { success: false, message: 'Internal server error' } as unknown as NoteResponseDTO, + { status: 500 }, + ), + ) as NextResponse; + } } // --------------------------------------------------------------------------- @@ -107,34 +118,30 @@ export async function PATCH(request: Request): Promise - n.id === result.data.id - ? { - ...n, - text: result.data.text, - time: result.data.time ?? n.time, - updatedAt: now, - } - : n, - ), - ); - - const response = addHeaders(NextResponse.json({ success: true })); - logAuditMutation(request, { - action: 'update', - targetType: 'video-note', - targetId: result.data.id, - statusCode: response.status, - metadata: { lessonId: result.data.lessonId }, - }); - - return response; + try { + await notesRepo.update( + result.data.id, + result.data.userId, + result.data.lessonId, + { text: result.data.text, time: result.data.time }, + ); + + const response = addHeaders(NextResponse.json({ success: true })); + logAuditMutation(request, { + action: 'update', + targetType: 'video-note', + targetId: result.data.id, + statusCode: response.status, + metadata: { lessonId: result.data.lessonId }, + }); + + return response; + } catch (error) { + edgeLog('error', '/api/notes', 'Database error in PATCH', { error }); + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); + } } // --------------------------------------------------------------------------- @@ -150,22 +157,23 @@ export async function DELETE(request: Request): Promise n.id !== result.data.id), - ); - - const response = addHeaders(NextResponse.json({ success: true })); - logAuditMutation(request, { - action: 'delete', - targetType: 'video-note', - targetId: result.data.id, - statusCode: response.status, - metadata: { lessonId: result.data.lessonId }, - }); - - return response; + try { + await notesRepo.remove(result.data.id, result.data.userId, result.data.lessonId); + + const response = addHeaders(NextResponse.json({ success: true })); + logAuditMutation(request, { + action: 'delete', + targetType: 'video-note', + targetId: result.data.id, + statusCode: response.status, + metadata: { lessonId: result.data.lessonId }, + }); + + return response; + } catch (error) { + edgeLog('error', '/api/notes', 'Database error in DELETE', { error }); + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); + } } diff --git a/src/app/api/video-analytics/__tests__/route.test.ts b/src/app/api/video-analytics/__tests__/route.test.ts new file mode 100644 index 00000000..23dc5e3c --- /dev/null +++ b/src/app/api/video-analytics/__tests__/route.test.ts @@ -0,0 +1,188 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { POST } from '../route'; + +// Mock the repository +vi.mock('@/lib/db/repositories/video-events.repository', () => ({ + create: vi.fn(), +})); + +// Mock rate limiting +vi.mock('@/lib/ratelimit', () => ({ + withRateLimit: vi.fn(() => ({ + addHeaders: (response: Response) => response, + rateLimitResponse: null, + })), +})); + +// Mock edge logging +vi.mock('@/../infra/edge-config', () => ({ + edgeLog: vi.fn(), +})); + +import * as videoEventsRepo from '@/lib/db/repositories/video-events.repository'; + +describe('/api/video-analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe('POST', () => { + it('should record an analytics event', async () => { + vi.mocked(videoEventsRepo.create).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + eventType: 'play', + payload: { timestamp: 30.5 }, + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(videoEventsRepo.create).toHaveBeenCalledWith( + 'user-1', + 'lesson-1', + 'play', + { timestamp: 30.5 }, + ); + }); + + it('should record event without userId (anonymous)', async () => { + vi.mocked(videoEventsRepo.create).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + lessonId: 'lesson-1', + eventType: 'pause', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(200); + expect(json.success).toBe(true); + expect(videoEventsRepo.create).toHaveBeenCalledWith( + undefined, + 'lesson-1', + 'pause', + {}, + ); + }); + + it('should record event with empty payload', async () => { + vi.mocked(videoEventsRepo.create).mockResolvedValue(undefined); + + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + eventType: 'complete', + }), + }); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(videoEventsRepo.create).toHaveBeenCalledWith( + 'user-1', + 'lesson-1', + 'complete', + {}, + ); + }); + + it('should return 400 for missing lessonId', async () => { + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + eventType: 'play', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + expect(json.message).toBe('Invalid payload'); + }); + + it('should return 400 for missing eventType', async () => { + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + }), + }); + + const response = await POST(request); + const json = await response.json(); + + expect(response.status).toBe(400); + expect(json.success).toBe(false); + }); + + it('should return 500 on database error', async () => { + vi.mocked(videoEventsRepo.create).mockRejectedValue(new Error('DB error')); + + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + eventType: 'play', + }), + }); + + const response = await POST(request); + expect(response.status).toBe(500); + }); + + it('should handle complex payload objects', async () => { + vi.mocked(videoEventsRepo.create).mockResolvedValue(undefined); + + const complexPayload = { + currentTime: 45.5, + duration: 120, + volume: 0.8, + playbackRate: 1.5, + quality: '1080p', + metadata: { + browser: 'Chrome', + platform: 'Windows', + }, + }; + + const request = new Request('http://localhost/api/video-analytics', { + method: 'POST', + body: JSON.stringify({ + userId: 'user-1', + lessonId: 'lesson-1', + eventType: 'progress', + payload: complexPayload, + }), + }); + + const response = await POST(request); + + expect(response.status).toBe(200); + expect(videoEventsRepo.create).toHaveBeenCalledWith( + 'user-1', + 'lesson-1', + 'progress', + complexPayload, + ); + }); + }); +}); diff --git a/src/app/api/video-analytics/route.ts b/src/app/api/video-analytics/route.ts index 8767a687..f11b6439 100644 --- a/src/app/api/video-analytics/route.ts +++ b/src/app/api/video-analytics/route.ts @@ -3,21 +3,7 @@ import type { SuccessResponse } from '@/types/api'; import { withRateLimit } from '@/lib/ratelimit'; import { edgeLog } from '@/../infra/edge-config'; -export const runtime = 'edge'; - -type AnalyticsEvent = { - userId?: string; - lessonId: string; - eventType: string; - payload: Record; -}; - -export const analyticsStore = new Map(); - -const keyFor = (userId: string | undefined, lessonId: string) => { - const safeUserId = encodeURIComponent(userId ?? 'anon'); - return `${safeUserId}::${encodeURIComponent(lessonId)}`; -}; +import * as videoEventsRepo from '@/lib/db/repositories/video-events.repository'; export async function POST(request: Request) { edgeLog('info', '/api/video-analytics', 'POST request received'); @@ -39,16 +25,19 @@ export async function POST(request: Request) { ); } - const event: AnalyticsEvent = { - userId: body.userId, - lessonId: body.lessonId, - eventType: body.eventType, - payload: body.payload ?? {}, - }; - - const key = keyFor(body.userId, body.lessonId); - const prev = analyticsStore.get(key) ?? []; - analyticsStore.set(key, [...prev, event].slice(-1000)); + try { + await videoEventsRepo.create( + body.userId, + body.lessonId, + body.eventType, + body.payload ?? {}, + ); - return addHeaders(NextResponse.json({ success: true })); + return addHeaders(NextResponse.json({ success: true })); + } catch (error) { + edgeLog('error', '/api/video-analytics', 'Database error in POST', { error }); + return addHeaders( + NextResponse.json({ success: false, message: 'Internal server error' }, { status: 500 }), + ); + } } diff --git a/src/lib/db/migrations/001_create_notes_table.sql b/src/lib/db/migrations/001_create_notes_table.sql new file mode 100644 index 00000000..8ff55fb3 --- /dev/null +++ b/src/lib/db/migrations/001_create_notes_table.sql @@ -0,0 +1,18 @@ +-- Migration: Create notes table +-- Description: Stores video notes for users per lesson + +CREATE TABLE IF NOT EXISTS notes ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(255), + lesson_id VARCHAR(255) NOT NULL, + time_seconds DECIMAL(10,3) NOT NULL, + text TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for efficient lookups by user and lesson +CREATE INDEX IF NOT EXISTS idx_notes_user_lesson ON notes(user_id, lesson_id); + +-- Index for ordering by creation time +CREATE INDEX IF NOT EXISTS idx_notes_created_at ON notes(created_at DESC); diff --git a/src/lib/db/migrations/002_create_bookmarks_table.sql b/src/lib/db/migrations/002_create_bookmarks_table.sql new file mode 100644 index 00000000..e96ca6df --- /dev/null +++ b/src/lib/db/migrations/002_create_bookmarks_table.sql @@ -0,0 +1,19 @@ +-- Migration: Create bookmarks table +-- Description: Stores video bookmarks for users per lesson + +CREATE TABLE IF NOT EXISTS bookmarks ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(255), + lesson_id VARCHAR(255) NOT NULL, + time_seconds DECIMAL(10,3) NOT NULL, + title VARCHAR(255) NOT NULL, + note TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for efficient lookups by user and lesson +CREATE INDEX IF NOT EXISTS idx_bookmarks_user_lesson ON bookmarks(user_id, lesson_id); + +-- Index for ordering by creation time +CREATE INDEX IF NOT EXISTS idx_bookmarks_created_at ON bookmarks(created_at DESC); diff --git a/src/lib/db/migrations/003_create_content_approvals_table.sql b/src/lib/db/migrations/003_create_content_approvals_table.sql new file mode 100644 index 00000000..3b58a45d --- /dev/null +++ b/src/lib/db/migrations/003_create_content_approvals_table.sql @@ -0,0 +1,24 @@ +-- Migration: Create content_approvals table +-- Description: Stores content approval requests and their review status + +CREATE TABLE IF NOT EXISTS content_approvals ( + id VARCHAR(64) PRIMARY KEY, + content_id VARCHAR(255) NOT NULL, + content_type VARCHAR(20) NOT NULL CHECK (content_type IN ('COURSE', 'POST')), + title VARCHAR(200) NOT NULL, + submitted_by VARCHAR(255) NOT NULL, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING', 'APPROVED', 'REJECTED')), + reviewed_by VARCHAR(255), + reviewed_at TIMESTAMPTZ, + review_note VARCHAR(500) +); + +-- Index for filtering by status +CREATE INDEX IF NOT EXISTS idx_approvals_status ON content_approvals(status); + +-- Index for lookups by submitter +CREATE INDEX IF NOT EXISTS idx_approvals_submitted_by ON content_approvals(submitted_by); + +-- Index for ordering by submission time +CREATE INDEX IF NOT EXISTS idx_approvals_submitted_at ON content_approvals(submitted_at DESC); diff --git a/src/lib/db/migrations/004_create_video_events_table.sql b/src/lib/db/migrations/004_create_video_events_table.sql new file mode 100644 index 00000000..8900b2ea --- /dev/null +++ b/src/lib/db/migrations/004_create_video_events_table.sql @@ -0,0 +1,20 @@ +-- Migration: Create video_events table +-- Description: Stores video analytics events (play, pause, seek, etc.) + +CREATE TABLE IF NOT EXISTS video_events ( + id SERIAL PRIMARY KEY, + user_id VARCHAR(255), + lesson_id VARCHAR(255) NOT NULL, + event_type VARCHAR(100) NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for efficient lookups by user and lesson +CREATE INDEX IF NOT EXISTS idx_video_events_user_lesson ON video_events(user_id, lesson_id); + +-- Index for time-based queries and cleanup +CREATE INDEX IF NOT EXISTS idx_video_events_created_at ON video_events(created_at DESC); + +-- Index for filtering by event type +CREATE INDEX IF NOT EXISTS idx_video_events_type ON video_events(event_type); diff --git a/src/lib/db/repositories/approvals.repository.ts b/src/lib/db/repositories/approvals.repository.ts new file mode 100644 index 00000000..4ac81daf --- /dev/null +++ b/src/lib/db/repositories/approvals.repository.ts @@ -0,0 +1,129 @@ +import { query } from '../pool'; +import { ApprovalStatus } from '@/types/approvals'; +import type { ApprovalItem } from '@/types/api'; + +/** + * Approvals Repository + * Handles CRUD operations for content approvals in PostgreSQL + */ + +export async function findAll(status?: ApprovalStatus): Promise { + let sql = ` + SELECT id, content_id, content_type, title, submitted_by, submitted_at, + status, reviewed_by, reviewed_at, review_note + FROM content_approvals + `; + const params: (string | undefined)[] = []; + + if (status) { + sql += ` WHERE status = $1`; + params.push(status); + } + + sql += ` ORDER BY submitted_at DESC`; + + const result = await query(sql, params); + + return result.rows.map((row) => ({ + id: row.id, + contentId: row.content_id, + contentType: row.content_type, + title: row.title, + submittedBy: row.submitted_by, + submittedAt: row.submitted_at.toISOString(), + status: row.status as ApprovalStatus, + reviewedBy: row.reviewed_by ?? undefined, + reviewedAt: row.reviewed_at?.toISOString(), + reviewNote: row.review_note ?? undefined, + })); +} + +export async function findById(id: string): Promise { + const result = await query( + `SELECT id, content_id, content_type, title, submitted_by, submitted_at, + status, reviewed_by, reviewed_at, review_note + FROM content_approvals + WHERE id = $1`, + [id] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + return { + id: row.id, + contentId: row.content_id, + contentType: row.content_type, + title: row.title, + submittedBy: row.submitted_by, + submittedAt: row.submitted_at.toISOString(), + status: row.status as ApprovalStatus, + reviewedBy: row.reviewed_by ?? undefined, + reviewedAt: row.reviewed_at?.toISOString(), + reviewNote: row.review_note ?? undefined, + }; +} + +export async function create(data: { + id: string; + contentId: string; + contentType: 'COURSE' | 'POST'; + title: string; + submittedBy: string; +}): Promise { + const now = new Date(); + + await query( + `INSERT INTO content_approvals (id, content_id, content_type, title, submitted_by, submitted_at, status) + VALUES ($1, $2, $3, $4, $5, $6, 'PENDING')`, + [data.id, data.contentId, data.contentType, data.title, data.submittedBy, now] + ); + + return { + id: data.id, + contentId: data.contentId, + contentType: data.contentType, + title: data.title, + submittedBy: data.submittedBy, + submittedAt: now.toISOString(), + status: 'PENDING', + }; +} + +export async function review( + id: string, + status: 'APPROVED' | 'REJECTED', + reviewedBy: string, + reviewNote?: string +): Promise { + const now = new Date(); + + const result = await query( + `UPDATE content_approvals + SET status = $1, reviewed_by = $2, reviewed_at = $3, review_note = $4 + WHERE id = $5 + RETURNING id, content_id, content_type, title, submitted_by, submitted_at, + status, reviewed_by, reviewed_at, review_note`, + [status, reviewedBy, now, reviewNote ?? null, id] + ); + + if (result.rows.length === 0) { + return null; + } + + const row = result.rows[0]; + return { + id: row.id, + contentId: row.content_id, + contentType: row.content_type, + title: row.title, + submittedBy: row.submitted_by, + submittedAt: row.submitted_at.toISOString(), + status: row.status as ApprovalStatus, + reviewedBy: row.reviewed_by ?? undefined, + reviewedAt: row.reviewed_at?.toISOString(), + reviewNote: row.review_note ?? undefined, + }; +} diff --git a/src/lib/db/repositories/bookmarks.repository.ts b/src/lib/db/repositories/bookmarks.repository.ts new file mode 100644 index 00000000..de5d26b7 --- /dev/null +++ b/src/lib/db/repositories/bookmarks.repository.ts @@ -0,0 +1,101 @@ +import { query } from '../pool'; +import type { VideoBookmark } from '@/types/api'; + +/** + * Bookmarks Repository + * Handles CRUD operations for video bookmarks in PostgreSQL + */ + +export async function findByUserAndLesson( + userId: string | undefined, + lessonId: string +): Promise { + const result = await query( + `SELECT id, time_seconds as time, title, note, created_at, updated_at + FROM bookmarks + WHERE (user_id = $1 OR ($1 IS NULL AND user_id IS NULL)) + AND lesson_id = $2 + ORDER BY created_at DESC`, + [userId ?? null, lessonId] + ); + + return result.rows.map((row) => ({ + id: row.id, + time: parseFloat(row.time), + title: row.title, + note: row.note ?? undefined, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })); +} + +export async function create( + userId: string | undefined, + lessonId: string, + bookmark: { id: string; time: number; title: string; note?: string } +): Promise { + const now = new Date(); + + await query( + `INSERT INTO bookmarks (id, user_id, lesson_id, time_seconds, title, note, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (id) DO UPDATE SET + time_seconds = EXCLUDED.time_seconds, + title = EXCLUDED.title, + note = EXCLUDED.note, + updated_at = EXCLUDED.updated_at`, + [bookmark.id, userId ?? null, lessonId, bookmark.time, bookmark.title, bookmark.note ?? null, now, now] + ); + + return { + id: bookmark.id, + time: bookmark.time, + title: bookmark.title, + note: bookmark.note, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; +} + +export async function update( + id: string, + userId: string | undefined, + lessonId: string, + data: { title: string; note?: string; time?: number } +): Promise { + const now = new Date(); + + if (data.time !== undefined) { + await query( + `UPDATE bookmarks + SET title = $1, note = $2, time_seconds = $3, updated_at = $4 + WHERE id = $5 + AND (user_id = $6 OR ($6 IS NULL AND user_id IS NULL)) + AND lesson_id = $7`, + [data.title, data.note ?? null, data.time, now, id, userId ?? null, lessonId] + ); + } else { + await query( + `UPDATE bookmarks + SET title = $1, note = $2, updated_at = $3 + WHERE id = $4 + AND (user_id = $5 OR ($5 IS NULL AND user_id IS NULL)) + AND lesson_id = $6`, + [data.title, data.note ?? null, now, id, userId ?? null, lessonId] + ); + } +} + +export async function remove( + id: string, + userId: string | undefined, + lessonId: string +): Promise { + await query( + `DELETE FROM bookmarks + WHERE id = $1 + AND (user_id = $2 OR ($2 IS NULL AND user_id IS NULL)) + AND lesson_id = $3`, + [id, userId ?? null, lessonId] + ); +} diff --git a/src/lib/db/repositories/notes.repository.ts b/src/lib/db/repositories/notes.repository.ts new file mode 100644 index 00000000..6b8a0369 --- /dev/null +++ b/src/lib/db/repositories/notes.repository.ts @@ -0,0 +1,98 @@ +import { query } from '../pool'; +import type { VideoNote } from '@/types/api'; + +/** + * Notes Repository + * Handles CRUD operations for video notes in PostgreSQL + */ + +export async function findByUserAndLesson( + userId: string | undefined, + lessonId: string +): Promise { + const result = await query( + `SELECT id, time_seconds as time, text, created_at, updated_at + FROM notes + WHERE (user_id = $1 OR ($1 IS NULL AND user_id IS NULL)) + AND lesson_id = $2 + ORDER BY created_at DESC`, + [userId ?? null, lessonId] + ); + + return result.rows.map((row) => ({ + id: row.id, + time: parseFloat(row.time), + text: row.text, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })); +} + +export async function create( + userId: string | undefined, + lessonId: string, + note: { id: string; time: number; text: string } +): Promise { + const now = new Date(); + + await query( + `INSERT INTO notes (id, user_id, lesson_id, time_seconds, text, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (id) DO UPDATE SET + time_seconds = EXCLUDED.time_seconds, + text = EXCLUDED.text, + updated_at = EXCLUDED.updated_at`, + [note.id, userId ?? null, lessonId, note.time, note.text, now, now] + ); + + return { + id: note.id, + time: note.time, + text: note.text, + createdAt: now.toISOString(), + updatedAt: now.toISOString(), + }; +} + +export async function update( + id: string, + userId: string | undefined, + lessonId: string, + data: { text: string; time?: number } +): Promise { + const now = new Date(); + + if (data.time !== undefined) { + await query( + `UPDATE notes + SET text = $1, time_seconds = $2, updated_at = $3 + WHERE id = $4 + AND (user_id = $5 OR ($5 IS NULL AND user_id IS NULL)) + AND lesson_id = $6`, + [data.text, data.time, now, id, userId ?? null, lessonId] + ); + } else { + await query( + `UPDATE notes + SET text = $1, updated_at = $2 + WHERE id = $3 + AND (user_id = $4 OR ($4 IS NULL AND user_id IS NULL)) + AND lesson_id = $5`, + [data.text, now, id, userId ?? null, lessonId] + ); + } +} + +export async function remove( + id: string, + userId: string | undefined, + lessonId: string +): Promise { + await query( + `DELETE FROM notes + WHERE id = $1 + AND (user_id = $2 OR ($2 IS NULL AND user_id IS NULL)) + AND lesson_id = $3`, + [id, userId ?? null, lessonId] + ); +} diff --git a/src/lib/db/repositories/video-events.repository.ts b/src/lib/db/repositories/video-events.repository.ts new file mode 100644 index 00000000..7940352e --- /dev/null +++ b/src/lib/db/repositories/video-events.repository.ts @@ -0,0 +1,19 @@ +import { query } from '../pool'; + +/** + * Video Events Repository + * Handles insert operations for video analytics events in PostgreSQL + */ + +export async function create( + userId: string | undefined, + lessonId: string, + eventType: string, + payload: Record +): Promise { + await query( + `INSERT INTO video_events (user_id, lesson_id, event_type, payload, created_at) + VALUES ($1, $2, $3, $4, NOW())`, + [userId ?? null, lessonId, eventType, JSON.stringify(payload)] + ); +}