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
1 change: 1 addition & 0 deletions apps/api/src/modules/media-recommendations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./router";
67 changes: 67 additions & 0 deletions apps/api/src/modules/media-recommendations/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { Elysia, t } from "elysia";

const profileCard = t.Object({
user_id: t.String(),
username: t.Nullable(t.String()),
full_name: t.Nullable(t.String()),
avatar_path: t.Nullable(t.String()),
});

const received = t.Object({
id: t.String(),
tmdb_id: t.Number(),
media_type: t.String(),
title: t.String(),
poster_path: t.Nullable(t.String()),
message: t.Nullable(t.String()),
read_at: t.Nullable(t.String()),
created_at: t.String(),
sender: profileCard,
});

export const MediaRecommendationModel = new Elysia({
name: "MediaRecommendation.Model",
}).model({
"mediaRecommendations.SendInput": t.Object({
tmdb_id: t.Number(),
media_type: t.Union([t.Literal("movie"), t.Literal("tv")]),
title: t.String({ minLength: 1 }),
poster_path: t.Optional(t.Nullable(t.String())),
recipient_ids: t.Array(t.String({ minLength: 1 }), { minItems: 1 }),
message: t.Optional(t.Nullable(t.String({ maxLength: 500 }))),
}),
"mediaRecommendations.SendResult": t.Object({
ok: t.Boolean(),
count: t.Number(),
}),
"mediaRecommendations.Received": received,
"mediaRecommendations.ReceivedList": t.Array(received),
"mediaRecommendations.FriendList": t.Array(profileCard),
"mediaRecommendations.UnreadCount": t.Object({
count: t.Number(),
}),
"mediaRecommendations.OkResponse": t.Object({
ok: t.Boolean(),
}),
});

export const mediaRecommendationModels = MediaRecommendationModel.models;

export type RecommendationProfileCardDto = {
user_id: string;
username: string | null;
full_name: string | null;
avatar_path: string | null;
};

export type ReceivedRecommendationDto = {
id: string;
tmdb_id: number;
media_type: string;
title: string;
poster_path: string | null;
message: string | null;
read_at: string | null;
created_at: string;
sender: RecommendationProfileCardDto;
};
2 changes: 2 additions & 0 deletions apps/api/src/modules/media-recommendations/mutations/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { sendRecommendation } from "./send-recommendation";
export { markRecommendationRead } from "./mark-read";
20 changes: 20 additions & 0 deletions apps/api/src/modules/media-recommendations/mutations/mark-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { db } from "@seen/db";
import { mediaRecommendations } from "@seen/db/schema";
import { and, eq, isNull } from "@seen/db/orm";

export async function markRecommendationRead(
userId: string,
recommendationId: string,
): Promise<{ ok: boolean }> {
await db
.update(mediaRecommendations)
.set({ readAt: new Date() })
.where(
and(
eq(mediaRecommendations.id, recommendationId),
eq(mediaRecommendations.recipientId, userId),
isNull(mediaRecommendations.readAt),
),
);
return { ok: true };
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { db } from "@seen/db";
import { follows, mediaRecommendations } from "@seen/db/schema";
import { and, eq, inArray } from "@seen/db/orm";

import { notifyRecommendation } from "../notify";

type SendInput = {
tmdb_id: number;
media_type: "movie" | "tv";
title: string;
poster_path?: string | null;
recipient_ids: string[];
message?: string | null;
};

export async function sendRecommendation(
senderId: string,
input: SendInput,
): Promise<{ ok: boolean; count: number }> {
const requested = [...new Set(input.recipient_ids)].filter((id) => id !== senderId);
if (requested.length === 0) return { ok: true, count: 0 };

// Recipients must be people the sender follows (one-directional).
const following = await db
.select({ id: follows.followeeId })
.from(follows)
.where(and(eq(follows.followerId, senderId), inArray(follows.followeeId, requested)));
const recipientIds = following.map((row) => row.id);
if (recipientIds.length === 0) return { ok: true, count: 0 };

const rows = await db
.insert(mediaRecommendations)
.values(
recipientIds.map((recipientId) => ({
senderId,
recipientId,
tmdbId: input.tmdb_id,
mediaType: input.media_type,
title: input.title,
posterPath: input.poster_path ?? null,
message: input.message ?? null,
})),
)
.returning({ id: mediaRecommendations.id, recipientId: mediaRecommendations.recipientId });

for (const row of rows) {
void notifyRecommendation({
recommendationId: row.id,
recipientUserId: row.recipientId,
actorId: senderId,
title: input.title,
});
}

return { ok: true, count: rows.length };
}
20 changes: 20 additions & 0 deletions apps/api/src/modules/media-recommendations/notification-rules.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { ExpoPushMessage } from "../../lib/expo-push";

export type RecommendationNotification = {
recommendationId: string;
actorName: string;
title: string;
};

export function recommendationPushMessages(
event: RecommendationNotification,
tokens: string[],
): ExpoPushMessage[] {
return tokens.map((token) => ({
to: token,
title: "New recommendation",
body: `${event.actorName} recommended ${event.title}.`,
sound: "default",
data: { type: "media-recommendation.received", recommendationId: event.recommendationId },
}));
}
40 changes: 40 additions & 0 deletions apps/api/src/modules/media-recommendations/notify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { db } from "@seen/db";
import { profiles, pushTokens } from "@seen/db/schema";
import { eq } from "@seen/db/orm";

import { sendExpoPush } from "../../lib/expo-push";
import { maybeTrigger } from "../../lib/trigger";
import { recommendationPushMessages } from "./notification-rules";

export async function notifyRecommendation(event: {
recommendationId: string;
recipientUserId: string;
actorId: string;
title: string;
}): Promise<void> {
try {
const [tokens, [actor]] = await Promise.all([
db
.select({ token: pushTokens.token })
.from(pushTokens)
.where(eq(pushTokens.userId, event.recipientUserId)),
db
.select({ fullName: profiles.fullName })
.from(profiles)
.where(eq(profiles.id, event.actorId)),
]);
const messages = recommendationPushMessages(
{
recommendationId: event.recommendationId,
actorName: actor?.fullName ?? "Someone",
title: event.title,
},
tokens.map((row) => row.token),
);
if (messages.length === 0) return;
const enqueued = maybeTrigger("send-push", { messages });
if (!enqueued) await sendExpoPush(messages);
} catch (error) {
console.error("media-recommendations: push dispatch failed", error);
}
}
11 changes: 11 additions & 0 deletions apps/api/src/modules/media-recommendations/queries/count-unread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { db } from "@seen/db";
import { mediaRecommendations } from "@seen/db/schema";
import { and, eq, isNull, sql } from "@seen/db/orm";

export async function countUnread(userId: string): Promise<{ count: number }> {
const [row] = await db
.select({ count: sql<number>`count(*)::int` })
.from(mediaRecommendations)
.where(and(eq(mediaRecommendations.recipientId, userId), isNull(mediaRecommendations.readAt)));
return { count: row?.count ?? 0 };
}
3 changes: 3 additions & 0 deletions apps/api/src/modules/media-recommendations/queries/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export { listReceived } from "./list-received";
export { listRecommendableFriends } from "./list-recommendable-friends";
export { countUnread } from "./count-unread";
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { db } from "@seen/db";
import { mediaRecommendations, profiles } from "@seen/db/schema";
import { desc, eq } from "@seen/db/orm";

import type { ReceivedRecommendationDto } from "../model";

export async function listReceived(userId: string): Promise<ReceivedRecommendationDto[]> {
const rows = await db
.select({
id: mediaRecommendations.id,
tmdbId: mediaRecommendations.tmdbId,
mediaType: mediaRecommendations.mediaType,
title: mediaRecommendations.title,
posterPath: mediaRecommendations.posterPath,
message: mediaRecommendations.message,
readAt: mediaRecommendations.readAt,
createdAt: mediaRecommendations.createdAt,
senderId: profiles.id,
username: profiles.username,
fullName: profiles.fullName,
avatarPath: profiles.avatarPath,
})
.from(mediaRecommendations)
.innerJoin(profiles, eq(profiles.id, mediaRecommendations.senderId))
.where(eq(mediaRecommendations.recipientId, userId))
.orderBy(desc(mediaRecommendations.createdAt))
.limit(100);

return rows.map((row) => ({
id: row.id,
tmdb_id: row.tmdbId,
media_type: row.mediaType,
title: row.title,
poster_path: row.posterPath,
message: row.message,
read_at: row.readAt?.toISOString() ?? null,
created_at: row.createdAt.toISOString(),
sender: {
user_id: row.senderId,
username: row.username,
full_name: row.fullName,
avatar_path: row.avatarPath,
},
}));
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { db } from "@seen/db";
import { sql } from "@seen/db/orm";

import type { RecommendationProfileCardDto } from "../model";

type FriendRow = {
id: string;
username: string;
full_name: string;
avatar_path: string | null;
};

// Candidate recipients = people the current user follows (one-directional, no
// mutual-follow requirement — unlike watch-session invites).
export async function listRecommendableFriends(
userId: string,
): Promise<RecommendationProfileCardDto[]> {
const result = await db.execute<FriendRow>(sql`
SELECT p.id, p.username, p.full_name, p.avatar_path
FROM follows f
JOIN profiles p ON p.id = f.followee_id
WHERE f.follower_id = ${userId}
ORDER BY p.full_name
LIMIT 100
`);

return result.rows.map((row) => ({
user_id: row.id,
username: row.username,
full_name: row.full_name,
avatar_path: row.avatar_path,
}));
}
34 changes: 34 additions & 0 deletions apps/api/src/modules/media-recommendations/router.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Elysia } from "elysia";

import { authGuard } from "../../auth-plugin";
import { MediaRecommendationModel } from "./model";
import { countUnread, listReceived, listRecommendableFriends } from "./queries";
import { markRecommendationRead, sendRecommendation } from "./mutations";

export const mediaRecommendationController = new Elysia({
name: "MediaRecommendation.Controller",
prefix: "/media-recommendations",
})
.use(authGuard)
.use(MediaRecommendationModel)
.post("/", ({ user, body }) => sendRecommendation(user.id, body), {
auth: true,
body: "mediaRecommendations.SendInput",
response: { 200: "mediaRecommendations.SendResult" },
})
.get("/received", ({ user }) => listReceived(user.id), {
auth: true,
response: { 200: "mediaRecommendations.ReceivedList" },
})
.get("/unread-count", ({ user }) => countUnread(user.id), {
auth: true,
response: { 200: "mediaRecommendations.UnreadCount" },
})
.get("/recommendable-friends", ({ user }) => listRecommendableFriends(user.id), {
auth: true,
response: { 200: "mediaRecommendations.FriendList" },
})
.post("/:id/read", ({ user, params }) => markRecommendationRead(user.id, params.id), {
auth: true,
response: { 200: "mediaRecommendations.OkResponse" },
});
2 changes: 2 additions & 0 deletions apps/api/src/modules/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { eventsController } from "./events";
import { importController } from "./import";
import { libraryController } from "./library";
import { likesController } from "./likes";
import { mediaRecommendationController } from "./media-recommendations";
import { notInterestedController } from "./not-interested";
import { notificationController } from "./notifications";
import { platformsController } from "./platforms";
Expand Down Expand Up @@ -38,6 +39,7 @@ export const apiRouter = new Elysia({ name: "api.router" })
.use(recommendationsController)
.use(socialController)
.use(watchSessionController)
.use(mediaRecommendationController)
.use(notificationController)
.use(analyticsController)
.use(whatsNewController);
15 changes: 15 additions & 0 deletions apps/api/src/modules/tmdb/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,16 @@ const episodeDetail = t.Composite([
}),
]);

const personDetail = t.Object({
id: t.Number(),
name: t.String(),
biography: t.Optional(t.Nullable(t.String())),
profile_path: t.Optional(t.Nullable(t.String())),
known_for_department: t.Optional(t.Nullable(t.String())),
acting: t.Array(summary),
crew: t.Array(summary),
});

const providerRef = t.Object({
providerId: t.Number(),
name: t.String(),
Expand Down Expand Up @@ -158,6 +168,10 @@ export const TmdbModel = new Elysia({ name: "Tmdb.Model" }).model({
mediaType,
tmdbId: t.Numeric(),
}),
"tmdb.PersonParams": t.Object({
personId: t.Numeric(),
}),
"tmdb.PersonDetail": personDetail,
"tmdb.LanguageQuery": t.Object({
language: t.Optional(t.String({ minLength: 2, maxLength: 12 })),
}),
Expand Down Expand Up @@ -191,6 +205,7 @@ export type CreditDto = Static<typeof credit>;
export type SeasonSummaryDto = Static<typeof seasonSummary>;
export type EpisodeSummaryDto = Static<typeof episodeSummary>;
export type MovieDetailDto = Static<typeof movieDetail>;
export type PersonDetailDto = Static<typeof personDetail>;
export type SeasonDetailDto = Static<typeof seasonDetail>;
export type EpisodeDetailDto = Static<typeof episodeDetail>;
export type DiscoverFeedDto = Static<typeof discoverFeed>;
Expand Down
Loading
Loading