Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
-- CreateEnum
CREATE TYPE "EventType" AS ENUM ('PROFILE_VIEW', 'CARD_VIEW', 'LINK_CLICK', 'FOLLOW_ATTEMPT', 'FOLLOW_SUCCESS', 'QR_SCAN', 'SHARE_ACTION', 'COPY_LINK');

-- CreateTable
CREATE TABLE "engagement_events" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"viewer_id" TEXT,
"card_id" TEXT,
"event_type" "EventType" NOT NULL,
"platform" TEXT,
"source" TEXT,
"ip_hash" TEXT,
"user_agent" TEXT,
"metadata" JSONB,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "engagement_events_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "engagement_events_user_id_idx" ON "engagement_events"("user_id");

-- CreateIndex
CREATE INDEX "engagement_events_event_type_idx" ON "engagement_events"("event_type");

-- CreateIndex
CREATE INDEX "engagement_events_created_at_idx" ON "engagement_events"("created_at");

-- CreateIndex
CREATE INDEX "engagement_events_user_id_event_type_idx" ON "engagement_events"("user_id", "event_type");

-- AddForeignKey
ALTER TABLE "engagement_events" ADD CONSTRAINT "engagement_events_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
78 changes: 60 additions & 18 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,18 +30,20 @@ model User {
viewedCards CardView[] @relation("cardViewer")
followLogs FollowLog[]

engagementEvents EngagementEvent[]

@@unique([provider, providerId])
@@map("users")
}

model PlatformLink {
id String @id @default(uuid())
userId String @map("user_id")
id String @id @default(uuid())
userId String @map("user_id")
platform String
username String
url String
displayOrder Int @default(0) @map("display_order")
createdAt DateTime @default(now()) @map("created_at")
displayOrder Int @default(0) @map("display_order")
createdAt DateTime @default(now()) @map("created_at")

user User @relation(fields: [userId], references: [id], onDelete: Cascade)
cardLinks CardLink[]
Expand All @@ -50,12 +52,12 @@ model PlatformLink {
}

model Card {
id String @id @default(uuid())
userId String @map("user_id")
id String @id @default(uuid())
userId String @map("user_id")
title String
isDefault Boolean @default(false) @map("is_default")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
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[]
Expand Down Expand Up @@ -96,17 +98,17 @@ model OAuthToken {

model CardView {
id String @id @default(uuid())
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
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")
viewerAgent String? @map("viewer_agent")
source String @default("qr") // "qr" | "link" | "web" | "app"
source String @default("qr") // "qr" | "link" | "web" | "app"
createdAt DateTime @default(now()) @map("created_at")

card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade)
viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)
card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade)
viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)

@@map("card_views")
}
Expand All @@ -116,11 +118,51 @@ model FollowLog {
followerId String @map("follower_id")
targetUsername String @map("target_username")
platform String
status String @default("success") // "success" | "error"
layer String // "api" | "webview" | "link"
status String @default("success") // "success" | "error"
layer String // "api" | "webview" | "link"
createdAt DateTime @default(now()) @map("created_at")

follower User @relation(fields: [followerId], references: [id], onDelete: Cascade)

@@map("follow_logs")
}

enum EventType {
PROFILE_VIEW
CARD_VIEW
LINK_CLICK
FOLLOW_ATTEMPT
FOLLOW_SUCCESS
QR_SCAN
SHARE_ACTION
COPY_LINK
}

model EngagementEvent {
id String @id @default(uuid())

userId String @map("user_id")
viewerId String? @map("viewer_id")

cardId String? @map("card_id")

eventType EventType @map("event_type")

platform String?
source String?

ipHash String? @map("ip_hash")
userAgent String? @map("user_agent")

metadata Json?

createdAt DateTime @default(now()) @map("created_at")

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

@@index([userId])
@@index([eventType])
@@index([createdAt])
@@index([userId, eventType])
@@map("engagement_events")
}
85 changes: 54 additions & 31 deletions apps/backend/src/routes/public.ts
Original file line number Diff line number Diff line change
@@ -1,58 +1,59 @@
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { generateQRBuffer, generateQRSvg } from '../utils/qr.js';
import { trackEvent } from '../services/analytics/trackEvent.js';

type PublicProfileLink = {
id: string;
platform: string;
username: string;
url: string;
displayOrder: number;
username: string;
url: string;
displayOrder: number;
}

type UsernamePublicProfileResponse = {
username: string;
type UsernamePublicProfileResponse = {
username: string;
displayName: string;
bio: string | null;
pronouns: string | null;
role: string | null;
bio: string | null;
pronouns: string | null;
role: string | null;
company: string | null;
avatarUrl: string | null;
avatarUrl: string | null;
accentColor: string;
links: PublicProfileLink[]
}
}

type PublicProfileCardLink = {
id: string;
platform: string;
username: string;
url: string;
username: string;
url: string;
}

type CardPublicProfileResponse = {
id: string;
title: string;
id: string;
title: string;
owner: {
username: string;
displayName: string;
username: string;
displayName: string;
bio: string | null;
avatarUrl: string | null;
accentColor: string;
};
accentColor: string;
};
links: PublicProfileCardLink[]
}

type UsernameCardPublicProfileResponse = {
title: string;
title: string;
owner: {
username: string;
username: string;
displayName: string;
bio: string | null;
pronouns: string | null;
role: string | null;
bio: string | null;
pronouns: string | null;
role: string | null;
company: string | null;
avatarUrl: string | null;
avatarUrl: string | null;
accentColor: string;
};
};
links: PublicProfileCardLink[]
}

Expand Down Expand Up @@ -97,17 +98,39 @@ export async function publicRoutes(app: FastifyInstance) {

// Don't track if the owner is viewing their own profile
if (viewerId !== user.id) {
// Background view tracking
app.prisma.cardView.create({
data: {
ownerId: user.id,
cardId: null, // this is a profile view, not a card view
cardId: null,
viewerId,
viewerIp: request.ip || null,
viewerAgent: request.headers['user-agent'] || null,
source: (request.query as any)?.source || 'link',
},
}).catch(err => app.log.error('Failed to log view:', err));

trackEvent(app.prisma, {
userId: user.id,

viewerId: viewerId || undefined,

eventType: 'PROFILE_VIEW',

source: (request.query as any)?.source || 'link',

ip: request.ip || undefined,

userAgent:
typeof request.headers['user-agent'] === 'string'
? request.headers['user-agent']
: undefined,

metadata: {
username: user.username,
},
}).catch(err =>
app.log.error('Failed to track engagement event:', err)
);
}

const response: UsernamePublicProfileResponse = {
Expand All @@ -128,7 +151,7 @@ export async function publicRoutes(app: FastifyInstance) {
})),
}

return response;
return response;

});

Expand Down Expand Up @@ -175,7 +198,7 @@ export async function publicRoutes(app: FastifyInstance) {
})),
}

return response;
return response;

});

Expand Down Expand Up @@ -218,7 +241,7 @@ export async function publicRoutes(app: FastifyInstance) {
viewerId = decoded.id;
}
}
} catch (e) {}
} catch (e) { }

if (viewerId !== user.id) {
app.prisma.cardView.create({
Expand Down Expand Up @@ -254,7 +277,7 @@ export async function publicRoutes(app: FastifyInstance) {
displayOrder: cl.displayOrder,
})),
}
return response;
return response;
});

// ─── QR Code Generation ───
Expand Down
58 changes: 58 additions & 0 deletions apps/backend/src/services/analytics/trackEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import crypto from 'crypto';
import { PrismaClient, Prisma } from '@prisma/client';

type TrackEventInput = {
userId: string;

viewerId?: string;
cardId?: string;

eventType:
| 'PROFILE_VIEW'
| 'CARD_VIEW'
| 'LINK_CLICK'
| 'FOLLOW_ATTEMPT'
| 'FOLLOW_SUCCESS'
| 'QR_SCAN'
| 'SHARE_ACTION'
| 'COPY_LINK';

platform?: string;
source?: string;

ip?: string;
userAgent?: string;

metadata?: Prisma.InputJsonValue;
};

export async function trackEvent(
prisma: PrismaClient,
data: TrackEventInput
) {
const ipHash = data.ip
? crypto
.createHash('sha256')
.update(data.ip)
.digest('hex')
: null;

return prisma.engagementEvent.create({
data: {
userId: data.userId,

viewerId: data.viewerId,
cardId: data.cardId,

eventType: data.eventType,

platform: data.platform,
source: data.source,

ipHash,
userAgent: data.userAgent,

metadata: data.metadata
}
});
}
Loading