Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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' });
}
});
18 changes: 17 additions & 1 deletion frontend/server/api/project/[slug]/insights.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,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,
}),
) ?? [],
};
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This endpoint returns a transformed shape (isLF boolean + structured achievements) but it fetches Tinybird data typed as ProjectInsights[] (which currently models isLF as number and achievements as tuples). Please align the types by introducing a Tinybird/raw type for the fetch and a separate API response type for the mapped return value, then type the handler accordingly.

Copilot uses AI. Check for mistakes.
} catch (error: unknown) {
if (error && typeof error === 'object' && 'statusCode' in error && error.statusCode === 404) {
throw error;
Expand Down
65 changes: 65 additions & 0 deletions frontend/server/api/project/insights.get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2025 The Linux Foundation and each contributor.
// SPDX-License-Identifier: MIT
import type { ProjectInsights } 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<ProjectInsights[]>('/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,
})) ?? [],
Copy link

Copilot AI Mar 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This handler maps isLF from a number to a boolean and converts achievements tuples into objects, but it still uses the shared ProjectInsights type for the Tinybird response. To keep type safety and avoid confusing consumers, use a raw/Tinybird type for fetchFromTinybird and explicitly type the returned payload as the mapped API shape (with isLF: boolean and structured achievements).

Copilot uses AI. Check for mistakes.
}));
} catch (error: unknown) {
console.error('Error fetching project insights:', error);
throw createError({
statusCode: 500,
statusMessage: 'Failed to fetch project insights',
});
}
});
1 change: 1 addition & 0 deletions frontend/server/middleware/jwt-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default defineEventHandler(async (event) => {
'/api/community/list',
'/api/security/update',
'/api/collection/community',
'/api/collection/like',
];
const protectedAndPermissionRoutes = ['/api/chat'];

Expand Down
Loading