Skip to content
25 changes: 24 additions & 1 deletion apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -95,19 +95,40 @@ model PlatformLink {
@@map("platform_links")
}

enum CardVisibility {
PUBLIC // Anyone can view the card
UNLISTED // Anyone with the link can view, but not publicly listed
PRIVATE // Only the card owner can view
}

model Card {
id String @id @default(uuid())
userId String @map("user_id")

title String
description String?

slug String @unique

visibility CardVisibility @default(PUBLIC)

qrEnabled Boolean @default(true)

viewCount Int @default(0)

isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

cardLinks CardLink[]
views CardView[]


@@map("cards")
@@index([userId])
@@index([viewCount])
}

model CardLink {
Expand Down Expand Up @@ -145,7 +166,7 @@ model CardView {
cardId String? @map("card_id") // null = default profile view
ownerId String @map("owner_id") // card/profile owner
viewerId String? @map("viewer_id") // null = anonymous web viewer
viewerIp String? @map("viewer_ip")
viewerIp String? @map("viewer_ip") //hashed
viewerAgent String? @map("viewer_agent")
source String @default("qr") // "qr" | "link" | "web" | "app"
createdAt DateTime @default(now()) @map("created_at")
Expand All @@ -155,6 +176,8 @@ model CardView {
viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)

@@map("card_views")
@@index([cardId])
@@index([ownerId])
}

model FollowLog {
Expand Down
218 changes: 196 additions & 22 deletions apps/backend/src/routes/cards.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import * as cardService from '../services/cardService'
import { handleDbError } from '../utils/error.util.js';
import { createCardSchema, updateCardSchema } from '../utils/validators.js';
import { createCardSchema ,updateCardSchema, addPlatformLinkSchema} from '../validations/card.validation';

import type { CardResponse } from '../services/cardService';
import type { CardResponse, UpdateCardBody } from '../services/cardService';
import type { Card } from '@devcard/shared';
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';

Check failure on line 7 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups
import { CardVisibility } from '@prisma/client';

Check failure on line 8 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`@prisma/client` import should occur before import of `../services/cardService`

Check failure on line 8 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

There should be at least one empty line between import groups

Check failure on line 8 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

All imports in the declaration are only used as types. Use `import type`
import { hashIp } from '../utils/refreshToken';

Check failure on line 9 in apps/backend/src/routes/cards.ts

View workflow job for this annotation

GitHub Actions / backend-ci

`../utils/refreshToken` import should occur before import of `../validations/card.validation`

interface CreateCardBody {
export interface CreateCardBody {
title: string;
linkIds: string[];
description?: string;
visibility?: CardVisibility
}

interface UpdateCardBody {
title?: string;
linkIds?: string[];
}

interface CardParams {
id: string;
Expand Down Expand Up @@ -57,7 +57,6 @@
});

// ─── List Cards ───

app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise<CardResponse[] | void> => {
const userId = (request.user as { id: string }).id;
try {
Expand All @@ -67,8 +66,7 @@
}
});

// ─── Create Card ───

// ─── Creates Card ───
app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise<Card | void> => {
const userId = (request.user as { id: string }).id;
const parsed = createCardSchema.safeParse(request.body);
Expand All @@ -81,32 +79,34 @@
const card = await cardService.createCard(app, userId, parsed.data)
return reply.status(201).send(card)
} catch (error: any) {
if (error?.code === 'OWNERSHIP') {return reply.status(403).send({ error: 'One or more links do not belong to your account' })}
if (error?.code === 'OWNERSHIP'){return reply.status(403).send({ error: 'One or more links do not belong to your account' })}
return handleDbError(error, request, reply)
}
});

// ─── Update Card ───

app.put('/:id', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply): Promise<CardResponse> => {
app.put('/:id/update', async (request: FastifyRequest<{ Params: CardParams; Body: UpdateCardBody }>, reply: FastifyReply) => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;

try {
const parsed = updateCardSchema.safeParse(request.body)
if (!parsed.success) {return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() })}
const updated = await cardService.updateCard(app, userId, id, parsed.data)
if (!updated) {return reply.status(404).send({ error: 'Card not found' })}
return updated
return reply.status(200).send(updated)
} catch (error: any) {
if (error?.code === 'OWNERSHIP') {return reply.status(403).send({ error: 'One or more links do not belong to your account' })}
return handleDbError(error, request, reply)
if(error.code === 'NOT_FOUND'){
return reply.status(404).send({
error: error.message,
});
}
app.log.error(error)
handleDbError(error,request,reply)
}
});

// ─── Delete Card ───

app.delete('/:id', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> => {
app.delete('/:id/delete', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<void> => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;

Expand All @@ -128,17 +128,191 @@
});

// ─── Set Default Card ───

app.put('/:id/default', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise<object | void> => {
const userId = (request.user as { id: string }).id;
const { id } = request.params;

try {
const resp = await cardService.setDefaultCard(app, userId, id)
if (!resp) {return reply.status(404).send({ error: 'Card not found' })}
return resp
} catch (error) {
return reply.status(200).send('Default card updated')
} catch (error:any) {
if(error.code === 'NOT_FOUND'){
return reply.status(404).send({
error: error.message,
});
}
app.log.error(error)
return handleDbError(error, request, reply)
}
});

//Add platform-link
app.put('/:id/platform-link', async(request: FastifyRequest<{Params:{id: string}, Body: {platformLinkId: string}}>, reply: FastifyReply) => {
const cardId = request.params.id;
const userId = request.user.id;
const parsed = addPlatformLinkSchema.safeParse(request.body);

if (!parsed.success) {
return reply.status(400).send({ error: 'Validation failed', details: parsed.error.flatten() });
}

try {

const platformLinkId = parsed.data.platformLinkId
await cardService.addPlatFormLinks(app, userId, cardId, platformLinkId)

return reply.status(200).send('Platform link added successfully')

} catch (error: any) {
if (error?.code === 'CARD_NOT_FOUND') {
return reply.status(404).send({
error: error.message,
});
}

if (error?.code === 'PLATFORM_LINK_NOT_FOUND') {
return reply.status(403).send({
error: error.message,
});
}

if (error?.code === 'LINK_ALREADY_EXISTS') {
return reply.status(409).send({
error: error.message,
});
}
app.log.error(error)
handleDbError(error, request, reply)
}
})

//Share card
app.post('/:id/share',async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => {
const cardId = request.params.id;
const userId = request.user.id;

try {
const link = await cardService.shareCard(app, userId, cardId);
return reply.status(200).send(link)
} catch (error:any) {
if (error?.code === 'CARD_NOT_FOUND') {
return reply.status(404).send({
error: error.message,
});
}
if (error?.code === 'CARD_PRIVATE') {
return reply.status(403).send({
error: error.message,
});
}

app.log.error(error)
handleDbError(error, request, reply)
}
})

// TODO:
// Determine view source dynamically (url, qr, app, etc.).
// The shared card endpoint is currently used by multiple entry points,
// so source should not be hardcoded to "link".
//Get shared card
app.get('/share/:slug', async(request: FastifyRequest<{Params: {slug: string}}>, reply: FastifyReply) => {
const paramsSlug = request.params.slug;
const userId = request.user.id
const ip = hashIp(request.ip);
const userAgent = request.headers['user-agent'] ?? 'unknown';


try {
const card = await cardService.getSharedCard(app, paramsSlug)

await app.prisma.$transaction([
app.prisma.card.update({
where: {
id: card.id,
},
data: {
viewCount: {
increment: 1,
},
},
}),

app.prisma.cardView.create({
data: {
cardId: card.id,
ownerId: card.userId,
viewerId: userId,
source: 'link',
viewerIp: ip,
viewerAgent: userAgent,
},
}),
]);
return reply.status(200).send(card)
} catch (error:any) {
if (error?.code === 'CARD_NOT_FOUND') {
return reply.status(404).send({
error: error.message,
});
}

app.log.error(error)
handleDbError(error, request,reply)
}
})

//Generates qr
app.get('/:id/qr', async(request: FastifyRequest<{Params: {id: string}}>, reply:FastifyReply) => {
const cardId = request.params.id
const userId = request.user.id

try {
const qrImage = await cardService.genrateQr(app, userId, cardId)
return reply.type('image/png').send(qrImage)
} catch (error:any) {
if (error?.code === 'CARD_NOT_FOUND') {
return reply.status(404).send({
error: error.message,
});
}
if (error?.code === 'CARD_PRIVATE') {
return reply.status(403).send({
error: error.message,
});
}
if (error?.code === 'QR_DISABLED') {
return reply.status(403).send({
error: error.message,
});
}
if (error?.code === 'QR_IMAGE') {
return reply.status(500).send({
error: error.message,
});
}
app.log.error(error)
handleDbError(error,request, reply)
}
})

//Get analytics
app.get('/:id/analytics', async(request:FastifyRequest<{Params: {id:string}}>, reply: FastifyReply) => {
const cardId = request.params.id
const userId = request.user.id

try {
const analytics = await cardService.cardAnalytics(app, userId,cardId)
return reply.status(200).send(analytics)
} catch (error:any) {
if (error?.code === 'CARD_NOT_FOUND') {
return reply.status(404).send({
error: error.message,
});
}
app.log.error(error)
handleDbError(error , request, reply)
}
})
}
Loading
Loading