Skip to content

Commit c602ee0

Browse files
committed
feat: integrate card APIs in mobile
1 parent ffa4650 commit c602ee0

17 files changed

Lines changed: 1315 additions & 408 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
CREATE TYPE "CardVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE');
2+
3+
ALTER TABLE "cards"
4+
ADD COLUMN "description" TEXT,
5+
ADD COLUMN "slug" TEXT,
6+
ADD COLUMN "visibility" "CardVisibility" NOT NULL DEFAULT 'PUBLIC',
7+
ADD COLUMN "qr_enabled" BOOLEAN NOT NULL DEFAULT true,
8+
ADD COLUMN "view_count" INTEGER NOT NULL DEFAULT 0;
9+
10+
UPDATE "cards"
11+
SET "slug" = concat(
12+
trim(both '-' from regexp_replace(lower("title"), '[^a-z0-9]+', '-', 'g')),
13+
'-',
14+
substring("id" from 1 for 8)
15+
)
16+
WHERE "slug" IS NULL;
17+
18+
ALTER TABLE "cards"
19+
ALTER COLUMN "slug" SET NOT NULL;
20+
21+
CREATE UNIQUE INDEX "cards_slug_key" ON "cards"("slug");
22+
CREATE INDEX "cards_user_id_idx" ON "cards"("user_id");
23+
CREATE INDEX "cards_view_count_idx" ON "cards"("view_count");
24+
CREATE INDEX "card_views_card_id_idx" ON "card_views"("card_id");
25+
CREATE INDEX "card_views_owner_id_idx" ON "card_views"("owner_id");

apps/backend/prisma/schema.prisma

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,21 @@ model PlatformLink {
5454
@@map("platform_links")
5555
}
5656

57+
enum CardVisibility {
58+
PUBLIC
59+
UNLISTED
60+
PRIVATE
61+
}
62+
5763
model Card {
5864
id String @id @default(uuid())
5965
userId String @map("user_id")
6066
title String
67+
description String?
68+
slug String @unique
69+
visibility CardVisibility @default(PUBLIC)
70+
qrEnabled Boolean @default(true) @map("qr_enabled")
71+
viewCount Int @default(0) @map("view_count")
6172
isDefault Boolean @default(false) @map("is_default")
6273
createdAt DateTime @default(now()) @map("created_at")
6374
updatedAt DateTime @updatedAt @map("updated_at")
@@ -67,6 +78,8 @@ model Card {
6778
views CardView[]
6879
6980
@@map("cards")
81+
@@index([userId])
82+
@@index([viewCount])
7083
}
7184

7285
model CardLink {
@@ -114,6 +127,8 @@ model CardView {
114127
viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)
115128
116129
@@map("card_views")
130+
@@index([cardId])
131+
@@index([ownerId])
117132
}
118133

119134
model FollowLog {
@@ -194,4 +209,4 @@ model TeamMember{
194209
@@unique([userId, teamId])
195210
@@index([userId])
196211
@@map("team_members")
197-
}
212+
}

apps/backend/src/routes/auth.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,9 @@ export async function authRoutes(app: FastifyInstance) {
5757
// GitHub OAuth callback
5858
app.get('/github/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
5959
const { code, state } = request.query;
60+
const isMobileOAuth = state?.startsWith('mobile_');
6061
const storedState = request.cookies?.oauth_state;
61-
if (!state || !storedState || state !== storedState) {
62+
if (!state || (!isMobileOAuth && (!storedState || state !== storedState))) {
6263
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
6364
}
6465
reply.clearCookie('oauth_state', { path: '/' });
@@ -130,7 +131,7 @@ export async function authRoutes(app: FastifyInstance) {
130131

131132
const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });
132133

133-
if (request.query.state?.startsWith('mobile_')) {
134+
if (isMobileOAuth) {
134135
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
135136
return reply.redirect(`${mobileRedirect}#token=${token}`);
136137
}
@@ -183,8 +184,9 @@ export async function authRoutes(app: FastifyInstance) {
183184
app.get('/google/callback', async (request: FastifyRequest<{ Querystring: OAuthCallbackQuery }>, reply: FastifyReply) => {
184185
const { code, state } = request.query;
185186

187+
const isMobileOAuth = state?.startsWith('mobile_');
186188
const storedState = request.cookies?.oauth_state;
187-
if (!state || !storedState || state !== storedState) {
189+
if (!state || (!isMobileOAuth && (!storedState || state !== storedState))) {
188190
return reply.status(400).send({ error: 'Invalid or missing OAuth state — possible CSRF attack' });
189191
}
190192
reply.clearCookie('oauth_state', { path: '/' });
@@ -232,7 +234,7 @@ export async function authRoutes(app: FastifyInstance) {
232234

233235
const token = app.jwt.sign({ id: user.id, username: user.username }, { expiresIn: '30d' });
234236

235-
if (request.query.state?.startsWith('mobile_')) {
237+
if (isMobileOAuth) {
236238
const mobileRedirect = getMobileRedirectUri(request.query.state) || process.env.MOBILE_REDIRECT_URI;
237239
return reply.redirect(`${mobileRedirect}#token=${token}`);
238240
}

apps/backend/src/routes/cards.ts

Lines changed: 109 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -1,138 +1,161 @@
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'
45

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'
198

209
interface CardParams {
21-
id: string;
10+
id: string
2211
}
2312

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
3215
}
3316

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')
5019
}
5120

5221
export async function cardRoutes(app: FastifyInstance): Promise<void> {
5322
app.addHook('preHandler', async (request, reply) => {
54-
const server = request.server as any;
23+
const server = request.server as any
5524
if (typeof server?.authenticate === 'function') { await server.authenticate(request, reply); return }
5625
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+
})
6128

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> => {
6430
try {
65-
return await cardService.listCards(app, userId)
31+
return await cardService.listCards(app, getUserId(request))
6632
} catch (error) {
6733
return handleDbError(error, request, reply)
6834
}
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+
})
7636

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() })
8040

8141
try {
82-
const card = await cardService.createCard(app, userId, parsed.data)
42+
const card = await cardService.createCard(app, getUserId(request), parsed.data)
8343
return reply.status(201).send(card)
8444
} catch (error: any) {
8545
if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' })
8646
return handleDbError(error, request, reply)
8747
}
88-
});
89-
90-
// ─── Update Card ───
48+
})
9149

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() })
9553

9654
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)
10056
if (!updated) return reply.status(404).send({ error: 'Card not found' })
101-
return updated
57+
return reply.status(200).send(updated)
10258
} catch (error: any) {
10359
if (error?.code === 'OWNERSHIP') return reply.status(403).send({ error: 'One or more links do not belong to your account' })
10460
return handleDbError(error, request, reply)
10561
}
106-
});
107-
108-
// ─── Delete Card ───
62+
}
10963

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)
11366

67+
async function deleteCard(request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> {
11468
try {
115-
const res = await cardService.deleteCard(app, userId, id)
69+
const res = await cardService.deleteCard(app, getUserId(request), request.params.id)
11670
if (res && (res as any).code === 'NOT_FOUND') return reply.status(404).send({ error: 'Card not found' })
11771
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.' })
11872
return reply.status(204).send()
11973
} catch (error) {
12074
return handleDbError(error, request, reply)
12175
}
122-
});
76+
}
12377

124-
// ─── Set Default Card ───
78+
app.delete('/:id', deleteCard)
79+
app.delete('/:id/delete', deleteCard)
12580

12681
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-
13082
try {
131-
const resp = await cardService.setDefaultCard(app, userId, id)
83+
const resp = await cardService.setDefaultCard(app, getUserId(request), request.params.id)
13284
if (!resp) return reply.status(404).send({ error: 'Card not found' })
133-
return resp
85+
return reply.status(200).send(resp)
13486
} catch (error) {
13587
return handleDbError(error, request, reply)
13688
}
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+
})
138161
}

0 commit comments

Comments
 (0)