From 63af4c530f46232c4fffb66661607a6be19344e2 Mon Sep 17 00:00:00 2001 From: Samarth Sugandhi Date: Tue, 26 May 2026 17:15:40 +0530 Subject: [PATCH 1/2] feat(web+backend): add public profile URL with Open Graph meta tags --- apps/backend/src/app.ts | 2 + apps/backend/src/routes/publicCards.ts | 123 ++++ apps/web/src/routes/u/[username]/+page.svelte | 568 ++++++------------ packages/shared/src/types.ts | 42 ++ 4 files changed, 365 insertions(+), 370 deletions(-) create mode 100644 apps/backend/src/routes/publicCards.ts diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index f817eb46..05357dbc 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -21,6 +21,7 @@ import { followRoutes } from './routes/follow.js'; import { nfcRoutes } from './routes/nfc.js'; import { profileRoutes } from './routes/profiles.js'; import { publicRoutes } from './routes/public.js'; +import { publicCardRoutes } from './routes/publicCards.js'; import { validateEnv } from './utils/validateEnv.js'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -103,6 +104,7 @@ export async function buildApp():Promise { await app.register(authRoutes, { prefix: '/auth' }); await app.register(profileRoutes, { prefix: '/api/profiles' }); await app.register(cardRoutes, { prefix: '/api/cards' }); + await app.register(publicCardRoutes); await app.register(publicRoutes, { prefix: '/api/u' }); await app.register(followRoutes, { prefix: '/api/follow' }); await app.register(connectRoutes, { prefix: '/api/connect' }); diff --git a/apps/backend/src/routes/publicCards.ts b/apps/backend/src/routes/publicCards.ts new file mode 100644 index 00000000..63833e41 --- /dev/null +++ b/apps/backend/src/routes/publicCards.ts @@ -0,0 +1,123 @@ +import type { FastifyInstance } from 'fastify'; + +/** + * Public (unauthenticated) routes for DevCard profile sharing. + * These endpoints are intentionally open — no JWT required. + * Only safe, non-private fields are returned. + */ +export async function publicCardRoutes(fastify: FastifyInstance) { + /** + * GET /public/cards/:username + * + * Returns the default card for a given username. + * Used by the public profile page at /u/:username on the web app. + * + * Response shape: + * { + * username: string + * displayName: string | null + * bio: string | null + * avatarUrl: string | null + * links: Array<{ platform: string; url: string; label: string | null }> + * } + */ + fastify.get<{ Params: { username: string } }>( + "/public/cards/:username", + { + schema: { + params: { + type: "object", + required: ["username"], + properties: { + username: { type: "string" }, + }, + }, + response: { + 200: { + type: "object", + properties: { + username: { type: "string" }, + displayName: { type: ["string", "null"] }, + bio: { type: ["string", "null"] }, + avatarUrl: { type: ["string", "null"] }, + links: { + type: "array", + items: { + type: "object", + properties: { + platform: { type: "string" }, + url: { type: "string" }, + label: { type: ["string", "null"] }, + }, + }, + }, + }, + }, + 404: { + type: "object", + properties: { + error: { type: "string" }, + }, + }, + }, + }, + }, + async (request, reply) => { + const { username } = request.params; + + // Look up the user by their username (case-insensitive) + const user = await fastify.prisma.user.findFirst({ + where: { + username: { + equals: username, + mode: 'insensitive', + }, + }, + select: { + username: true, + displayName: true, + bio: true, + avatarUrl: true, + // Fetch the user's default card and its platform links + cards: { + where: { isDefault: true }, + take: 1, + select: { + cardLinks: { + select: { + platformLink: { + select: { + platform: true, + url: true, + }, + }, + }, + orderBy: { displayOrder: 'asc' }, + }, + }, + }, + }, + }); + + // 404 if user does not exist + if (!user) { + return reply.code(404).send({ error: 'User not found' }); + } + + const defaultCard = user.cards[0]; + + return reply.code(200).send({ + username: user.username, + displayName: user.displayName ?? null, + bio: user.bio ?? null, + avatarUrl: user.avatarUrl ?? null, + links: + defaultCard?.cardLinks.map((link: { platformLink: { platform: string; url: string } }) => ({ + platform: link.platformLink.platform, + url: link.platformLink.url, + label: null, + })) ?? [], + }); + } + ); +} diff --git a/apps/web/src/routes/u/[username]/+page.svelte b/apps/web/src/routes/u/[username]/+page.svelte index 50cb4226..f190efa2 100644 --- a/apps/web/src/routes/u/[username]/+page.svelte +++ b/apps/web/src/routes/u/[username]/+page.svelte @@ -1,437 +1,265 @@ + - {#if profile} - {profile.displayName} | DevCard - - {:else} - User Not Found | DevCard - {/if} + {card.displayName ?? card.username} | DevCard + + + + + + + + + + + + + + + + + + -
- -
- {#if error || !profile} -
-
😕
-

Profile not found

-

This DevCard has vanished into the digital void.

- Return Home -
- {:else} -
-
-
- {#if profile.avatarUrl} - {profile.displayName} - {:else} -
- {profile.displayName.charAt(0).toUpperCase()} -
- {/if} -
-
- -

{profile.displayName}

- {#if profile.role} -
- {profile.role}{profile.company ? ` @ ${profile.company}` : ''} -
- {/if} - - {#if profile.bio} -

{profile.bio}

- {/if} -
- -
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4a4a9dcc..d45ab0f1 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -158,3 +158,45 @@ export interface OAuthTokenInfo { connected: boolean; scopes: string; } + +/** + * Shared TypeScript types used across the DevCard monorepo. + * + * ADDED for issue: "web + backend: add public shareable profile URL with Open Graph meta tags" + * + * These types define the shape of the public (unauthenticated) card API + * response used by both the backend route and the SvelteKit web page. + */ + +// ─── Public profile types ───────────────────────────────────────────────────── + +/** + * A single platform link returned by the public card API. + */ +export interface PublicCardLink { + /** Platform identifier (e.g. "github", "linkedin", "twitter") */ + platform: string; + /** The full URL to the user's profile on this platform */ + url: string; + /** Optional custom display label (falls back to platform name if null) */ + label: string | null; +} + +/** + * Response shape for GET /public/cards/:username + * + * Only contains fields that are safe to expose publicly. + * No email, no analytics, no internal IDs. + */ +export interface PublicCardResponse { + /** The user's unique username (used in the /u/:username URL) */ + username: string; + /** The user's display name (may differ from username) */ + displayName: string | null; + /** Short bio shown on the public profile */ + bio: string | null; + /** URL to the user's avatar image */ + avatarUrl: string | null; + /** All platform links on the user's default card */ + links: PublicCardLink[]; +} From 7878f92107cbacabf9107fde4ec2b9aeb7d386d7 Mon Sep 17 00:00:00 2001 From: Samarth Sugandhi Date: Tue, 26 May 2026 17:27:52 +0530 Subject: [PATCH 2/2] fix: correct orderBy placement in publicCards Prisma query --- apps/backend/src/routes/publicCards.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/backend/src/routes/publicCards.ts b/apps/backend/src/routes/publicCards.ts index 63833e41..2391b47d 100644 --- a/apps/backend/src/routes/publicCards.ts +++ b/apps/backend/src/routes/publicCards.ts @@ -84,6 +84,7 @@ export async function publicCardRoutes(fastify: FastifyInstance) { take: 1, select: { cardLinks: { + orderBy: { displayOrder: 'asc' }, select: { platformLink: { select: { @@ -92,7 +93,6 @@ export async function publicCardRoutes(fastify: FastifyInstance) { }, }, }, - orderBy: { displayOrder: 'asc' }, }, }, },