Skip to content
Merged
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
3 changes: 2 additions & 1 deletion frontend/server/api/collection/community/[id].delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Pool } from 'pg';
import { CommunityCollectionRepository } from '~~/server/repo/communityCollection.repo';
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: DELETE /api/collection/community/:id
Expand Down Expand Up @@ -39,7 +40,7 @@ export default defineEventHandler(async (event): Promise<{ success: boolean } |
}

try {
const username = user.sub.includes('|') ? user.sub.split('|').pop()! : user.sub;
const username = getAuthUsername(user.sub);

const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
const ssoUser = await ssoUserRepo.upsert({
Expand Down
3 changes: 2 additions & 1 deletion frontend/server/api/collection/community/[id].put.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '~~/server/repo/communityCollection.repo';
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: PUT /api/collection/community/:id
Expand Down Expand Up @@ -51,7 +52,7 @@ export default defineEventHandler(async (event): Promise<CommunityCollection | E
}

try {
const username = user.sub.includes('|') ? user.sub.split('|').pop()! : user.sub;
const username = getAuthUsername(user.sub);

const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
const ssoUser = await ssoUserRepo.upsert({
Expand Down
3 changes: 2 additions & 1 deletion frontend/server/api/collection/community/index.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from '~~/server/repo/communityCollection.repo';
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: POST /api/collection/community
Expand Down Expand Up @@ -46,7 +47,7 @@ export default defineEventHandler(async (event): Promise<CommunityCollection | E

try {
// Derive username from Auth0 sub (e.g. "auth0|abc123" -> "abc123")
const username = user.sub.includes('|') ? user.sub.split('|').pop()! : user.sub;
const username = getAuthUsername(user.sub);

// Upsert SSO user
const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
Expand Down
3 changes: 2 additions & 1 deletion frontend/server/api/collection/community/my.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import type { Pagination } from '~~/types/shared/pagination';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: GET /api/collection/community/my
Expand Down Expand Up @@ -41,7 +42,7 @@ export default defineEventHandler(
const pageSize: number = Number(query?.pageSize) || 10;

try {
const username = user.sub.includes('|') ? user.sub.split('|').pop()! : user.sub;
const username = getAuthUsername(user.sub);

const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
const ssoUser = await ssoUserRepo.findByUsername(username);
Expand Down
62 changes: 62 additions & 0 deletions frontend/server/api/collection/like/index.delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { Pool } from 'pg';
import { CollectionLikeRepository } from '~~/server/repo/collectionLike.repo';
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: DELETE /api/collection/like
* Description: Unlikes a collection for the authenticated user.
*
* Request Body:
* - collectionId (string, required): The ID of the collection to unlike
*
* Response:
* - 200: Success
* - 400: Validation error
* - 401: Unauthorized
* - 500: Internal Server Error
*/
export default defineEventHandler(async (event): Promise<{ success: boolean } | Error> => {
const user = event.context.user as DecodedOidcToken | undefined;

if (!user?.sub) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}

const body = await readBody<{ collectionId?: string }>(event);

if (!body?.collectionId?.trim()) {
throw createError({ statusCode: 400, statusMessage: 'collectionId is required' });
}

const cmDbPool = event.context.cmDbPool as Pool | undefined;

if (!cmDbPool) {
throw createError({ statusCode: 503, statusMessage: 'Database not available' });
}

try {
const username = getAuthUsername(user.sub);

const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
const ssoUser = await ssoUserRepo.findByUsername(username);

if (!ssoUser) {
return { success: true };
}

const repo = new CollectionLikeRepository(cmDbPool);
await repo.unlike(body.collectionId.trim(), ssoUser.id);

return { success: true };
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error;
}
console.error('Error unliking collection:', error);
throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' });
}
});
68 changes: 68 additions & 0 deletions frontend/server/api/collection/like/index.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { Pool } from 'pg';
import { CollectionLikeRepository, type LikedCollection } from '~~/server/repo/collectionLike.repo';
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import type { Pagination } from '~~/types/shared/pagination';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: GET /api/collection/like
* Description: Returns a paginated list of collections liked by the authenticated user.
*
* Query Parameters:
* - page (number, optional): The page number to fetch (default is 0).
* - pageSize (number, optional): The number of items per page (default is 10).
*
* Response:
* - 200: Paginated list of liked collections with author, name, description, logo, project count, and updatedAt
* - 401: Unauthorized
* - 500: Internal Server Error
*/
export default defineEventHandler(async (event): Promise<Pagination<LikedCollection> | Error> => {
const user = event.context.user as DecodedOidcToken | undefined;

if (!user?.sub) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}

const cmDbPool = event.context.cmDbPool as Pool | undefined;

if (!cmDbPool) {
throw createError({ statusCode: 503, statusMessage: 'Database not available' });
}

const query = getQuery(event);
const page: number = Number(query?.page) || 0;
const pageSize: number = Number(query?.pageSize) || 10;

try {
const username = getAuthUsername(user.sub);

const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
const ssoUser = await ssoUserRepo.findByUsername(username);

if (!ssoUser) {
return {
page,
pageSize,
total: 0,
data: [],
};
}

const repo = new CollectionLikeRepository(cmDbPool);
const result = await repo.findLikedByUser(ssoUser.id, { page, pageSize });

return {
page,
pageSize,
total: result.total,
data: result.data,
};
} catch (error) {
console.error('Error fetching liked collections:', error);
throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' });
}
});
75 changes: 75 additions & 0 deletions frontend/server/api/collection/like/index.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { Pool } from 'pg';
import { CollectionLikeRepository } from '~~/server/repo/collectionLike.repo';
import { InsightsSsoUserRepository } from '~~/server/repo/insightsSsoUser.repo';
import type { DecodedOidcToken } from '~~/types/auth/auth-jwt.types';
import { getAuthUsername } from '~~/server/utils/common';

/**
* API Endpoint: POST /api/collection/like
* Description: Likes a collection for the authenticated user.
*
* Request Body:
* - collectionId (string, required): The ID of the collection to like
*
* Response:
* - 200: Success
* - 400: Validation error
* - 401: Unauthorized
* - 500: Internal Server Error
*/
export default defineEventHandler(async (event): Promise<{ success: boolean } | Error> => {
const user = event.context.user as DecodedOidcToken | undefined;

if (!user?.sub) {
throw createError({ statusCode: 401, statusMessage: 'Unauthorized' });
}

const body = await readBody<{ collectionId?: string }>(event);

if (!body?.collectionId?.trim()) {
throw createError({ statusCode: 400, statusMessage: 'collectionId is required' });
}

const cmDbPool = event.context.cmDbPool as Pool | undefined;

if (!cmDbPool) {
throw createError({ statusCode: 503, statusMessage: 'Database not available' });
}

try {
const username = getAuthUsername(user.sub);

const ssoUserRepo = new InsightsSsoUserRepository(cmDbPool);
const ssoUser = await ssoUserRepo.upsert({
id: user.sub,
displayName: user.name,
avatarUrl: user.picture,
email: user.email,
username,
});

const collectionId = body.collectionId.trim();

const repo = new CollectionLikeRepository(cmDbPool);
await repo.like(collectionId, ssoUser.id);

return { success: true };
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error) {
throw error;
}
// FK violation means the collection doesn't exist
if (
error &&
typeof error === 'object' &&
'code' in error &&
(error as { code: string }).code === '23503'
) {
throw createError({ statusCode: 404, statusMessage: 'Collection not found' });
}
console.error('Error liking collection:', error);
throw createError({ statusCode: 500, statusMessage: 'Internal Server Error' });
}
});
29 changes: 24 additions & 5 deletions frontend/server/api/project/[slug]/insights.get.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { ProjectInsights } from '~~/types/project';
import type { ProjectInsightsTinybird } from '~~/types/project';
import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';
import { useApiTrackEvent } from '~~/server/utils/plausible';

Expand All @@ -15,9 +15,12 @@ export default defineEventHandler(async (event) => {
}

try {
const response = await fetchFromTinybird<ProjectInsights[]>('/v0/pipes/project_insights.json', {
slug,
});
const response = await fetchFromTinybird<ProjectInsightsTinybird[]>(
'/v0/pipes/project_insights.json',
{
slug,
},
);

useApiTrackEvent({
event,
Expand All @@ -29,7 +32,23 @@ export default defineEventHandler(async (event) => {
},
});

return response.data?.[0];
const project = response.data?.[0];
if (!project) {
return project;
}

return {
...project,
isLF: !!project.isLF,
achievements:
project.achievements?.map(
([leaderboardType, rank, totalCount]: [string, number, number]) => ({
leaderboardType,
rank,
totalCount,
}),
) ?? [],
};
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
throw error;
Expand Down
68 changes: 68 additions & 0 deletions frontend/server/api/project/insights.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { ProjectInsightsTinybird } from '~~/types/project';
import { fetchFromTinybird } from '~~/server/data/tinybird/tinybird';

/**
* API Endpoint: /api/project/insights
* Method: GET
* Description: Fetches project insights for multiple projects by slugs or IDs.
*
* Query Parameters:
* - slugs (string | string[]): One or more project slugs to fetch insights for.
* - ids (string | string[]): One or more project IDs to fetch insights for.
*
* At least one of `slugs` or `ids` must be provided.
*
* Response:
* - Array of ProjectInsights objects with achievements mapped to structured objects.
*/
export default defineEventHandler(async (event) => {
const query = getQuery(event);

const slugs = Array.isArray(query.slugs)
? (query.slugs as string[])
: query.slugs
? [query.slugs as string]
: [];

const ids = Array.isArray(query.ids)
? (query.ids as string[])
: query.ids
? [query.ids as string]
: [];

if (slugs.length === 0 && ids.length === 0) {
throw createError({
statusCode: 400,
statusMessage: 'At least one project slug or id is required',
});
}

try {
const response = await fetchFromTinybird<ProjectInsightsTinybird[]>(
'/v0/pipes/project_insights.json',
{
slugs: slugs.length > 0 ? slugs : undefined,
ids: ids.length > 0 ? ids : undefined,
},
);

return response.data.map((project) => ({
...project,
isLF: !!project.isLF,
achievements:
project.achievements?.map(([leaderboardType, rank, totalCount]) => ({
leaderboardType,
rank,
totalCount,
})) ?? [],
}));
} catch (error: unknown) {
console.error('Error fetching project insights:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch project insights',
});
}
});
Loading
Loading