From 1252b73df05121d54c6ce864b09138ecbb3f7273 Mon Sep 17 00:00:00 2001 From: Joe Bailey Date: Thu, 7 May 2026 19:16:57 +0100 Subject: [PATCH 01/18] Add watch partners and collaborative show filters --- app/components/nav.vue | 3 ++ app/pages/my-shows.vue | 28 ++++++++++++--- app/pages/show/[id].vue | 23 ++++++++++++- app/pages/watch-partners.vue | 43 ++++++++++++++++++++++++ drizzle/0002_watch_partners.sql | 15 +++++++++ server/api/show/[id].get.ts | 21 ++++++++++-- server/api/show/[id].patch.ts | 23 +++++++++++-- server/api/shows.get.ts | 21 ++++++++++-- server/api/watch-partners/[id].delete.ts | 23 +++++++++++++ server/api/watch-partners/index.get.ts | 16 +++++++++ server/api/watch-partners/index.post.ts | 28 +++++++++++++++ server/db/schema.ts | 15 +++++++++ server/types/transformed.d.ts | 6 ++++ 13 files changed, 254 insertions(+), 11 deletions(-) create mode 100644 app/pages/watch-partners.vue create mode 100644 drizzle/0002_watch_partners.sql create mode 100644 server/api/watch-partners/[id].delete.ts create mode 100644 server/api/watch-partners/index.get.ts create mode 100644 server/api/watch-partners/index.post.ts diff --git a/app/components/nav.vue b/app/components/nav.vue index d3b7c0a..2269d0c 100644 --- a/app/components/nav.vue +++ b/app/components/nav.vue @@ -105,6 +105,9 @@ nav { Cancelled + + Friends & Family +
diff --git a/app/pages/my-shows.vue b/app/pages/my-shows.vue index 355a13f..f467dd7 100644 --- a/app/pages/my-shows.vue +++ b/app/pages/my-shows.vue @@ -48,6 +48,11 @@ First episode date + @@ -66,6 +71,7 @@ export default defineComponent({ const shows = ref([]) as Ref const pages = ref(0) + const partners = ref<{id:number, name:string}[]>([]) const route = useRoute() const router = useRouter() @@ -80,7 +86,7 @@ export default defineComponent({ ? route.query.order[0] : route.query.order - watch(() => [route.query.category, route.query.p, route.query.sort, route.query.order], async () => { + watch(() => [route.query.category, route.query.p, route.query.sort, route.query.order, route.query.watchingWith], async () => { const nuxtApp = useNuxtApp() nuxtApp.callHook('page:loading:start') const data = await $fetch('/api/shows', { @@ -95,7 +101,10 @@ export default defineComponent({ : route.query.sort, order: Array.isArray(route.query.order) ? route.query.order[0] - : route.query.order + : route.query.order, + watchingWith: Array.isArray(route.query.watchingWith) + ? route.query.watchingWith[0] + : route.query.watchingWith } }) shows.value = data.tv_shows ?? [] @@ -113,6 +122,11 @@ export default defineComponent({ router.push({ path: route.path, query: { ...route.query, sort: value, p: 1 } }) } + const changeWatchingWith = (event: Event) => { + const value = (event.target as HTMLSelectElement).value + router.push({ path: route.path, query: { ...route.query, watchingWith: value === 'all' ? undefined : value, p: 1 } }) + } + const toggleOrder = () => { const newOrder = route.query.order === 'desc' ? 'asc' : 'desc' router.push({ path: route.path, query: { ...route.query, order: newOrder, p: 1 } }) @@ -124,11 +138,15 @@ export default defineComponent({ showCategory: category, p: route.query.p, sort, - order + order, + watchingWith: Array.isArray(route.query.watchingWith) + ? route.query.watchingWith[0] + : route.query.watchingWith } }) shows.value = data.value?.tv_shows ?? [] pages.value = data.value?.pages ?? 0 + partners.value = await $fetch('/api/watch-partners', { headers }) return { shows, @@ -136,7 +154,9 @@ export default defineComponent({ deleteShowCallback, route, changeSort, - toggleOrder + toggleOrder, + partners, + changeWatchingWith } } }) diff --git a/app/pages/show/[id].vue b/app/pages/show/[id].vue index ef752f0..a5ec0a0 100644 --- a/app/pages/show/[id].vue +++ b/app/pages/show/[id].vue @@ -68,6 +68,12 @@ h1 {

+
+ + +
Network: {{ network }} Average Runtime: {{ runtime }} minutes Start Date: @@ -115,6 +121,8 @@ export default defineComponent({ ) as string const showId = parseInt(idParam) const show = ref(null) as Ref + const partners = ref<{id:number, name:string}[]>([]) + const selectedPartners = ref([]) const response = await useFetch(`/api/show/${showId}`, { headers @@ -122,6 +130,7 @@ export default defineComponent({ if (response.data.value) { show.value = reactive(response.data.value) + selectedPartners.value = (response.data.value.watchingWith ?? []).map((item: { id: number }) => item.id) } else { throw createError({ statusCode: 404, message: 'Page not found' }) } @@ -145,6 +154,13 @@ export default defineComponent({ return [] }) + async function updateWatchingWith (event: Event) { + const target = event.target as HTMLSelectElement + const watchPartnerIds = Array.from(target.selectedOptions).map(option => parseInt(option.value, 10)) + selectedPartners.value = watchPartnerIds + await $fetch(`/api/show/${showId}`, { method: 'PATCH', headers, body: { watchPartnerIds } }) + } + async function updateShow (episode: number) { const response = await useFetch(`/api/show/${showId}`, { method: 'PATCH', @@ -167,6 +183,8 @@ export default defineComponent({ } } + partners.value = await $fetch('/api/watch-partners', { headers }) + const deleteShowCallback = () => { router.push('/my-shows') } @@ -175,7 +193,10 @@ export default defineComponent({ ...toRefs(show.value), updateShow, seasons, - deleteShowCallback + deleteShowCallback, + partners, + selectedPartners, + updateWatchingWith } } }) diff --git a/app/pages/watch-partners.vue b/app/pages/watch-partners.vue new file mode 100644 index 0000000..6c47f81 --- /dev/null +++ b/app/pages/watch-partners.vue @@ -0,0 +1,43 @@ + + + diff --git a/drizzle/0002_watch_partners.sql b/drizzle/0002_watch_partners.sql new file mode 100644 index 0000000..fb4f2f6 --- /dev/null +++ b/drizzle/0002_watch_partners.sql @@ -0,0 +1,15 @@ +CREATE TABLE `watchPartners` ( + `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, + `userId` text NOT NULL, + `name` text NOT NULL, + FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); + +CREATE TABLE `showWatchPartners` ( + `showId` integer NOT NULL, + `watchPartnerId` integer NOT NULL, + FOREIGN KEY (`showId`) REFERENCES `tv_shows`(`id`) ON UPDATE no action ON DELETE cascade, + FOREIGN KEY (`watchPartnerId`) REFERENCES `watchPartners`(`id`) ON UPDATE no action ON DELETE cascade +); + +CREATE UNIQUE INDEX `showPartnerIdx` ON `showWatchPartners` (`showId`,`watchPartnerId`); diff --git a/server/api/show/[id].get.ts b/server/api/show/[id].get.ts index 233b569..f147323 100644 --- a/server/api/show/[id].get.ts +++ b/server/api/show/[id].get.ts @@ -1,7 +1,7 @@ import type { H3Event } from 'h3' import { eq, and, asc, sql, gt } from 'drizzle-orm' import { getAuthenticatedUserEmail } from '../../lib/auth' -import { tvShows, users, episodateTvShows, episodes } from '../../db/schema' +import { tvShows, users, episodateTvShows, episodes, showWatchPartners, watchPartners } from '../../db/schema' import { useDb } from '../../lib/db' import { getEpisodesForShow } from '../../lib/getEpisodesForShow' @@ -101,6 +101,22 @@ export default defineEventHandler(async (event: H3Event): Promise item.id && item.name) } }) diff --git a/server/api/show/[id].patch.ts b/server/api/show/[id].patch.ts index b097494..00be6d4 100644 --- a/server/api/show/[id].patch.ts +++ b/server/api/show/[id].patch.ts @@ -2,7 +2,7 @@ import type { H3Event } from 'h3' import { eq, and, inArray, sql } from 'drizzle-orm' import type { BatchItem } from 'drizzle-orm/batch' import { getShowExists } from '../../lib/getShowExists' -import { episodes, watchedEpisodes, users } from '../../db/schema' +import { episodes, watchedEpisodes, users, tvShows, showWatchPartners } from '../../db/schema' import { getAuthenticatedUserEmail } from '../../lib/auth' import { useDb } from '../../lib/db' import { getUserByEmail } from '../../lib/getUserByEmail' @@ -40,8 +40,27 @@ export default defineEventHandler(async (event: H3Event) => { throw createError({ statusMessage: 'You must pass a body', statusCode: 400 }) } + + if (Array.isArray(body.watchPartnerIds)) { + const showRecord = await DB.select({ id: tvShows.id }) + .from(tvShows) + .leftJoin(users, eq(users.id, tvShows.userId)) + .where(and(eq(tvShows.showId, showId), eq(users.email, userEmail))) + .limit(1) + + if (showRecord[0]) { + await DB.delete(showWatchPartners).where(eq(showWatchPartners.showId, showRecord[0].id)) + if (body.watchPartnerIds.length > 0) { + await DB.insert(showWatchPartners).values(body.watchPartnerIds.map((watchPartnerId: number) => ({ + showId: showRecord[0].id, + watchPartnerId + }))) + } + } + } + if (!body.episode) { - throw createError({ statusMessage: 'You must pass an episode', statusCode: 400 }) + return { success: true } } // Find the episode in the database that has been passed in the payload diff --git a/server/api/shows.get.ts b/server/api/shows.get.ts index 586ed65..975aee9 100644 --- a/server/api/shows.get.ts +++ b/server/api/shows.get.ts @@ -3,7 +3,7 @@ import { asc, desc, eq, inArray, and, countDistinct, gt, sql, notInArray, lte } import { alias } from 'drizzle-orm/sqlite-core' import type { SQLiteSelect } from 'drizzle-orm/sqlite-core' import { getAuthenticatedUserEmail } from '../lib/auth' -import { tvShows, users, episodateTvShows, episodes, watchedEpisodes } from '../db/schema' +import { tvShows, users, episodateTvShows, episodes, watchedEpisodes, showWatchPartners, watchPartners } from '../db/schema' import { pageSize as ps } from '../api/shows.get' import { useDb } from '../lib/db' @@ -39,6 +39,9 @@ export default defineEventHandler(async (event: H3Event): Promise const orderParam = Array.isArray(queryParams.order) ? queryParams.order[0] : queryParams.order const order: SortOrder = orderParam === 'desc' ? 'desc' : 'asc' + const watchingFilterParam = Array.isArray(queryParams.watchingWith) ? queryParams.watchingWith[0] : queryParams.watchingWith + const watchingWith = watchingFilterParam ? parseInt(watchingFilterParam as string, 10) : 0 + // If offset starts to be slow // We could use SELECT WHERE NOT IN ( SELECT episodateTvShows.id FROM episodateTvShows ORDER BY episodateTvShows.name ASC LIMIT :offset ) // This effectively skips the first X rows @@ -73,7 +76,21 @@ export default defineEventHandler(async (event: H3Event): Promise ) ) .groupBy(episodateTvShows.id) - .where(eq(users.email, userEmail)) + .where( + and( + eq(users.email, userEmail), + watchingWith > 0 + ? sql`exists ( + select 1 from ${showWatchPartners} swp + join ${watchPartners} wp on wp.id = swp.watchPartnerId + where swp.showId = ${tvShows.id} and wp.id = ${watchingWith} + )` + : sql`1=1`, + watchingWith === -1 + ? sql`not exists (select 1 from ${showWatchPartners} swp where swp.showId = ${tvShows.id})` + : sql`1=1` + ) + ) if (sort === 'episodesToWatch') { q = q.orderBy( diff --git a/server/api/watch-partners/[id].delete.ts b/server/api/watch-partners/[id].delete.ts new file mode 100644 index 0000000..5d74577 --- /dev/null +++ b/server/api/watch-partners/[id].delete.ts @@ -0,0 +1,23 @@ +import { and, eq } from 'drizzle-orm' +import { getAuthenticatedUserEmail } from '../../lib/auth' +import { useDb } from '../../lib/db' +import { getUserByEmail } from '../../lib/getUserByEmail' +import { watchPartners } from '../../db/schema' + +export default defineEventHandler(async (event) => { + const idParam = getRouterParam(event, 'id') + if (!idParam) { + throw createError({ statusCode: 400, statusMessage: 'Missing id' }) + } + + const partnerId = parseInt(idParam, 10) + const userEmail = await getAuthenticatedUserEmail(event) + const user = await getUserByEmail(userEmail, event) + if (!user) { + throw createError({ statusCode: 400, statusMessage: 'Could not find user' }) + } + + const DB = await useDb() + await DB.delete(watchPartners).where(and(eq(watchPartners.id, partnerId), eq(watchPartners.userId, user.id))) + return { success: true } +}) diff --git a/server/api/watch-partners/index.get.ts b/server/api/watch-partners/index.get.ts new file mode 100644 index 0000000..0dce85e --- /dev/null +++ b/server/api/watch-partners/index.get.ts @@ -0,0 +1,16 @@ +import { eq } from 'drizzle-orm' +import { getAuthenticatedUserEmail } from '../../lib/auth' +import { useDb } from '../../lib/db' +import { getUserByEmail } from '../../lib/getUserByEmail' +import { watchPartners } from '../../db/schema' + +export default defineEventHandler(async (event) => { + const userEmail = await getAuthenticatedUserEmail(event) + const user = await getUserByEmail(userEmail, event) + if (!user) { + throw createError({ statusCode: 400, statusMessage: 'Could not find user' }) + } + + const DB = await useDb() + return DB.select().from(watchPartners).where(eq(watchPartners.userId, user.id)) +}) diff --git a/server/api/watch-partners/index.post.ts b/server/api/watch-partners/index.post.ts new file mode 100644 index 0000000..6eb455f --- /dev/null +++ b/server/api/watch-partners/index.post.ts @@ -0,0 +1,28 @@ +import { eq } from 'drizzle-orm' +import { getAuthenticatedUserEmail } from '../../lib/auth' +import { useDb } from '../../lib/db' +import { getUserByEmail } from '../../lib/getUserByEmail' +import { watchPartners } from '../../db/schema' + +export default defineEventHandler(async (event) => { + const userEmail = await getAuthenticatedUserEmail(event) + const user = await getUserByEmail(userEmail, event) + if (!user) { + throw createError({ statusCode: 400, statusMessage: 'Could not find user' }) + } + + const body = await readBody<{name?: string}>(event) + const name = body?.name?.trim() + if (!name) { + throw createError({ statusCode: 400, statusMessage: 'Name is required' }) + } + + const DB = await useDb() + const existing = await DB.select().from(watchPartners).where(eq(watchPartners.userId, user.id)) + if (existing.some(partner => partner.name.toLowerCase() === name.toLowerCase())) { + throw createError({ statusCode: 409, statusMessage: 'Partner already exists' }) + } + + await DB.insert(watchPartners).values({ userId: user.id, name }) + return DB.select().from(watchPartners).where(eq(watchPartners.userId, user.id)) +}) diff --git a/server/db/schema.ts b/server/db/schema.ts index 09a7559..c84f169 100644 --- a/server/db/schema.ts +++ b/server/db/schema.ts @@ -66,6 +66,21 @@ export const watchedEpisodes = sqliteTable('watchedEpisodes', { } }) +export const watchPartners = sqliteTable('watchPartners', { + id: integer('id').notNull().primaryKey({ autoIncrement: true }), + userId: text('userId').notNull().references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull() +}) + +export const showWatchPartners = sqliteTable('showWatchPartners', { + showId: integer('showId').notNull().references(() => tvShows.id, { onDelete: 'cascade' }), + watchPartnerId: integer('watchPartnerId').notNull().references(() => watchPartners.id, { onDelete: 'cascade' }) +}, (table) => { + return { + showPartnerIdx: uniqueIndex('showPartnerIdx').on(table.showId, table.watchPartnerId) + } +}) + export const users = sqliteTable('user', { id: text('id').notNull().primaryKey(), name: text('name'), diff --git a/server/types/transformed.d.ts b/server/types/transformed.d.ts index 9aac854..da386b6 100644 --- a/server/types/transformed.d.ts +++ b/server/types/transformed.d.ts @@ -11,10 +11,16 @@ declare global { interface EpisodesTransformed extends Episodes { watched: boolean } + interface WatchPartner { + id: number + name: string + } + interface EpisodateShowTransformed extends EpisodateShow { tracked: boolean updatedAt: string countdown: EpisodesTransformed | null episodes: EpisodesTransformed[] + watchingWith: WatchPartner[] } } From 896a3814aa2497da8b05e1803559c37cd3174cbe Mon Sep 17 00:00:00 2001 From: Joe Bailey Date: Thu, 14 May 2026 20:43:26 +0100 Subject: [PATCH 02/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/api/watch-partners/[id].delete.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/server/api/watch-partners/[id].delete.ts b/server/api/watch-partners/[id].delete.ts index 5d74577..911f362 100644 --- a/server/api/watch-partners/[id].delete.ts +++ b/server/api/watch-partners/[id].delete.ts @@ -11,6 +11,10 @@ export default defineEventHandler(async (event) => { } const partnerId = parseInt(idParam, 10) + if (!Number.isInteger(partnerId)) { + throw createError({ statusCode: 400, statusMessage: 'Invalid id' }) + } + const userEmail = await getAuthenticatedUserEmail(event) const user = await getUserByEmail(userEmail, event) if (!user) { From 50b97171487a2c7a91996873beb708d385f68cb0 Mon Sep 17 00:00:00 2001 From: Joe Bailey Date: Thu, 14 May 2026 20:44:01 +0100 Subject: [PATCH 03/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/api/watch-partners/index.post.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/server/api/watch-partners/index.post.ts b/server/api/watch-partners/index.post.ts index 6eb455f..a29bbba 100644 --- a/server/api/watch-partners/index.post.ts +++ b/server/api/watch-partners/index.post.ts @@ -16,6 +16,9 @@ export default defineEventHandler(async (event) => { if (!name) { throw createError({ statusCode: 400, statusMessage: 'Name is required' }) } + if (name.length > 64) { + throw createError({ statusCode: 400, statusMessage: 'Name must be between 1 and 64 characters' }) + } const DB = await useDb() const existing = await DB.select().from(watchPartners).where(eq(watchPartners.userId, user.id)) From 4d361ba32d9e5c3e015d68e0cdaa1f3593357846 Mon Sep 17 00:00:00 2001 From: Joe Bailey Date: Thu, 14 May 2026 20:45:52 +0100 Subject: [PATCH 04/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- app/pages/watch-partners.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pages/watch-partners.vue b/app/pages/watch-partners.vue index 6c47f81..26913bc 100644 --- a/app/pages/watch-partners.vue +++ b/app/pages/watch-partners.vue @@ -2,7 +2,7 @@

Friends & Family

- +
    From 95ec9a76c054d5d50ead6fb156bd9cd8903bab45 Mon Sep 17 00:00:00 2001 From: Joe Bailey Date: Thu, 14 May 2026 20:46:40 +0100 Subject: [PATCH 05/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- server/api/show/[id].get.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/server/api/show/[id].get.ts b/server/api/show/[id].get.ts index f147323..d82cfaf 100644 --- a/server/api/show/[id].get.ts +++ b/server/api/show/[id].get.ts @@ -108,7 +108,13 @@ export default defineEventHandler(async (event: H3Event): Promise Date: Thu, 14 May 2026 20:47:03 +0100 Subject: [PATCH 06/18] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- drizzle/0002_watch_partners.sql | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/drizzle/0002_watch_partners.sql b/drizzle/0002_watch_partners.sql index fb4f2f6..0bcac43 100644 --- a/drizzle/0002_watch_partners.sql +++ b/drizzle/0002_watch_partners.sql @@ -5,6 +5,10 @@ CREATE TABLE `watchPartners` ( FOREIGN KEY (`userId`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade ); +CREATE INDEX `watchPartnersUserIdIdx` ON `watchPartners` (`userId`); + +CREATE UNIQUE INDEX `watchPartnersUserIdNameIdx` ON `watchPartners` (`userId`, `name` COLLATE NOCASE); + CREATE TABLE `showWatchPartners` ( `showId` integer NOT NULL, `watchPartnerId` integer NOT NULL, From e1cc5d3828d0de460f865547183b4d54f98cb5af Mon Sep 17 00:00:00 2001 From: Joe Date: Thu, 14 May 2026 20:47:11 +0100 Subject: [PATCH 07/18] Tidy up --- app/app.vue | 2 +- app/assets/scss/global.scss | 8 +- app/components/episode.vue | 2 +- app/components/nav.vue | 6 +- app/components/showButtons.vue | 6 +- app/components/shows.vue | 7 +- app/pages/my-shows.vue | 11 +-- app/pages/show/[id].vue | 146 +++++++++++++++++++++++++++++---- app/pages/watch-partners.vue | 52 ++++++++++-- server/api/show/[id].get.ts | 3 +- server/api/show/[id].patch.ts | 37 +++++++-- server/api/shows.get.ts | 5 +- 12 files changed, 230 insertions(+), 55 deletions(-) diff --git a/app/app.vue b/app/app.vue index a5e5751..b3ab6ba 100644 --- a/app/app.vue +++ b/app/app.vue @@ -1,6 +1,6 @@