diff --git a/apps/backend/prisma/schema.prisma b/apps/backend/prisma/schema.prisma index 38fb91fe..2184aeaf 100644 --- a/apps/backend/prisma/schema.prisma +++ b/apps/backend/prisma/schema.prisma @@ -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 { @@ -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") @@ -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 { diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index e5f98762..d803d489 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -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'; +import { CardVisibility } from '@prisma/client'; +import { hashIp } from '../utils/refreshToken'; -interface CreateCardBody { +export interface CreateCardBody { title: string; linkIds: string[]; + description?: string; + visibility?: CardVisibility } -interface UpdateCardBody { - title?: string; - linkIds?: string[]; -} interface CardParams { id: string; @@ -57,7 +57,6 @@ export async function cardRoutes(app: FastifyInstance): Promise { }); // ─── List Cards ─── - app.get('/', async (request: FastifyRequest, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; try { @@ -67,8 +66,7 @@ export async function cardRoutes(app: FastifyInstance): Promise { } }); - // ─── Create Card ─── - + // ─── Creates Card ─── app.post('/', async (request: FastifyRequest<{ Body: CreateCardBody }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const parsed = createCardSchema.safeParse(request.body); @@ -81,14 +79,13 @@ export async function cardRoutes(app: FastifyInstance): Promise { 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 => { + 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; @@ -96,17 +93,20 @@ export async function cardRoutes(app: FastifyInstance): Promise { 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 => { + app.delete('/:id/delete', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const { id } = request.params; @@ -128,7 +128,6 @@ export async function cardRoutes(app: FastifyInstance): Promise { }); // ─── Set Default Card ─── - app.put('/:id/default', async (request: FastifyRequest<{ Params: CardParams }>, reply: FastifyReply): Promise => { const userId = (request.user as { id: string }).id; const { id } = request.params; @@ -136,9 +135,184 @@ export async function cardRoutes(app: FastifyInstance): Promise { 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) + } + }) } diff --git a/apps/backend/src/services/cardService.ts b/apps/backend/src/services/cardService.ts index fd3b9903..39377948 100644 --- a/apps/backend/src/services/cardService.ts +++ b/apps/backend/src/services/cardService.ts @@ -1,10 +1,24 @@ -import type { Prisma } from '@prisma/client'; +import { Card, CardVisibility, Prisma } from '@prisma/client'; + +import { generateUniqueSlug } from '../utils/slug'; + +import type { CreateCardBody } from '../routes/cards'; import type { FastifyInstance } from 'fastify'; +import QRCode from 'qrcode'; type CardLinkResponse = { platformLink: unknown }; type RawCard = { id: string; title: string; isDefault: boolean; cardLinks: CardLinkResponse[] }; export type CardResponse = { id: string; title: string; isDefault: boolean; links: unknown[] }; + +export interface UpdateCardBody{ + title?:string; + description?:string; + visibility?: CardVisibility; + qrEnabled?: boolean; +} + + function mapCard(card: RawCard): CardResponse { return { id: card.id, @@ -14,6 +28,7 @@ function mapCard(card: RawCard): CardResponse { }; } +//List card service export async function listCards(app: FastifyInstance, userId: string): Promise { const cards = (await app.prisma.card.findMany({ where: { userId }, @@ -25,17 +40,28 @@ export async function listCards(app: FastifyInstance, userId: string): Promise { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ - where: { id: { in: body.linkIds }, userId }, - select: { id: true }, - }); +//Creates card service +export async function createCard(app: FastifyInstance, userId: string, body: CreateCardBody): Promise { + const {title , description , linkIds , visibility} = body + + const ownedLinks = await app.prisma.platformLink.findMany({ + where: { id: { in: linkIds }, userId }, + select: { id: true }, + }); + + if (ownedLinks.length !== linkIds.length) { + throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); + } + + const finalSlug = await generateUniqueSlug(title, async(slug) => { + const existing = await app.prisma.card.findUnique({ + where: { + slug + } + }) + return !!existing + }) - if (ownedLinks.length !== body.linkIds.length) { - throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); - } - } const maxRetries = 3; for (let attempt = 1; attempt <= maxRetries; attempt++) { @@ -47,10 +73,13 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t return tx.card.create({ data: { userId, - title: body.title, + title, + slug: finalSlug, isDefault: cardCount === 0, + description, + visibility: visibility ?? CardVisibility.PUBLIC, cardLinks: { - create: body.linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), + create: linkIds.map((linkId, index) => ({ platformLinkId: linkId, displayOrder: index })), }, }, include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, @@ -72,65 +101,44 @@ export async function createCard(app: FastifyInstance, userId: string, body: { t ) { continue; } - app.log.error(error); - throw error; + throw error } } throw new Error('Failed to create card after retrying serialization conflicts'); } +//Update card service export async function updateCard( app: FastifyInstance, userId: string, id: string, - body: { title?: string; linkIds?: string[] }, -): Promise { - const existing = await app.prisma.card.findFirst({ where: { id, userId } }); - if (!existing) { - return null; - } + body: UpdateCardBody, +): Promise { + const {title, description, visibility, qrEnabled} = body - if (body.title) { - await app.prisma.card.update({ where: { id }, data: { title: body.title } }); - } - - if (body.linkIds) { - if (body.linkIds.length > 0) { - const ownedLinks = await app.prisma.platformLink.findMany({ - where: { id: { in: body.linkIds }, userId }, - select: { id: true }, - }); - - if (ownedLinks.length !== body.linkIds.length) { - throw Object.assign(new Error('Link ownership mismatch'), { code: 'OWNERSHIP' }); - } + const existing = await app.prisma.card.findFirst({ where: { id, userId } }); + if (!existing) { + throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); } - const linkIds = body.linkIds; - await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { - await tx.cardLink.deleteMany({ where: { cardId: id } }); - if (linkIds.length > 0) { - await tx.cardLink.createMany({ - data: linkIds.map((linkId, index) => ({ cardId: id, platformLinkId: linkId, displayOrder: index })), - }); + const updated = await app.prisma.card.update({ + where: { + id, + }, + data:{ + title, + description, + visibility, + qrEnabled } - }); - } - - const updated = (await app.prisma.card.findUnique({ - where: { id }, - include: { cardLinks: { include: { platformLink: true }, orderBy: { displayOrder: 'asc' } } }, - })) as unknown as RawCard | null; - - if (!updated) { - return null; - } + }) - return mapCard(updated); + return updated; } +//Delete card service export async function deleteCard(app: FastifyInstance, userId: string, id: string): Promise { return await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { const existing = await tx.card.findFirst({ where: { id, userId } }); @@ -159,11 +167,13 @@ export async function deleteCard(app: FastifyInstance, userId: string, id: strin }); } +//Set default card service export async function setDefaultCard(app: FastifyInstance, userId: string, id: string): Promise<{ message: string } | null> { const existing = await app.prisma.card.findFirst({ where: { id, userId } }); - if (!existing) { - return null; - } + + if (!existing) { + throw Object.assign(new Error('NotFound'), { code: 'NOT_FOUND' }); + } await app.prisma.$transaction(async (tx: Prisma.TransactionClient) => { await tx.card.updateMany({ where: { userId }, data: { isDefault: false } }); @@ -172,3 +182,197 @@ export async function setDefaultCard(app: FastifyInstance, userId: string, id: s return { message: 'Default card updated' }; } + +//Adds platfrom link +export async function addPlatFormLinks(app: FastifyInstance, userId: string, id:string, platformLinkId: string){ + const ownedCard = await app.prisma.card.findFirst({ + where: { + id, + userId + } + }) + + if (!ownedCard) { + throw Object.assign( + new Error('Card not found or you do not have permission to modify it'), + { code: 'CARD_NOT_FOUND' } + ); + } + const [existingLink, platformLink] = await Promise.all([ + app.prisma.cardLink.findUnique({ + where: { + cardId_platformLinkId: { + cardId: id, + platformLinkId, + }, + }, + }), + + app.prisma.platformLink.findFirst({ + where: { + id: platformLinkId, + userId, + }, + }), + ]); + + if (!platformLink) { + throw Object.assign( + new Error('Platform link not found or does not belong to your account'), + { code: 'PLATFORM_LINK_NOT_FOUND' } + ); + } + + if (existingLink) { + throw Object.assign( + new Error('This platform link has already been added to the card'), + { code: 'LINK_ALREADY_EXISTS' } + ); + } + + await app.prisma.cardLink.create({ + data: { + cardId: id, + platformLinkId + } + }) +} + +//Shares card +export async function shareCard(app: FastifyInstance, userId:string, id: string){ + const card = await app.prisma.card.findFirst({ + where:{ + id, + userId + } + }) + + if (!card) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + + if(card?.visibility === CardVisibility.PRIVATE){ + throw Object.assign( + new Error('Private cards cannot be shared'), + { code: 'CARD_PRIVATE' } + ); + } + + return { + shareUrl: `/cards/share/${card.slug}`, + }; +} + +//Gets share card +export async function getSharedCard(app:FastifyInstance, slug:string){ + const card = await app.prisma.card.findUnique({ + where: { + slug + }, + include: { + cardLinks: { + include: { + platformLink: true + } + } + } + }) + + if(!card){ + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + return card +} + +//Genreate qr +export async function genrateQr(app: FastifyInstance,userId:string, id: string){ + const card = await app.prisma.card.findFirst({ + where:{ + id, + userId + } + }) + + if (!card) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + + if(card?.visibility === CardVisibility.PRIVATE){ + throw Object.assign( + new Error('Private cards cannot be shared'), + { code: 'CARD_PRIVATE' } + ); + } + + if(!card.qrEnabled){ + throw Object.assign( + new Error('QR is not availbled for this card'), + { code: 'QR_DISABLED' } + ); + } + + const shareUrl = `${process.env.MOBILE_REDIRECT_URI}/cards/share/${card.slug}` + const qrImage = await QRCode.toBuffer(shareUrl); + + if(!qrImage){ + throw Object.assign( + new Error('QR generation failed'), + { code: 'QR_IMAGE' } + ); + } + + return qrImage; + + +} + +//TODO:Add pagination +export async function cardAnalytics(app: FastifyInstance, userId:string, id: string){ + const cardAnalytics = await app.prisma.card.findFirst({ + where: { + id, + userId + }, + include: { + views: { + orderBy: { + createdAt: 'desc' + }, + include: { + viewer : { + select: { + id:true, + username: true, + avatarUrl: true, + displayName: true, + role: true, + accentColor: true + } + } + } + } + }, + + }) + + if (!cardAnalytics) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + return cardAnalytics +} \ No newline at end of file diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..8e54c207 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -45,12 +45,3 @@ export const reorderLinksSchema = z.object({ ), }); -export const createCardSchema = z.object({ - title: z.string().min(1).max(100), - linkIds: z.array(z.string().uuid()), -}); - -export const updateCardSchema = z.object({ - title: z.string().min(1).max(100).optional(), - linkIds: z.array(z.string().uuid()).optional(), -}); diff --git a/apps/backend/src/validations/card.validation.ts b/apps/backend/src/validations/card.validation.ts new file mode 100644 index 00000000..21257501 --- /dev/null +++ b/apps/backend/src/validations/card.validation.ts @@ -0,0 +1,44 @@ +import { CardVisibility } from '@prisma/client'; +import {z} from 'zod' + +export const createCardSchema = z.object({ + title: z.string().min(1).max(100), + + linkIds: z + .array(z.string().uuid()) + .nonempty({ + message: 'At least one link is required', + }) + .refine( + (ids) => new Set(ids).size === ids.length, + { + message: 'Duplicate links are not allowed', + } + ), + description: z.string().min(1).max(100).optional(), + visibility: z.nativeEnum(CardVisibility).optional(), +}); + +export const updateCardSchema = z + .object({ + title: z.string().min(1).max(100).optional(), + description: z.string().min(1).max(100).optional(), + visibility: z.nativeEnum(CardVisibility).optional(), + qrEnabled: z.boolean().optional(), + }) + .refine( + (data) => + data.title !== undefined || + data.description !== undefined || + data.visibility !== undefined || + data.qrEnabled !== undefined, + { + message: 'At least one field must be provided', + } +); + +export const addPlatformLinkSchema = z.object({ + platformLinkId: z.string().uuid({ + message: 'Invalid platform link ID', + }), +}); \ No newline at end of file