Skip to content
Open
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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@ FIREBASE_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\nyour_private_key\n-----END PR
# VITE_USE_FIRESTORE_EMULATOR=true
# VITE_FIRESTORE_EMULATOR_HOST=127.0.0.1
# VITE_FIRESTORE_EMULATOR_PORT=8080

# PostgreSQL / Prisma Database Connection
DATABASE_URL="postgresql://username:password@localhost:5432/strive_dev?schema=public"

1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ functions/lib/**/*.map
.vercel
*.md
*.yml
docs/
8 changes: 7 additions & 1 deletion api/_lib/authMiddleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,17 @@ export async function verifyAuth(req) {
throw new Error("unauthenticated: Missing or invalid Authorization header");
}

// Allow test scripts to mock the verified token
if (process.env.NODE_ENV === "test" && process.env.MOCK_AUTH_USER_ID) {
if (process.env.MOCK_AUTH_USER_ID === "null") throw new Error("unauthenticated: Invalid token");
return { uid: process.env.MOCK_AUTH_USER_ID };
}

const token = authHeader.split("Bearer ")[1];
try {
const decodedToken = await admin.auth().verifyIdToken(token);
return decodedToken;
} catch (error) {
} catch {
throw new Error("unauthenticated: Invalid token");
}
}
22 changes: 22 additions & 0 deletions api/_lib/errorHandler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { sendError } from "./utils.js";

export function handleApiError(res, err) {
if (err.message && err.message.startsWith("unauthenticated:")) {
return sendError(res, 401, "unauthenticated", err.message.split(": ")[1] || err.message);
}
if (err.name === "ServiceError" || err.status) {
const status = err.status || 500;
const codes = {
400: "invalid-argument",
401: "unauthenticated",
403: "permission-denied",
404: "not-found",
409: "conflict"
};
const code = codes[status] || "internal";
return sendError(res, status, code, err.message);
}

console.error("Unexpected API Error:", err);
return sendError(res, 500, "internal", "An unexpected internal error occurred.");
}
1 change: 0 additions & 1 deletion api/_lib/firebaseAdmin.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,4 @@ if (!admin.apps.length) {
});
}

export const db = admin.firestore();
export { admin };
86 changes: 2 additions & 84 deletions api/_lib/listUtils.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { db } from "./firebaseAdmin.js";
import { verifyAuth } from "./authMiddleware.js";
import { fetchWithTimeout } from "./utils.js";

export class HttpRequestError extends Error {
Expand All @@ -10,86 +8,6 @@ export class HttpRequestError extends Error {
}
}

function normalizeListId(listId) {
return listId === "watchlist" ? "watchlist" : String(listId || "").trim();
}

function getUserListRef(uid, listId) {
return db.collection("users").doc(uid).collection("lists").doc(listId);
}

function getLibraryItemsRef(uid) {
return db.collection("users").doc(uid).collection("library_items");
}

function createListItemsAccessor(uid, listId) {
const normalizedListId = normalizeListId(listId);
const collectionRef = getLibraryItemsRef(uid);
const queryRef = collectionRef.where(
"tracking.listIds",
"array-contains",
normalizedListId,
);

return {
collectionRef,
queryRef,
listId: normalizedListId,
get: () => queryRef.get(),
doc: (docId) => collectionRef.doc(String(docId)),
};
}

export async function resolveAuthorizedCustomList(uid, listId) {
const listRef = getUserListRef(uid, listId);

const listDoc = await listRef.get();
if (!listDoc.exists) {
throw new HttpRequestError(404, "List not found");
}

const listData = listDoc.data() || {};
if (!listData.ownerId || listData.ownerId !== uid) {
throw new HttpRequestError(
403,
"Forbidden: You do not have permission to access this list",
);
}

return { listRef, listData };
}

export async function resolveListExportContext(uid, listId) {
const normalizedListId = normalizeListId(listId);
if (normalizedListId === "watchlist") {
return {
itemsCollectionRef: createListItemsAccessor(uid, normalizedListId),
listName: "Watchlist",
};
}

const { listData } = await resolveAuthorizedCustomList(uid, normalizedListId);
const itemsCollectionRef = createListItemsAccessor(uid, normalizedListId);

const listName =
typeof listData.name === "string" && listData.name.trim()
? listData.name.trim()
: normalizedListId;
return {
itemsCollectionRef,
listName,
};
}

export async function resolveListItemsCollection(uid, listId) {
const normalizedListId = normalizeListId(listId);
if (normalizedListId !== "watchlist") {
await resolveAuthorizedCustomList(uid, normalizedListId);
}

return createListItemsAccessor(uid, normalizedListId);
}

export async function fetchTmdbExternalIds(mediaType, tmdbId, tmdbToken) {
if (!tmdbToken) return null;
const url = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}/external_ids`;
Expand Down Expand Up @@ -123,10 +41,10 @@ export async function fetchTmdbDetails(mediaType, tmdbId, tmdbToken) {
}

export function getImdbApiBaseUrl() {
const baseUrl = process.env.IMDB_API_BASE_URL;
const baseUrl = process.env.IMDB_API_BASE_URL || process.env.VITE_IMDB_BASE_URL;
if (!baseUrl) {
console.warn(
"IMDB_API_BASE_URL environment variable is not configured. IMDb ratings will be unavailable.",
"IMDb API base URL environment variable is not configured. IMDb ratings will be unavailable.",
);
return null;
}
Expand Down
13 changes: 13 additions & 0 deletions api/_lib/prisma.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { PrismaClient } from "@prisma/client";

// Global singleton pattern to prevent exhausting database connection pools
// during serverless execution and hot-reloading in development.
const globalForPrisma = globalThis;

export const prisma = globalForPrisma.prisma || new PrismaClient();

if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma;
}

export default prisma;
37 changes: 37 additions & 0 deletions api/_lib/repositories/CatalogRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import prisma from "../prisma.js";

export async function getMedia({ titleKey }) {
return prisma.catalogTitle.findUnique({
where: { titleKey },
include: {
seasons: true,
episodes: {
orderBy: [
{ seasonNumber: "asc" },
{ episodeNumber: "asc" }
]
}
}
});
}

export async function searchCatalog({ query, userId = null, limit = 20 }) {
if (userId) {
return prisma.$queryRaw`
SELECT ct.*,
CASE WHEN uli.title_key IS NOT NULL THEN true ELSE false END AS "inLibrary"
FROM catalog_titles ct
LEFT JOIN user_library_items uli ON ct.title_key = uli.title_key AND uli.user_id = ${userId}
WHERE ct.title % ${query}
ORDER BY SIMILARITY(ct.title, ${query}) DESC
LIMIT ${limit};
`;
}
return prisma.$queryRaw`
SELECT ct.*, false AS "inLibrary"
FROM catalog_titles ct
WHERE ct.title % ${query}
ORDER BY SIMILARITY(ct.title, ${query}) DESC
LIMIT ${limit};
`;
}
122 changes: 122 additions & 0 deletions api/_lib/repositories/LibraryRepository.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import prisma from "../prisma.js";

export async function getLibrary({ userId, status, cursor, limit = 50 }) {
const where = { userId };
if (status) {
where.status = status;
}

const items = await prisma.userLibraryItem.findMany({
where,
orderBy: { addedAt: "desc" },
take: limit + 1,
cursor: cursor ? { userId_titleKey: { userId, titleKey: cursor } } : undefined,
include: {
catalogTitle: true,
}
});

let nextCursor = null;
if (items.length > limit) {
const nextItem = items.pop();
nextCursor = nextItem.titleKey;
}

return { items, nextCursor };
}

export async function getContinueWatching({ userId, limit = 20 }) {
// Using query shape from Phase 3.3.1 investigation: early LIMIT subquery
return prisma.$queryRaw`
SELECT uli.*, ct.title, ct.poster_path
FROM (
SELECT * FROM user_library_items
WHERE user_id = ${userId} AND status = 'watching'
ORDER BY last_watched_at DESC NULLS LAST
LIMIT ${limit}
) uli
JOIN catalog_titles ct ON uli.title_key = ct.title_key;
`;
}

export async function upsertLibraryItem({ userId, titleKey, data }) {
return prisma.userLibraryItem.upsert({
where: { userId_titleKey: { userId, titleKey } },
create: { userId, titleKey, ...data },
update: { ...data }
});
}

export async function updateLibraryStatus({ userId, titleKey, status, lastWatchedAt, userRating, notes }) {
const updateData = {};
if (status !== undefined && status !== null) {
updateData.status = status;
}
if (lastWatchedAt !== undefined) {
updateData.lastWatchedAt = lastWatchedAt;
}
if (userRating !== undefined) {
updateData.userRating = userRating;
}
if (notes !== undefined) {
updateData.notes = notes;
}

const defaultStatus = status || "plan_to_watch";

return prisma.userLibraryItem.upsert({
where: { userId_titleKey: { userId, titleKey } },
create: {
userId,
titleKey,
status: defaultStatus,
userRating: userRating !== undefined ? userRating : null,
lastWatchedAt: lastWatchedAt || null,
notes: notes !== undefined ? notes : null
},
update: updateData
});
}

export async function deleteLibraryItem({ userId, titleKey }) {
return prisma.$transaction(async (tx) => {
await tx.userEpisodeState.deleteMany({
where: { userId, titleKey }
});

await tx.userListItem.deleteMany({
where: { userId, titleKey }
});

return tx.userLibraryItem.delete({
where: { userId_titleKey: { userId, titleKey } }
});
});
}

export async function batchUpdateLibraryStatus({ userId, titleKeys, status, lastWatchedAt }) {
const data = { status };
if (lastWatchedAt !== undefined) {
data.lastWatchedAt = lastWatchedAt;
}
return prisma.userLibraryItem.updateMany({
where: { userId, titleKey: { in: titleKeys } },
data
});
}

export async function batchDeleteLibraryItems({ userId, titleKeys }) {
return prisma.$transaction(async (tx) => {
await tx.userEpisodeState.deleteMany({
where: { userId, titleKey: { in: titleKeys } }
});

await tx.userListItem.deleteMany({
where: { userId, titleKey: { in: titleKeys } }
});

return tx.userLibraryItem.deleteMany({
where: { userId, titleKey: { in: titleKeys } }
});
});
}
Loading