add CRUD routes + prometheus client

Co-authored-by: James Hawkins <jhawki2005@gmail.com>
This commit is contained in:
mrjvs
2023-10-29 15:12:13 +01:00
parent c0d137b4b4
commit bb571fc349
10 changed files with 530 additions and 11 deletions

54
src/db/models/Bookmark.ts Normal file
View File

@@ -0,0 +1,54 @@
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
import { z } from 'zod';
export const bookmarkMetaSchema = z.object({
title: z.string(),
year: z.number(),
poster: z.string().optional(),
type: z.string(),
});
export type BookmarkMeta = z.infer<typeof bookmarkMetaSchema>;
@Entity({ tableName: 'bookmark' })
@Unique({ properties: ['tmdbId', 'userId'] })
export class Bookmark {
@PrimaryKey({ name: 'tmdb_id' })
tmdbId!: string;
@PrimaryKey({ name: 'user_id' })
userId!: string;
@Property({
name: 'meta',
type: types.json,
})
meta!: BookmarkMeta;
@Property({ name: 'updated_at', type: 'date' })
updatedAt!: Date;
}
export interface BookmarkDTO {
tmdbId: string;
meta: {
title: string;
year: number;
poster?: string;
type: string;
};
updatedAt: string;
}
export function formatBookmark(bookmark: Bookmark): BookmarkDTO {
return {
tmdbId: bookmark.tmdbId,
meta: {
title: bookmark.meta.title,
year: bookmark.meta.year,
poster: bookmark.meta.poster,
type: bookmark.meta.type,
},
updatedAt: bookmark.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,81 @@
import { Entity, PrimaryKey, Property, Unique, types } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
import { z } from 'zod';
export const progressMetaSchema = z.object({
title: z.string(),
year: z.number(),
poster: z.string().optional(),
type: z.string(),
});
export type ProgressMeta = z.infer<typeof progressMetaSchema>;
@Entity({ tableName: 'progress_item' })
@Unique({ properties: ['tmdbId', 'userId', 'seasonId', 'episodeId'] })
export class ProgressItem {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'tmdb_id' })
tmdbId!: string;
@Property({ name: 'user_id' })
userId!: string;
@Property({ name: 'season_id', nullable: true })
seasonId?: string;
@Property({ name: 'episode_id', nullable: true })
episodeId?: string;
@Property({
name: 'meta',
type: types.json,
})
meta!: ProgressMeta;
@Property({ name: 'updated_at', type: 'date' })
updatedAt!: Date;
/* progress */
@Property({ name: 'duration', type: 'bigint' })
duration!: number;
@Property({ name: 'watched', type: 'bigint' })
watched!: number;
}
export interface ProgressItemDTO {
tmdbId: string;
seasonId?: string;
episodeId?: string;
meta: {
title: string;
year: number;
poster?: string;
type: string;
};
duration: number;
watched: number;
updatedAt: string;
}
export function formatProgressItem(
progressItem: ProgressItem,
): ProgressItemDTO {
return {
tmdbId: progressItem.tmdbId,
seasonId: progressItem.seasonId,
episodeId: progressItem.episodeId,
meta: {
title: progressItem.meta.title,
year: progressItem.meta.year,
poster: progressItem.meta.poster,
type: progressItem.meta.type,
},
duration: progressItem.duration,
watched: progressItem.watched,
updatedAt: progressItem.updatedAt.toISOString(),
};
}

View File

@@ -0,0 +1,35 @@
import { Entity, PrimaryKey, Property } from '@mikro-orm/core';
import { randomUUID } from 'crypto';
@Entity({ tableName: 'user_settings' })
export class UserSettings {
@PrimaryKey({ name: 'id', type: 'uuid' })
id: string = randomUUID();
@Property({ name: 'application_theme', nullable: true })
applicationTheme?: string | null;
@Property({ name: 'application_language', nullable: true })
applicationLanguage?: string | null;
@Property({ name: 'default_subtitle_language', nullable: true })
defaultSubtitleLanguage?: string | null;
}
export interface UserSettingsDTO {
id: string;
applicationTheme?: string | null;
applicationLanguage?: string | null;
defaultSubtitleLanguage?: string | null;
}
export function formatUserSettings(
userSettings: UserSettings,
): UserSettingsDTO {
return {
id: userSettings.id,
applicationTheme: userSettings.applicationTheme,
applicationLanguage: userSettings.applicationLanguage,
defaultSubtitleLanguage: userSettings.defaultSubtitleLanguage,
};
}

View File

@@ -2,17 +2,26 @@ import { loginAuthRouter } from '@/routes/auth/login';
import { manageAuthRouter } from '@/routes/auth/manage';
import { metaRouter } from '@/routes/meta';
import { sessionsRouter } from '@/routes/sessions';
import { userBookmarkRouter } from '@/routes/users/bookmark';
import { userDeleteRouter } from '@/routes/users/delete';
import { userEditRouter } from '@/routes/users/edit';
import { userProgressRouter } from '@/routes/users/progress';
import { userSessionsRouter } from '@/routes/users/sessions';
import { userSettingsRouter } from '@/routes/users/settings';
import { FastifyInstance } from 'fastify';
import metricsPlugin from 'fastify-metrics';
export async function setupRoutes(app: FastifyInstance) {
app.register(manageAuthRouter.register);
app.register(loginAuthRouter.register);
app.register(userSessionsRouter.register);
app.register(sessionsRouter.register);
app.register(userEditRouter.register);
app.register(userDeleteRouter.register);
app.register(metaRouter.register);
await app.register(metricsPlugin, { endpoint: '/metrics' });
await app.register(manageAuthRouter.register);
await app.register(loginAuthRouter.register);
await app.register(userSessionsRouter.register);
await app.register(sessionsRouter.register);
await app.register(userEditRouter.register);
await app.register(userDeleteRouter.register);
await app.register(metaRouter.register);
await app.register(userProgressRouter.register);
await app.register(userBookmarkRouter.register);
await app.register(userSettingsRouter.register);
}

View File

@@ -0,0 +1,100 @@
import {
Bookmark,
bookmarkMetaSchema,
formatBookmark,
} from '@/db/models/Bookmark';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userBookmarkRouter = makeRouter((app) => {
app.get(
'/users/:uid/bookmarks',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot access other user information', 403);
const bookmarks = await em.find(Bookmark, {
userId: params.uid,
});
return bookmarks.map(formatBookmark);
}),
);
app.post(
'/users/:uid/bookmarks/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: z.object({
meta: bookmarkMetaSchema,
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const oldBookmark = await em.findOne(Bookmark, {
userId: params.uid,
tmdbId: params.tmdbid,
});
if (oldBookmark) throw new StatusError('Already bookmarked', 400);
const bookmark = new Bookmark();
em.assign(bookmark, {
userId: params.uid,
tmdbId: params.tmdbid,
meta: body.meta,
updatedAt: new Date(),
});
await em.persistAndFlush(bookmark);
return formatBookmark(bookmark);
}),
);
app.delete(
'/users/:uid/bookmarks/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const bookmark = await em.findOne(Bookmark, {
userId: params.uid,
tmdbId: params.tmdbid,
});
if (!bookmark) return { tmdbId: params.tmdbid };
await em.removeAndFlush(bookmark);
return { tmdbId: params.tmdbid };
}),
);
});

View File

@@ -0,0 +1,126 @@
import {
ProgressItem,
formatProgressItem,
progressMetaSchema,
} from '@/db/models/ProgressItem';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userProgressRouter = makeRouter((app) => {
app.put(
'/users/:uid/progress/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: z.object({
meta: progressMetaSchema,
seasonId: z.string().optional(),
episodeId: z.string().optional(),
duration: z.number(),
watched: z.number(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
let progressItem = await em.findOne(ProgressItem, {
userId: params.uid,
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
});
if (!progressItem) {
progressItem = new ProgressItem();
progressItem.tmdbId = params.tmdbid;
progressItem.userId = params.uid;
progressItem.episodeId = body.episodeId;
progressItem.seasonId = body.seasonId;
}
em.assign(progressItem, {
duration: body.duration,
watched: body.watched,
meta: body.meta,
updatedAt: new Date(),
});
await em.persistAndFlush(progressItem);
return formatProgressItem(progressItem);
}),
);
app.delete(
'/users/:uid/progress/:tmdbid',
{
schema: {
params: z.object({
uid: z.string(),
tmdbid: z.string(),
}),
body: z.object({
seasonId: z.string().optional(),
episodeId: z.string().optional(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const progressItem = await em.findOne(ProgressItem, {
userId: params.uid,
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
});
if (!progressItem) {
return {
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
};
}
await em.removeAndFlush(progressItem);
return {
tmdbId: params.tmdbid,
episodeId: body.episodeId,
seasonId: body.seasonId,
};
}),
);
app.get(
'/users/:uid/progress',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
const items = await em.find(ProgressItem, {
userId: params.uid,
});
return items.map(formatProgressItem);
}),
);
});

View File

@@ -0,0 +1,72 @@
import { UserSettings, formatUserSettings } from '@/db/models/UserSettings';
import { StatusError } from '@/services/error';
import { handle } from '@/services/handler';
import { makeRouter } from '@/services/router';
import { z } from 'zod';
export const userSettingsRouter = makeRouter((app) => {
app.get(
'/users/:uid/settings',
{
schema: {
params: z.object({
uid: z.string(),
}),
},
},
handle(async ({ auth, params, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot get other user information', 403);
const settings = await em.findOne(UserSettings, {
id: params.uid,
});
if (!settings) return { id: params.uid };
return formatUserSettings(settings);
}),
);
app.put(
'/users/:uid/settings',
{
schema: {
params: z.object({
uid: z.string(),
}),
body: z.object({
applicationLanguage: z.string().optional(),
applicationTheme: z.string().optional(),
defaultSubtitleLanguage: z.string().optional(),
}),
},
},
handle(async ({ auth, params, body, em }) => {
await auth.assert();
if (auth.user.id !== params.uid)
throw new StatusError('Cannot modify user other than yourself', 403);
let settings = await em.findOne(UserSettings, {
id: params.uid,
});
if (!settings) {
settings = new UserSettings();
settings.id = params.uid;
}
if (body.applicationLanguage)
settings.applicationLanguage = body.applicationLanguage;
if (body.applicationTheme)
settings.applicationTheme = body.applicationTheme;
if (body.defaultSubtitleLanguage)
settings.defaultSubtitleLanguage = body.defaultSubtitleLanguage;
await em.persistAndFlush(settings);
return formatUserSettings(settings);
}),
);
});