diff --git a/apps/backend/prisma/migrations/20260610080236_db/migration.sql b/apps/backend/prisma/migrations/20260610080236_db/migration.sql new file mode 100644 index 00000000..8524802f --- /dev/null +++ b/apps/backend/prisma/migrations/20260610080236_db/migration.sql @@ -0,0 +1,89 @@ +-- CreateEnum +CREATE TYPE "TeamRole" AS ENUM ('OWNER', 'ADMIN', 'MEMBER'); + +-- CreateTable +CREATE TABLE "Event" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "location" TEXT NOT NULL, + "description" TEXT, + "organizerId" TEXT NOT NULL, + "startDate" TIMESTAMP(3) NOT NULL, + "endDate" TIMESTAMP(3) NOT NULL, + "isPublic" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Event_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "EventAttendee" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "eventId" TEXT NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "EventAttendee_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "teams" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "avatarUrl" TEXT, + "ownerId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "teams_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "team_members" ( + "id" TEXT NOT NULL, + "teamId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "role" "TeamRole" NOT NULL, + "joinedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "team_members_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Event_slug_key" ON "Event"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "EventAttendee_userId_eventId_key" ON "EventAttendee"("userId", "eventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "teams_slug_key" ON "teams"("slug"); + +-- CreateIndex +CREATE INDEX "teams_slug_idx" ON "teams"("slug"); + +-- CreateIndex +CREATE INDEX "team_members_userId_idx" ON "team_members"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "team_members_userId_teamId_key" ON "team_members"("userId", "teamId"); + +-- AddForeignKey +ALTER TABLE "Event" ADD CONSTRAINT "Event_organizerId_fkey" FOREIGN KEY ("organizerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttendee" ADD CONSTRAINT "EventAttendee_eventId_fkey" FOREIGN KEY ("eventId") REFERENCES "Event"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "EventAttendee" ADD CONSTRAINT "EventAttendee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "teams" ADD CONSTRAINT "teams_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "teams"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260610083544_devcard_auth/migration.sql b/apps/backend/prisma/migrations/20260610083544_devcard_auth/migration.sql new file mode 100644 index 00000000..821aed48 --- /dev/null +++ b/apps/backend/prisma/migrations/20260610083544_devcard_auth/migration.sql @@ -0,0 +1,72 @@ +/* + Warnings: + + - You are about to drop the column `provider` on the `users` table. All the data in the column will be lost. + - You are about to drop the column `provider_id` on the `users` table. All the data in the column will be lost. + - A unique constraint covering the columns `[phone_number]` on the table `users` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateEnum +CREATE TYPE "Role" AS ENUM ('SUPERADMIN', 'ADMIN', 'USER'); + +-- DropIndex +DROP INDEX "users_provider_provider_id_key"; + +-- AlterTable +ALTER TABLE "users" DROP COLUMN "provider", +DROP COLUMN "provider_id", +ADD COLUMN "authRole" "Role" NOT NULL DEFAULT 'USER', +ADD COLUMN "email_verified" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "isActive" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "last_sign_in_at" TIMESTAMP(3), +ADD COLUMN "phone_number" TEXT; + +-- CreateTable +CREATE TABLE "user_identities" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "provider" TEXT NOT NULL, + "provider_id" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "refresh_tokens" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "token_hash" TEXT NOT NULL, + "family" TEXT NOT NULL, + "expires_at" TIMESTAMP(3) NOT NULL, + "revoked_at" TIMESTAMP(3), + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "user_agent" TEXT, + "ip" TEXT, + + CONSTRAINT "refresh_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_identities_user_id_idx" ON "user_identities"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_identities_provider_provider_id_key" ON "user_identities"("provider", "provider_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "refresh_tokens_token_hash_key" ON "refresh_tokens"("token_hash"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens"("user_id"); + +-- CreateIndex +CREATE INDEX "refresh_tokens_family_idx" ON "refresh_tokens"("family"); + +-- CreateIndex +CREATE UNIQUE INDEX "users_phone_number_key" ON "users"("phone_number"); + +-- AddForeignKey +ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/apps/backend/prisma/migrations/20260614091507_card/migration.sql b/apps/backend/prisma/migrations/20260614091507_card/migration.sql new file mode 100644 index 00000000..9f97d1b8 --- /dev/null +++ b/apps/backend/prisma/migrations/20260614091507_card/migration.sql @@ -0,0 +1,25 @@ +/* + Warnings: + + - A unique constraint covering the columns `[slug]` on the table `cards` will be added. If there are existing duplicate values, this will fail. + - Added the required column `slug` to the `cards` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "CardVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE'); + +-- AlterTable +ALTER TABLE "cards" ADD COLUMN "description" TEXT, +ADD COLUMN "qrEnabled" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "slug" TEXT NOT NULL, +ADD COLUMN "viewCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "visibility" "CardVisibility" NOT NULL DEFAULT 'PUBLIC'; + +-- CreateIndex +CREATE UNIQUE INDEX "cards_slug_key" ON "cards"("slug"); + +-- CreateIndex +CREATE INDEX "cards_slug_idx" ON "cards"("slug"); + +-- CreateIndex +CREATE INDEX "cards_viewCount_idx" ON "cards"("viewCount"); diff --git a/apps/backend/prisma/migrations/20260614093817_card/migration.sql b/apps/backend/prisma/migrations/20260614093817_card/migration.sql new file mode 100644 index 00000000..314fe9dd --- /dev/null +++ b/apps/backend/prisma/migrations/20260614093817_card/migration.sql @@ -0,0 +1,11 @@ +-- DropIndex +DROP INDEX "cards_slug_idx"; + +-- CreateIndex +CREATE INDEX "card_views_card_id_idx" ON "card_views"("card_id"); + +-- CreateIndex +CREATE INDEX "card_views_owner_id_idx" ON "card_views"("owner_id"); + +-- CreateIndex +CREATE INDEX "cards_user_id_idx" ON "cards"("user_id"); diff --git a/apps/backend/prisma/migrations/20260615154452_new_seed/migration.sql b/apps/backend/prisma/migrations/20260615154452_new_seed/migration.sql new file mode 100644 index 00000000..e4f2a636 --- /dev/null +++ b/apps/backend/prisma/migrations/20260615154452_new_seed/migration.sql @@ -0,0 +1,34 @@ +/* + Warnings: + + - You are about to drop the column `description` on the `cards` table. All the data in the column will be lost. + - You are about to drop the column `qrEnabled` on the `cards` table. All the data in the column will be lost. + - You are about to drop the column `slug` on the `cards` table. All the data in the column will be lost. + - You are about to drop the column `viewCount` on the `cards` table. All the data in the column will be lost. + - You are about to drop the column `visibility` on the `cards` table. All the data in the column will be lost. + +*/ +-- DropIndex +DROP INDEX "card_views_card_id_idx"; + +-- DropIndex +DROP INDEX "card_views_owner_id_idx"; + +-- DropIndex +DROP INDEX "cards_slug_key"; + +-- DropIndex +DROP INDEX "cards_user_id_idx"; + +-- DropIndex +DROP INDEX "cards_viewCount_idx"; + +-- AlterTable +ALTER TABLE "cards" DROP COLUMN "description", +DROP COLUMN "qrEnabled", +DROP COLUMN "slug", +DROP COLUMN "viewCount", +DROP COLUMN "visibility"; + +-- DropEnum +DROP TYPE "CardVisibility"; diff --git a/apps/backend/prisma/migrations/20260616000000_card_sharing_fields/migration.sql b/apps/backend/prisma/migrations/20260616000000_card_sharing_fields/migration.sql new file mode 100644 index 00000000..b8c526d3 --- /dev/null +++ b/apps/backend/prisma/migrations/20260616000000_card_sharing_fields/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "CardVisibility" AS ENUM ('PUBLIC', 'UNLISTED', 'PRIVATE'); + +-- AlterTable +ALTER TABLE "cards" ADD COLUMN "description" TEXT, +ADD COLUMN "qrEnabled" BOOLEAN NOT NULL DEFAULT true, +ADD COLUMN "slug" TEXT NOT NULL, +ADD COLUMN "viewCount" INTEGER NOT NULL DEFAULT 0, +ADD COLUMN "visibility" "CardVisibility" NOT NULL DEFAULT 'PUBLIC'; + +-- CreateIndex +CREATE INDEX "card_views_card_id_idx" ON "card_views"("card_id"); + +-- CreateIndex +CREATE INDEX "card_views_owner_id_idx" ON "card_views"("owner_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "cards_slug_key" ON "cards"("slug"); + +-- CreateIndex +CREATE INDEX "cards_user_id_idx" ON "cards"("user_id"); + +-- CreateIndex +CREATE INDEX "cards_viewCount_idx" ON "cards"("viewCount"); + 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/prisma/seed.ts b/apps/backend/prisma/seed.ts index 8f3c6428..44cec8d1 100644 --- a/apps/backend/prisma/seed.ts +++ b/apps/backend/prisma/seed.ts @@ -1,27 +1,46 @@ -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, Role, CardVisibility, TeamRole } from '@prisma/client'; const prisma = new PrismaClient(); async function main() { - console.log('Seeding database.......'); + console.log('Seeding DevCard database...'); + + // --------------------------------------------------------------------------- + // Reset existing demo data (idempotent re-runs). + // Order matters: Team.owner uses onDelete: Restrict, so teams must be removed + // before their owning user. Most other relations cascade from the user. + // --------------------------------------------------------------------------- + const existing = await prisma.user.findUnique({ + where: { username: 'devcard-demo' }, + select: { id: true }, + }); - const user = await prisma.user.upsert({ - where: { - username: 'devcard-demo', - }, - update: {}, - create: { + if (existing) { + await prisma.teamMember.deleteMany({ where: { userId: existing.id } }); + await prisma.team.deleteMany({ where: { ownerId: existing.id } }); + await prisma.eventAttendee.deleteMany({ where: { userId: existing.id } }); + await prisma.event.deleteMany({ where: { organizerId: existing.id } }); + await prisma.user.delete({ where: { id: existing.id } }); + } + + // --------------------------------------------------------------------------- + // User + // --------------------------------------------------------------------------- + const testUser = await prisma.user.create({ + data: { email: 'demo@devcard.dev', username: 'devcard-demo', displayName: 'Alex Chen', bio: 'Full-stack developer • Open source enthusiast • Builder of things', pronouns: 'they/them', role: 'Senior Software Engineer', + authRole: Role.USER, company: 'OpenSource Inc.', + avatarUrl: null, accentColor: '#6366f1', - isActive: true, emailVerified: true, - + phoneNumber: '+10000000000', + isActive: true, identities: { create: { provider: 'github', @@ -31,124 +50,50 @@ async function main() { }, }); - console.log(`User created: ${user.username}`); - - await prisma.cardLink.deleteMany({}); - await prisma.card.deleteMany({}); - await prisma.platformLink.deleteMany({ - where: { - userId: user.id, - }, - }); - - const links = await Promise.all([ - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'github', - username: 'alexchen', - url: 'https://github.com/alexchen', - displayOrder: 0, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'linkedin', - username: 'alexchen-dev', - url: 'https://linkedin.com/in/alexchen-dev', - displayOrder: 1, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'twitter', - username: 'alexchendev', - url: 'https://x.com/alexchendev', - displayOrder: 2, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'portfolio', - username: 'alexchen.dev', - url: 'https://alexchen.dev', - displayOrder: 3, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'devfolio', - username: 'alexchen', - url: 'https://devfolio.co/@alexchen', - displayOrder: 4, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'leetcode', - username: 'alexchen', - url: 'https://leetcode.com/u/alexchen', - displayOrder: 5, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'discord', - username: 'alexchen#4242', - url: '', - displayOrder: 6, - }, - }), - - prisma.platformLink.create({ - data: { - userId: user.id, - platform: 'email', - username: 'alex@devcard.dev', - url: 'mailto:alex@devcard.dev', - displayOrder: 7, - }, - }), - ]); + console.log(` Created user: ${testUser.displayName} (@${testUser.username})`); + + // --------------------------------------------------------------------------- + // Platform links + // --------------------------------------------------------------------------- + const linkData = [ + { platform: 'github', username: 'alexchen', url: 'https://github.com/alexchen' }, + { platform: 'linkedin', username: 'alexchen-dev', url: 'https://www.linkedin.com/in/alexchen-dev' }, + { platform: 'twitter', username: 'alexchendev', url: 'https://x.com/alexchendev' }, + { platform: 'devfolio', username: 'alexchen', url: 'https://devfolio.co/@alexchen' }, + { platform: 'portfolio', username: 'https://alexchen.dev', url: 'https://alexchen.dev' }, + { platform: 'leetcode', username: 'alexchen', url: 'https://leetcode.com/u/alexchen' }, + { platform: 'discord', username: 'alexchen#4242', url: '' }, + { platform: 'email', username: 'alex@devcard.dev', url: 'mailto:alex@devcard.dev' }, + ]; + + const links = await Promise.all( + linkData.map((data, displayOrder) => + prisma.platformLink.create({ + data: { userId: testUser.id, displayOrder, ...data }, + }) + ) + ); - console.log(`${links.length} platform links created`); + console.log(` Created ${links.length} platform links`); + // --------------------------------------------------------------------------- + // Cards + // --------------------------------------------------------------------------- const professionalCard = await prisma.card.create({ data: { - userId: user.id, + userId: testUser.id, title: 'Professional', + description: 'My professional links for work and networking.', + slug: 'devcard-demo-professional', + visibility: CardVisibility.PUBLIC, + qrEnabled: true, isDefault: true, - cardLinks: { create: [ - { - platformLinkId: links[0].id, - displayOrder: 0, - }, - { - platformLinkId: links[1].id, - displayOrder: 1, - }, - { - platformLinkId: links[2].id, - displayOrder: 2, - }, - { - platformLinkId: links[3].id, - displayOrder: 3, - }, + { platformLinkId: links[0].id, displayOrder: 0 }, // GitHub + { platformLinkId: links[1].id, displayOrder: 1 }, // LinkedIn + { platformLinkId: links[2].id, displayOrder: 2 }, // Twitter + { platformLinkId: links[4].id, displayOrder: 3 }, // Portfolio ], }, }, @@ -156,44 +101,80 @@ async function main() { const hackathonCard = await prisma.card.create({ data: { - userId: user.id, + userId: testUser.id, title: 'Hackathon', - + description: 'Find me at hackathons and dev events.', + slug: 'devcard-demo-hackathon', + visibility: CardVisibility.UNLISTED, + qrEnabled: true, + isDefault: false, cardLinks: { create: [ - { - platformLinkId: links[0].id, - displayOrder: 0, - }, - { - platformLinkId: links[4].id, - displayOrder: 1, - }, - { - platformLinkId: links[6].id, - displayOrder: 2, - }, - { - platformLinkId: links[2].id, - displayOrder: 3, - }, + { platformLinkId: links[0].id, displayOrder: 0 }, // GitHub + { platformLinkId: links[3].id, displayOrder: 1 }, // Devfolio + { platformLinkId: links[6].id, displayOrder: 2 }, // Discord + { platformLinkId: links[2].id, displayOrder: 3 }, // Twitter ], }, }, }); - console.log( - `Cards created: ${professionalCard.title}, ${hackathonCard.title}`, - ); + console.log(` Created cards: "${professionalCard.title}", "${hackathonCard.title}"`); + + // --------------------------------------------------------------------------- + // Event + attendee + // --------------------------------------------------------------------------- + const event = await prisma.event.create({ + data: { + name: 'DevCard Launch Hackathon', + slug: 'devcard-launch-hackathon', + location: 'San Francisco, CA', + description: 'A weekend hackathon to celebrate the DevCard launch.', + organizerId: testUser.id, + startDate: new Date('2026-07-01T09:00:00Z'), + endDate: new Date('2026-07-03T18:00:00Z'), + isPublic: true, + attendees: { + create: { + userId: testUser.id, + joinedAt: new Date('2026-06-15T12:00:00Z'), + }, + }, + }, + }); - console.log('\nSeed complete'); + console.log(` Created event: "${event.name}"`); + + // --------------------------------------------------------------------------- + // Team + membership + // --------------------------------------------------------------------------- + const team = await prisma.team.create({ + data: { + name: 'OpenSource Inc.', + slug: 'opensource-inc', + description: 'The team behind DevCard.', + avatarUrl: null, + ownerId: testUser.id, + members: { + create: { + userId: testUser.id, + role: TeamRole.OWNER, + joinedAt: new Date('2026-06-10T08:00:00Z'), + }, + }, + }, + }); + + console.log(` Created team: "${team.name}"`); + + console.log('\nSeed complete! Try: GET /api/u/devcard-demo'); } main() - .catch((err) => { - console.error('Seed failed', err); + .catch((error) => { + console.error('Seed failed:', error); return; }) .finally(async () => { await prisma.$disconnect(); - }); \ No newline at end of file + }); diff --git a/apps/backend/src/__tests__/cards.test.ts b/apps/backend/src/__tests__/cards.test.ts index 3542a539..a8d78e9c 100644 --- a/apps/backend/src/__tests__/cards.test.ts +++ b/apps/backend/src/__tests__/cards.test.ts @@ -1,3 +1,4 @@ +import { CardVisibility } from '@prisma/client'; import Fastify, { type FastifyInstance } from 'fastify'; import { describe, it, expect, beforeEach, vi } from 'vitest'; @@ -7,7 +8,7 @@ import type { PrismaClient } from '@prisma/client'; const USER_ID = 'user-123'; const CARD_ID = 'card-abc'; -// Must be valid UUIDs — createCardSchema and updateCardSchema use z.string().uuid() +// Must be valid UUIDs — the card/link schemas use z.string().uuid() const OWNED_LINK_ID = '11111111-1111-1111-1111-111111111111'; const FOREIGN_LINK_ID = '22222222-2222-2222-2222-222222222222'; @@ -15,14 +16,21 @@ const mockCard = { id: CARD_ID, userId: USER_ID, title: 'My Card', + slug: 'my-card', + description: null, + visibility: CardVisibility.PUBLIC, + qrEnabled: true, + viewCount: 0, isDefault: true, createdAt: new Date(), updatedAt: new Date(), cardLinks: [], }; -// $transaction executes the callback synchronously against the same mock client, -// mirroring Prisma's interactive-transactions API without a real DB connection. +// $transaction is used in two shapes by the service/routes: +// 1. interactive: $transaction(async (tx) => ...) — runs the callback against the mock client +// 2. sequential: $transaction([p1, p2]) — resolves an array of pre-built promises +// The mock supports both so error/rollback paths can be asserted without a real DB. const mockPrisma = { card: { count: vi.fn(), @@ -35,24 +43,35 @@ const mockPrisma = { delete: vi.fn(), }, cardLink: { - deleteMany: vi.fn(), - createMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), }, platformLink: { findMany: vi.fn(), + findFirst: vi.fn(), + }, + cardView: { + create: vi.fn(), }, $transaction: vi.fn(), }; -// Re-wire $transaction before every test so that it executes the callback -// against the same mock client, preserving existing per-operation mocks. +// Re-wire $transaction before every test so that the interactive form executes the +// callback against the same mock client (preserving per-operation mocks), and the +// sequential array form resolves like Prisma's Promise.all semantics. function wireTransaction(): void { - mockPrisma.$transaction.mockImplementation( - async (callback: (tx: typeof mockPrisma) => Promise) => callback(mockPrisma), - ); + mockPrisma.$transaction.mockImplementation(async (arg: unknown) => { + if (typeof arg === 'function') { + return (arg as (tx: typeof mockPrisma) => Promise)(mockPrisma); + } + if (Array.isArray(arg)) { + return Promise.all(arg); + } + return undefined; + }); } -async function buildApp():Promise { +async function buildApp(): Promise { const app = Fastify({ logger: false }); app.decorate('prisma', mockPrisma as unknown as PrismaClient); app.decorate('authenticate', async (request: any) => { @@ -67,7 +86,7 @@ async function buildApp():Promise { // POST /api/cards // ───────────────────────────────────────────────────────────────────────────── -describe('POST /api/cards — link ownership validation', () => { +describe('POST /api/cards — create & link ownership validation', () => { beforeEach(() => { vi.clearAllMocks(); wireTransaction(); @@ -106,6 +125,7 @@ describe('POST /api/cards — link ownership validation', () => { it('creates the card when all linkIds are owned by the user', async () => { mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); // slug is unique mockPrisma.card.count.mockResolvedValue(0); mockPrisma.card.create.mockResolvedValue({ ...mockCard, cardLinks: [] }); @@ -121,12 +141,12 @@ describe('POST /api/cards — link ownership validation', () => { where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, select: { id: true }, }); + // Creation runs inside the (serializable) transaction + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.card.create).toHaveBeenCalled(); }); - it('skips the ownership check and creates the card when linkIds is empty', async () => { - mockPrisma.card.count.mockResolvedValue(1); - mockPrisma.card.create.mockResolvedValue({ ...mockCard, isDefault: false, cardLinks: [] }); - + it('returns 400 when linkIds is empty (schema now requires at least one link)', async () => { const app = await buildApp(); const res = await app.inject({ method: 'POST', @@ -134,8 +154,42 @@ describe('POST /api/cards — link ownership validation', () => { payload: { title: 'Empty Card', linkIds: [] }, }); - expect(res.statusCode).toBe(201); + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Validation failed'); + // Validation fails before any DB work expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + expect(mockPrisma.card.create).not.toHaveBeenCalled(); + }); + + it('returns 400 when duplicate linkIds are supplied', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Dupe Card', linkIds: [OWNED_LINK_ID, OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(400); + expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + }); + + it('retries and succeeds when the create hits a serialization conflict (P2034)', async () => { + mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); + mockPrisma.card.count.mockResolvedValue(0); + mockPrisma.card.create + .mockRejectedValueOnce(Object.assign(new Error('serialization failure'), { code: 'P2034' })) + .mockResolvedValueOnce({ ...mockCard, cardLinks: [] }); + + const app = await buildApp(); + const res = await app.inject({ + method: 'POST', + url: '/api/cards', + payload: { title: 'Test Card', linkIds: [OWNED_LINK_ID] }, + }); + + expect(res.statusCode).toBe(201); + expect(mockPrisma.$transaction).toHaveBeenCalledTimes(2); }); it('returns 500 when the ownership query throws unexpectedly', async () => { @@ -153,8 +207,9 @@ describe('POST /api/cards — link ownership validation', () => { expect(mockPrisma.card.create).not.toHaveBeenCalled(); }); - it('returns 500 when card.count throws and no partial write occurs', async () => { + it('returns 500 when card.count throws inside the transaction', async () => { mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); mockPrisma.card.count.mockRejectedValue(new Error('Query timeout')); const app = await buildApp(); @@ -168,8 +223,9 @@ describe('POST /api/cards — link ownership validation', () => { expect(mockPrisma.card.create).not.toHaveBeenCalled(); }); - it('returns 500 when card.create throws', async () => { + it('returns 500 when card.create throws a non-retryable error', async () => { mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); + mockPrisma.card.findUnique.mockResolvedValue(null); mockPrisma.card.count.mockResolvedValue(0); mockPrisma.card.create.mockRejectedValue(new Error('FK constraint violation')); @@ -185,131 +241,174 @@ describe('POST /api/cards — link ownership validation', () => { }); // ───────────────────────────────────────────────────────────────────────────── -// PUT /api/cards/:id +// PUT /api/cards/:id/update // ───────────────────────────────────────────────────────────────────────────── -describe('PUT /api/cards/:id — link ownership validation', () => { +describe('PUT /api/cards/:id/update — card metadata', () => { beforeEach(() => { vi.clearAllMocks(); wireTransaction(); }); - it('returns 403 when a supplied linkId belongs to another user', async () => { + it('updates title/description/visibility/qrEnabled for an owned card', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockResolvedValue([]); + mockPrisma.card.update.mockResolvedValue({ + ...mockCard, + title: 'Renamed', + visibility: CardVisibility.UNLISTED, + qrEnabled: false, + }); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [FOREIGN_LINK_ID] }, + url: `/api/cards/${CARD_ID}/update`, + payload: { title: 'Renamed', visibility: 'UNLISTED', qrEnabled: false }, }); - expect(res.statusCode).toBe(403); - expect(res.json().error).toBe('One or more links do not belong to your account'); - // Existing links must not have been touched - expect(mockPrisma.$transaction).not.toHaveBeenCalled(); - expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); - expect(mockPrisma.cardLink.createMany).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(200); + expect(mockPrisma.card.findFirst).toHaveBeenCalledWith({ where: { id: CARD_ID, userId: USER_ID } }); + expect(mockPrisma.card.update).toHaveBeenCalledWith({ + where: { id: CARD_ID }, + data: { title: 'Renamed', description: undefined, visibility: 'UNLISTED', qrEnabled: false }, + }); + }); + + it('returns 404 when the card does not belong to the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}/update`, + payload: { title: 'Renamed' }, + }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.card.update).not.toHaveBeenCalled(); + }); + + it('returns 400 when the body is empty (schema requires at least one field)', async () => { + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}/update`, + payload: {}, + }); + + expect(res.statusCode).toBe(400); + expect(res.json().error).toBe('Validation failed'); + expect(mockPrisma.card.findFirst).not.toHaveBeenCalled(); }); - it('updates links atomically when all supplied linkIds are owned', async () => { + it('returns 500 when card.update throws', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); - mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 0 }); - mockPrisma.cardLink.createMany.mockResolvedValue({ count: 1 }); - mockPrisma.card.findUnique.mockResolvedValue({ ...mockCard, cardLinks: [] }); + mockPrisma.card.update.mockRejectedValue(new Error('DB write failure')); + + const app = await buildApp(); + const res = await app.inject({ + method: 'PUT', + url: `/api/cards/${CARD_ID}/update`, + payload: { title: 'Renamed' }, + }); + + expect(res.statusCode).toBe(500); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// PUT /api/cards/:id/platform-link +// ───────────────────────────────────────────────────────────────────────────── + +describe('PUT /api/cards/:id/platform-link', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 when a new owned platform link is added', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + mockPrisma.cardLink.findUnique.mockResolvedValue(null); // not already linked + mockPrisma.platformLink.findFirst.mockResolvedValue({ id: OWNED_LINK_ID, userId: USER_ID }); + mockPrisma.cardLink.create.mockResolvedValue({ id: 'cl-1', cardId: CARD_ID, platformLinkId: OWNED_LINK_ID }); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: OWNED_LINK_ID }, }); expect(res.statusCode).toBe(200); - expect(mockPrisma.platformLink.findMany).toHaveBeenCalledWith({ - where: { id: { in: [OWNED_LINK_ID] }, userId: USER_ID }, - select: { id: true }, + expect(mockPrisma.cardLink.create).toHaveBeenCalledWith({ + data: { cardId: CARD_ID, platformLinkId: OWNED_LINK_ID }, }); - // Both operations must run inside the transaction, not as bare queries - expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); - expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalledWith({ where: { cardId: CARD_ID } }); - expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); }); - it('returns 404 when the card does not belong to the user', async () => { + it('returns 404 when the card is not owned by the user', async () => { mockPrisma.card.findFirst.mockResolvedValue(null); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: OWNED_LINK_ID }, }); expect(res.statusCode).toBe(404); - expect(mockPrisma.platformLink.findMany).not.toHaveBeenCalled(); + expect(mockPrisma.cardLink.create).not.toHaveBeenCalled(); }); - it('returns 500 when the ownership query throws and no mutation occurs', async () => { + it('returns 403 when the platform link does not belong to the user', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockRejectedValue(new Error('DB timeout')); + mockPrisma.cardLink.findUnique.mockResolvedValue(null); + mockPrisma.platformLink.findFirst.mockResolvedValue(null); // foreign / missing link const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: FOREIGN_LINK_ID }, }); - expect(res.statusCode).toBe(500); - expect(mockPrisma.$transaction).not.toHaveBeenCalled(); - expect(mockPrisma.cardLink.deleteMany).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(403); + expect(mockPrisma.cardLink.create).not.toHaveBeenCalled(); }); - it('returns 500 and preserves existing links when the transaction fails mid-flight', async () => { - // Ownership check passes; deleteMany succeeds; createMany fails. - // The transaction rolls back, so the card retains its original links. + it('returns 409 when the platform link is already on the card', async () => { mockPrisma.card.findFirst.mockResolvedValue(mockCard); - mockPrisma.platformLink.findMany.mockResolvedValue([{ id: OWNED_LINK_ID }]); - mockPrisma.cardLink.deleteMany.mockResolvedValue({ count: 1 }); - mockPrisma.cardLink.createMany.mockRejectedValue(new Error('FK constraint')); + mockPrisma.cardLink.findUnique.mockResolvedValue({ id: 'cl-existing' }); + mockPrisma.platformLink.findFirst.mockResolvedValue({ id: OWNED_LINK_ID, userId: USER_ID }); const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: OWNED_LINK_ID }, }); - expect(res.statusCode).toBe(500); - // Both were attempted inside the transaction (the DB rolls them back together) - expect(mockPrisma.cardLink.deleteMany).toHaveBeenCalled(); - expect(mockPrisma.cardLink.createMany).toHaveBeenCalled(); - // The final read must not have been called -- we short-circuited on error - expect(mockPrisma.card.findUnique).not.toHaveBeenCalled(); + expect(res.statusCode).toBe(409); + expect(mockPrisma.cardLink.create).not.toHaveBeenCalled(); }); - it('returns 500 when card.findFirst throws', async () => { - mockPrisma.card.findFirst.mockRejectedValue(new Error('Connection refused')); - + it('returns 400 when platformLinkId is not a valid UUID', async () => { const app = await buildApp(); const res = await app.inject({ method: 'PUT', - url: `/api/cards/${CARD_ID}`, - payload: { linkIds: [OWNED_LINK_ID] }, + url: `/api/cards/${CARD_ID}/platform-link`, + payload: { platformLinkId: 'not-a-uuid' }, }); - expect(res.statusCode).toBe(500); + expect(res.statusCode).toBe(400); + expect(mockPrisma.card.findFirst).not.toHaveBeenCalled(); }); }); // ───────────────────────────────────────────────────────────────────────────── -// DELETE /api/cards/:id +// DELETE /api/cards/:id/delete // ───────────────────────────────────────────────────────────────────────────── -describe('DELETE /api/cards/:id', () => { +describe('DELETE /api/cards/:id/delete', () => { beforeEach(() => { vi.clearAllMocks(); wireTransaction(); @@ -321,7 +420,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.delete.mockResolvedValue(mockCard); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(204); expect(mockPrisma.card.delete).toHaveBeenCalledWith({ where: { id: CARD_ID } }); @@ -340,7 +439,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.delete.mockResolvedValue(mockCard); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(204); expect(mockPrisma.card.update).toHaveBeenCalledWith({ @@ -354,7 +453,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.findFirst.mockResolvedValue(null); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(404); expect(mockPrisma.card.delete).not.toHaveBeenCalled(); @@ -365,7 +464,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.count.mockResolvedValue(1); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(400); expect(res.json().error).toBe('Cannot delete the last remaining card. A user must have at least one card.'); @@ -378,7 +477,7 @@ describe('DELETE /api/cards/:id', () => { mockPrisma.card.delete.mockRejectedValue(new Error('Deadlock detected')); const app = await buildApp(); - const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}` }); + const res = await app.inject({ method: 'DELETE', url: `/api/cards/${CARD_ID}/delete` }); expect(res.statusCode).toBe(500); }); @@ -403,7 +502,7 @@ describe('PUT /api/cards/:id/default', () => { const res = await app.inject({ method: 'PUT', url: `/api/cards/${CARD_ID}/default` }); expect(res.statusCode).toBe(200); - expect(res.json().message).toBe('Default card updated'); + expect(res.body).toBe('Default card updated'); expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); // Clear-all and set-one must both run inside the transaction expect(mockPrisma.card.updateMany).toHaveBeenCalledWith({ @@ -440,4 +539,163 @@ describe('PUT /api/cards/:id/default', () => { expect(mockPrisma.card.updateMany).toHaveBeenCalled(); expect(mockPrisma.card.update).toHaveBeenCalled(); }); -}); \ No newline at end of file +}); + +// ───────────────────────────────────────────────────────────────────────────── +// POST /api/cards/:id/share +// ───────────────────────────────────────────────────────────────────────────── + +describe('POST /api/cards/:id/share', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 with a share URL for a non-private owned card', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` }); + + expect(res.statusCode).toBe(200); + expect(res.json().shareUrl).toBe(`/cards/share/${mockCard.slug}`); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 403 when the card is private', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, visibility: CardVisibility.PRIVATE }); + + const app = await buildApp(); + const res = await app.inject({ method: 'POST', url: `/api/cards/${CARD_ID}/share` }); + + expect(res.statusCode).toBe(403); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/cards/share/:slug +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/cards/share/:slug', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 and records a view for an existing shared card', async () => { + const sharedCard = { ...mockCard, cardLinks: [] }; + mockPrisma.card.findUnique.mockResolvedValue(sharedCard); + mockPrisma.card.update.mockResolvedValue(sharedCard); + mockPrisma.cardView.create.mockResolvedValue({ id: 'view-1' }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/share/${mockCard.slug}` }); + + expect(res.statusCode).toBe(200); + // View tracking runs in the sequential transaction: increment count + log view + expect(mockPrisma.$transaction).toHaveBeenCalledOnce(); + expect(mockPrisma.card.update).toHaveBeenCalledWith({ + where: { id: mockCard.id }, + data: { viewCount: { increment: 1 } }, + }); + expect(mockPrisma.cardView.create).toHaveBeenCalled(); + }); + + it('returns 404 when no card matches the slug', async () => { + mockPrisma.card.findUnique.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: '/api/cards/share/missing-slug' }); + + expect(res.statusCode).toBe(404); + expect(mockPrisma.$transaction).not.toHaveBeenCalled(); + expect(mockPrisma.cardView.create).not.toHaveBeenCalled(); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/cards/:id/qr +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/cards/:id/qr', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + process.env.MOBILE_REDIRECT_URI = 'https://devcard.test'; + }); + + it('returns 200 with a PNG image for a shareable, qr-enabled card', async () => { + mockPrisma.card.findFirst.mockResolvedValue(mockCard); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(200); + expect(res.headers['content-type']).toContain('image/png'); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(404); + }); + + it('returns 403 when the card is private', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, visibility: CardVisibility.PRIVATE }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(403); + }); + + it('returns 403 when QR is disabled for the card', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, qrEnabled: false }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/qr` }); + + expect(res.statusCode).toBe(403); + }); +}); + +// ───────────────────────────────────────────────────────────────────────────── +// GET /api/cards/:id/analytics +// ───────────────────────────────────────────────────────────────────────────── + +describe('GET /api/cards/:id/analytics', () => { + beforeEach(() => { + vi.clearAllMocks(); + wireTransaction(); + }); + + it('returns 200 with the card and its views', async () => { + mockPrisma.card.findFirst.mockResolvedValue({ ...mockCard, views: [] }); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/analytics` }); + + expect(res.statusCode).toBe(200); + expect(mockPrisma.card.findFirst).toHaveBeenCalled(); + }); + + it('returns 404 when the card is not owned by the user', async () => { + mockPrisma.card.findFirst.mockResolvedValue(null); + + const app = await buildApp(); + const res = await app.inject({ method: 'GET', url: `/api/cards/${CARD_ID}/analytics` }); + + expect(res.statusCode).toBe(404); + }); +}); diff --git a/apps/backend/src/routes/cards.ts b/apps/backend/src/routes/cards.ts index e5f98762..ee23cf1e 100644 --- a/apps/backend/src/routes/cards.ts +++ b/apps/backend/src/routes/cards.ts @@ -1,20 +1,21 @@ + import * as cardService from '../services/cardService' import { handleDbError } from '../utils/error.util.js'; -import { createCardSchema, updateCardSchema } from '../utils/validators.js'; +import { hashIp } from '../utils/refreshToken'; +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 { CardVisibility } from '@prisma/client'; import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; -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 +58,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 +67,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 +80,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 +94,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 +129,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 +136,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..bbd776dc 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 { type Card, CardVisibility, type Prisma } from '@prisma/client'; +import QRCode from 'qrcode'; + +import { generateUniqueSlug } from '../utils/slug'; + +import type { CreateCardBody } from '../routes/cards'; import type { FastifyInstance } from 'fastify'; 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): Promise { + 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): Promise<{ shareUrl: 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): Promise> { + 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): Promise { + 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): Promise> { + const card = 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 (!card) { + throw Object.assign( + new Error('Card not found'), + { code: 'CARD_NOT_FOUND' } + ); + } + + return card +} \ No newline at end of file diff --git a/apps/backend/src/utils/validators.ts b/apps/backend/src/utils/validators.ts index bd41bef2..0492f6d4 100644 --- a/apps/backend/src/utils/validators.ts +++ b/apps/backend/src/utils/validators.ts @@ -1,5 +1,5 @@ -import { z } from 'zod'; import { getPlatform } from '@devcard/shared'; +import { z } from 'zod'; export const updateProfileSchema = z.object({ displayName: z.string().min(1).max(100).optional(), @@ -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