diff --git a/apps/api/src/__tests__/index.test.ts b/apps/api/src/__tests__/index.test.ts index fa505a7..d221c4a 100644 --- a/apps/api/src/__tests__/index.test.ts +++ b/apps/api/src/__tests__/index.test.ts @@ -40,25 +40,33 @@ vi.mock('../jobs/seedPlans', () => ({ })) describe('Server entry point (index.ts)', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + }) + it('starts the server on the configured port', async () => { await import('../index') - - expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function)) + await vi.waitFor(() => { + expect(mockListen).toHaveBeenCalledWith(4000, expect.any(Function)) + }) }) it('logs server start message', async () => { await import('../index') - - expect(mockLogger.info).toHaveBeenCalledWith( - { port: 4000 }, - 'Server started', - ) + await vi.waitFor(() => { + expect(mockLogger.info).toHaveBeenCalledWith( + { port: 4000 }, + 'Server started', + ) + }) }) it('starts health check job when interval is configured', async () => { await import('../index') - - expect(mockStartHealthCheckJob).toHaveBeenCalledWith(3600000) + await vi.waitFor(() => { + expect(mockStartHealthCheckJob).toHaveBeenCalledWith(3600000) + }) expect(mockLogger.info).toHaveBeenCalledWith( { interval: 3600000 }, 'Health check job started', @@ -67,7 +75,8 @@ describe('Server entry point (index.ts)', () => { it('seeds plans on startup (fire-and-forget)', async () => { await import('../index') - - expect(mockSeedPlans).toHaveBeenCalled() + await vi.waitFor(() => { + expect(mockSeedPlans).toHaveBeenCalled() + }) }) }) diff --git a/apps/api/src/controllers/__tests__/qr.controller.test.ts b/apps/api/src/controllers/__tests__/qr.controller.test.ts index 5fd0709..097639c 100644 --- a/apps/api/src/controllers/__tests__/qr.controller.test.ts +++ b/apps/api/src/controllers/__tests__/qr.controller.test.ts @@ -1,16 +1,38 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import type { Request, Response, NextFunction } from 'express' +const mockDb = vi.hoisted(() => ({ + select: vi.fn(), +})) + +vi.mock('../../db', () => ({ + db: mockDb, +})) + +vi.mock('../../utils/env', () => ({ + env: { BASE_URL: 'http://localhost:3000' }, +})) + vi.mock('../../services/qr.service', () => ({ generateQrCode: vi.fn(), })) vi.mock('../../services/url.services', () => ({ - resolveUrl: vi.fn(), + getQrCache: vi.fn(), + setQrCache: vi.fn(), + deleteAllQrCaches: vi.fn(), })) import * as qrController from '../qr.controller' +function mockChain() { + const chain: any = { limit: vi.fn() } + chain.where = vi.fn().mockReturnValue(chain) + chain.from = vi.fn().mockReturnValue(chain) + chain.limit = vi.fn().mockResolvedValue([]) + return chain +} + function mockReq(overrides: Partial = {}): Request { return { user: { id: 'user-1', role: 'user' } as any, @@ -34,83 +56,142 @@ beforeEach(() => { }) describe('generateQr', () => { + function setupDbRow(overrides = {}) { + const chain = mockChain() + chain.limit.mockResolvedValue([{ qrExpiresAt: null, userId: 'user-1', ...overrides }]) + mockDb.select.mockReturnValue(chain) + return chain + } + it('generates PNG QR code', async () => { + setupDbRow() const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } }) const res = mockRes() const next = vi.fn() - const { resolveUrl } = await import('../../services/url.services') + const { getQrCache } = await import('../../services/url.services') const { generateQrCode } = await import('../../services/qr.service') - vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any) - vi.mocked(generateQrCode).mockResolvedValueOnce(Buffer.from('png-data')) + vi.mocked(getQrCache).mockResolvedValue(null as any) + vi.mocked(generateQrCode).mockResolvedValue(Buffer.from('png-data')) await qrController.generateQr(req, res, next) + expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'png', undefined) expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png') expect(res.send).toHaveBeenCalledWith(Buffer.from('png-data')) }) it('generates SVG QR code', async () => { + setupDbRow() const req = mockReq({ params: { code: 'abc' }, query: { format: 'svg' } }) const res = mockRes() const next = vi.fn() - const { resolveUrl } = await import('../../services/url.services') + const { getQrCache } = await import('../../services/url.services') const { generateQrCode } = await import('../../services/qr.service') - vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any) - vi.mocked(generateQrCode).mockResolvedValueOnce('...') + vi.mocked(getQrCache).mockResolvedValue(null as any) + vi.mocked(generateQrCode).mockResolvedValue('...') await qrController.generateQr(req, res, next) + expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'svg', undefined) expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/svg+xml') expect(res.send).toHaveBeenCalledWith('...') }) it('generates QR with logo', async () => { + setupDbRow() const req = mockReq({ params: { code: 'abc' }, query: { format: 'png', logo: 'https://example.com/logo.png' } }) const res = mockRes() const next = vi.fn() - const { resolveUrl } = await import('../../services/url.services') + const { getQrCache } = await import('../../services/url.services') const { generateQrCode } = await import('../../services/qr.service') - vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any) - vi.mocked(generateQrCode).mockResolvedValueOnce(Buffer.from('png-with-logo')) + vi.mocked(getQrCache).mockResolvedValue(null as any) + vi.mocked(generateQrCode).mockResolvedValue(Buffer.from('png-with-logo')) await qrController.generateQr(req, res, next) - expect(generateQrCode).toHaveBeenCalledWith('https://example.com', 'png', 'https://example.com/logo.png') + expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'png', 'https://example.com/logo.png') expect(res.send).toHaveBeenCalledWith(Buffer.from('png-with-logo')) }) it('defaults to PNG format', async () => { + setupDbRow() const req = mockReq({ params: { code: 'abc' }, query: {} }) const res = mockRes() const next = vi.fn() - const { resolveUrl } = await import('../../services/url.services') + const { getQrCache } = await import('../../services/url.services') const { generateQrCode } = await import('../../services/qr.service') - vi.mocked(resolveUrl).mockResolvedValueOnce({ url: 'https://example.com' } as any) - vi.mocked(generateQrCode).mockResolvedValueOnce(Buffer.from('png-data')) + vi.mocked(getQrCache).mockResolvedValue(null as any) + vi.mocked(generateQrCode).mockResolvedValue(Buffer.from('png-data')) await qrController.generateQr(req, res, next) - expect(generateQrCode).toHaveBeenCalledWith('https://example.com', 'png', undefined) + expect(generateQrCode).toHaveBeenCalledWith('http://localhost:3000/abc', 'png', undefined) }) - it('passes errors to next', async () => { - const req = mockReq({ params: { code: 'abc' }, query: {} }) + it('returns 410 when QR has expired', async () => { + setupDbRow({ qrExpiresAt: new Date('2020-01-01') }) + const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } }) + const res = mockRes() + const next = vi.fn() + + await qrController.generateQr(req, res, next) + + expect(res.status).toHaveBeenCalledWith(410) + expect(res.json).toHaveBeenCalledWith({ + error: 'QR_CODE_EXPIRED', + message: expect.stringContaining('expired'), + expiredAt: expect.any(String), + }) + }) + + it('returns cached QR without regenerating', async () => { + setupDbRow() + const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } }) const res = mockRes() const next = vi.fn() - const { resolveUrl } = await import('../../services/url.services') - vi.mocked(resolveUrl).mockRejectedValueOnce(new Error('not found')) + const { getQrCache } = await import('../../services/url.services') + const { generateQrCode } = await import('../../services/qr.service') + + vi.mocked(getQrCache).mockResolvedValue({ data: Buffer.from('cached-qr').toString('base64'), createdAt: new Date() }) + + await qrController.generateQr(req, res, next) + + expect(generateQrCode).not.toHaveBeenCalled() + expect(res.setHeader).toHaveBeenCalledWith('Content-Type', 'image/png') + expect(res.send).toHaveBeenCalledWith(expect.any(Buffer)) + }) + + it('returns 404 if code does not exist', async () => { + const chain = mockChain() + chain.limit.mockResolvedValue([]) + mockDb.select.mockReturnValue(chain) + + const req = mockReq({ params: { code: 'missing' }, query: { format: 'png' } }) + const res = mockRes() + const next = vi.fn() + + await qrController.generateQr(req, res, next) + + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404 })) + }) + + it('returns 404 if user does not own the URL', async () => { + setupDbRow({ userId: 'other-user' }) + const req = mockReq({ params: { code: 'abc' }, query: { format: 'png' } }) + const res = mockRes() + const next = vi.fn() await qrController.generateQr(req, res, next) - expect(next).toHaveBeenCalledWith(expect.any(Error)) + expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: 404 })) }) }) diff --git a/apps/api/src/controllers/qr.controller.ts b/apps/api/src/controllers/qr.controller.ts index a7df745..c052cf9 100644 --- a/apps/api/src/controllers/qr.controller.ts +++ b/apps/api/src/controllers/qr.controller.ts @@ -1,34 +1,102 @@ import type { Request, Response, NextFunction } from 'express' -import { z } from 'zod' +import { and, eq, isNull } from 'drizzle-orm' import { getUrlParamsSchema } from '../validators/url.validators' +import { qrQuerySchema, regenerateQrSchema } from '../validators/qr.validators' import { generateQrCode } from '../services/qr.service' -import { resolveUrl } from '../services/url.services' +import { getQrCache, setQrCache, deleteAllQrCaches } from '../services/url.services' +import { db } from '../db' +import { urls } from '../db/schema' import { env } from '../utils/env' - -const qrQuerySchema = z.object({ - format: z.enum(['png', 'svg']).default('png'), - logo: z.string().optional(), -}) +import { AppError } from '../utils/AppError' export async function generateQr(req: Request, res: Response, next: NextFunction) { try { const { code } = getUrlParamsSchema.parse(req.params) const { format, logo } = qrQuerySchema.parse(req.query) - const urlRow = await resolveUrl(code) - const targetUrl = urlRow.url + const [urlRow] = await db + .select({ qrExpiresAt: urls.qrExpiresAt, userId: urls.userId }) + .from(urls) + .where(and(eq(urls.code, code), isNull(urls.deletedAt))) + .limit(1) + + if (!urlRow) { + throw new AppError('URL not found', 404, 'URL_NOT_FOUND') + } + + if (urlRow.userId !== req.user!.id) { + throw new AppError('URL not found', 404, 'URL_NOT_FOUND') + } + + if (urlRow.qrExpiresAt && urlRow.qrExpiresAt < new Date()) { + res.status(410).json({ + error: 'QR_CODE_EXPIRED', + message: `This QR code expired on ${urlRow.qrExpiresAt.toISOString()}`, + expiredAt: urlRow.qrExpiresAt.toISOString(), + }) + return + } + const cached = await getQrCache(code, format, logo) + if (cached) { + const buf = Buffer.from(cached.data, 'base64') + res.setHeader('Content-Type', format === 'svg' ? 'image/svg+xml' : 'image/png') + res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.${format}"`) + res.send(buf) + return + } + + const targetUrl = `${env.BASE_URL}/${code}` const result = await generateQrCode(targetUrl, format, logo) - if (format === 'svg') { - res.setHeader('Content-Type', 'image/svg+xml') - res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.svg"`) - res.send(result) - } else { - res.setHeader('Content-Type', 'image/png') - res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.png"`) - res.send(result) + const data = format === 'svg' ? result.toString() : (result as Buffer).toString('base64') + await setQrCache(code, format, data, logo) + + res.setHeader('Content-Type', format === 'svg' ? 'image/svg+xml' : 'image/png') + res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.${format}"`) + res.send(result) + } catch (err) { + next(err) + } +} + +export async function regenerateQr(req: Request, res: Response, next: NextFunction) { + try { + const { code } = getUrlParamsSchema.parse(req.params) + const { format, logo } = qrQuerySchema.parse(req.query) + const { expiresAt } = regenerateQrSchema.parse(req.body) + + const [urlRow] = await db + .select({ qrExpiresAt: urls.qrExpiresAt, userId: urls.userId }) + .from(urls) + .where(and(eq(urls.code, code), isNull(urls.deletedAt))) + .limit(1) + + if (!urlRow) { + throw new AppError('URL not found', 404, 'URL_NOT_FOUND') + } + + if (urlRow.userId !== req.user!.id) { + throw new AppError('URL not found', 404, 'URL_NOT_FOUND') } + + if (!urlRow.qrExpiresAt || urlRow.qrExpiresAt >= new Date()) { + throw new AppError('QR code has not expired yet', 400, 'QR_NOT_YET_EXPIRED') + } + + await deleteAllQrCaches(code) + + await db.update(urls).set({ qrExpiresAt: new Date(expiresAt) }).where(eq(urls.code, code)) + + const targetUrl = `${env.BASE_URL}/${code}` + const result = await generateQrCode(targetUrl, format, logo) + + const data = format === 'svg' ? result.toString() : (result as Buffer).toString('base64') + await setQrCache(code, format, data, logo) + + res.setHeader('Content-Type', format === 'svg' ? 'image/svg+xml' : 'image/png') + res.setHeader('Content-Disposition', `attachment; filename="${code}-qr.${format}"`) + res.send(result) } catch (err) { next(err) } diff --git a/apps/api/src/controllers/url.controllers.ts b/apps/api/src/controllers/url.controllers.ts index f6097a0..073fed5 100644 --- a/apps/api/src/controllers/url.controllers.ts +++ b/apps/api/src/controllers/url.controllers.ts @@ -81,6 +81,7 @@ export async function getUrlInfo(req: Request, res: Response, next: NextFunction activeAt: url.activeAt?.toISOString() ?? null, hasPassword: !!url.passwordHash, blockBots: url.blockBots, + qrExpiresAt: url.qrExpiresAt?.toISOString() ?? null, createdAt: url.createdAt.toISOString(), }, }) diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index c6b8007..86a3759 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm' import { pgTable, serial, text, timestamp, integer, index, uuid, boolean, uniqueIndex, primaryKey, jsonb, numeric, varchar } from 'drizzle-orm/pg-core' export const users = pgTable('users', { @@ -57,6 +58,7 @@ export const urls = pgTable( affiliateId: text('affiliate_id'), affiliateNetwork: text('affiliate_network'), updatedAt: timestamp('updated_at').defaultNow().notNull(), + qrExpiresAt: timestamp('qr_expires_at'), }, (table) => ({ userDeletedIdx: index('urls_user_deleted_idx').on(table.userId, table.deletedAt), @@ -286,3 +288,24 @@ export const customDomains = pgTable( domainIdx: uniqueIndex('custom_domains_domain_idx').on(table.domain), }), ) + +export const qrCodes = pgTable( + 'qr_codes', + { + id: serial('id').primaryKey(), + code: text('code') + .notNull() + .references(() => urls.code, { onDelete: 'cascade' }), + format: text('format').notNull(), + logoUrl: text('logo_url'), + data: text('data').notNull(), + createdAt: timestamp('created_at').defaultNow().notNull(), + }, + (table) => ({ + uniqueCacheKey: uniqueIndex('qr_codes_cache_key_idx').on( + table.code, + table.format, + sql`COALESCE(${table.logoUrl}, '')`, + ), + }), +) diff --git a/apps/api/src/routes/__tests__/url.routes.test.ts b/apps/api/src/routes/__tests__/url.routes.test.ts index b54f22f..e64a491 100644 --- a/apps/api/src/routes/__tests__/url.routes.test.ts +++ b/apps/api/src/routes/__tests__/url.routes.test.ts @@ -25,6 +25,7 @@ const mockLinkController = vi.hoisted(() => ({ const mockQrController = vi.hoisted(() => ({ generateQr: vi.fn(), + regenerateQr: vi.fn(), })) vi.mock('../../controllers/url.controllers', () => mockUrlController) @@ -196,6 +197,14 @@ describe('URL Routes', () => { expect(res.status).toBe(200) }) + it('POST /:code/qr/regenerate — regenerates QR code', async () => { + mockQrController.regenerateQr.mockImplementation((_req, res) => { + res.setHeader('Content-Type', 'image/png').send(Buffer.from('new-qr')) + }) + const res = await supertest(createApp()).post('/abc/qr/regenerate').send({ expiresAt: '2026-12-31T23:59:59.000Z' }) + expect(res.status).toBe(200) + }) + it('POST /import/csv — imports CSV', async () => { mockLinkController.importCsv.mockImplementation((_req, res) => { res.status(201).json({ success: true, data: [{ success: true, code: 'abc' }] }) diff --git a/apps/api/src/routes/url.routes.ts b/apps/api/src/routes/url.routes.ts index c376d34..95e8ca9 100644 --- a/apps/api/src/routes/url.routes.ts +++ b/apps/api/src/routes/url.routes.ts @@ -16,7 +16,8 @@ router.get('/:code/info', urlController.getUrlInfo) router.get('/:code/visits', urlController.getVisits) router.get('/:code/stats', urlController.getUrlStats) router.get('/:code/visits/export', urlController.exportVisits) -router.get('/:code/qr', qrController.generateQr) +router.get('/:code/qr', requireAuth, qrController.generateQr) +router.post('/:code/qr/regenerate', requireAuth, qrController.regenerateQr) router.post('/:code/verify-password', passwordLimiter, urlController.verifyPasswordRedirect) router.delete('/:code', requireAuth, urlController.deleteUrl) router.delete('/:code/purge', requireAuth, requireAAL(), urlController.purgeUrl) diff --git a/apps/api/src/services/link.service.ts b/apps/api/src/services/link.service.ts index 6bbe953..6af5aaa 100644 --- a/apps/api/src/services/link.service.ts +++ b/apps/api/src/services/link.service.ts @@ -9,6 +9,7 @@ import type { UpdateLinkSettingsInput } from '../validators/link.validators' import { logAction } from './audit.service' import { cacheDel, buildCacheKeyForUrl } from './cache' import { getUserPlan } from './subscription.service' +import { deleteAllQrCaches } from './url.services' const LINK_CHAIN_MAX_HOPS = 5 const JWT_SECRET = new TextEncoder().encode(env.LINK_ACCESS_SECRET) @@ -143,11 +144,18 @@ export async function updateLinkSettings(code: string, userId: string, input: Up if (input.blockBots !== undefined) { updates.blockBots = input.blockBots } + if (input.qrExpiresAt !== undefined) { + updates.qrExpiresAt = input.qrExpiresAt ? new Date(input.qrExpiresAt) : null + } if (Object.keys(updates).length === 0) return await db.update(urls).set(updates).where(eq(urls.code, code)) cacheDel(buildCacheKeyForUrl(code)).catch(() => {}) + + if (input.qrExpiresAt !== undefined) { + deleteAllQrCaches(code).catch(() => {}) + } } export async function resolveChain( diff --git a/apps/api/src/services/url.services.ts b/apps/api/src/services/url.services.ts index 5286795..4a79b72 100644 --- a/apps/api/src/services/url.services.ts +++ b/apps/api/src/services/url.services.ts @@ -1,5 +1,5 @@ import { db, dbReplica } from '../db' -import { urls, visits, tags, urlTags, urlCollections, collections, urlStatsHourly } from '../db/schema' +import { urls, visits, tags, urlTags, urlCollections, collections, urlStatsHourly, qrCodes } from '../db/schema' import { eq, desc, count, sql, and, gte, isNull, inArray, asc, type SQL } from 'drizzle-orm' import { generateShortCode } from '../utils/codeGenerator' import { RESERVED_CODES } from '../constants/reservedWords' @@ -23,6 +23,41 @@ const UNIQUE_VISIT_WINDOW_HOURS = env.UNIQUE_VISIT_WINDOW_HOURS export { AppError } from '../utils/AppError' +export async function getQrCache(code: string, format: string, logoUrl?: string) { + const rows = await db + .select({ data: qrCodes.data, createdAt: qrCodes.createdAt }) + .from(qrCodes) + .where( + and( + eq(qrCodes.code, code), + eq(qrCodes.format, format), + logoUrl ? eq(qrCodes.logoUrl, logoUrl) : sql`${qrCodes.logoUrl} IS NULL`, + ), + ) + .limit(1) + return rows[0] ?? null +} + +export async function setQrCache(code: string, format: string, data: string, logoUrl?: string) { + await db + .delete(qrCodes) + .where( + and( + eq(qrCodes.code, code), + eq(qrCodes.format, format), + logoUrl ? eq(qrCodes.logoUrl, logoUrl) : sql`${qrCodes.logoUrl} IS NULL`, + ), + ) + + await db + .insert(qrCodes) + .values({ code, format, data, logoUrl: logoUrl ?? null }) +} + +export async function deleteAllQrCaches(code: string) { + await db.delete(qrCodes).where(eq(qrCodes.code, code)) +} + export async function createShortUrl(input: CreateUrlInput, userId: string) { const code = input.customCode || generateShortCode() @@ -66,6 +101,7 @@ export async function createShortUrl(input: CreateUrlInput, userId: string) { const activeAt = input.activeAt ? new Date(input.activeAt) : null const passwordHash = input.password ? await bcrypt.hash(input.password, 12) : null const blockBots = input.blockBots ?? false + const qrExpiresAt = input.qrExpiresAt ? new Date(input.qrExpiresAt) : null await db.insert(urls).values({ code, @@ -75,6 +111,7 @@ export async function createShortUrl(input: CreateUrlInput, userId: string) { activeAt, passwordHash, blockBots, + qrExpiresAt, }) fetchLinkPreview(input.url) @@ -115,7 +152,7 @@ export async function createShortUrl(input: CreateUrlInput, userId: string) { .catch(() => {}) } - return formatUrlResponse(code, input.url, 0, 0, expiresAt, undefined, undefined, undefined, undefined, !!passwordHash, activeAt, blockBots) + return formatUrlResponse(code, input.url, 0, 0, expiresAt, undefined, undefined, undefined, undefined, !!passwordHash, activeAt, blockBots, qrExpiresAt) } export async function resolveUrl(code: string) { @@ -466,7 +503,7 @@ export async function listUrls(query: ListUrlsQueryInput, userId?: string, isAdm return { urls: rows.map((u) => formatUrlResponse( - u.code, u.url, u.visits, u.uniqueVisits, u.expiresAt, u.title, u.description, u.image, u.createdAt, !!u.passwordHash, u.activeAt, u.blockBots, + u.code, u.url, u.visits, u.uniqueVisits, u.expiresAt, u.title, u.description, u.image, u.createdAt, !!u.passwordHash, u.activeAt, u.blockBots, u.qrExpiresAt, )), pagination: { page, @@ -561,6 +598,7 @@ function formatUrlResponse( hasPassword?: boolean, activeAt?: Date | null, blockBots?: boolean, + qrExpiresAt?: Date | null, ) { return { code, @@ -575,6 +613,7 @@ function formatUrlResponse( activeAt: activeAt?.toISOString() ?? null, hasPassword: hasPassword ?? false, blockBots: blockBots ?? false, + qrExpiresAt: qrExpiresAt?.toISOString() ?? null, createdAt: (createdAt ?? new Date()).toISOString(), } } diff --git a/apps/api/src/validators/link.validators.ts b/apps/api/src/validators/link.validators.ts index 4f34cd7..832abd7 100644 --- a/apps/api/src/validators/link.validators.ts +++ b/apps/api/src/validators/link.validators.ts @@ -13,6 +13,7 @@ export const updateLinkSettingsSchema = z.object({ expiresAt: z.string().datetime({ message: 'expiresAt must be an ISO 8601 date' }).optional().nullable(), password: z.string().min(4).max(128).optional().nullable(), blockBots: z.boolean().optional(), + qrExpiresAt: z.string().datetime({ message: 'qrExpiresAt must be an ISO 8601 date' }).optional().nullable(), }).strict() export const bulkOperationSchema = z.object({ diff --git a/apps/api/src/validators/qr.validators.ts b/apps/api/src/validators/qr.validators.ts new file mode 100644 index 0000000..bdbf4c2 --- /dev/null +++ b/apps/api/src/validators/qr.validators.ts @@ -0,0 +1,12 @@ +import { z } from 'zod' + +export const qrQuerySchema = z.object({ + format: z.enum(['png', 'svg']).default('png'), + logo: z.string().optional(), +}) + +export const regenerateQrSchema = z.object({ + expiresAt: z.string().datetime({ message: 'expiresAt must be an ISO 8601 date' }), +}).strict() + +export type RegenerateQrInput = z.infer diff --git a/apps/api/src/validators/url.validators.ts b/apps/api/src/validators/url.validators.ts index 242ce58..a81d481 100644 --- a/apps/api/src/validators/url.validators.ts +++ b/apps/api/src/validators/url.validators.ts @@ -51,6 +51,10 @@ export const createUrlSchema = z.object({ .datetime({ message: 'activeAt must be an ISO 8601 date' }) .optional(), blockBots: z.boolean().optional(), + qrExpiresAt: z + .string() + .datetime({ message: 'qrExpiresAt must be an ISO 8601 date' }) + .optional(), tags: z .array(z.string().min(1).max(50)) .max(10, 'Max 10 tags per link') diff --git a/apps/dashboard/src/components/copy-button.tsx b/apps/dashboard/src/components/copy-button.tsx new file mode 100644 index 0000000..880218e --- /dev/null +++ b/apps/dashboard/src/components/copy-button.tsx @@ -0,0 +1,45 @@ +import { useState, useCallback } from "react" +import { Button } from "@/components/ui/button" +import { Check, Copy } from "lucide-react" +import { cn } from "@/lib/utils" + +interface CopyButtonProps { + text: string + className?: string + variant?: "primary" | "outline" | "ghost" + size?: "default" | "sm" | "lg" | "icon" +} + +export default function CopyButton({ text, className, variant = "outline", size = "icon" }: CopyButtonProps) { + const [copied, setCopied] = useState(false) + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(text) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } catch { + const input = document.createElement("input") + input.value = text + document.body.appendChild(input) + input.select() + document.execCommand("copy") + document.body.removeChild(input) + setCopied(true) + setTimeout(() => setCopied(false), 1500) + } + }, [text]) + + return ( + + ) +} diff --git a/apps/dashboard/src/components/status-badge.tsx b/apps/dashboard/src/components/status-badge.tsx new file mode 100644 index 0000000..f33bdb8 --- /dev/null +++ b/apps/dashboard/src/components/status-badge.tsx @@ -0,0 +1,44 @@ +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" + +interface StatusBadgeProps { + status: string + className?: string +} + +const statusConfig: Record = { + active: { label: "Active", variant: "success" }, + expired: { label: "Expired", variant: "destructive" }, + scheduled: { label: "Scheduled", variant: "outline" }, + "password-protected": { label: "Password", variant: "default" }, + "blocked-bots": { label: "Bots Blocked", variant: "secondary" }, + inactive: { label: "Inactive", variant: "secondary" }, +} + +export default function StatusBadge({ status, className }: StatusBadgeProps) { + const config = statusConfig[status] ?? { label: status, variant: "outline" as const } + + return ( + + {config.label} + + ) +} + +export function getLinkStatus(link: { expiresAt: string | null; activeAt: string | null; hasPassword: boolean; blockBots: boolean }): string { + const now = new Date() + + if (link.activeAt && new Date(link.activeAt) > now) { + return "scheduled" + } + if (link.expiresAt && new Date(link.expiresAt) < now) { + return "expired" + } + if (link.hasPassword) { + return "password-protected" + } + if (link.blockBots) { + return "blocked-bots" + } + return "active" +} diff --git a/apps/dashboard/src/components/ui/command.tsx b/apps/dashboard/src/components/ui/command.tsx new file mode 100644 index 0000000..fa19473 --- /dev/null +++ b/apps/dashboard/src/components/ui/command.tsx @@ -0,0 +1,114 @@ +import { cn } from "@/lib/utils" +import { useState, useRef, useEffect, type ReactNode } from "react" + +interface CommandProps { + children: ReactNode + className?: string + filter?: (value: string, search: string) => boolean +} + +export function Command({ children, className, filter }: CommandProps) { + const [search, setSearch] = useState("") + const inputRef = useRef(null) + + useEffect(() => { + setTimeout(() => inputRef.current?.focus(), 50) + }, []) + + return ( +
+ {children} +
+ ) +} + +interface CommandInputProps { + placeholder?: string + value?: string + onValueChange?: (value: string) => void + className?: string +} + +export function CommandInput({ placeholder = "Search...", value, onValueChange, className }: CommandInputProps) { + return ( +
+ el?.focus()} + value={value} + onChange={(e) => onValueChange?.(e.target.value)} + placeholder={placeholder} + className={cn( + "flex h-9 w-full rounded-md bg-transparent py-2 text-sm text-foreground outline-none placeholder:text-muted-foreground", + className + )} + /> +
+ ) +} + +interface CommandListProps { + children: ReactNode + className?: string +} + +export function CommandList({ children, className }: CommandListProps) { + return ( +
+ {children} +
+ ) +} + +interface CommandEmptyProps { + children: ReactNode + className?: string +} + +export function CommandEmpty({ children, className }: CommandEmptyProps) { + return ( +
+ {children} +
+ ) +} + +interface CommandItemProps { + children: ReactNode + value?: string + onSelect?: () => void + disabled?: boolean + className?: string +} + +export function CommandItem({ children, value, onSelect, disabled, className }: CommandItemProps) { + return ( + + ) +} + +interface CommandGroupProps { + children: ReactNode + heading?: string + className?: string +} + +export function CommandGroup({ children, heading, className }: CommandGroupProps) { + return ( +
+ {heading && ( +
{heading}
+ )} + {children} +
+ ) +} diff --git a/apps/dashboard/src/components/ui/dialog.tsx b/apps/dashboard/src/components/ui/dialog.tsx new file mode 100644 index 0000000..470934f --- /dev/null +++ b/apps/dashboard/src/components/ui/dialog.tsx @@ -0,0 +1,60 @@ +import { cn } from "@/lib/utils" +import { useEffect, useRef, type ReactNode } from "react" + +interface DialogProps { + open: boolean + onClose: () => void + children: ReactNode +} + +export function Dialog({ open, onClose, children }: DialogProps) { + const overlayRef = useRef(null) + + useEffect(() => { + if (!open) return + const handler = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose() + } + document.addEventListener("keydown", handler) + document.body.style.overflow = "hidden" + return () => { + document.removeEventListener("keydown", handler) + document.body.style.overflow = "" + } + }, [open, onClose]) + + if (!open) return null + + return ( +
{ + if (e.target === overlayRef.current) onClose() + }} + > +
+ {children} +
+
+ ) +} + +export function DialogHeader({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} + +export function DialogTitle({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

+} + +export function DialogDescription({ children, className }: { children: ReactNode; className?: string }) { + return

{children}

+} + +export function DialogFooter({ children, className }: { children: ReactNode; className?: string }) { + return
{children}
+} diff --git a/apps/dashboard/src/components/ui/label.tsx b/apps/dashboard/src/components/ui/label.tsx new file mode 100644 index 0000000..16292c3 --- /dev/null +++ b/apps/dashboard/src/components/ui/label.tsx @@ -0,0 +1,18 @@ +import { cn } from "@/lib/utils" +import { forwardRef, type LabelHTMLAttributes } from "react" + +const Label = forwardRef>( + ({ className, ...props }, ref) => ( +