diff --git a/api/src/controllers/profiles/watchlist.ts b/api/src/controllers/profiles/watchlist.ts index 51da5686..57f01959 100644 --- a/api/src/controllers/profiles/watchlist.ts +++ b/api/src/controllers/profiles/watchlist.ts @@ -10,7 +10,7 @@ import { import { db } from "~/db"; import { entries, shows } from "~/db/schema"; import { watchlist } from "~/db/schema/watchlist"; -import { conflictUpdateAllExcept, getColumns } from "~/db/utils"; +import { coalesce, getColumns, rowToModel } from "~/db/utils"; import { Entry } from "~/models/entry"; import { KError } from "~/models/error"; import { bubble, madeInAbyss } from "~/models/examples"; @@ -26,9 +26,16 @@ import { processLanguages, } from "~/models/utils"; import { desc } from "~/models/utils/descriptions"; -import { MovieWatchStatus, SerieWatchStatus } from "~/models/watchlist"; +import { + MovieWatchStatus, + SeedMovieWatchStatus, + SeedSerieWatchStatus, + SerieWatchStatus, +} from "~/models/watchlist"; import { getOrCreateProfile } from "./profile"; +console.log(); + async function setWatchStatus({ show, status, @@ -37,7 +44,7 @@ async function setWatchStatus({ show: | { pk: number; kind: "movie" } | { pk: number; kind: "serie"; entriesCount: number }; - status: Omit; + status: SeedSerieWatchStatus; userId: string; }) { const profilePk = await getOrCreateProfile(userId); @@ -53,6 +60,7 @@ async function setWatchStatus({ .insert(watchlist) .values({ ...status, + startedAt: coalesce(sql`${status.startedAt ?? null}`, sql`now()`), profilePk: profilePk, seenCount: status.status === "completed" @@ -70,38 +78,33 @@ async function setWatchStatus({ .onConflictDoUpdate({ target: [watchlist.profilePk, watchlist.showPk], set: { - ...conflictUpdateAllExcept(watchlist, [ - "profilePk", - "showPk", - "createdAt", - "seenCount", - "nextEntry", - "lastPlayedAt", - ]), - ...(status.status === "completed" - ? { - seenCount: sql`excluded.seen_count`, - nextEntry: sql`null`, - } - : {}), + status: sql`excluded.status`, + startedAt: coalesce( + sql`${status.startedAt ?? null}`, + watchlist.startedAt, + ), + completedAt: sql` + case + when excluded.status = 'completed' then coalesce(excluded.completed_at, now()) + else coalesce(excluded.completed_at, ${watchlist.completedAt}) + end + `, // only set seenCount & nextEntry when marking as "rewatching" // if it's already rewatching, the history updates are more up-dated. - ...(status.status === "rewatching" - ? { - seenCount: sql` - case when ${watchlist.status} != 'rewatching' - then excluded.seen_count - else - ${watchlist.seenCount} - end`, - nextEntry: sql` - case when ${watchlist.status} != 'rewatching' - then excluded.next_entry - else - ${watchlist.nextEntry} - end`, - } - : {}), + seenCount: sql` + case + when excluded.status = 'completed' then excluded.seen_count + when excluded.status = 'rewatching' and ${watchlist.status} != 'rewatching' then excluded.seen_count + else ${watchlist.seenCount} + end + `, + nextEntry: sql` + case + when excluded.status = 'completed' then null + when excluded.status = 'rewatching' and ${watchlist.status} != 'rewatching' then excluded.next_entry + else ${watchlist.nextEntry} + end + `, }, }) .returning({ @@ -296,7 +299,7 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) example: madeInAbyss.slug, }), }), - body: t.Omit(SerieWatchStatus, ["seenCount"]), + body: SeedSerieWatchStatus, response: { 200: t.Intersect([SerieWatchStatus, DbMetadata]), 404: KError, @@ -341,7 +344,88 @@ export const watchlistH = new Elysia({ tags: ["profiles"] }) example: bubble.slug, }), }), - body: t.Omit(MovieWatchStatus, ["percent"]), + body: SeedMovieWatchStatus, + response: { + 200: t.Intersect([MovieWatchStatus, DbMetadata]), + 404: KError, + }, + permissions: ["core.read"], + }, + ) + .delete( + "/series/:id/watchstatus", + async ({ params: { id }, jwt: { sub }, status }) => { + const profilePk = await getOrCreateProfile(sub); + + const rows = await db.execute(sql` + delete from ${watchlist} using ${shows} + where ${and( + eq(watchlist.profilePk, profilePk), + eq(watchlist.showPk, shows.pk), + eq(shows.kind, "serie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + )} + returning ${watchlist}.* + `); + + if (rows.rowCount === 0) { + return status(404, { + status: 404, + message: `No serie found in your watchlist the id/slug: '${id}'.`, + }); + } + + return rowToModel(rows.rows[0], watchlist); + }, + { + detail: { description: "Set watchstatus of a serie." }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the serie.", + example: madeInAbyss.slug, + }), + }), + response: { + 200: t.Intersect([SerieWatchStatus, DbMetadata]), + 404: KError, + }, + permissions: ["core.read"], + }, + ) + .delete( + "/movies/:id/watchstatus", + async ({ params: { id }, jwt: { sub }, status }) => { + const profilePk = await getOrCreateProfile(sub); + + const rows = await db.execute(sql` + delete from ${watchlist} using ${shows} + where ${and( + eq(watchlist.profilePk, profilePk), + eq(watchlist.showPk, shows.pk), + eq(shows.kind, "movie"), + isUuid(id) ? eq(shows.id, id) : eq(shows.slug, id), + )} + returning ${watchlist}.* + `); + + if (rows.rowCount === 0) { + return status(404, { + status: 404, + message: `No movie found in your watchlist the id/slug: '${id}'.`, + }); + } + + const ret = rowToModel(rows.rows[0], watchlist); + return { ...ret, percent: ret.seenCount }; + }, + { + detail: { description: "Set watchstatus of a movie." }, + params: t.Object({ + id: t.String({ + description: "The id or slug of the movie.", + example: bubble.slug, + }), + }), response: { 200: t.Intersect([MovieWatchStatus, DbMetadata]), 404: KError, diff --git a/api/src/db/schema/utils.ts b/api/src/db/schema/utils.ts index ae113bfc..ebe97480 100644 --- a/api/src/db/schema/utils.ts +++ b/api/src/db/schema/utils.ts @@ -31,6 +31,7 @@ export const timestamp = customType<{ return `timestamp${precision}${config?.withTimezone ? " with time zone" : ""}`; }, fromDriver(value: string): string { + if (!value) return value; // postgres format: 2025-06-22 16:13:37.489301+00 // what we want: 2025-06-22T16:13:37Z return `${value.substring(0, 10)}T${value.substring(11, 19)}Z`; diff --git a/api/src/db/utils.ts b/api/src/db/utils.ts index ade66c06..20d47cd5 100644 --- a/api/src/db/utils.ts +++ b/api/src/db/utils.ts @@ -11,11 +11,13 @@ import { type TableConfig, View, ViewBaseConfig, + InferSelectModel, } from "drizzle-orm"; import type { CasingCache } from "drizzle-orm/casing"; import type { AnyMySqlSelect } from "drizzle-orm/mysql-core"; import type { AnyPgSelect, + PgTable, PgTableWithColumns, SelectedFieldsFlat, } from "drizzle-orm/pg-core"; @@ -52,6 +54,21 @@ export function getColumns< : table._.selectedFields; } +// See https://github.com/drizzle-team/drizzle-orm/issues/2842 +export function rowToModel< + TTable extends PgTable, + TModel = InferSelectModel, +>(row: Record, table: TTable): TModel { + // @ts-expect-error: drizzle internal + const casing = db.dialect.casing as CasingCache; + return Object.fromEntries( + Object.entries(table).map(([schemaName, schema]) => [ + schemaName, + schema.mapFromDriverValue?.(row[casing.getColumnCasing(schema)] ?? null), + ]), + ) as TModel; +} + // See https://github.com/drizzle-team/drizzle-orm/issues/1728 export function conflictUpdateAllExcept< T extends Table, diff --git a/api/src/models/watchlist.ts b/api/src/models/watchlist.ts index 7ce5b557..a21b3b22 100644 --- a/api/src/models/watchlist.ts +++ b/api/src/models/watchlist.ts @@ -21,13 +21,24 @@ export const SerieWatchStatus = t.Object({ }); export type SerieWatchStatus = typeof SerieWatchStatus.static; -export const MovieWatchStatus = t.Composite([ - t.Omit(SerieWatchStatus, ["startedAt", "seenCount"]), - t.Object({ - percent: t.Integer({ - minimum: 0, - maximum: 100, - }), +export const MovieWatchStatus = t.Object({ + status: WatchlistStatus, + score: SerieWatchStatus.properties.score, + completedAt: SerieWatchStatus.properties.completedAt, + percent: t.Integer({ + minimum: 0, + maximum: 100, }), -]); +}); export type MovieWatchStatus = typeof MovieWatchStatus.static; + +export const SeedSerieWatchStatus = t.Object({ + status: SerieWatchStatus.properties.status, + score: t.Optional(SerieWatchStatus.properties.score), + startedAt: t.Optional(SerieWatchStatus.properties.startedAt), + completedAt: t.Optional(SerieWatchStatus.properties.completedAt), +}); +export type SeedSerieWatchStatus = typeof SeedSerieWatchStatus.static; + +export const SeedMovieWatchStatus = t.Omit(SeedSerieWatchStatus, ["startedAt"]); +export type SeedMovieWatchStatus = typeof SeedMovieWatchStatus.static; diff --git a/front/src/components/items/context-menus.tsx b/front/src/components/items/context-menus.tsx index 6f91b190..d7e569cd 100644 --- a/front/src/components/items/context-menus.tsx +++ b/front/src/components/items/context-menus.tsx @@ -70,19 +70,19 @@ export const ItemContext = ({ const { t } = useTranslation(); const mutation = useMutation({ - path: [kind, slug, "watchStatus"], + path: ["api", `${kind}s`, slug, "watchstatus"], compute: (newStatus: WatchStatusV | null) => ({ method: newStatus ? "POST" : "DELETE", - params: newStatus ? { status: newStatus } : undefined, + body: newStatus ? { status: newStatus } : undefined, }), invalidate: [kind, slug], }); - const metadataRefreshMutation = useMutation({ - method: "POST", - path: [kind, slug, "refresh"], - invalidate: null, - }); + // const metadataRefreshMutation = useMutation({ + // method: "POST", + // path: [kind, slug, "refresh"], + // invalidate: null, + // }); return ( )} - {account?.isAdmin === true && ( - <> -
- metadataRefreshMutation.mutate()} - /> - - )} + {/* {account?.isAdmin === true && ( */} + {/* <> */} + {/*
*/} + {/* metadataRefreshMutation.mutate()} */} + {/* /> */} + {/* */} + {/* )} */}
); }; diff --git a/front/src/components/items/watchlist-info.tsx b/front/src/components/items/watchlist-info.tsx index a68f5aac..bcf854f6 100644 --- a/front/src/components/items/watchlist-info.tsx +++ b/front/src/components/items/watchlist-info.tsx @@ -46,7 +46,7 @@ export const WatchListInfo = ({ const { t } = useTranslation(); const mutation = useMutation({ - path: [kind, slug, "watchStatus"], + path: ["api", `${kind}s`, slug, "watchstatus"], compute: (newStatus: WatchStatus | null) => ({ method: newStatus ? "POST" : "DELETE", body: newStatus ? { status: newStatus } : undefined, diff --git a/front/src/ui/details/header.tsx b/front/src/ui/details/header.tsx index 04610a5c..f56d8498 100644 --- a/front/src/ui/details/header.tsx +++ b/front/src/ui/details/header.tsx @@ -58,7 +58,7 @@ import { UL, } from "~/primitives"; import { useAccount } from "~/providers/account-context"; -import { Fetch, type QueryIdentifier, useMutation } from "~/query"; +import { Fetch, type QueryIdentifier } from "~/query"; import { displayRuntime, getDisplayDate } from "~/utils"; const ButtonList = ({ @@ -78,11 +78,11 @@ const ButtonList = ({ const { css, theme } = useYoshiki(); const { t } = useTranslation(); - const metadataRefreshMutation = useMutation({ - method: "POST", - path: [kind, slug, "refresh"], - invalidate: null, - }); + // const metadataRefreshMutation = useMutation({ + // method: "POST", + // path: [kind, slug, "refresh"], + // invalidate: null, + // }); return ( )} - {account?.isAdmin === true && ( - <> - {kind === "movie" &&
} - metadataRefreshMutation.mutate()} - /> - - )} + {/* {account?.isAdmin === true && ( */} + {/* <> */} + {/* {kind === "movie" &&
} */} + {/* metadataRefreshMutation.mutate()} */} + {/* /> */} + {/* */} + {/* )} */} )}