From d4fe94fcc46e62f4178253b0ed7c28492a9d522f Mon Sep 17 00:00:00 2001 From: miicolas Date: Mon, 8 Jun 2026 20:23:43 +0200 Subject: [PATCH 1/5] refactor(db): split schema into per-domain folders Break the monolithic schema/domain.ts into per-domain folders, each with its own schema.ts, relations.ts and types.ts (media, profiles, reviews, library, rating-stats, events, providers). Relations move next to the tables they describe and are re-exported through the schema barrel, so client.ts no longer needs a separate relations import. Also adds the previously missing relations (likes, interaction/ recommendation events, user platforms) and inferred row types per domain. Pure reorganization: drizzle-kit reports no schema changes. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/src/client.ts | 3 +- packages/db/src/relations.ts | 46 --- packages/db/src/schema/domain.ts | 382 ------------------ packages/db/src/schema/events/index.ts | 3 + packages/db/src/schema/events/relations.ts | 18 + packages/db/src/schema/events/schema.ts | 73 ++++ packages/db/src/schema/events/types.ts | 7 + packages/db/src/schema/index.ts | 8 +- packages/db/src/schema/library/index.ts | 3 + packages/db/src/schema/library/relations.ts | 25 ++ packages/db/src/schema/library/schema.ts | 69 ++++ packages/db/src/schema/library/types.ts | 10 + packages/db/src/schema/media/index.ts | 2 + packages/db/src/schema/media/schema.ts | 41 ++ packages/db/src/schema/media/types.ts | 4 + packages/db/src/schema/profiles/index.ts | 3 + packages/db/src/schema/profiles/relations.ts | 22 + packages/db/src/schema/profiles/schema.ts | 25 ++ packages/db/src/schema/profiles/types.ts | 4 + packages/db/src/schema/providers/index.ts | 3 + packages/db/src/schema/providers/relations.ts | 19 + packages/db/src/schema/providers/schema.ts | 68 ++++ packages/db/src/schema/providers/types.ts | 10 + packages/db/src/schema/rating-stats/index.ts | 2 + packages/db/src/schema/rating-stats/schema.ts | 77 ++++ packages/db/src/schema/rating-stats/types.ts | 19 + packages/db/src/schema/reviews/index.ts | 3 + packages/db/src/schema/reviews/relations.ts | 18 + packages/db/src/schema/reviews/schema.ts | 84 ++++ packages/db/src/schema/reviews/types.ts | 7 + 30 files changed, 627 insertions(+), 431 deletions(-) delete mode 100644 packages/db/src/relations.ts delete mode 100644 packages/db/src/schema/domain.ts create mode 100644 packages/db/src/schema/events/index.ts create mode 100644 packages/db/src/schema/events/relations.ts create mode 100644 packages/db/src/schema/events/schema.ts create mode 100644 packages/db/src/schema/events/types.ts create mode 100644 packages/db/src/schema/library/index.ts create mode 100644 packages/db/src/schema/library/relations.ts create mode 100644 packages/db/src/schema/library/schema.ts create mode 100644 packages/db/src/schema/library/types.ts create mode 100644 packages/db/src/schema/media/index.ts create mode 100644 packages/db/src/schema/media/schema.ts create mode 100644 packages/db/src/schema/media/types.ts create mode 100644 packages/db/src/schema/profiles/index.ts create mode 100644 packages/db/src/schema/profiles/relations.ts create mode 100644 packages/db/src/schema/profiles/schema.ts create mode 100644 packages/db/src/schema/profiles/types.ts create mode 100644 packages/db/src/schema/providers/index.ts create mode 100644 packages/db/src/schema/providers/relations.ts create mode 100644 packages/db/src/schema/providers/schema.ts create mode 100644 packages/db/src/schema/providers/types.ts create mode 100644 packages/db/src/schema/rating-stats/index.ts create mode 100644 packages/db/src/schema/rating-stats/schema.ts create mode 100644 packages/db/src/schema/rating-stats/types.ts create mode 100644 packages/db/src/schema/reviews/index.ts create mode 100644 packages/db/src/schema/reviews/relations.ts create mode 100644 packages/db/src/schema/reviews/schema.ts create mode 100644 packages/db/src/schema/reviews/types.ts diff --git a/packages/db/src/client.ts b/packages/db/src/client.ts index 027efd6..e87a192 100644 --- a/packages/db/src/client.ts +++ b/packages/db/src/client.ts @@ -1,7 +1,6 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool, types } from "pg"; -import * as relations from "./relations"; import * as schema from "./schema"; const connectionString = process.env.DATABASE_URL ?? "postgres://seen:seen@localhost:5432/seen"; @@ -15,7 +14,7 @@ export const pool = new Pool({ }); export const db = drizzle(pool, { - schema: { ...schema, ...relations }, + schema, casing: "snake_case", }); diff --git a/packages/db/src/relations.ts b/packages/db/src/relations.ts deleted file mode 100644 index e72bcf2..0000000 --- a/packages/db/src/relations.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { relations } from "drizzle-orm"; - -import { episodeReviews, notInterested, profiles, reviews, user, watchlist } from "./schema"; - -export const userDomainRelations = relations(user, ({ many, one }) => ({ - profile: one(profiles), - reviews: many(reviews), - episodeReviews: many(episodeReviews), - watchlist: many(watchlist), - notInterested: many(notInterested), -})); - -export const profileRelations = relations(profiles, ({ one }) => ({ - user: one(user, { - fields: [profiles.id], - references: [user.id], - }), -})); - -export const reviewRelations = relations(reviews, ({ one }) => ({ - user: one(user, { - fields: [reviews.userId], - references: [user.id], - }), -})); - -export const episodeReviewRelations = relations(episodeReviews, ({ one }) => ({ - user: one(user, { - fields: [episodeReviews.userId], - references: [user.id], - }), -})); - -export const watchlistRelations = relations(watchlist, ({ one }) => ({ - user: one(user, { - fields: [watchlist.userId], - references: [user.id], - }), -})); - -export const notInterestedRelations = relations(notInterested, ({ one }) => ({ - user: one(user, { - fields: [notInterested.userId], - references: [user.id], - }), -})); diff --git a/packages/db/src/schema/domain.ts b/packages/db/src/schema/domain.ts deleted file mode 100644 index 79429d1..0000000 --- a/packages/db/src/schema/domain.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { sql } from "drizzle-orm"; -import { - bigint, - boolean, - check, - date, - index, - integer, - jsonb, - numeric, - pgTable, - pgView, - primaryKey, - smallint, - text, - timestamp, - unique, - uuid, -} from "drizzle-orm/pg-core"; - -import { user } from "./auth"; - -export const movies = pgTable( - "movies", - { - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - title: text("title").notNull(), - originalTitle: text("original_title"), - overview: text("overview"), - releaseDate: date("release_date"), - posterPath: text("poster_path"), - backdropPath: text("backdrop_path"), - voteAverage: numeric("vote_average", { mode: "number" }), - voteCount: integer("vote_count"), - popularity: numeric("popularity", { mode: "number" }), - runtime: integer("runtime"), - genres: jsonb("genres"), - language: text("language").notNull().default("fr-FR"), - detail: jsonb("detail"), - detailFetchedAt: timestamp("detail_fetched_at", { withTimezone: true }), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.tmdbId, table.mediaType] }), - check("movies_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), - ], -); - -export const profiles = pgTable( - "profiles", - { - id: text("id") - .primaryKey() - .references(() => user.id, { onDelete: "cascade" }), - fullName: text("full_name").notNull(), - username: text("username").notNull().unique(), - avatarPath: text("avatar_path"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - check("profiles_full_name_not_blank", sql`length(btrim(${table.fullName})) > 0`), - check( - "profiles_username_format", - sql`${table.username} = lower(${table.username}) and ${table.username} ~ '^[a-z0-9_.]{3,20}$'`, - ), - ], -); - -export const reviews = pgTable( - "reviews", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - rating: smallint("rating"), - title: text("title"), - comment: text("comment"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - unique("reviews_user_movie_unique").on(table.userId, table.tmdbId, table.mediaType), - index("reviews_movie_idx").on(table.tmdbId, table.mediaType, table.createdAt), - index("reviews_user_idx").on(table.userId, table.createdAt), - check("reviews_rating_range", sql`${table.rating} is null or ${table.rating} between 1 and 10`), - check( - "reviews_has_content", - sql`${table.rating} is not null or (${table.title} is not null and length(btrim(${table.title})) > 0) or (${table.comment} is not null and length(btrim(${table.comment})) > 0)`, - ), - ], -); - -export const watchlist = pgTable( - "watchlist", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(), - visibility: text("visibility").notNull().default("private"), - }, - (table) => [ - unique("watchlist_user_media_unique").on(table.userId, table.tmdbId, table.mediaType), - index("watchlist_user_added_idx").on(table.userId, table.addedAt), - index("watchlist_user_media_type_added_idx").on(table.userId, table.mediaType, table.addedAt), - check("watchlist_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), - check("watchlist_visibility_check", sql`${table.visibility} in ('private')`), - ], -); - -export const likes = pgTable( - "likes", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - kind: text("kind").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - unique("likes_user_media_kind_unique").on( - table.userId, - table.tmdbId, - table.mediaType, - table.kind, - ), - index("likes_user_kind_created_idx").on(table.userId, table.kind, table.createdAt), - check("likes_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), - check("likes_kind_check", sql`${table.kind} in ('like', 'favorite')`), - ], -); - -export const notInterested = pgTable( - "not_interested", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - reason: text("reason"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - unique("not_interested_user_media_unique").on(table.userId, table.tmdbId, table.mediaType), - index("not_interested_user_created_idx").on(table.userId, table.createdAt), - check("not_interested_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), - ], -); - -export const episodeReviews = pgTable( - "episode_reviews", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - seriesTmdbId: bigint("series_tmdb_id", { mode: "number" }).notNull(), - episodeTmdbId: bigint("episode_tmdb_id", { mode: "number" }).notNull(), - seasonNumber: integer("season_number").notNull(), - episodeNumber: integer("episode_number").notNull(), - rating: smallint("rating").notNull(), - title: text("title"), - comment: text("comment"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - unique("episode_reviews_user_episode_unique").on( - table.userId, - table.seriesTmdbId, - table.seasonNumber, - table.episodeNumber, - ), - index("episode_reviews_series_idx").on(table.seriesTmdbId, table.createdAt), - index("episode_reviews_episode_idx").on( - table.seriesTmdbId, - table.seasonNumber, - table.episodeNumber, - table.createdAt, - ), - index("episode_reviews_user_idx").on(table.userId, table.createdAt), - check("episode_reviews_season_number_check", sql`${table.seasonNumber} >= 0`), - check("episode_reviews_episode_number_check", sql`${table.episodeNumber} > 0`), - check("episode_reviews_rating_range", sql`${table.rating} between 1 and 10`), - check( - "episode_reviews_has_content", - sql`${table.rating} is not null or (${table.title} is not null and length(btrim(${table.title})) > 0) or (${table.comment} is not null and length(btrim(${table.comment})) > 0)`, - ), - ], -); - -export const mediaRatingStats = pgTable( - "media_rating_stats", - { - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - sumRating: bigint("sum_rating", { mode: "number" }).notNull().default(0), - ratingCount: bigint("rating_count", { mode: "number" }).notNull().default(0), - reviewCount: bigint("review_count", { mode: "number" }).notNull().default(0), - histogram: integer("histogram") - .array() - .notNull() - .default(sql`'{0,0,0,0,0,0,0,0,0,0}'::integer[]`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [primaryKey({ columns: [table.tmdbId, table.mediaType] })], -); - -export const episodeRatingStats = pgTable( - "episode_rating_stats", - { - seriesTmdbId: bigint("series_tmdb_id", { mode: "number" }).notNull(), - seasonNumber: integer("season_number").notNull(), - episodeNumber: integer("episode_number").notNull(), - sumRating: bigint("sum_rating", { mode: "number" }).notNull().default(0), - ratingCount: bigint("rating_count", { mode: "number" }).notNull().default(0), - histogram: integer("histogram") - .array() - .notNull() - .default(sql`'{0,0,0,0,0,0,0,0,0,0}'::integer[]`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ columns: [table.seriesTmdbId, table.seasonNumber, table.episodeNumber] }), - ], -); - -export const seriesRatingStats = pgTable("series_rating_stats", { - seriesTmdbId: bigint("series_tmdb_id", { mode: "number" }).primaryKey(), - sumOfEpisodeAvgs: numeric("sum_of_episode_avgs", { mode: "number" }).notNull().default(0), - episodesWithRatings: integer("episodes_with_ratings").notNull().default(0), - totalRatingCount: bigint("total_rating_count", { mode: "number" }).notNull().default(0), - histogram: integer("histogram") - .array() - .notNull() - .default(sql`'{0,0,0,0,0,0,0,0,0,0}'::integer[]`), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const interactionEvents = pgTable( - "interaction_events", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - tmdbId: bigint("tmdb_id", { mode: "number" }), - mediaType: text("media_type"), - type: text("type").notNull(), - metadata: jsonb("metadata"), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - index("interaction_events_user_idx").on(table.userId, table.createdAt), - index("interaction_events_media_idx").on(table.tmdbId, table.type), - check( - "interaction_events_type_check", - sql`${table.type} in ('opened_detail', 'viewed_trailer', 'searched', 'search_query', 'shared', 'clicked_streaming', 'added_watchlist', 'removed_watchlist', 'marked_watched', 'rated', 'reviewed', 'liked', 'dismissed', 'not_interested')`, - ), - check( - "interaction_events_media_type_check", - sql`${table.mediaType} is null or ${table.mediaType} in ('movie', 'tv')`, - ), - ], -); - -export const recommendationEvents = pgTable( - "recommendation_events", - { - id: uuid("id").primaryKey().defaultRandom(), - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - source: text("source").notNull(), - position: integer("position").notNull(), - shownAt: timestamp("shown_at", { withTimezone: true }).notNull().defaultNow(), - clicked: boolean("clicked").notNull().default(false), - addedToWatchlist: boolean("added_to_watchlist").notNull().default(false), - markedWatched: boolean("marked_watched").notNull().default(false), - rated: boolean("rated").notNull().default(false), - shared: boolean("shared").notNull().default(false), - dismissed: boolean("dismissed").notNull().default(false), - timeSpentMs: integer("time_spent_ms"), - }, - (table) => [ - index("recommendation_events_user_idx").on(table.userId, table.shownAt), - index("recommendation_events_media_idx").on(table.tmdbId), - check( - "recommendation_events_source_check", - sql`${table.source} in ('content', 'collaborative', 'trending', 'availability', 'social')`, - ), - check("recommendation_events_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), - ], -); - -export const providers = pgTable("providers", { - providerId: bigint("provider_id", { mode: "number" }).primaryKey(), - name: text("name").notNull(), - logoPath: text("logo_path"), - displayPriority: integer("display_priority"), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), -}); - -export const mediaProviders = pgTable( - "media_providers", - { - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - region: text("region").notNull(), - providerId: bigint("provider_id", { mode: "number" }).notNull(), - offerType: text("offer_type").notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - primaryKey({ - columns: [table.tmdbId, table.mediaType, table.region, table.providerId, table.offerType], - }), - index("media_providers_region_provider_idx").on(table.region, table.providerId), - check("media_providers_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), - check( - "media_providers_offer_type_check", - sql`${table.offerType} in ('flatrate', 'rent', 'buy', 'ads', 'free')`, - ), - ], -); - -export const userPlatforms = pgTable( - "user_platforms", - { - userId: text("user_id") - .notNull() - .references(() => user.id, { onDelete: "cascade" }), - providerId: bigint("provider_id", { mode: "number" }) - .notNull() - .references(() => providers.providerId, { onDelete: "cascade" }), - region: text("region").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), - }, - (table) => [ - unique("user_platforms_user_provider_region_unique").on( - table.userId, - table.providerId, - table.region, - ), - index("user_platforms_user_idx").on(table.userId), - index("user_platforms_user_region_idx").on(table.userId, table.region), - ], -); - -export const movieReviewStats = pgView("movie_review_stats", { - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - ratingCount: bigint("rating_count", { mode: "number" }).notNull(), - avgRating: numeric("avg_rating", { mode: "number" }), - reviewCount: bigint("review_count", { mode: "number" }).notNull(), - histogram: integer("histogram").array(), -}).existing(); - -export const seriesEpisodeReviewStats = pgView("series_episode_review_stats", { - tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), - mediaType: text("media_type").notNull(), - ratingCount: bigint("rating_count", { mode: "number" }).notNull(), - avgRating: numeric("avg_rating", { mode: "number" }), - reviewCount: bigint("review_count", { mode: "number" }).notNull(), - histogram: integer("histogram").array(), -}).existing(); diff --git a/packages/db/src/schema/events/index.ts b/packages/db/src/schema/events/index.ts new file mode 100644 index 0000000..9a92524 --- /dev/null +++ b/packages/db/src/schema/events/index.ts @@ -0,0 +1,3 @@ +export * from "./schema"; +export * from "./relations"; +export * from "./types"; diff --git a/packages/db/src/schema/events/relations.ts b/packages/db/src/schema/events/relations.ts new file mode 100644 index 0000000..2cc9c1d --- /dev/null +++ b/packages/db/src/schema/events/relations.ts @@ -0,0 +1,18 @@ +import { relations } from "drizzle-orm"; + +import { user } from "../auth"; +import { interactionEvents, recommendationEvents } from "./schema"; + +export const interactionEventsRelations = relations(interactionEvents, ({ one }) => ({ + user: one(user, { + fields: [interactionEvents.userId], + references: [user.id], + }), +})); + +export const recommendationEventsRelations = relations(recommendationEvents, ({ one }) => ({ + user: one(user, { + fields: [recommendationEvents.userId], + references: [user.id], + }), +})); diff --git a/packages/db/src/schema/events/schema.ts b/packages/db/src/schema/events/schema.ts new file mode 100644 index 0000000..2ea85d8 --- /dev/null +++ b/packages/db/src/schema/events/schema.ts @@ -0,0 +1,73 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + boolean, + check, + index, + integer, + jsonb, + pgTable, + text, + timestamp, + uuid, +} from "drizzle-orm/pg-core"; + +import { user } from "../auth"; + +export const interactionEvents = pgTable( + "interaction_events", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: bigint("tmdb_id", { mode: "number" }), + mediaType: text("media_type"), + type: text("type").notNull(), + metadata: jsonb("metadata"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + index("interaction_events_user_idx").on(table.userId, table.createdAt), + index("interaction_events_media_idx").on(table.tmdbId, table.type), + check( + "interaction_events_type_check", + sql`${table.type} in ('opened_detail', 'viewed_trailer', 'searched', 'search_query', 'shared', 'clicked_streaming', 'added_watchlist', 'removed_watchlist', 'marked_watched', 'rated', 'reviewed', 'liked', 'dismissed', 'not_interested')`, + ), + check( + "interaction_events_media_type_check", + sql`${table.mediaType} is null or ${table.mediaType} in ('movie', 'tv')`, + ), + ], +); + +export const recommendationEvents = pgTable( + "recommendation_events", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + source: text("source").notNull(), + position: integer("position").notNull(), + shownAt: timestamp("shown_at", { withTimezone: true }).notNull().defaultNow(), + clicked: boolean("clicked").notNull().default(false), + addedToWatchlist: boolean("added_to_watchlist").notNull().default(false), + markedWatched: boolean("marked_watched").notNull().default(false), + rated: boolean("rated").notNull().default(false), + shared: boolean("shared").notNull().default(false), + dismissed: boolean("dismissed").notNull().default(false), + timeSpentMs: integer("time_spent_ms"), + }, + (table) => [ + index("recommendation_events_user_idx").on(table.userId, table.shownAt), + index("recommendation_events_media_idx").on(table.tmdbId), + check( + "recommendation_events_source_check", + sql`${table.source} in ('content', 'collaborative', 'trending', 'availability', 'social')`, + ), + check("recommendation_events_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + ], +); diff --git a/packages/db/src/schema/events/types.ts b/packages/db/src/schema/events/types.ts new file mode 100644 index 0000000..495c898 --- /dev/null +++ b/packages/db/src/schema/events/types.ts @@ -0,0 +1,7 @@ +import type { interactionEvents, recommendationEvents } from "./schema"; + +export type InteractionEvent = typeof interactionEvents.$inferSelect; +export type NewInteractionEvent = typeof interactionEvents.$inferInsert; + +export type RecommendationEvent = typeof recommendationEvents.$inferSelect; +export type NewRecommendationEvent = typeof recommendationEvents.$inferInsert; diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 39e3bfd..68de088 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -1,2 +1,8 @@ export * from "./auth"; -export * from "./domain"; +export * from "./media"; +export * from "./profiles"; +export * from "./reviews"; +export * from "./library"; +export * from "./rating-stats"; +export * from "./events"; +export * from "./providers"; diff --git a/packages/db/src/schema/library/index.ts b/packages/db/src/schema/library/index.ts new file mode 100644 index 0000000..9a92524 --- /dev/null +++ b/packages/db/src/schema/library/index.ts @@ -0,0 +1,3 @@ +export * from "./schema"; +export * from "./relations"; +export * from "./types"; diff --git a/packages/db/src/schema/library/relations.ts b/packages/db/src/schema/library/relations.ts new file mode 100644 index 0000000..167d651 --- /dev/null +++ b/packages/db/src/schema/library/relations.ts @@ -0,0 +1,25 @@ +import { relations } from "drizzle-orm"; + +import { user } from "../auth"; +import { likes, notInterested, watchlist } from "./schema"; + +export const watchlistRelations = relations(watchlist, ({ one }) => ({ + user: one(user, { + fields: [watchlist.userId], + references: [user.id], + }), +})); + +export const likesRelations = relations(likes, ({ one }) => ({ + user: one(user, { + fields: [likes.userId], + references: [user.id], + }), +})); + +export const notInterestedRelations = relations(notInterested, ({ one }) => ({ + user: one(user, { + fields: [notInterested.userId], + references: [user.id], + }), +})); diff --git a/packages/db/src/schema/library/schema.ts b/packages/db/src/schema/library/schema.ts new file mode 100644 index 0000000..2e94902 --- /dev/null +++ b/packages/db/src/schema/library/schema.ts @@ -0,0 +1,69 @@ +import { sql } from "drizzle-orm"; +import { bigint, check, index, pgTable, text, timestamp, unique, uuid } from "drizzle-orm/pg-core"; + +import { user } from "../auth"; + +export const watchlist = pgTable( + "watchlist", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + addedAt: timestamp("added_at", { withTimezone: true }).notNull().defaultNow(), + visibility: text("visibility").notNull().default("private"), + }, + (table) => [ + unique("watchlist_user_media_unique").on(table.userId, table.tmdbId, table.mediaType), + index("watchlist_user_added_idx").on(table.userId, table.addedAt), + index("watchlist_user_media_type_added_idx").on(table.userId, table.mediaType, table.addedAt), + check("watchlist_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + check("watchlist_visibility_check", sql`${table.visibility} in ('private')`), + ], +); + +export const likes = pgTable( + "likes", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + kind: text("kind").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("likes_user_media_kind_unique").on( + table.userId, + table.tmdbId, + table.mediaType, + table.kind, + ), + index("likes_user_kind_created_idx").on(table.userId, table.kind, table.createdAt), + check("likes_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + check("likes_kind_check", sql`${table.kind} in ('like', 'favorite')`), + ], +); + +export const notInterested = pgTable( + "not_interested", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + reason: text("reason"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("not_interested_user_media_unique").on(table.userId, table.tmdbId, table.mediaType), + index("not_interested_user_created_idx").on(table.userId, table.createdAt), + check("not_interested_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + ], +); diff --git a/packages/db/src/schema/library/types.ts b/packages/db/src/schema/library/types.ts new file mode 100644 index 0000000..4ef808b --- /dev/null +++ b/packages/db/src/schema/library/types.ts @@ -0,0 +1,10 @@ +import type { likes, notInterested, watchlist } from "./schema"; + +export type WatchlistItem = typeof watchlist.$inferSelect; +export type NewWatchlistItem = typeof watchlist.$inferInsert; + +export type Like = typeof likes.$inferSelect; +export type NewLike = typeof likes.$inferInsert; + +export type NotInterestedItem = typeof notInterested.$inferSelect; +export type NewNotInterestedItem = typeof notInterested.$inferInsert; diff --git a/packages/db/src/schema/media/index.ts b/packages/db/src/schema/media/index.ts new file mode 100644 index 0000000..cf89995 --- /dev/null +++ b/packages/db/src/schema/media/index.ts @@ -0,0 +1,2 @@ +export * from "./schema"; +export * from "./types"; diff --git a/packages/db/src/schema/media/schema.ts b/packages/db/src/schema/media/schema.ts new file mode 100644 index 0000000..c571a69 --- /dev/null +++ b/packages/db/src/schema/media/schema.ts @@ -0,0 +1,41 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + check, + date, + integer, + jsonb, + numeric, + pgTable, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +export const movies = pgTable( + "movies", + { + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + title: text("title").notNull(), + originalTitle: text("original_title"), + overview: text("overview"), + releaseDate: date("release_date"), + posterPath: text("poster_path"), + backdropPath: text("backdrop_path"), + voteAverage: numeric("vote_average", { mode: "number" }), + voteCount: integer("vote_count"), + popularity: numeric("popularity", { mode: "number" }), + runtime: integer("runtime"), + genres: jsonb("genres"), + language: text("language").notNull().default("fr-FR"), + detail: jsonb("detail"), + detailFetchedAt: timestamp("detail_fetched_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.tmdbId, table.mediaType] }), + check("movies_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + ], +); diff --git a/packages/db/src/schema/media/types.ts b/packages/db/src/schema/media/types.ts new file mode 100644 index 0000000..01c592b --- /dev/null +++ b/packages/db/src/schema/media/types.ts @@ -0,0 +1,4 @@ +import type { movies } from "./schema"; + +export type Media = typeof movies.$inferSelect; +export type NewMedia = typeof movies.$inferInsert; diff --git a/packages/db/src/schema/profiles/index.ts b/packages/db/src/schema/profiles/index.ts new file mode 100644 index 0000000..9a92524 --- /dev/null +++ b/packages/db/src/schema/profiles/index.ts @@ -0,0 +1,3 @@ +export * from "./schema"; +export * from "./relations"; +export * from "./types"; diff --git a/packages/db/src/schema/profiles/relations.ts b/packages/db/src/schema/profiles/relations.ts new file mode 100644 index 0000000..c7f5eea --- /dev/null +++ b/packages/db/src/schema/profiles/relations.ts @@ -0,0 +1,22 @@ +import { relations } from "drizzle-orm"; + +import { user } from "../auth"; +import { episodeReviews, reviews } from "../reviews/schema"; +import { likes, notInterested, watchlist } from "../library/schema"; +import { profiles } from "./schema"; + +export const userDomainRelations = relations(user, ({ many, one }) => ({ + profile: one(profiles), + reviews: many(reviews), + episodeReviews: many(episodeReviews), + watchlist: many(watchlist), + likes: many(likes), + notInterested: many(notInterested), +})); + +export const profileRelations = relations(profiles, ({ one }) => ({ + user: one(user, { + fields: [profiles.id], + references: [user.id], + }), +})); diff --git a/packages/db/src/schema/profiles/schema.ts b/packages/db/src/schema/profiles/schema.ts new file mode 100644 index 0000000..2e504eb --- /dev/null +++ b/packages/db/src/schema/profiles/schema.ts @@ -0,0 +1,25 @@ +import { sql } from "drizzle-orm"; +import { check, pgTable, text, timestamp } from "drizzle-orm/pg-core"; + +import { user } from "../auth"; + +export const profiles = pgTable( + "profiles", + { + id: text("id") + .primaryKey() + .references(() => user.id, { onDelete: "cascade" }), + fullName: text("full_name").notNull(), + username: text("username").notNull().unique(), + avatarPath: text("avatar_path"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + check("profiles_full_name_not_blank", sql`length(btrim(${table.fullName})) > 0`), + check( + "profiles_username_format", + sql`${table.username} = lower(${table.username}) and ${table.username} ~ '^[a-z0-9_.]{3,20}$'`, + ), + ], +); diff --git a/packages/db/src/schema/profiles/types.ts b/packages/db/src/schema/profiles/types.ts new file mode 100644 index 0000000..87b2940 --- /dev/null +++ b/packages/db/src/schema/profiles/types.ts @@ -0,0 +1,4 @@ +import type { profiles } from "./schema"; + +export type Profile = typeof profiles.$inferSelect; +export type NewProfile = typeof profiles.$inferInsert; diff --git a/packages/db/src/schema/providers/index.ts b/packages/db/src/schema/providers/index.ts new file mode 100644 index 0000000..9a92524 --- /dev/null +++ b/packages/db/src/schema/providers/index.ts @@ -0,0 +1,3 @@ +export * from "./schema"; +export * from "./relations"; +export * from "./types"; diff --git a/packages/db/src/schema/providers/relations.ts b/packages/db/src/schema/providers/relations.ts new file mode 100644 index 0000000..1114c8b --- /dev/null +++ b/packages/db/src/schema/providers/relations.ts @@ -0,0 +1,19 @@ +import { relations } from "drizzle-orm"; + +import { user } from "../auth"; +import { providers, userPlatforms } from "./schema"; + +export const providersRelations = relations(providers, ({ many }) => ({ + userPlatforms: many(userPlatforms), +})); + +export const userPlatformsRelations = relations(userPlatforms, ({ one }) => ({ + user: one(user, { + fields: [userPlatforms.userId], + references: [user.id], + }), + provider: one(providers, { + fields: [userPlatforms.providerId], + references: [providers.providerId], + }), +})); diff --git a/packages/db/src/schema/providers/schema.ts b/packages/db/src/schema/providers/schema.ts new file mode 100644 index 0000000..4a9faa6 --- /dev/null +++ b/packages/db/src/schema/providers/schema.ts @@ -0,0 +1,68 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + check, + index, + integer, + pgTable, + primaryKey, + text, + timestamp, + unique, +} from "drizzle-orm/pg-core"; + +import { user } from "../auth"; + +export const providers = pgTable("providers", { + providerId: bigint("provider_id", { mode: "number" }).primaryKey(), + name: text("name").notNull(), + logoPath: text("logo_path"), + displayPriority: integer("display_priority"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const mediaProviders = pgTable( + "media_providers", + { + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + region: text("region").notNull(), + providerId: bigint("provider_id", { mode: "number" }).notNull(), + offerType: text("offer_type").notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ + columns: [table.tmdbId, table.mediaType, table.region, table.providerId, table.offerType], + }), + index("media_providers_region_provider_idx").on(table.region, table.providerId), + check("media_providers_media_type_check", sql`${table.mediaType} in ('movie', 'tv')`), + check( + "media_providers_offer_type_check", + sql`${table.offerType} in ('flatrate', 'rent', 'buy', 'ads', 'free')`, + ), + ], +); + +export const userPlatforms = pgTable( + "user_platforms", + { + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + providerId: bigint("provider_id", { mode: "number" }) + .notNull() + .references(() => providers.providerId, { onDelete: "cascade" }), + region: text("region").notNull(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("user_platforms_user_provider_region_unique").on( + table.userId, + table.providerId, + table.region, + ), + index("user_platforms_user_idx").on(table.userId), + index("user_platforms_user_region_idx").on(table.userId, table.region), + ], +); diff --git a/packages/db/src/schema/providers/types.ts b/packages/db/src/schema/providers/types.ts new file mode 100644 index 0000000..e3bcd35 --- /dev/null +++ b/packages/db/src/schema/providers/types.ts @@ -0,0 +1,10 @@ +import type { mediaProviders, providers, userPlatforms } from "./schema"; + +export type Provider = typeof providers.$inferSelect; +export type NewProvider = typeof providers.$inferInsert; + +export type MediaProvider = typeof mediaProviders.$inferSelect; +export type NewMediaProvider = typeof mediaProviders.$inferInsert; + +export type UserPlatform = typeof userPlatforms.$inferSelect; +export type NewUserPlatform = typeof userPlatforms.$inferInsert; diff --git a/packages/db/src/schema/rating-stats/index.ts b/packages/db/src/schema/rating-stats/index.ts new file mode 100644 index 0000000..cf89995 --- /dev/null +++ b/packages/db/src/schema/rating-stats/index.ts @@ -0,0 +1,2 @@ +export * from "./schema"; +export * from "./types"; diff --git a/packages/db/src/schema/rating-stats/schema.ts b/packages/db/src/schema/rating-stats/schema.ts new file mode 100644 index 0000000..3630216 --- /dev/null +++ b/packages/db/src/schema/rating-stats/schema.ts @@ -0,0 +1,77 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + integer, + numeric, + pgTable, + pgView, + primaryKey, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +export const mediaRatingStats = pgTable( + "media_rating_stats", + { + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + sumRating: bigint("sum_rating", { mode: "number" }).notNull().default(0), + ratingCount: bigint("rating_count", { mode: "number" }).notNull().default(0), + reviewCount: bigint("review_count", { mode: "number" }).notNull().default(0), + histogram: integer("histogram") + .array() + .notNull() + .default(sql`'{0,0,0,0,0,0,0,0,0,0}'::integer[]`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [primaryKey({ columns: [table.tmdbId, table.mediaType] })], +); + +export const episodeRatingStats = pgTable( + "episode_rating_stats", + { + seriesTmdbId: bigint("series_tmdb_id", { mode: "number" }).notNull(), + seasonNumber: integer("season_number").notNull(), + episodeNumber: integer("episode_number").notNull(), + sumRating: bigint("sum_rating", { mode: "number" }).notNull().default(0), + ratingCount: bigint("rating_count", { mode: "number" }).notNull().default(0), + histogram: integer("histogram") + .array() + .notNull() + .default(sql`'{0,0,0,0,0,0,0,0,0,0}'::integer[]`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + primaryKey({ columns: [table.seriesTmdbId, table.seasonNumber, table.episodeNumber] }), + ], +); + +export const seriesRatingStats = pgTable("series_rating_stats", { + seriesTmdbId: bigint("series_tmdb_id", { mode: "number" }).primaryKey(), + sumOfEpisodeAvgs: numeric("sum_of_episode_avgs", { mode: "number" }).notNull().default(0), + episodesWithRatings: integer("episodes_with_ratings").notNull().default(0), + totalRatingCount: bigint("total_rating_count", { mode: "number" }).notNull().default(0), + histogram: integer("histogram") + .array() + .notNull() + .default(sql`'{0,0,0,0,0,0,0,0,0,0}'::integer[]`), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const movieReviewStats = pgView("movie_review_stats", { + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + ratingCount: bigint("rating_count", { mode: "number" }).notNull(), + avgRating: numeric("avg_rating", { mode: "number" }), + reviewCount: bigint("review_count", { mode: "number" }).notNull(), + histogram: integer("histogram").array(), +}).existing(); + +export const seriesEpisodeReviewStats = pgView("series_episode_review_stats", { + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + ratingCount: bigint("rating_count", { mode: "number" }).notNull(), + avgRating: numeric("avg_rating", { mode: "number" }), + reviewCount: bigint("review_count", { mode: "number" }).notNull(), + histogram: integer("histogram").array(), +}).existing(); diff --git a/packages/db/src/schema/rating-stats/types.ts b/packages/db/src/schema/rating-stats/types.ts new file mode 100644 index 0000000..6a59bbc --- /dev/null +++ b/packages/db/src/schema/rating-stats/types.ts @@ -0,0 +1,19 @@ +import type { + episodeRatingStats, + mediaRatingStats, + movieReviewStats, + seriesEpisodeReviewStats, + seriesRatingStats, +} from "./schema"; + +export type MediaRatingStats = typeof mediaRatingStats.$inferSelect; +export type NewMediaRatingStats = typeof mediaRatingStats.$inferInsert; + +export type EpisodeRatingStats = typeof episodeRatingStats.$inferSelect; +export type NewEpisodeRatingStats = typeof episodeRatingStats.$inferInsert; + +export type SeriesRatingStats = typeof seriesRatingStats.$inferSelect; +export type NewSeriesRatingStats = typeof seriesRatingStats.$inferInsert; + +export type MovieReviewStats = typeof movieReviewStats.$inferSelect; +export type SeriesEpisodeReviewStats = typeof seriesEpisodeReviewStats.$inferSelect; diff --git a/packages/db/src/schema/reviews/index.ts b/packages/db/src/schema/reviews/index.ts new file mode 100644 index 0000000..9a92524 --- /dev/null +++ b/packages/db/src/schema/reviews/index.ts @@ -0,0 +1,3 @@ +export * from "./schema"; +export * from "./relations"; +export * from "./types"; diff --git a/packages/db/src/schema/reviews/relations.ts b/packages/db/src/schema/reviews/relations.ts new file mode 100644 index 0000000..13924ef --- /dev/null +++ b/packages/db/src/schema/reviews/relations.ts @@ -0,0 +1,18 @@ +import { relations } from "drizzle-orm"; + +import { user } from "../auth"; +import { episodeReviews, reviews } from "./schema"; + +export const reviewRelations = relations(reviews, ({ one }) => ({ + user: one(user, { + fields: [reviews.userId], + references: [user.id], + }), +})); + +export const episodeReviewRelations = relations(episodeReviews, ({ one }) => ({ + user: one(user, { + fields: [episodeReviews.userId], + references: [user.id], + }), +})); diff --git a/packages/db/src/schema/reviews/schema.ts b/packages/db/src/schema/reviews/schema.ts new file mode 100644 index 0000000..c77cdbf --- /dev/null +++ b/packages/db/src/schema/reviews/schema.ts @@ -0,0 +1,84 @@ +import { sql } from "drizzle-orm"; +import { + bigint, + check, + index, + integer, + pgTable, + smallint, + text, + timestamp, + unique, + uuid, +} from "drizzle-orm/pg-core"; + +import { user } from "../auth"; + +export const reviews = pgTable( + "reviews", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + tmdbId: bigint("tmdb_id", { mode: "number" }).notNull(), + mediaType: text("media_type").notNull(), + rating: smallint("rating"), + title: text("title"), + comment: text("comment"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("reviews_user_movie_unique").on(table.userId, table.tmdbId, table.mediaType), + index("reviews_movie_idx").on(table.tmdbId, table.mediaType, table.createdAt), + index("reviews_user_idx").on(table.userId, table.createdAt), + check("reviews_rating_range", sql`${table.rating} is null or ${table.rating} between 1 and 10`), + check( + "reviews_has_content", + sql`${table.rating} is not null or (${table.title} is not null and length(btrim(${table.title})) > 0) or (${table.comment} is not null and length(btrim(${table.comment})) > 0)`, + ), + ], +); + +export const episodeReviews = pgTable( + "episode_reviews", + { + id: uuid("id").primaryKey().defaultRandom(), + userId: text("user_id") + .notNull() + .references(() => user.id, { onDelete: "cascade" }), + seriesTmdbId: bigint("series_tmdb_id", { mode: "number" }).notNull(), + episodeTmdbId: bigint("episode_tmdb_id", { mode: "number" }).notNull(), + seasonNumber: integer("season_number").notNull(), + episodeNumber: integer("episode_number").notNull(), + rating: smallint("rating").notNull(), + title: text("title"), + comment: text("comment"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + unique("episode_reviews_user_episode_unique").on( + table.userId, + table.seriesTmdbId, + table.seasonNumber, + table.episodeNumber, + ), + index("episode_reviews_series_idx").on(table.seriesTmdbId, table.createdAt), + index("episode_reviews_episode_idx").on( + table.seriesTmdbId, + table.seasonNumber, + table.episodeNumber, + table.createdAt, + ), + index("episode_reviews_user_idx").on(table.userId, table.createdAt), + check("episode_reviews_season_number_check", sql`${table.seasonNumber} >= 0`), + check("episode_reviews_episode_number_check", sql`${table.episodeNumber} > 0`), + check("episode_reviews_rating_range", sql`${table.rating} between 1 and 10`), + check( + "episode_reviews_has_content", + sql`${table.rating} is not null or (${table.title} is not null and length(btrim(${table.title})) > 0) or (${table.comment} is not null and length(btrim(${table.comment})) > 0)`, + ), + ], +); diff --git a/packages/db/src/schema/reviews/types.ts b/packages/db/src/schema/reviews/types.ts new file mode 100644 index 0000000..1b8ea93 --- /dev/null +++ b/packages/db/src/schema/reviews/types.ts @@ -0,0 +1,7 @@ +import type { episodeReviews, reviews } from "./schema"; + +export type Review = typeof reviews.$inferSelect; +export type NewReview = typeof reviews.$inferInsert; + +export type EpisodeReview = typeof episodeReviews.$inferSelect; +export type NewEpisodeReview = typeof episodeReviews.$inferInsert; From fe508b428afaf80a8de23a2cc10ae3f8fc67d441 Mon Sep 17 00:00:00 2001 From: miicolas Date: Mon, 8 Jun 2026 20:23:49 +0200 Subject: [PATCH 2/5] feat(platforms): dedupe and cap the provider catalog Collapse ad-funded tiers and reseller "channels" (e.g. "Netflix Standard with Ads", "Max Amazon Channel") onto a single canonical row, preferring the clean base entry, drop providers with no logo, and cap the list to 30 so the picker stays scannable. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../platforms/queries/list-providers.ts | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/apps/api/src/modules/platforms/queries/list-providers.ts b/apps/api/src/modules/platforms/queries/list-providers.ts index 18f156b..65b1caa 100644 --- a/apps/api/src/modules/platforms/queries/list-providers.ts +++ b/apps/api/src/modules/platforms/queries/list-providers.ts @@ -17,6 +17,27 @@ type TmdbProvidersResponse = { results?: TmdbProvider[]; }; +// Cap the catalog so the picker stays scannable. TMDB returns 100+ entries per +// region, most of them niche or reseller channels. +const MAX_PROVIDERS = 30; + +// Ad-funded tiers and reseller "channels" (e.g. "Netflix Standard with Ads", +// "Max Amazon Channel") are the same service to the user. Collapse them onto a +// single canonical key so the list shows one row per provider. +function canonicalKey(name: string): string { + return name + .toLowerCase() + .replace(/\s+(amazon|apple tv|roku premium|verizon)\s+channel$/, "") + .replace(/\s+(standard|basic|premium)?\s*with ads$/, "") + .replace(/\bplus\b/g, "+") + .replace(/[^a-z0-9+]/g, ""); +} + +// Prefer the clean base entry over an ad/reseller variant when collapsing. +function isVariant(name: string): boolean { + return /(with ads|amazon channel|apple tv channel|roku premium channel)$/i.test(name.trim()); +} + async function fetchCatalog(mediaType: "movie" | "tv", region: string): Promise { const res = await tmdbFetch( `/watch/providers/${mediaType}`, @@ -77,12 +98,34 @@ export async function listProviders(region: string): Promise { } } - const list = [...dedup.values()].sort((a, b) => { - const pa = priorities.get(a.providerId) ?? 9999; - const pb = priorities.get(b.providerId) ?? 9999; - if (pa !== pb) return pa - pb; - return a.name.localeCompare(b.name); - }); + const priorityOf = (id: number) => priorities.get(id) ?? 9999; + + // Collapse ad/reseller variants onto one canonical row, keeping the cleanest, + // highest-priority entry of each group, and drop entries with no logo so the + // picker can render a logo per row. + const byCanonical = new Map(); + for (const entry of dedup.values()) { + if (!entry.logoPath) continue; + const key = canonicalKey(entry.name); + const current = byCanonical.get(key); + if (!current) { + byCanonical.set(key, entry); + continue; + } + const better = + (isVariant(current.name) ? 1 : 0) - (isVariant(entry.name) ? 1 : 0) || + priorityOf(current.providerId) - priorityOf(entry.providerId); + if (better > 0) byCanonical.set(key, entry); + } + + const list = [...byCanonical.values()] + .sort((a, b) => { + const pa = priorityOf(a.providerId); + const pb = priorityOf(b.providerId); + if (pa !== pb) return pa - pb; + return a.name.localeCompare(b.name); + }) + .slice(0, MAX_PROVIDERS); // Await the warm so the `providers` table is authoritative before the client // can POST a selection back: setUserPlatforms validates provider ids against From 215ea8e32d7c19fe7a85ba62d620fa2ca03a5dd6 Mon Sep 17 00:00:00 2001 From: miicolas Date: Mon, 8 Jun 2026 20:23:58 +0200 Subject: [PATCH 3/5] feat(platforms): redesign picker as a native provider list Replace the SwiftUI Form/Toggle picker with a themed RN list of ProviderRow items (logo, name, trailing checkmark), and register the platforms screen in the profile stack so it has a proper native header. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../mobile/src/app/(tabs)/profile/_layout.tsx | 8 + .../screens/platforms-picker/index.tsx | 161 +++++++++++++----- .../screens/platforms-picker/provider-row.tsx | 73 ++++++++ 3 files changed, 197 insertions(+), 45 deletions(-) create mode 100644 apps/mobile/src/components/screens/platforms-picker/provider-row.tsx diff --git a/apps/mobile/src/app/(tabs)/profile/_layout.tsx b/apps/mobile/src/app/(tabs)/profile/_layout.tsx index fd940f3..4d96a48 100644 --- a/apps/mobile/src/app/(tabs)/profile/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/profile/_layout.tsx @@ -15,6 +15,14 @@ export default function ProfileLayout() { contentStyle: { backgroundColor: theme.background }, }}> + -
-
-
- -
+ + {isOnboarding ? ( + + + {headerTitle} + + + {subtitle} + + + ) : ( + + {subtitle} + + )} + + + {t("platforms.selectedCount", { count: selected.size, plural: selected.size === 1 ? "" : "s", - })}> - {providers.data.length === 0 ? ( -
+ }).toUpperCase()} + + + {providers.data.length === 0 ? ( + + + + {providers.isLoading ? "…" : t("platforms.empty")} + + + ) : ( + + {providers.data.map((provider, index) => ( + + {index > 0 ? ( + + ) : null} + toggle(provider.providerId)} + /> + + ))} + + )} {errorMessage ? ( -
-
+ + + + {errorMessage} + + ) : null} -
-
-
- + + + ); } + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + content: { + paddingHorizontal: SPACING.MD, + gap: SPACING.MD, + }, + header: { + gap: SPACING.XS, + }, + list: { + borderRadius: BORDER_RADIUS.MD, + overflow: "hidden", + }, + separator: { + height: StyleSheet.hairlineWidth, + marginLeft: SPACING.MD + 40 + SPACING.MD, + }, + empty: { + alignItems: "center", + gap: SPACING.SM, + paddingVertical: SPACING.XL, + }, + error: { + flexDirection: "row", + alignItems: "center", + gap: SPACING.XS, + }, + actions: { + gap: SPACING.XS, + paddingTop: SPACING.SM, + }, +}); diff --git a/apps/mobile/src/components/screens/platforms-picker/provider-row.tsx b/apps/mobile/src/components/screens/platforms-picker/provider-row.tsx new file mode 100644 index 0000000..ed72a2e --- /dev/null +++ b/apps/mobile/src/components/screens/platforms-picker/provider-row.tsx @@ -0,0 +1,73 @@ +import { SymbolView } from "expo-symbols"; +import { Image } from "expo-image"; +import { Pressable, StyleSheet, View } from "react-native"; + +import { Text } from "@/components/ui/text"; +import { BORDER_RADIUS, SPACING } from "@/constants/design-tokens"; +import { useAccentColor } from "@/hooks/use-accent-color"; +import { useTheme } from "@/hooks/use-theme"; +import { tmdbImageUrl } from "@/lib/tmdb"; + +const LOGO_SIZE = 40; + +interface ProviderRowProps { + name: string; + logoPath: string | null; + selected: boolean; + onToggle: () => void; +} + +// One selectable streaming service: logo, name, and a trailing checkmark when +// picked. Used in the RN platforms picker list. +export function ProviderRow({ name, logoPath, selected, onToggle }: ProviderRowProps) { + const theme = useTheme(); + const { accentHex } = useAccentColor(); + const uri = tmdbImageUrl(logoPath, "w92"); + + return ( + [styles.row, pressed && styles.pressed]}> + + + + {name} + + + + + ); +} + +const styles = StyleSheet.create({ + row: { + flexDirection: "row", + alignItems: "center", + gap: SPACING.MD, + paddingVertical: SPACING.SM, + paddingHorizontal: SPACING.MD, + }, + pressed: { + opacity: 0.6, + }, + logo: { + width: LOGO_SIZE, + height: LOGO_SIZE, + borderRadius: BORDER_RADIUS.SM, + }, + label: { + flex: 1, + }, +}); From 084ae6a3303106eba54a4397284090c36dea30b5 Mon Sep 17 00:00:00 2001 From: miicolas Date: Mon, 8 Jun 2026 20:24:05 +0200 Subject: [PATCH 4/5] feat(discover): show an inline platforms prompt card Extract the "pick your platforms" nudge out of the discover container into a compact GlassPanel card (PlatformsPrompt) that sits inline between the shelves instead of reading as a full-screen empty state. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/discover/container.tsx | 26 +------ .../components/discover/platforms-prompt.tsx | 73 +++++++++++++++++++ 2 files changed, 75 insertions(+), 24 deletions(-) create mode 100644 apps/mobile/src/components/discover/platforms-prompt.tsx diff --git a/apps/mobile/src/components/discover/container.tsx b/apps/mobile/src/components/discover/container.tsx index 1beefcc..3ccadc8 100644 --- a/apps/mobile/src/components/discover/container.tsx +++ b/apps/mobile/src/components/discover/container.tsx @@ -1,8 +1,6 @@ -import { useRouter } from "expo-router"; import { useTranslation } from "react-i18next"; import { StyleSheet, View } from "react-native"; -import { Button } from "@/components/ui/button/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Text } from "@/components/ui/text"; import { SPACING } from "@/constants/design-tokens"; @@ -10,11 +8,11 @@ import { useMyPlatforms } from "@/hooks/platforms/use-my-platforms"; import { useNotInterestedList } from "@/hooks/not-interested/use-not-interested-list"; import { useAvailableFeed } from "@/hooks/recommendations/use-available-feed"; import { useDiscoverMedia } from "@/hooks/tmdb/use-discover-media"; -import { hapticTap } from "@/lib/haptics"; import type { MediaFilter, TmdbMovieSummary } from "@/lib/tmdb"; import { DiscoverSkeleton } from "./discover-skeleton"; import { HeroCard } from "./hero-card"; +import { PlatformsPrompt } from "./platforms-prompt"; import { PosterCard } from "./poster-card"; import { RankingCard } from "./ranking-card"; import { Shelf } from "./shelf"; @@ -24,7 +22,6 @@ const keyOf = (media: TmdbMovieSummary, index: number) => export const DiscoverContainer = ({ filter }: { filter: MediaFilter }) => { const { t } = useTranslation(); - const router = useRouter(); const { trending, topToday, newReleases, genres, isLoading, error, isOffline } = useDiscoverMedia(filter); const myPlatforms = useMyPlatforms(); @@ -121,22 +118,7 @@ export const DiscoverContainer = ({ filter }: { filter: MediaFilter }) => { /> ) : !myPlatforms.isLoading ? ( - - { - hapticTap(); - router.push("/profile/platforms"); - }} - /> - } - /> - + ) : null} + + + + + + {t("discover.pickPlatformsTitle")} + + + {t("discover.pickPlatformsSubtitle")} + + + +