tubearr/src/db/repositories/channel-repository.ts
John Lightner 4606dce553 feat: Tubearr — full project state through M006/S01
Migrated git root from W:/programming/Projects/ to W:/programming/Projects/Tubearr/.
Previous history preserved in Tubearr-full-backup.bundle at parent directory.

Completed milestones: M001 through M005
Active: M006/S02 (Add Channel UX)
2026-03-24 20:20:10 -05:00

193 lines
5.4 KiB
TypeScript

import { eq, and, sql } from 'drizzle-orm';
import { type LibSQLDatabase } from 'drizzle-orm/libsql';
import type * as schema from '../schema/index';
import { channels, contentItems } from '../schema/index';
import type { Channel, Platform, MonitoringMode } from '../../types/index';
// ── Types ──
/** Fields needed to create a new channel (auto-generated fields excluded). */
export type CreateChannelData = Omit<
Channel,
'id' | 'createdAt' | 'updatedAt' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode'
> & { monitoringMode?: Channel['monitoringMode'] };
/** Fields that can be updated on an existing channel. */
export type UpdateChannelData = Partial<
Pick<Channel, 'name' | 'checkInterval' | 'monitoringEnabled' | 'imageUrl' | 'formatProfileId' | 'lastCheckedAt' | 'lastCheckStatus' | 'monitoringMode'>
>;
type Db = LibSQLDatabase<typeof schema>;
// ── Repository Functions ──
/** Insert a new channel and return the created row. */
export async function createChannel(
db: Db,
data: CreateChannelData
): Promise<Channel> {
const result = await db
.insert(channels)
.values({
name: data.name,
platform: data.platform,
platformId: data.platformId,
url: data.url,
monitoringEnabled: data.monitoringEnabled,
checkInterval: data.checkInterval,
imageUrl: data.imageUrl,
metadata: data.metadata,
formatProfileId: data.formatProfileId,
monitoringMode: data.monitoringMode ?? 'all',
})
.returning();
return mapRow(result[0]);
}
/** Get a channel by ID. Returns null if not found. */
export async function getChannelById(
db: Db,
id: number
): Promise<Channel | null> {
const rows = await db
.select()
.from(channels)
.where(eq(channels.id, id))
.limit(1);
return rows.length > 0 ? mapRow(rows[0]) : null;
}
/** Get all channels, ordered by name. */
export async function getAllChannels(db: Db): Promise<Channel[]> {
const rows = await db.select().from(channels).orderBy(channels.name);
return rows.map(mapRow);
}
/** Get all channels with monitoring enabled. */
export async function getEnabledChannels(db: Db): Promise<Channel[]> {
const rows = await db
.select()
.from(channels)
.where(eq(channels.monitoringEnabled, true))
.orderBy(channels.name);
return rows.map(mapRow);
}
/** Update specific fields on a channel. Sets updatedAt to now. Returns updated row or null. */
export async function updateChannel(
db: Db,
id: number,
data: UpdateChannelData
): Promise<Channel | null> {
const result = await db
.update(channels)
.set({
...data,
updatedAt: sql`(datetime('now'))`,
})
.where(eq(channels.id, id))
.returning();
return result.length > 0 ? mapRow(result[0]) : null;
}
/**
* Change a channel's monitoring mode and cascade the `monitored` flag to all existing content items.
*
* Cascade logic:
* - 'all' or 'existing' → existing items become monitored (true)
* - 'future' or 'none' → existing items become unmonitored (false)
*
* Also syncs `monitoringEnabled`: mode !== 'none' → enabled (per D022).
* Returns updated channel or null if not found.
*/
export async function setMonitoringMode(
db: Db,
id: number,
mode: MonitoringMode
): Promise<Channel | null> {
// Step 1: Cascade monitored flag to all content items for this channel
const cascadeMonitored = mode === 'all' || mode === 'existing';
await db
.update(contentItems)
.set({
monitored: cascadeMonitored,
updatedAt: sql`(datetime('now'))`,
})
.where(eq(contentItems.channelId, id));
// Step 2: Update the channel's monitoringMode and monitoringEnabled
const result = await db
.update(channels)
.set({
monitoringMode: mode,
monitoringEnabled: mode !== 'none',
updatedAt: sql`(datetime('now'))`,
})
.where(eq(channels.id, id))
.returning();
return result.length > 0 ? mapRow(result[0]) : null;
}
/** Delete a channel by ID. Returns true if a row was deleted. */
export async function deleteChannel(
db: Db,
id: number
): Promise<boolean> {
const result = await db
.delete(channels)
.where(eq(channels.id, id))
.returning({ id: channels.id });
return result.length > 0;
}
/** Find a channel by platform and platformId (for duplicate detection). */
export async function getChannelByPlatformId(
db: Db,
platform: string,
platformId: string
): Promise<Channel | null> {
const rows = await db
.select()
.from(channels)
.where(
and(
eq(channels.platform, platform),
eq(channels.platformId, platformId)
)
)
.limit(1);
return rows.length > 0 ? mapRow(rows[0]) : null;
}
// ── Row Mapping ──
/**
* Map a raw Drizzle row to the Channel domain type.
* Ensures boolean and nullable fields are correctly typed.
*/
function mapRow(row: typeof channels.$inferSelect): Channel {
return {
id: row.id,
name: row.name,
platform: row.platform as Platform,
platformId: row.platformId,
url: row.url,
monitoringEnabled: row.monitoringEnabled,
checkInterval: row.checkInterval,
imageUrl: row.imageUrl,
metadata: row.metadata as Record<string, unknown> | null,
formatProfileId: row.formatProfileId,
monitoringMode: (row.monitoringMode ?? 'all') as Channel['monitoringMode'],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
lastCheckedAt: row.lastCheckedAt,
lastCheckStatus: row.lastCheckStatus as Channel['lastCheckStatus'],
};
}