|
1 | | -import { handleDbError } from '../utils/error.util.js'; |
2 | | -import { createCardSchema, updateCardSchema } from '../utils/validators.js'; |
3 | | -import * as cardService from '../services/cardService' |
| 1 | +import { createHash } from 'node:crypto' |
| 2 | +import { handleDbError } from '../utils/error.util.js' |
| 3 | +import { addPlatformLinkSchema, createCardSchema, updateCardSchema } from '../utils/validators.js' |
| 4 | +import * as cardService from '../services/cardService.js' |
4 | 5 |
|
5 | | -import type { Card } from '@devcard/shared'; |
6 | | -import type { Prisma } from '@prisma/client'; |
7 | | -import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; |
8 | | - |
9 | | - |
10 | | -interface CreateCardBody { |
11 | | - title: string; |
12 | | - linkIds: string[]; |
13 | | -} |
14 | | - |
15 | | -interface UpdateCardBody { |
16 | | - title?: string; |
17 | | - linkIds?: string[]; |
18 | | -} |
| 6 | +import type { CardResponse, CreateCardBody, UpdateCardBody } from '../services/cardService.js' |
| 7 | +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify' |
19 | 8 |
|
20 | 9 | interface CardParams { |
21 | | - id: string; |
| 10 | + id: string |
22 | 11 | } |
23 | 12 |
|
24 | | -interface PlatformLink { |
25 | | - id: string; |
26 | | - userId: string; |
27 | | - platform: string; |
28 | | - username: string; |
29 | | - url: string; |
30 | | - displayOrder: number; |
31 | | - createdAt: Date; |
| 13 | +function getUserId(request: FastifyRequest): string { |
| 14 | + return (request.user as { id: string }).id |
32 | 15 | } |
33 | 16 |
|
34 | | -interface CardLinkWithPlatform { |
35 | | - id: string; |
36 | | - cardId: string; |
37 | | - platformLinkId: string; |
38 | | - displayOrder: number; |
39 | | - platformLink: PlatformLink; |
40 | | -} |
41 | | - |
42 | | -interface CardWithLinks { |
43 | | - id: string; |
44 | | - userId: string; |
45 | | - title: string; |
46 | | - isDefault: boolean; |
47 | | - createdAt: Date; |
48 | | - updatedAt: Date; |
49 | | - cardLinks: CardLinkWithPlatform[]; |
| 17 | +function hashIp(ip: string): string { |
| 18 | + return createHash('sha256').update(ip).digest('hex') |
50 | 19 | } |
51 | 20 |
|
52 | 21 | export async function cardRoutes(app: FastifyInstance): Promise<void> { |
53 | 22 | app.addHook('preHandler', async (request, reply) => { |
54 | | - const server = request.server as any; |
| 23 | + const server = request.server as any |
55 | 24 | if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return } |
56 | 25 | if (typeof (app as any).authenticate === 'function') { await (app as any).authenticate(request, reply); return } |
57 | | - try { await request.jwtVerify() } catch (e) { reply.status(401).send({ error: 'Unauthorized' }) } |
58 | | - }); |
59 | | - |
60 | | - // ─── List Cards ─── |
| 26 | + try { await request.jwtVerify() } catch { reply.status(401).send({ error: 'Unauthorized' }) } |
| 27 | + }) |
61 | 28 |
|
62 | | - app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise<Card[] | void> => { |
63 | | - const userId = (request.user as { id: string }).id; |
| 29 | + app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise<CardResponse[] | void> => { |
64 | 30 | try { |
65 | | - return await cardService.listCards(app, userId) |
| 31 | + return await cardService.listCards(app, getUserId(request)) |
66 | 32 | } catch (error) { |
67 | 33 | return handleDbError(error, request, reply) |
68 | 34 | } |
69 | | - }); |
70 | | - |
71 | | - // ─── Create Card ─── |
72 | | - |
73 | | - app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<Card | void> => { |
74 | | - const userId = (request.user as { id: string }).id; |
75 | | - const parsed = createCardSchema.safeParse(request.body); |
| 35 | + }) |
76 | 36 |
|
77 | | - if (!parsed.success) { |
78 | | - return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }); |
79 | | - } |
| 37 | + app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<CardResponse | void> => { |
| 38 | + const parsed = createCardSchema.safeParse(request.body) |
| 39 | + if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) |
80 | 40 |
|
81 | 41 | try { |
82 | | - const card = await cardService.createCard(app, userId, parsed.data) |
| 42 | + const card = await cardService.createCard(app, getUserId(request), parsed.data) |
83 | 43 | return reply.status(201).send(card) |
84 | 44 | } catch (error: any) { |
85 | 45 | if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) |
86 | 46 | return handleDbError(error, request, reply) |
87 | 47 | } |
88 | | - }); |
89 | | - |
90 | | - // ─── Update Card ─── |
| 48 | + }) |
91 | 49 |
|
92 | | - app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise<Card | void> => { |
93 | | - const userId = (request.user as { id: string }).id; |
94 | | - const { id } = request.params; |
| 50 | + async function updateCard(request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise<CardResponse | void> { |
| 51 | + const parsed = updateCardSchema.safeParse(request.body) |
| 52 | + if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) |
95 | 53 |
|
96 | 54 | try { |
97 | | - const parsed = updateCardSchema.safeParse(request.body) |
98 | | - if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) |
99 | | - const updated = await cardService.updateCard(app, userId, id, parsed.data) |
| 55 | + const updated = await cardService.updateCard(app, getUserId(request), request.params.id, parsed.data) |
100 | 56 | if (!updated) return reply.status(404).send({ error: 'Card not found' }) |
101 | | - return updated |
| 57 | + return reply.status(200).send(updated) |
102 | 58 | } catch (error: any) { |
103 | 59 | if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' }) |
104 | 60 | return handleDbError(error, request, reply) |
105 | 61 | } |
106 | | - }); |
107 | | - |
108 | | - // ─── Delete Card ─── |
| 62 | + } |
109 | 63 |
|
110 | | - app.delete('/:id', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> => { |
111 | | - const userId = (request.user as { id: string }).id; |
112 | | - const { id } = request.params; |
| 64 | + app.put('/:id', updateCard) |
| 65 | + app.put('/:id/update', updateCard) |
113 | 66 |
|
| 67 | + async function deleteCard(request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> { |
114 | 68 | try { |
115 | | - const res = await cardService.deleteCard(app, userId, id) |
| 69 | + const res = await cardService.deleteCard(app, getUserId(request), request.params.id) |
116 | 70 | if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' }) |
117 | 71 | if (res && (res as any).code === 'LAST_CARD') return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' }) |
118 | 72 | return reply.status(204).send() |
119 | 73 | } catch (error) { |
120 | 74 | return handleDbError(error, request, reply) |
121 | 75 | } |
122 | | - }); |
| 76 | + } |
123 | 77 |
|
124 | | - // ─── Set Default Card ─── |
| 78 | + app.delete('/:id', deleteCard) |
| 79 | + app.delete('/:id/delete', deleteCard) |
125 | 80 |
|
126 | 81 | app.put('/:id/default', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<object | void> => { |
127 | | - const userId = (request.user as { id: string }).id; |
128 | | - const { id } = request.params; |
129 | | - |
130 | 82 | try { |
131 | | - const resp = await cardService.setDefaultCard(app, userId, id) |
| 83 | + const resp = await cardService.setDefaultCard(app, getUserId(request), request.params.id) |
132 | 84 | if (!resp) return reply.status(404).send({ error: 'Card not found' }) |
133 | | - return resp |
| 85 | + return reply.status(200).send(resp) |
134 | 86 | } catch (error) { |
135 | 87 | return handleDbError(error, request, reply) |
136 | 88 | } |
137 | | - }); |
| 89 | + }) |
| 90 | + |
| 91 | + app.put('/:id/platform-link', async (request: FastifyRequest<{ Params: CardParams; Body: { platformLinkId: string } }>, reply: FastifyReply): Promise<void> => { |
| 92 | + const parsed = addPlatformLinkSchema.safeParse(request.body) |
| 93 | + if (!parsed.success) return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() }) |
| 94 | + |
| 95 | + try { |
| 96 | + await cardService.addPlatformLink(app, getUserId(request), request.params.id, parsed.data.platformLinkId) |
| 97 | + return reply.status(200).send({ message: 'Platform link added successfully' }) |
| 98 | + } catch (error: any) { |
| 99 | + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) |
| 100 | + if (error?.code === 'PLATFORM_LINK_NOT_FOUND') return reply.status(403).send({ error: error.message }) |
| 101 | + if (error?.code === 'LINK_ALREADY_EXISTS') return reply.status(409).send({ error: error.message }) |
| 102 | + return handleDbError(error, request, reply) |
| 103 | + } |
| 104 | + }) |
| 105 | + |
| 106 | + app.post('/:id/share', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => { |
| 107 | + try { |
| 108 | + return reply.status(200).send(await cardService.shareCard(app, getUserId(request), request.params.id)) |
| 109 | + } catch (error: any) { |
| 110 | + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) |
| 111 | + if (error?.code === 'CARD_PRIVATE') return reply.status(403).send({ error: error.message }) |
| 112 | + return handleDbError(error, request, reply) |
| 113 | + } |
| 114 | + }) |
| 115 | + |
| 116 | + app.get('/share/:slug', async (request: FastifyRequest<{ Params: { slug: string } }>, reply: FastifyReply) => { |
| 117 | + try { |
| 118 | + const card = await cardService.getSharedCard(app, request.params.slug) |
| 119 | + const viewerId = getUserId(request) |
| 120 | + |
| 121 | + await app.prisma.$transaction([ |
| 122 | + app.prisma.card.update({ where: { id: card.id }, data: { viewCount: { increment: 1 } } }), |
| 123 | + app.prisma.cardView.create({ |
| 124 | + data: { |
| 125 | + cardId: card.id, |
| 126 | + ownerId: card.userId, |
| 127 | + viewerId, |
| 128 | + source: 'link', |
| 129 | + viewerIp: hashIp(request.ip), |
| 130 | + viewerAgent: request.headers['user-agent'] ?? 'unknown', |
| 131 | + }, |
| 132 | + }), |
| 133 | + ]) |
| 134 | + |
| 135 | + return reply.status(200).send(card) |
| 136 | + } catch (error: any) { |
| 137 | + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) |
| 138 | + return handleDbError(error, request, reply) |
| 139 | + } |
| 140 | + }) |
| 141 | + |
| 142 | + app.get('/:id/qr', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => { |
| 143 | + try { |
| 144 | + const qrImage = await cardService.generateCardQr(app, getUserId(request), request.params.id) |
| 145 | + return reply.type('image/png').send(qrImage) |
| 146 | + } catch (error: any) { |
| 147 | + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) |
| 148 | + if (error?.code === 'CARD_PRIVATE' || error?.code === 'QR_DISABLED') return reply.status(403).send({ error: error.message }) |
| 149 | + return handleDbError(error, request, reply) |
| 150 | + } |
| 151 | + }) |
| 152 | + |
| 153 | + app.get('/:id/analytics', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply) => { |
| 154 | + try { |
| 155 | + return reply.status(200).send(await cardService.cardAnalytics(app, getUserId(request), request.params.id)) |
| 156 | + } catch (error: any) { |
| 157 | + if (error?.code === 'CARD_NOT_FOUND') return reply.status(404).send({ error: error.message }) |
| 158 | + return handleDbError(error, request, reply) |
| 159 | + } |
| 160 | + }) |
138 | 161 | } |
0 commit comments