Skip to content

Commit 5887247

Browse files
committed
feat: Add analytics tracking for profile and card views, and introduce platform connection management.
1 parent e9a462f commit 5887247

15 files changed

Lines changed: 1007 additions & 20 deletions

File tree

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
-- CreateTable
2+
CREATE TABLE "card_views" (
3+
"id" TEXT NOT NULL,
4+
"card_id" TEXT,
5+
"owner_id" TEXT NOT NULL,
6+
"viewer_id" TEXT,
7+
"viewer_ip" TEXT,
8+
"viewer_agent" TEXT,
9+
"source" TEXT NOT NULL DEFAULT 'qr',
10+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
11+
12+
CONSTRAINT "card_views_pkey" PRIMARY KEY ("id")
13+
);
14+
15+
-- CreateTable
16+
CREATE TABLE "follow_logs" (
17+
"id" TEXT NOT NULL,
18+
"follower_id" TEXT NOT NULL,
19+
"target_username" TEXT NOT NULL,
20+
"platform" TEXT NOT NULL,
21+
"status" TEXT NOT NULL DEFAULT 'success',
22+
"layer" TEXT NOT NULL,
23+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
24+
25+
CONSTRAINT "follow_logs_pkey" PRIMARY KEY ("id")
26+
);
27+
28+
-- AddForeignKey
29+
ALTER TABLE "card_views" ADD CONSTRAINT "card_views_card_id_fkey" FOREIGN KEY ("card_id") REFERENCES "cards"("id") ON DELETE SET NULL ON UPDATE CASCADE;
30+
31+
-- AddForeignKey
32+
ALTER TABLE "card_views" ADD CONSTRAINT "card_views_owner_id_fkey" FOREIGN KEY ("owner_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
33+
34+
-- AddForeignKey
35+
ALTER TABLE "card_views" ADD CONSTRAINT "card_views_viewer_id_fkey" FOREIGN KEY ("viewer_id") REFERENCES "users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
36+
37+
-- AddForeignKey
38+
ALTER TABLE "follow_logs" ADD CONSTRAINT "follow_logs_follower_id_fkey" FOREIGN KEY ("follower_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

apps/backend/prisma/schema.prisma

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ model User {
2626
platformLinks PlatformLink[]
2727
cards Card[]
2828
oauthTokens OAuthToken[]
29+
ownedViews CardView[] @relation("cardOwner")
30+
viewedCards CardView[] @relation("cardViewer")
31+
followLogs FollowLog[]
2932
3033
@@unique([provider, providerId])
3134
@@map("users")
@@ -56,6 +59,7 @@ model Card {
5659
5760
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
5861
cardLinks CardLink[]
62+
views CardView[]
5963
6064
@@map("cards")
6165
}
@@ -89,3 +93,34 @@ model OAuthToken {
8993
@@unique([userId, platform])
9094
@@map("oauth_tokens")
9195
}
96+
97+
model CardView {
98+
id String @id @default(uuid())
99+
cardId String? @map("card_id") // null = default profile view
100+
ownerId String @map("owner_id") // card/profile owner
101+
viewerId String? @map("viewer_id") // null = anonymous web viewer
102+
viewerIp String? @map("viewer_ip")
103+
viewerAgent String? @map("viewer_agent")
104+
source String @default("qr") // "qr" | "link" | "web" | "app"
105+
createdAt DateTime @default(now()) @map("created_at")
106+
107+
card Card? @relation(fields: [cardId], references: [id], onDelete: SetNull)
108+
owner User @relation("cardOwner", fields: [ownerId], references: [id], onDelete: Cascade)
109+
viewer User? @relation("cardViewer", fields: [viewerId], references: [id], onDelete: SetNull)
110+
111+
@@map("card_views")
112+
}
113+
114+
model FollowLog {
115+
id String @id @default(uuid())
116+
followerId String @map("follower_id")
117+
targetUsername String @map("target_username")
118+
platform String
119+
status String @default("success") // "success" | "error"
120+
layer String // "api" | "webview" | "link"
121+
createdAt DateTime @default(now()) @map("created_at")
122+
123+
follower User @relation(fields: [followerId], references: [id], onDelete: Cascade)
124+
125+
@@map("follow_logs")
126+
}

apps/backend/src/app.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import { profileRoutes } from './routes/profiles.js';
1515
import { cardRoutes } from './routes/cards.js';
1616
import { publicRoutes } from './routes/public.js';
1717
import { followRoutes } from './routes/follow.js';
18+
import { connectRoutes } from './routes/connect.js';
19+
import { analyticsRoutes } from './routes/analytics.js';
1820

1921
const __dirname = path.dirname(fileURLToPath(import.meta.url));
2022

@@ -70,6 +72,8 @@ export async function buildApp() {
7072
await app.register(cardRoutes, { prefix: '/api/cards' });
7173
await app.register(publicRoutes, { prefix: '/api/u' });
7274
await app.register(followRoutes, { prefix: '/api/follow' });
75+
await app.register(connectRoutes, { prefix: '/api/connect' });
76+
await app.register(analyticsRoutes, { prefix: '/api/analytics' });
7377

7478
// ─── Health Check ───
7579
app.get('/health', async () => ({
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import type { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
2+
3+
export async function analyticsRoutes(app: FastifyInstance) {
4+
5+
app.get('/overview', {
6+
preHandler: [app.authenticate],
7+
}, async (request: FastifyRequest, reply: FastifyReply) => {
8+
const userId = (request.user as any).id;
9+
10+
const today = new Date();
11+
today.setHours(0, 0, 0, 0);
12+
13+
const [totalViews, viewsToday, totalFollows, recentViews] = await Promise.all([
14+
// Total views of this user's cards/profile
15+
app.prisma.cardView.count({
16+
where: { ownerId: userId },
17+
}),
18+
// Views today
19+
app.prisma.cardView.count({
20+
where: { ownerId: userId, createdAt: { gte: today } },
21+
}),
22+
// Follows performed BY this user
23+
app.prisma.followLog.count({
24+
where: { followerId: userId, status: 'success' },
25+
}),
26+
// Recent views (last 5)
27+
app.prisma.cardView.findMany({
28+
where: { ownerId: userId },
29+
orderBy: { createdAt: 'desc' },
30+
take: 5,
31+
include: {
32+
viewer: {
33+
select: { displayName: true, avatarUrl: true },
34+
},
35+
card: {
36+
select: { title: true },
37+
},
38+
},
39+
}),
40+
]);
41+
42+
// Count unique viewers
43+
// In raw SQL this is `SELECT COUNT(DISTINCT viewer_id) FROM card_views WHERE owner_id = ?`
44+
// Prisma group-by as workaround:
45+
const uniqueViewersQuery = await app.prisma.cardView.groupBy({
46+
by: ['viewerId', 'viewerIp'],
47+
where: { ownerId: userId },
48+
});
49+
const uniqueViewers = uniqueViewersQuery.length;
50+
51+
return {
52+
totalViews,
53+
viewsToday,
54+
totalFollows,
55+
uniqueViewers,
56+
recentViews,
57+
};
58+
});
59+
60+
app.get('/views', {
61+
preHandler: [app.authenticate],
62+
}, async (request: FastifyRequest<{ Querystring: { page?: string, cardId?: string } }>, reply: FastifyReply) => {
63+
const userId = (request.user as any).id;
64+
const page = parseInt(request.query.page || '1', 10);
65+
const limit = 20;
66+
const skip = (page - 1) * limit;
67+
68+
const whereClause: any = { ownerId: userId };
69+
if (request.query.cardId) {
70+
whereClause.cardId = request.query.cardId;
71+
}
72+
73+
const [total, views] = await Promise.all([
74+
app.prisma.cardView.count({ where: whereClause }),
75+
app.prisma.cardView.findMany({
76+
where: whereClause,
77+
orderBy: { createdAt: 'desc' },
78+
skip,
79+
take: limit,
80+
include: {
81+
viewer: {
82+
select: { id: true, username: true, displayName: true, avatarUrl: true },
83+
},
84+
card: {
85+
select: { id: true, title: true },
86+
},
87+
},
88+
}),
89+
]);
90+
91+
return {
92+
data: views,
93+
meta: {
94+
total,
95+
page,
96+
limit,
97+
totalPages: Math.ceil(total / limit),
98+
},
99+
};
100+
});
101+
}

apps/backend/src/routes/auth.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,15 @@ export async function authRoutes(app: FastifyInstance) {
9696
},
9797
});
9898

99+
// Save the authentication token for 'user:email read:user' so we have a basic platform connection
100+
const encryptedToken = (app as any).encryption ? (app as any).encryption.encrypt(tokenData.access_token) : tokenData.access_token;
101+
102+
await app.prisma.oAuthToken.upsert({
103+
where: { userId_platform: { userId: user.id, platform: 'github' } },
104+
update: { accessToken: encryptedToken, scopes: 'read:user user:email' },
105+
create: { userId: user.id, platform: 'github', accessToken: encryptedToken, scopes: 'read:user user:email' },
106+
});
107+
99108
// Generate JWT
100109
const token = app.jwt.sign(
101110
{ id: user.id, username: user.username },
@@ -239,14 +248,22 @@ export async function authRoutes(app: FastifyInstance) {
239248
avatarUrl: true,
240249
accentColor: true,
241250
createdAt: true,
251+
oauthTokens: {
252+
select: { platform: true, scopes: true, createdAt: true },
253+
},
242254
},
243255
});
244256

245257
if (!user) {
246258
return reply.status(404).send({ error: 'User not found' });
247259
}
248260

249-
return user;
261+
const { oauthTokens, ...userData } = user;
262+
263+
return {
264+
...userData,
265+
connectedPlatforms: oauthTokens,
266+
};
250267
});
251268

252269
// ─── Logout ───

0 commit comments

Comments
 (0)