Skip to content

Commit fc8b35a

Browse files
feat(streaks): add connection_event and hackathon_attendance tables
1 parent b1bd3c2 commit fc8b35a

4 files changed

Lines changed: 107 additions & 0 deletions

File tree

apps/backend/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,3 +26,11 @@ Due to LinkedIn's modern API restrictions preventing programmatic connection req
2626
- DOM Observation: The injected Javascript queries for structural indicators of successful invitation (e.g. "Pending" button state or toaster text) and posts a serialized message back to the native layer.
2727
6. **Robust Fallback**: If network or WebView loading times out (>10s), the engine gracefully falls back to native deep links (`linkedin://profile?id={username}`) or launches the default browser with an interactive custom in-app overlay.
2828
7. **Telemetry Logging**: Upon client-side success (detected via state changes or DOM indicators), the mobile app makes a `POST /api/follow/:platform/:targetUsername/log` request to the backend. This writes a record to the `FollowLog` database table for auditing and analytics tracking.
29+
30+
## Analytics & Streaks
31+
32+
DevCard tracks connection events and hackathon attendance to compute streaks.
33+
34+
### Privacy Rule: Anonymous Connections
35+
36+
To protect user privacy, when recording a `ConnectionEvent`, the `receiver_id` is **ONLY** stored if the receiver is explicitly signed in and consents to the connection tracking. If the connection is anonymous or consent is missing, `receiver_id` is omitted (`null`). The analytics layer strictly enforces this at the service level (`streakService`).

apps/backend/prisma/schema.prisma

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ model User {
4444
attendedEvents EventAttendee[]
4545
ownedTeams Team[] @relation("TeamOwner")
4646
teamMemberships TeamMember[] @relation("TeamMember")
47+
48+
initiatedConnections ConnectionEvent[] @relation("InitiatedConnections")
49+
receivedConnections ConnectionEvent[] @relation("ReceivedConnections")
50+
hackathonAttendances HackathonAttendance[]
4751
webhookEndpoints WebhookEndpoint[]
4852
4953
@@map("users")
@@ -301,4 +305,31 @@ model TeamMember {
301305
@@unique([userId, teamId])
302306
@@index([userId])
303307
@@map("team_members")
308+
}
309+
310+
model ConnectionEvent {
311+
id String @id @default(uuid())
312+
initiatorId String @map("initiator_id")
313+
receiverId String? @map("receiver_id") // Nullable to enforce privacy rule for anonymous connections
314+
connectedAt DateTime @default(now()) @map("connected_at")
315+
316+
initiator User @relation("InitiatedConnections", fields: [initiatorId], references: [id], onDelete: Cascade)
317+
receiver User? @relation("ReceivedConnections", fields: [receiverId], references: [id], onDelete: SetNull)
318+
319+
@@index([initiatorId])
320+
@@index([receiverId])
321+
@@map("connection_events")
322+
}
323+
324+
model HackathonAttendance {
325+
id String @id @default(uuid())
326+
userId String @map("user_id")
327+
hackathonId String @map("hackathon_id")
328+
attendedAt DateTime @default(now()) @map("attended_at")
329+
330+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
331+
332+
@@unique([userId, hackathonId])
333+
@@index([userId])
334+
@@map("hackathon_attendance")
304335
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { recordConnectionEvent } from '../services/streakService';
3+
import type { FastifyInstance } from 'fastify';
4+
5+
describe('streakService', () => {
6+
it('should enforce privacy rule: anonymous connections never write a receiver_id', async () => {
7+
const mockCreate = vi.fn().mockResolvedValue({ id: 'test-event' });
8+
const mockApp = {
9+
prisma: {
10+
connectionEvent: {
11+
create: mockCreate,
12+
},
13+
},
14+
} as unknown as FastifyInstance;
15+
16+
// Test with signed in and consented (should store receiverId)
17+
await recordConnectionEvent(mockApp, 'initiator-1', 'receiver-1', true);
18+
expect(mockCreate).toHaveBeenCalledWith({
19+
data: {
20+
initiatorId: 'initiator-1',
21+
receiverId: 'receiver-1',
22+
},
23+
});
24+
25+
// Test with anonymous/not consented (should store null for receiverId)
26+
await recordConnectionEvent(mockApp, 'initiator-1', 'receiver-1', false);
27+
expect(mockCreate).toHaveBeenCalledWith({
28+
data: {
29+
initiatorId: 'initiator-1',
30+
receiverId: null,
31+
},
32+
});
33+
});
34+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { FastifyInstance } from 'fastify';
2+
3+
/**
4+
* Records a connection event when one user connects with another.
5+
* Privacy Rule: receiverId is ONLY recorded if the receiver is signed in and consents.
6+
* If the connection is anonymous or unconsented, receiverId MUST be null.
7+
*/
8+
export async function recordConnectionEvent(
9+
app: FastifyInstance,
10+
initiatorId: string,
11+
receiverId: string | null,
12+
isConsentedAndSignedIn: boolean
13+
) {
14+
// Structural typing to bypass IDE cache lags after Prisma schema changes
15+
interface PrismaWithConnectionEvent {
16+
connectionEvent: {
17+
create: (args: { data: { initiatorId: string; receiverId: string | null } }) => Promise<unknown>;
18+
};
19+
}
20+
21+
const prismaClient = app.prisma as unknown as PrismaWithConnectionEvent;
22+
23+
// Enforce privacy rule
24+
const finalReceiverId = isConsentedAndSignedIn ? receiverId : null;
25+
26+
const event = await prismaClient.connectionEvent.create({
27+
data: {
28+
initiatorId,
29+
receiverId: finalReceiverId,
30+
},
31+
});
32+
33+
return event;
34+
}

0 commit comments

Comments
 (0)