From 455deb3fdee9aaa1d5a388f69cd35e65533c5679 Mon Sep 17 00:00:00 2001 From: David Cramer Date: Wed, 17 May 2023 17:37:51 -0700 Subject: [PATCH 1/2] ref: Overhaul the api to use jsonschema There are a variety of fixes and changes in here. --- apps/api/src/app.ts | 42 +++- apps/api/src/bin/mocks.ts | 2 +- apps/api/src/lib/auth.ts | 16 +- apps/api/src/lib/filter.ts | 4 + apps/api/src/lib/serializers/bottle.ts | 77 ++++++-- apps/api/src/lib/serializers/collection.ts | 13 ++ apps/api/src/lib/serializers/comment.ts | 42 ++++ apps/api/src/lib/serializers/edition.ts | 15 ++ apps/api/src/lib/serializers/entity.ts | 19 ++ apps/api/src/lib/serializers/follow.ts | 138 +++++++++++-- apps/api/src/lib/serializers/friend.ts | 16 -- apps/api/src/lib/serializers/index.ts | 54 ++++++ apps/api/src/lib/serializers/notification.ts | 130 +++++++++++++ apps/api/src/lib/serializers/tasting.ts | 158 +++++++++------ apps/api/src/lib/serializers/user.ts | 97 ++++++---- apps/api/src/lib/test/fixtures.ts | 4 +- apps/api/src/routes/addBottle.ts | 13 +- apps/api/src/routes/addEntity.test.ts | 2 +- apps/api/src/routes/addEntity.ts | 13 +- apps/api/src/routes/addTasting.ts | 69 ++----- apps/api/src/routes/addTastingComment.test.ts | 1 + apps/api/src/routes/addTastingComment.ts | 14 +- apps/api/src/routes/authBasic.ts | 18 +- apps/api/src/routes/authDetails.ts | 19 +- apps/api/src/routes/authGoogle.ts | 18 +- apps/api/src/routes/deleteUserFollow.ts | 16 +- apps/api/src/routes/getBottle.test.ts | 2 +- apps/api/src/routes/getBottle.tsx | 56 ++---- apps/api/src/routes/getEntity.test.ts | 2 +- apps/api/src/routes/getEntity.ts | 10 +- apps/api/src/routes/getTasting.test.ts | 2 +- apps/api/src/routes/getTasting.ts | 61 ++---- apps/api/src/routes/getUser.test.ts | 8 +- apps/api/src/routes/getUser.ts | 29 +-- apps/api/src/routes/index.ts | 8 +- .../api/src/routes/listBottleSuggestedTags.ts | 22 +++ apps/api/src/routes/listBottles.test.ts | 6 +- apps/api/src/routes/listBottles.ts | 84 ++++---- apps/api/src/routes/listCollections.ts | 28 ++- apps/api/src/routes/listComments.ts | 37 ++-- apps/api/src/routes/listEntities.ts | 24 ++- apps/api/src/routes/listFollowers.test.ts | 18 +- apps/api/src/routes/listFollowers.ts | 65 +++---- ...tFriends.test.ts => listFollowing.test.ts} | 8 +- .../{listFriends.ts => listFollowing.ts} | 48 +++-- apps/api/src/routes/listNotifications.ts | 182 +++--------------- apps/api/src/routes/listTastings.test.ts | 6 +- apps/api/src/routes/listTastings.ts | 98 +--------- apps/api/src/routes/listUsers.test.ts | 2 +- apps/api/src/routes/listUsers.ts | 26 ++- apps/api/src/routes/updateBottle.ts | 14 +- apps/api/src/routes/updateEntity.ts | 8 +- apps/api/src/routes/updateFollower.test.ts | 10 +- apps/api/src/routes/updateFollower.ts | 75 +++----- apps/api/src/routes/updateTastingImage.ts | 11 ++ apps/api/src/routes/updateUser.ts | 19 +- apps/api/src/routes/updateUserAvatar.ts | 11 ++ apps/api/src/schemas/bottle.ts | 129 +++++++++++++ apps/api/src/schemas/bottle.tsx | 73 ------- apps/api/src/schemas/collection.ts | 11 ++ apps/api/src/schemas/comment.ts | 21 ++ apps/api/src/schemas/edition.ts | 23 +++ apps/api/src/schemas/entity.ts | 65 +++++++ apps/api/src/schemas/entity.tsx | 16 -- apps/api/src/schemas/errors.ts | 9 + apps/api/src/schemas/follow.ts | 19 ++ apps/api/src/schemas/notification.ts | 19 ++ apps/api/src/schemas/paging.ts | 18 ++ apps/api/src/schemas/tasting.ts | 48 +++++ apps/api/src/schemas/user.ts | 25 +++ .../components/notifications/followEntry.tsx | 4 +- apps/web/src/components/reloadPrompt.tsx | 2 +- apps/web/src/components/tastingListItem.tsx | 10 +- apps/web/src/routes/bottleDetails.tsx | 14 +- apps/web/src/routes/friendList.tsx | 12 +- apps/web/src/routes/friendRequests.tsx | 19 +- apps/web/src/routes/friends.tsx | 4 +- apps/web/src/routes/profile.tsx | 4 +- 78 files changed, 1596 insertions(+), 939 deletions(-) create mode 100644 apps/api/src/lib/serializers/collection.ts create mode 100644 apps/api/src/lib/serializers/comment.ts create mode 100644 apps/api/src/lib/serializers/edition.ts create mode 100644 apps/api/src/lib/serializers/entity.ts delete mode 100644 apps/api/src/lib/serializers/friend.ts create mode 100644 apps/api/src/lib/serializers/index.ts create mode 100644 apps/api/src/lib/serializers/notification.ts rename apps/api/src/routes/{listFriends.test.ts => listFollowing.test.ts} (84%) rename apps/api/src/routes/{listFriends.ts => listFollowing.ts} (66%) create mode 100644 apps/api/src/schemas/bottle.ts delete mode 100644 apps/api/src/schemas/bottle.tsx create mode 100644 apps/api/src/schemas/collection.ts create mode 100644 apps/api/src/schemas/comment.ts create mode 100644 apps/api/src/schemas/edition.ts create mode 100644 apps/api/src/schemas/entity.ts delete mode 100644 apps/api/src/schemas/entity.tsx create mode 100644 apps/api/src/schemas/errors.ts create mode 100644 apps/api/src/schemas/follow.ts create mode 100644 apps/api/src/schemas/notification.ts create mode 100644 apps/api/src/schemas/paging.ts create mode 100644 apps/api/src/schemas/tasting.ts create mode 100644 apps/api/src/schemas/user.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index f929f584..43a3fb22 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -7,10 +7,28 @@ import config from "./config"; import { router } from "./routes"; import { initSentry } from "./instruments"; -import bottleSchema from "./schemas/bottle"; -import entitySchema from "./schemas/entity"; import FastifySentry from "./sentryPlugin"; +import { + bottleSchema, + newBottleSchema, + updateBottleSchema, +} from "./schemas/bottle"; +import { collectionSchema } from "./schemas/collection"; +import { commentSchema, newCommentSchema } from "./schemas/comment"; +import { editionSchema, newEditionSchema } from "./schemas/edition"; +import { + entitySchema, + newEntitySchema, + updateEntitySchema, +} from "./schemas/entity"; +import { error401Schema } from "./schemas/errors"; +import { followingSchema } from "./schemas/follow"; +import { notificationSchema } from "./schemas/notification"; +import pagingSchema from "./schemas/paging"; +import { newTastingSchema, tastingSchema } from "./schemas/tasting"; +import { updateUserSchema, userSchema } from "./schemas/user"; + initSentry({ dsn: config.SENTRY_DSN, release: config.VERSION, @@ -59,8 +77,28 @@ export default async function buildFastify(options = {}) { }, }); app.register(FastifyCors, { credentials: true, origin: config.CORS_HOST }); + app.addSchema(bottleSchema); + app.addSchema(newBottleSchema); + app.addSchema(updateBottleSchema); app.addSchema(entitySchema); + app.addSchema(newEntitySchema); + app.addSchema(updateEntitySchema); + app.addSchema(followingSchema); + app.addSchema(pagingSchema); + app.addSchema(userSchema); + app.addSchema(updateUserSchema); + app.addSchema(notificationSchema); + app.addSchema(tastingSchema); + app.addSchema(collectionSchema); + app.addSchema(commentSchema); + app.addSchema(newCommentSchema); + app.addSchema(newTastingSchema); + app.addSchema(editionSchema); + app.addSchema(newEditionSchema); + + app.addSchema(error401Schema); + app.register(router); app.register(FastifySentry); diff --git a/apps/api/src/bin/mocks.ts b/apps/api/src/bin/mocks.ts index f8078814..2195ef37 100644 --- a/apps/api/src/bin/mocks.ts +++ b/apps/api/src/bin/mocks.ts @@ -63,7 +63,7 @@ program await createNotification(db, { fromUserId: follow.fromUserId, objectType: objectTypeFromSchema(follows), - objectId: follow.fromUserId, + objectId: follow.id, userId: follow.toUserId, createdAt: follow.createdAt, }); diff --git a/apps/api/src/lib/auth.ts b/apps/api/src/lib/auth.ts index 98343b36..73d53665 100644 --- a/apps/api/src/lib/auth.ts +++ b/apps/api/src/lib/auth.ts @@ -3,20 +3,22 @@ import { sign, verify } from "jsonwebtoken"; import config from "../config"; import { NewUser, User, users } from "../db/schema"; import { random } from "./rand"; -import { SerializedUser, serializeUser } from "./serializers/user"; +import { serialize } from "./serializers"; +import { UserSerializer } from "./serializers/user"; -export const createAccessToken = (user: User): Promise => { +export const createAccessToken = async ( + user: User, +): Promise => { + const payload = await serialize(UserSerializer, user, user); return new Promise((res, rej) => { - sign(serializeUser(user, user), config.JWT_SECRET, {}, (err, token) => { + sign(payload, config.JWT_SECRET, {}, (err, token) => { if (err) rej(err); res(token); }); }); }; -export const verifyToken = ( - token: string | undefined, -): Promise => { +export const verifyToken = (token: string | undefined): Promise => { return new Promise((res, rej) => { if (!token) { rej("invalid token"); @@ -31,7 +33,7 @@ export const verifyToken = ( if (!decoded || typeof decoded === "string") { rej("invalid token"); } - res(decoded as SerializedUser); + res(decoded); }); }); }; diff --git a/apps/api/src/lib/filter.ts b/apps/api/src/lib/filter.ts index 6f67fd42..82b863fa 100644 --- a/apps/api/src/lib/filter.ts +++ b/apps/api/src/lib/filter.ts @@ -6,3 +6,7 @@ export const select = (obj: object, ...props: string[]) => export const omit = (obj: object, ...props: string[]) => filter(obj, (k) => !props.includes(k)); + +export function notEmpty(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +} diff --git a/apps/api/src/lib/serializers/bottle.ts b/apps/api/src/lib/serializers/bottle.ts index e7dc80eb..2a713ea2 100644 --- a/apps/api/src/lib/serializers/bottle.ts +++ b/apps/api/src/lib/serializers/bottle.ts @@ -1,18 +1,65 @@ -import { Bottle, Entity, User } from "../../db/schema"; +import { inArray } from "drizzle-orm"; +import { Result, Serializer, serialize } from "."; +import { db } from "../../db"; +import { Bottle, User, bottlesToDistillers, entities } from "../../db/schema"; +import { EntitySerializer } from "./entity"; -export const serializeBottle = ( - bottle: Bottle & { - brand: Entity; - distillers?: Entity[]; +export const BottleSerializer: Serializer = { + attrs: async (itemList: Bottle[], currentUser?: User) => { + const itemIds = itemList.map((t) => t.id); + + const distillerList = await db + .select() + .from(bottlesToDistillers) + .where(inArray(bottlesToDistillers.bottleId, itemIds)); + + const entityIds = Array.from( + new Set([ + ...itemList.map((i) => i.brandId), + ...distillerList.map((d) => d.distillerId), + ]), + ); + + const entityList = await db + .select() + .from(entities) + .where(inArray(entities.id, entityIds)); + const entitiesById = Object.fromEntries( + (await serialize(EntitySerializer, entityList, currentUser)).map( + (data, index) => [entityList[index].id, data], + ), + ); + + const distillersByBottleId: { + [bottleId: number]: Result; + } = {}; + distillerList.forEach((d) => { + if (!distillersByBottleId[d.bottleId]) + distillersByBottleId[d.bottleId] = [entitiesById[d.distillerId]]; + else distillersByBottleId[d.bottleId].push(entitiesById[d.distillerId]); + }); + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + brand: entitiesById[item.brandId], + distillers: distillersByBottleId[item.id] || [], + }, + ]; + }), + ); + }, + + item: (item: Bottle, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + name: item.name, + statedAge: item.statedAge, + category: item.category, + brand: attrs.brand, + distillers: attrs.distillers, + }; }, - currentUser?: User, -) => { - return { - id: bottle.id, - name: bottle.name, - statedAge: bottle.statedAge, - category: bottle.category, - brand: bottle.brand, - distillers: bottle.distillers || [], - }; }; diff --git a/apps/api/src/lib/serializers/collection.ts b/apps/api/src/lib/serializers/collection.ts new file mode 100644 index 00000000..012908a4 --- /dev/null +++ b/apps/api/src/lib/serializers/collection.ts @@ -0,0 +1,13 @@ +import { Collection, User } from "../../db/schema"; + +import { Serializer } from "."; + +export const CollectionSerializer: Serializer = { + item: (item: Collection, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + name: item.name, + createdAt: item.createdAt, + }; + }, +}; diff --git a/apps/api/src/lib/serializers/comment.ts b/apps/api/src/lib/serializers/comment.ts new file mode 100644 index 00000000..922e4f07 --- /dev/null +++ b/apps/api/src/lib/serializers/comment.ts @@ -0,0 +1,42 @@ +import { inArray } from "drizzle-orm"; +import { Serializer } from "."; +import { db } from "../../db"; +import { Comment, User, users } from "../../db/schema"; + +export const CommentSerializer: Serializer = { + attrs: async (itemList: Comment[], currentUser?: User) => { + const usersById = Object.fromEntries( + ( + await db + .select() + .from(users) + .where( + inArray( + users.id, + itemList.map((i) => i.createdById), + ), + ) + ).map((u) => [u.id, u]), + ); + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + createdBy: usersById[item.id], + }, + ]; + }), + ); + }, + + item: (item: Comment, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + comments: item.comment, + createdAt: item.createdAt, + createdBy: attrs.createdBy, + }; + }, +}; diff --git a/apps/api/src/lib/serializers/edition.ts b/apps/api/src/lib/serializers/edition.ts new file mode 100644 index 00000000..b108a80b --- /dev/null +++ b/apps/api/src/lib/serializers/edition.ts @@ -0,0 +1,15 @@ +import { Edition, User } from "../../db/schema"; + +import { Serializer } from "."; + +export const EditionSerializer: Serializer = { + item: (item: Edition, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + name: item.name, + barrel: item.barrel, + vintageYear: item.vintageYear, + createdAt: item.createdAt, + }; + }, +}; diff --git a/apps/api/src/lib/serializers/entity.ts b/apps/api/src/lib/serializers/entity.ts new file mode 100644 index 00000000..818696a6 --- /dev/null +++ b/apps/api/src/lib/serializers/entity.ts @@ -0,0 +1,19 @@ +import { Entity, User } from "../../db/schema"; + +import { Serializer } from "."; + +export const EntitySerializer: Serializer = { + item: (item: Entity, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + name: item.name, + country: item.country, + region: item.region, + type: item.type, + createdAt: item.createdAt, + + totalTastings: item.totalTastings, + totalBottles: item.totalBottles, + }; + }, +}; diff --git a/apps/api/src/lib/serializers/follow.ts b/apps/api/src/lib/serializers/follow.ts index 7d8b1037..50a6c2e0 100644 --- a/apps/api/src/lib/serializers/follow.ts +++ b/apps/api/src/lib/serializers/follow.ts @@ -1,18 +1,126 @@ -import { Follow, User } from "../../db/schema"; -import { serializeUser } from "./user"; +import { Follow, User, users } from "../../db/schema"; -export const serializeFollow = ( - follow: Follow & { - user: User; - followsBack?: string | null; +import { follows } from "../../db/schema"; + +import { and, eq, inArray } from "drizzle-orm"; +import { Serializer, serialize } from "."; +import { db } from "../../db"; +import { UserSerializer } from "./user"; + +export const FollowerSerializer: Serializer = { + attrs: async (itemList: Follow[], currentUser?: User) => { + const userList = await db + .select() + .from(users) + .where( + inArray( + users.id, + itemList.map((i) => i.fromUserId), + ), + ); + const usersById = Object.fromEntries( + (await serialize(UserSerializer, userList, currentUser)).map( + (data, index) => [userList[index].id, data], + ), + ); + + const followsBackByRef = currentUser + ? Object.fromEntries( + ( + await db + .select() + .from(follows) + .where( + and( + inArray( + follows.toUserId, + itemList.map((i) => i.fromUserId), + ), + eq(follows.fromUserId, currentUser.id), + ), + ) + ).map((f) => [f.toUserId, f]), + ) + : {}; + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + user: usersById[item.fromUserId], + followsBack: followsBackByRef[item.fromUserId]?.status || "none", + }, + ]; + }), + ); + }, + item: (item: Follow, attrs: Record, currentUser?: User) => { + return { + id: item.id, + status: item.status, + createdAt: item.createdAt, + user: attrs.user, + followsBack: attrs.followsBack, + }; + }, +}; + +export const FollowingSerializer: Serializer = { + attrs: async (itemList: Follow[], currentUser?: User) => { + const userList = await db + .select() + .from(users) + .where( + inArray( + users.id, + itemList.map((i) => i.toUserId), + ), + ); + const usersById = Object.fromEntries( + (await serialize(UserSerializer, userList, currentUser)).map( + (data, index) => [userList[index].id, data], + ), + ); + + const followsBackByRef = currentUser + ? Object.fromEntries( + ( + await db + .select() + .from(follows) + .where( + and( + inArray( + follows.fromUserId, + itemList.map((i) => i.toUserId), + ), + eq(follows.toUserId, currentUser.id), + ), + ) + ).map((f) => [f.fromUserId, f]), + ) + : {}; + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + user: usersById[item.toUserId], + followsBack: followsBackByRef[item.toUserId]?.status || "none", + }, + ]; + }), + ); + }, + item: (item: Follow, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + status: item.status, + createdAt: item.createdAt, + user: attrs.user, + followsBack: attrs.followsBack, + }; }, - currentUser?: User, -) => { - return { - id: follow.fromUserId, - status: follow.status, - createdAt: follow.createdAt, - user: serializeUser(follow.user, currentUser), - followsBack: follow.followsBack || "none", - }; }; diff --git a/apps/api/src/lib/serializers/friend.ts b/apps/api/src/lib/serializers/friend.ts deleted file mode 100644 index 757c33d3..00000000 --- a/apps/api/src/lib/serializers/friend.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Follow, User } from "../../db/schema"; -import { serializeUser } from "./user"; - -export const serializeFriend = ( - follow: Follow & { - user: User; - }, - currentUser?: User, -) => { - return { - id: follow.toUserId, - status: follow.status, - createdAt: follow.createdAt, - user: serializeUser(follow.user, currentUser), - }; -}; diff --git a/apps/api/src/lib/serializers/index.ts b/apps/api/src/lib/serializers/index.ts new file mode 100644 index 00000000..9fe7b673 --- /dev/null +++ b/apps/api/src/lib/serializers/index.ts @@ -0,0 +1,54 @@ +import { User } from "../../db/schema"; + +export type Result = Record; + +type Item = { + id: number; +}; + +export type Attrs = Record>; + +export interface Serializer { + attrs?(itemList: T[], currentUser?: User): Promise>; + item( + item: T, + attrs: Record>, + currentUser?: User, + ): Result; +} + +export async function DefaultAttrs( + itemList: T[], + currentUser?: User, +): Promise> { + return Object.fromEntries(itemList.map((i) => [i, {}])); +} + +export async function serialize( + serializer: Serializer, + item: T, + currentUser?: User, +): Promise; +export async function serialize( + serializer: Serializer, + itemList: T[], + currentUser?: User, +): Promise; +export async function serialize( + serializer: Serializer, + itemList: T | T[], + currentUser?: User, +): Promise { + if (Array.isArray(itemList) && !itemList.length) return []; + + const attrs = await (serializer.attrs || DefaultAttrs)( + Array.isArray(itemList) ? itemList : [itemList], + currentUser, + ); + + const results = (Array.isArray(itemList) ? itemList : [itemList]).map( + (i: T) => serializer.item(i, attrs[i.id] || {}, currentUser), + ); + + return Array.isArray(itemList) ? results : results[0]; +} diff --git a/apps/api/src/lib/serializers/notification.ts b/apps/api/src/lib/serializers/notification.ts new file mode 100644 index 00000000..db79825d --- /dev/null +++ b/apps/api/src/lib/serializers/notification.ts @@ -0,0 +1,130 @@ +import { + Notification, + User, + comments, + follows, + tastings, + toasts, + users, +} from "../../db/schema"; + +import { eq, inArray } from "drizzle-orm"; +import { Serializer, serialize } from "."; +import { db } from "../../db"; +import { FollowerSerializer } from "./follow"; +import { TastingSerializer } from "./tasting"; +import { UserSerializer } from "./user"; + +export const NotificationSerializer: Serializer = { + attrs: async (itemList: Notification[], currentUser: User) => { + const itemIds = itemList.map((t) => t.id); + const fromUserIds = itemList + .filter((i) => !!i.fromUserId) + .map((i) => i.fromUserId as number); + + const fromUserList = fromUserIds.length + ? await db.select().from(users).where(inArray(users.id, fromUserIds)) + : []; + const fromUserById = Object.fromEntries( + (await serialize(UserSerializer, fromUserList, currentUser)).map( + (data, index) => [fromUserList[index].id, data], + ), + ); + + const followIdList = itemList + .filter((i) => i.objectType === "follow") + .map((i) => i.objectId); + const followList = followIdList.length + ? await db.select().from(follows).where(inArray(follows.id, followIdList)) + : []; + const followsById = Object.fromEntries( + (await serialize(FollowerSerializer, followList, currentUser)).map( + (data, index) => [followList[index].id, data], + ), + ); + + const toastIdList = itemList + .filter((i) => i.objectType === "toast") + .map((i) => i.objectId); + const toastTastingList = toastIdList.length + ? await db + .select({ + toastId: toasts.id, + tasting: tastings, + }) + .from(tastings) + .innerJoin(toasts, eq(tastings.id, toasts.tastingId)) + .where(inArray(toasts.id, toastIdList)) + : []; + const toastsById = Object.fromEntries( + ( + await serialize( + TastingSerializer, + toastTastingList.map(({ tasting }) => tasting), + currentUser, + ) + ).map((data, index) => [toastTastingList[index].toastId, data]), + ); + + const commentIdList = itemList + .filter((i) => i.objectType === "comment") + .map((i) => i.objectId); + const commentTastingList = commentIdList.length + ? await db + .select({ + commentId: comments.id, + tasting: tastings, + }) + .from(tastings) + .innerJoin(toasts, eq(tastings.id, comments.tastingId)) + .where(inArray(comments.id, commentIdList)) + : []; + const commentsById = Object.fromEntries( + ( + await serialize( + TastingSerializer, + commentTastingList.map(({ tasting }) => tasting), + currentUser, + ) + ).map((data, index) => [commentTastingList[index].commentId, data]), + ); + + const getRef = (notification: Notification) => { + switch (notification.objectType) { + case "follow": + return followsById[notification.objectId]; + case "toast": + return toastsById[notification.objectId]; + case "comment": + return commentsById[notification.objectId]; + default: + return null; + } + }; + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + fromUser: item.fromUserId + ? fromUserById[item.fromUserId] + : undefined, + ref: getRef(item) || null, + }, + ]; + }), + ); + }, + + item: (item: Notification, attrs: Record, currentUser: User) => { + return { + id: `${item.id}`, + objectType: item.objectType, + objectId: item.objectId, + createdAt: item.createdAt, + fromUser: attrs.fromUser, + ref: attrs.ref, + }; + }, +}; diff --git a/apps/api/src/lib/serializers/tasting.ts b/apps/api/src/lib/serializers/tasting.ts index facebec6..714da64a 100644 --- a/apps/api/src/lib/serializers/tasting.ts +++ b/apps/api/src/lib/serializers/tasting.ts @@ -1,60 +1,110 @@ +import { and, eq, inArray } from "drizzle-orm"; +import { Serializer, serialize } from "."; import config from "../../config"; -import { Bottle, Edition, Entity, Tasting, User } from "../../db/schema"; -import { serializeBottle } from "./bottle"; -import { serializeUser } from "./user"; - -export const serializeTasting = ( - tasting: Tasting & { - createdBy: User; - edition?: Edition | null; - bottle: Bottle & { - brand: Entity; - distillers?: Entity[]; - }; - hasToasted?: boolean; +import { db } from "../../db"; +import { + Tasting, + User, + bottles, + editions, + tastings, + toasts, + users, +} from "../../db/schema"; +import { notEmpty } from "../filter"; +import { BottleSerializer } from "./bottle"; +import { EditionSerializer } from "./edition"; +import { UserSerializer } from "./user"; + +export const TastingSerializer: Serializer = { + attrs: async (itemList: Tasting[], currentUser?: User) => { + const itemIds = itemList.map((t) => t.id); + const results = await db + .select({ + id: tastings.id, + bottle: bottles, + createdBy: users, + edition: editions, + }) + .from(tastings) + .innerJoin(users, eq(tastings.createdById, users.id)) + .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) + .leftJoin(editions, eq(tastings.editionId, editions.id)) + .where(inArray(tastings.id, itemIds)); + + const userToastsList: number[] = currentUser + ? ( + await db + .select({ tastingId: toasts.tastingId }) + .from(toasts) + .where( + and( + inArray(toasts.tastingId, itemIds), + eq(toasts.createdById, currentUser.id), + ), + ) + ).map((t) => t.tastingId) + : []; + + const bottlesByRef = Object.fromEntries( + ( + await serialize( + BottleSerializer, + results.map((r) => r.bottle), + currentUser, + ) + ).map((data, index) => [results[index].id, data]), + ); + + const usersByRef = Object.fromEntries( + ( + await serialize( + UserSerializer, + results.map((r) => r.createdBy), + currentUser, + ) + ).map((data, index) => [results[index].id, data]), + ); + + const editionList = results.map((r) => r.edition).filter(notEmpty); + + const editionsByRef = Object.fromEntries( + (await serialize(EditionSerializer, editionList, currentUser)).map( + (data, index) => [editionList[index].id, data], + ), + ); + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + hasToasted: userToastsList.indexOf(item.id) !== -1, + edition: item.editionId ? editionsByRef[item.id] : null, + createdBy: usersByRef[item.id] || null, + bottle: bottlesByRef[item.id] || null, + }, + ]; + }), + ); }, - currentUser?: User, -) => { - const data: { [key: string]: any } = { - id: tasting.id, - imageUrl: tasting.imageUrl - ? `${config.URL_PREFIX}${tasting.imageUrl}` - : null, - bottle: serializeBottle(tasting.bottle, currentUser), - createdBy: serializeUser(tasting.createdBy, currentUser), - comments: tasting.comments, - tags: tasting.tags, - rating: tasting.rating, - edition: tasting.edition, - createdAt: tasting.createdAt, - hasToasted: tasting.hasToasted, - toasts: tasting.toasts, - }; - return data; -}; -export const serializeTastingRef = ( - tasting: { - id: number; - bottle: { - id: number; - name: string; - brand: { - id: number; - name: string; - }; + item: (item: Tasting, attrs: Record, currentUser?: User) => { + return { + id: `${item.id}`, + imageUrl: item.imageUrl ? `${config.URL_PREFIX}${item.imageUrl}` : null, + notes: item.notes, + tags: item.tags || [], + rating: item.rating, + createdAt: item.createdAt, + + comments: item.comments, + toasts: item.toasts, + + bottle: attrs.bottle, + createdBy: attrs.createdBy, + edition: attrs.edition, + hasToasted: attrs.hasToasted, }; }, - currentUser?: User, -) => { - return { - id: tasting.id, - bottle: { - name: tasting.bottle.name, - brand: { - id: tasting.bottle.brand.id, - name: tasting.bottle.brand.name, - }, - }, - }; }; diff --git a/apps/api/src/lib/serializers/user.ts b/apps/api/src/lib/serializers/user.ts index 5afaa388..abe5aff0 100644 --- a/apps/api/src/lib/serializers/user.ts +++ b/apps/api/src/lib/serializers/user.ts @@ -1,44 +1,65 @@ import config from "../../config"; -import { User } from "../../db/schema"; +import { User, follows } from "../../db/schema"; -export interface SerializedUser { - id: number; - displayName: string | null; - pictureUrl: string | null; - username: string; - admin?: boolean; - mod?: boolean; - email?: string; - createdAt?: string; - followStatus?: "none" | "following" | "pending"; -} +import { and, eq, inArray } from "drizzle-orm"; +import { Serializer } from "."; +import { db } from "../../db"; -export const serializeUser = ( - user: User & { - followStatus?: "none" | "following" | "pending"; +export const UserSerializer: Serializer = { + attrs: async (itemList: User[], currentUser?: User) => { + const followsByRef = currentUser + ? Object.fromEntries( + ( + await db + .select() + .from(follows) + .where( + and( + inArray( + follows.toUserId, + itemList.map((i) => i.id), + ), + eq(follows.fromUserId, currentUser.id), + ), + ) + ).map((f) => [f.toUserId, f]), + ) + : {}; + + return Object.fromEntries( + itemList.map((item) => { + return [ + item.id, + { + followStatus: followsByRef[item.id]?.status || "none", + }, + ]; + }), + ); }, - currentUser?: User, -): SerializedUser => { - const data: SerializedUser = { - id: user.id, - displayName: user.displayName, - username: user.username, - pictureUrl: user.pictureUrl - ? `${config.URL_PREFIX}${user.pictureUrl}` - : null, - followStatus: user.followStatus, - }; - if ( - currentUser && - (currentUser.admin || currentUser.mod || currentUser.id === user.id) - ) { - return { - ...data, - email: user.email, - createdAt: user.email, - admin: user.admin, - mod: user.admin || user.mod, + item: (item: User, attrs: Record, currentUser?: User) => { + const data = { + id: `${item.id}`, + displayName: item.displayName, + username: item.username, + pictureUrl: item.pictureUrl + ? `${config.URL_PREFIX}${item.pictureUrl}` + : null, + followStatus: attrs.followStatus, }; - } - return data; + + if ( + currentUser && + (currentUser.admin || currentUser.mod || currentUser.id === item.id) + ) { + return { + ...data, + email: item.email, + createdAt: item.createdAt, + admin: item.admin, + mod: item.admin || item.mod, + }; + } + return data; + }, }; diff --git a/apps/api/src/lib/test/fixtures.ts b/apps/api/src/lib/test/fixtures.ts index 316a6b93..82077dbe 100644 --- a/apps/api/src/lib/test/fixtures.ts +++ b/apps/api/src/lib/test/fixtures.ts @@ -33,7 +33,7 @@ export const User = async ({ ...data }: Partial = {}) => { .values({ displayName: faker.name.firstName(), email: faker.internet.email(), - username: faker.internet.userName(), + username: faker.internet.userName().toLowerCase(), admin: false, mod: false, active: true, @@ -159,7 +159,7 @@ export const Comment = async ({ ...data }: Partial = {}) => { export const AuthToken = async ({ user }: { user?: UserType | null } = {}) => { if (!user) user = await User(); - return createAccessToken(user); + return await createAccessToken(user); }; export const AuthenticatedHeaders = async ({ diff --git a/apps/api/src/routes/addBottle.ts b/apps/api/src/routes/addBottle.ts index 5963be37..980e1411 100644 --- a/apps/api/src/routes/addBottle.ts +++ b/apps/api/src/routes/addBottle.ts @@ -10,6 +10,8 @@ import { entities, } from "../db/schema"; import { EntityInput, upsertEntity } from "../lib/db"; +import { serialize } from "../lib/serializers"; +import { BottleSerializer } from "../lib/serializers/bottle"; import { requireAuth } from "../middleware/auth"; type BottleInput = { @@ -25,9 +27,12 @@ export default { url: "/bottles", schema: { body: { - type: "object", - $ref: "bottleSchema", - required: ["name", "brand"], + $ref: "/schemas/newBottle", + }, + response: { + 201: { + $ref: "/schemas/bottle", + }, }, }, preHandler: [requireAuth], @@ -119,7 +124,7 @@ export default { return bottle; }); - res.status(201).send(bottle); + res.status(201).send(await serialize(BottleSerializer, bottle, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/addEntity.test.ts b/apps/api/src/routes/addEntity.test.ts index e0fa1616..e0bdbbcc 100644 --- a/apps/api/src/routes/addEntity.test.ts +++ b/apps/api/src/routes/addEntity.test.ts @@ -71,6 +71,6 @@ test("updates existing entity with new type", async () => { .select() .from(entities) .where(eq(entities.id, data.id)); - expect(brand.id).toBe(entity.id); + expect(brand.id).toEqual(entity.id); expect(brand.type).toEqual(["distiller", "brand"]); }); diff --git a/apps/api/src/routes/addEntity.ts b/apps/api/src/routes/addEntity.ts index 65644f0c..816a41c8 100644 --- a/apps/api/src/routes/addEntity.ts +++ b/apps/api/src/routes/addEntity.ts @@ -3,6 +3,8 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { NewEntity, changes, entities } from "../db/schema"; +import { serialize } from "../lib/serializers"; +import { EntitySerializer } from "../lib/serializers/entity"; import { requireMod } from "../middleware/auth"; export default { @@ -10,9 +12,12 @@ export default { url: "/entities", schema: { body: { - type: "object", - $ref: "entitySchema", - required: ["name"], + $ref: "/schemas/newEntity", + }, + response: { + 201: { + $ref: "/schemas/entity", + }, }, }, preHandler: [requireMod], @@ -67,7 +72,7 @@ export default { return res.status(409).send("Unable to create entity"); } - res.status(201).send(entity); + res.status(201).send(await serialize(EntitySerializer, entity, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/addTasting.ts b/apps/api/src/routes/addTasting.ts index e958f0c7..4ab9c280 100644 --- a/apps/api/src/routes/addTasting.ts +++ b/apps/api/src/routes/addTasting.ts @@ -10,9 +10,9 @@ import { editions, entities, tastings, - users, } from "../db/schema"; -import { serializeTasting } from "../lib/serializers/tasting"; +import { serialize } from "../lib/serializers"; +import { TastingSerializer } from "../lib/serializers/tasting"; import { requireAuth } from "../middleware/auth"; export default { @@ -20,16 +20,11 @@ export default { url: "/tastings", schema: { body: { - type: "object", - required: ["bottle", "rating"], - properties: { - bottle: { type: "number" }, - rating: { type: "number", minimum: 0, maximum: 5 }, - notes: { type: "string" }, - tags: { type: "array", items: { type: "string" } }, - edition: { type: "string" }, - vintageYear: { type: "number" }, - barrel: { type: "number" }, + $ref: "/schemas/newTasting", + }, + response: { + 201: { + $ref: "/schemas/tasting", }, }, }, @@ -46,6 +41,15 @@ export default { return res.status(400).send({ error: "Could not identify bottle" }); } + if (body.vintageYear) { + if (body.vintageYear > new Date().getFullYear()) { + return res.status(400).send({ error: "Invalid vintageYear" }); + } + if (body.vintageYear < 1495) { + return res.status(400).send({ error: "Invalid vintageYear" }); + } + } + const hasEdition = body.edition || body.barrel || body.vintageYear; const tasting = await db.transaction(async (tx) => { @@ -147,46 +151,7 @@ export default { return tasting; }); - const [{ brand, createdBy, edition }] = await db - .select({ - brand: entities, - createdBy: users, - edition: editions, - }) - .from(tastings) - .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) - .innerJoin(entities, eq(entities.id, bottles.brandId)) - .innerJoin(users, eq(tastings.createdById, users.id)) - .leftJoin(editions, eq(tastings.editionId, editions.id)) - .where(eq(tastings.id, tasting.id)) - .limit(1); - - const distillersQuery = await db - .select({ - distiller: entities, - }) - .from(entities) - .innerJoin( - bottlesToDistillers, - eq(bottlesToDistillers.distillerId, entities.id), - ) - .where(eq(bottlesToDistillers.bottleId, bottle.id)); - - res.status(201).send( - serializeTasting( - { - ...tasting, - bottle: { - ...bottle, - brand, - distillers: distillersQuery.map(({ distiller }) => distiller), - }, - edition, - createdBy, - }, - req.user, - ), - ); + res.status(201).send(await serialize(TastingSerializer, tasting, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/addTastingComment.test.ts b/apps/api/src/routes/addTastingComment.test.ts index 8d4fc007..b2d6216c 100644 --- a/apps/api/src/routes/addTastingComment.test.ts +++ b/apps/api/src/routes/addTastingComment.test.ts @@ -21,6 +21,7 @@ test("new comment", async () => { url: `/tastings/${tasting.id}/comments`, payload: { comment: "Hello world!", + createdAt: new Date().toISOString(), }, headers: DefaultFixtures.authHeaders, }); diff --git a/apps/api/src/routes/addTastingComment.ts b/apps/api/src/routes/addTastingComment.ts index 5685bc62..3554e647 100644 --- a/apps/api/src/routes/addTastingComment.ts +++ b/apps/api/src/routes/addTastingComment.ts @@ -5,6 +5,8 @@ import { db, first } from "../db"; import { Comment, NewComment, comments, tastings } from "../db/schema"; import { isDistantFuture, isDistantPast } from "../lib/dates"; import { createNotification, objectTypeFromSchema } from "../lib/notifications"; +import { serialize } from "../lib/serializers"; +import { CommentSerializer } from "../lib/serializers/comment"; import { requireAuth } from "../middleware/auth"; export default { @@ -19,11 +21,11 @@ export default { }, }, body: { - type: "object", - required: ["comment"], - properties: { - comment: { type: "string" }, - createdAt: { type: "string" }, + $ref: "/schemas/newComment", + }, + response: { + 201: { + $ref: "/schemas/comment", }, }, }, @@ -85,7 +87,7 @@ export default { if (!comment) return res.status(409).send({}); - res.status(200).send(comment); + res.status(200).send(serialize(CommentSerializer, comment, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/authBasic.ts b/apps/api/src/routes/authBasic.ts index 3befecf7..03f3d063 100644 --- a/apps/api/src/routes/authBasic.ts +++ b/apps/api/src/routes/authBasic.ts @@ -6,7 +6,8 @@ import { eq } from "drizzle-orm"; import { db } from "../db"; import { users } from "../db/schema"; import { createAccessToken } from "../lib/auth"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { UserSerializer } from "../lib/serializers/user"; export default { method: "POST", @@ -20,6 +21,19 @@ export default { password: { type: "string" }, }, }, + response: { + 200: { + type: "object", + required: ["user", "accessToken"], + properties: { + user: { $ref: "/schemas/user" }, + accessToken: { type: "string" }, + }, + }, + 401: { + $ref: "/errors/401", + }, + }, }, handler: async function (req, res) { const { email, password } = req.body; @@ -42,7 +56,7 @@ export default { } return res.send({ - user: serializeUser(user, user), + user: await serialize(UserSerializer, user, req.user), accessToken: await createAccessToken(user), }); }, diff --git a/apps/api/src/routes/authDetails.ts b/apps/api/src/routes/authDetails.ts index ccc0ceb9..8715752e 100644 --- a/apps/api/src/routes/authDetails.ts +++ b/apps/api/src/routes/authDetails.ts @@ -4,13 +4,28 @@ import { IncomingMessage, Server, ServerResponse } from "http"; import { eq } from "drizzle-orm"; import { db } from "../db"; import { users } from "../db/schema"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { UserSerializer } from "../lib/serializers/user"; import { requireAuth } from "../middleware/auth"; export default { method: "GET", url: "/auth", preHandler: [requireAuth], + schema: { + response: { + 200: { + type: "object", + required: ["user"], + properties: { + user: { $ref: "/schemas/user" }, + }, + }, + 401: { + $ref: "/errors/401", + }, + }, + }, handler: async function (req, res) { // this would be a good place to add refreshTokens (swap to POST for that) const [user] = await db @@ -25,6 +40,6 @@ export default { return res.status(401).send({ error: "Unauthorized" }); } - return res.send({ user: serializeUser(user, user) }); + return res.send({ user: await serialize(UserSerializer, user, req.user) }); }, } as RouteOptions; diff --git a/apps/api/src/routes/authGoogle.ts b/apps/api/src/routes/authGoogle.ts index c63c33f0..bcf3ae0f 100644 --- a/apps/api/src/routes/authGoogle.ts +++ b/apps/api/src/routes/authGoogle.ts @@ -7,7 +7,8 @@ import config from "../config"; import { db } from "../db"; import { identities, users } from "../db/schema"; import { createAccessToken, createUser } from "../lib/auth"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { UserSerializer } from "../lib/serializers/user"; export default { method: "POST", @@ -20,6 +21,19 @@ export default { code: { type: "string" }, }, }, + response: { + 200: { + type: "object", + required: ["user", "accessToken"], + properties: { + user: { $ref: "/schemas/user" }, + accessToken: { type: "string" }, + }, + }, + 401: { + $ref: "/errors/401", + }, + }, }, handler: async function (req, res) { const { code } = req.body; @@ -105,7 +119,7 @@ export default { } return res.send({ - user: serializeUser(user, user), + user: await serialize(UserSerializer, user, req.user), accessToken: await createAccessToken(user), }); }, diff --git a/apps/api/src/routes/deleteUserFollow.ts b/apps/api/src/routes/deleteUserFollow.ts index b03a30ed..5ffec5da 100644 --- a/apps/api/src/routes/deleteUserFollow.ts +++ b/apps/api/src/routes/deleteUserFollow.ts @@ -34,7 +34,7 @@ export default { } await db.transaction(async (tx) => { - await tx + const [follow] = await tx .update(follows) .set({ status: "none", @@ -44,12 +44,14 @@ export default { eq(follows.fromUserId, req.user.id), eq(follows.toUserId, user.id), ), - ); - deleteNotification(tx, { - objectType: objectTypeFromSchema(follows), - objectId: req.user.id, - userId: user.id, - }); + ) + .returning(); + if (follow) + deleteNotification(tx, { + objectType: objectTypeFromSchema(follows), + objectId: follow.id, + userId: user.id, + }); }); res.status(200).send({ diff --git a/apps/api/src/routes/getBottle.test.ts b/apps/api/src/routes/getBottle.test.ts index 25c2e2a9..d2c270f0 100644 --- a/apps/api/src/routes/getBottle.test.ts +++ b/apps/api/src/routes/getBottle.test.ts @@ -22,5 +22,5 @@ test("get bottle", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(bottle1.id); + expect(data.id).toEqual(`${bottle1.id}`); }); diff --git a/apps/api/src/routes/getBottle.tsx b/apps/api/src/routes/getBottle.tsx index ea76a667..8106153f 100644 --- a/apps/api/src/routes/getBottle.tsx +++ b/apps/api/src/routes/getBottle.tsx @@ -2,13 +2,9 @@ import { eq, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { - bottles, - bottlesToDistillers, - entities, - tastings, - users, -} from "../db/schema"; +import { bottles, tastings } from "../db/schema"; +import { serialize } from "../lib/serializers"; +import { BottleSerializer } from "../lib/serializers/bottle"; export default { method: "GET", @@ -21,34 +17,29 @@ export default { bottleId: { type: "number" }, }, }, + response: { + 200: { + type: "object", + allOf: [{ $ref: "/schemas/bottle" }], + // $ref: "bottleSchema", + properties: { + avgRating: { type: "number" }, + tastings: { type: "number" }, + people: { type: "number" }, + }, + }, + }, }, handler: async (req, res) => { - const [{ bottle, brand, createdBy }] = await db - .select({ - bottle: bottles, - brand: entities, - createdBy: users, - }) + const [bottle] = await db + .select() .from(bottles) - .innerJoin(entities, eq(entities.id, bottles.brandId)) - .innerJoin(users, eq(users.id, bottles.createdById)) .where(eq(bottles.id, req.params.bottleId)); if (!bottle) { return res.status(404).send({ error: "Not found" }); } - const distillers = await db - .select({ - distiller: entities, - }) - .from(entities) - .innerJoin( - bottlesToDistillers, - eq(bottlesToDistillers.distillerId, entities.id), - ) - .where(eq(bottlesToDistillers.bottleId, bottle.id)); - const [{ count: totalPeople }] = await db .select({ count: sql`COUNT(DISTINCT ${tastings.createdById})`, @@ -64,15 +55,10 @@ export default { .where(eq(tastings.bottleId, bottle.id)); res.send({ - ...bottle, - createdBy, - brand, - distillers: distillers.map(({ distiller }) => distiller), - stats: { - tastings: bottle.totalTastings, - avgRating: avgRating, - people: totalPeople, - }, + ...(await serialize(BottleSerializer, bottle, req.user)), + tastings: bottle.totalTastings, + avgRating: avgRating, + people: totalPeople, }); }, } as RouteOptions< diff --git a/apps/api/src/routes/getEntity.test.ts b/apps/api/src/routes/getEntity.test.ts index 6adc42e6..f475463b 100644 --- a/apps/api/src/routes/getEntity.test.ts +++ b/apps/api/src/routes/getEntity.test.ts @@ -21,5 +21,5 @@ test("get entity", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(brand.id); + expect(data.id).toBe(`${brand.id}`); }); diff --git a/apps/api/src/routes/getEntity.ts b/apps/api/src/routes/getEntity.ts index bb8239b6..2b22b195 100644 --- a/apps/api/src/routes/getEntity.ts +++ b/apps/api/src/routes/getEntity.ts @@ -3,6 +3,8 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { entities } from "../db/schema"; +import { serialize } from "../lib/serializers"; +import { EntitySerializer } from "../lib/serializers/entity"; export default { method: "GET", @@ -15,6 +17,11 @@ export default { entityId: { type: "number" }, }, }, + response: { + 200: { + $ref: "/schemas/entity", + }, + }, }, handler: async (req, res) => { const [entity] = await db @@ -24,8 +31,7 @@ export default { if (!entity) { return res.status(404).send({ error: "Not found" }); } - - res.send(entity); + res.send(await serialize(EntitySerializer, entity, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/getTasting.test.ts b/apps/api/src/routes/getTasting.test.ts index a52781aa..85512bb3 100644 --- a/apps/api/src/routes/getTasting.test.ts +++ b/apps/api/src/routes/getTasting.test.ts @@ -21,5 +21,5 @@ test("get tasting", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(tasting.id); + expect(data.id).toBe(`${tasting.id}`); }); diff --git a/apps/api/src/routes/getTasting.ts b/apps/api/src/routes/getTasting.ts index 6ad44c4b..406bef34 100644 --- a/apps/api/src/routes/getTasting.ts +++ b/apps/api/src/routes/getTasting.ts @@ -2,15 +2,9 @@ import { eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { - bottles, - bottlesToDistillers, - editions, - entities, - tastings, - users, -} from "../db/schema"; -import { serializeTasting } from "../lib/serializers/tasting"; +import { tastings } from "../db/schema"; +import { serialize } from "../lib/serializers"; +import { TastingSerializer } from "../lib/serializers/tasting"; export default { method: "GET", @@ -23,54 +17,23 @@ export default { tastingId: { type: "number" }, }, }, + response: { + 200: { + $ref: "/schemas/tasting", + }, + }, }, handler: async (req, res) => { - const [{ tasting, bottle, brand, createdBy, edition }] = await db - .select({ - tasting: tastings, - bottle: bottles, - brand: entities, - createdBy: users, - edition: editions, - }) + const [tasting] = await db + .select() .from(tastings) - .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) - .innerJoin(entities, eq(entities.id, bottles.brandId)) - .innerJoin(users, eq(tastings.createdById, users.id)) - .leftJoin(editions, eq(tastings.editionId, editions.id)) - .where(eq(tastings.id, req.params.tastingId)) - .limit(1); + .where(eq(tastings.id, req.params.tastingId)); if (!tasting) { return res.status(404).send({ error: "Not found" }); } - const distillersQuery = await db - .select({ - distiller: entities, - }) - .from(entities) - .innerJoin( - bottlesToDistillers, - eq(bottlesToDistillers.distillerId, entities.id), - ) - .where(eq(bottlesToDistillers.bottleId, bottle.id)); - - res.send( - serializeTasting( - { - ...tasting, - createdBy, - edition, - bottle: { - ...bottle, - brand, - distillers: distillersQuery.map(({ distiller }) => distiller), - }, - }, - req.user, - ), - ); + res.send(await serialize(TastingSerializer, tasting, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/getUser.test.ts b/apps/api/src/routes/getUser.test.ts index 9a570e79..9c4fff7c 100644 --- a/apps/api/src/routes/getUser.test.ts +++ b/apps/api/src/routes/getUser.test.ts @@ -22,7 +22,7 @@ test("get user by id", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(user.id); + expect(data.id).toEqual(`${user.id}`); expect(data.followStatus).toBe("none"); }); @@ -35,7 +35,7 @@ test("get user:me", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(DefaultFixtures.user.id); + expect(data.id).toBe(`${DefaultFixtures.user.id}`); }); test("get user by username", async () => { @@ -47,7 +47,7 @@ test("get user by username", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(DefaultFixtures.user.id); + expect(data.id).toBe(`${DefaultFixtures.user.id}`); }); test("get user requires auth", async () => { @@ -74,6 +74,6 @@ test("get user w/ followStatus", async () => { expect(response).toRespondWith(200); const data = JSON.parse(response.payload); - expect(data.id).toBe(user.id); + expect(data.id).toBe(`${user.id}`); expect(data.followStatus).toBe("following"); }); diff --git a/apps/api/src/routes/getUser.ts b/apps/api/src/routes/getUser.ts index 39d86432..94830ded 100644 --- a/apps/api/src/routes/getUser.ts +++ b/apps/api/src/routes/getUser.ts @@ -1,9 +1,10 @@ -import { and, eq, sql } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { User, changes, follows, tastings, users } from "../db/schema"; -import { serializeUser } from "../lib/serializers/user"; +import { User, changes, tastings, users } from "../db/schema"; +import { serialize } from "../lib/serializers"; +import { UserSerializer } from "../lib/serializers/user"; import { requireAuth } from "../middleware/auth"; export default { @@ -56,28 +57,8 @@ export default { .from(changes) .where(eq(changes.createdById, user.id)); - const getFollowStatus = async (user: User) => { - const [follow] = await db - .select() - .from(follows) - .where( - and( - eq(follows.fromUserId, req.user.id), - eq(follows.toUserId, user.id), - ), - ); - if (!follow) return "none"; - return follow.status; - }; - res.send({ - ...serializeUser( - { - ...user, - followStatus: await getFollowStatus(user), - }, - req.user, - ), + ...(await serialize(UserSerializer, user, req.user)), stats: { tastings: totalTastings, bottles: totalBottles, diff --git a/apps/api/src/routes/index.ts b/apps/api/src/routes/index.ts index 68d0201a..9dd2c42c 100644 --- a/apps/api/src/routes/index.ts +++ b/apps/api/src/routes/index.ts @@ -23,7 +23,7 @@ import listCollections from "./listCollections"; import listComments from "./listComments"; import listEntities from "./listEntities"; import listFollowers from "./listFollowers"; -import listFriends from "./listFriends"; +import listFollowing from "./listFollowing"; import listNotifications from "./listNotifications"; import listTastings from "./listTastings"; import listUsers from "./listUsers"; @@ -81,7 +81,9 @@ export const router: FastifyPluginCallback = ( fastify.route(addTastingToast); fastify.route(addTastingComment); - fastify.route(listFriends); + fastify.route(listFollowers); + fastify.route(updateFollower); + fastify.route(listFollowing); fastify.route(listUsers); fastify.route(getUser); @@ -89,8 +91,6 @@ export const router: FastifyPluginCallback = ( fastify.route(updateUserAvatar); fastify.route(addUserFollow); fastify.route(deleteUserFollow); - fastify.route(listFollowers); - fastify.route(updateFollower); fastify.route(listCollections); diff --git a/apps/api/src/routes/listBottleSuggestedTags.ts b/apps/api/src/routes/listBottleSuggestedTags.ts index c633ee8e..e9e3853e 100644 --- a/apps/api/src/routes/listBottleSuggestedTags.ts +++ b/apps/api/src/routes/listBottleSuggestedTags.ts @@ -15,6 +15,28 @@ export default { bottleId: { type: "number" }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + type: "object", + required: ["name", "count"], + properties: { + name: { type: "string" }, + count: { type: "number" }, + }, + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, handler: async (req, res) => { const [bottle] = await db diff --git a/apps/api/src/routes/listBottles.test.ts b/apps/api/src/routes/listBottles.test.ts index 77485743..8b977014 100644 --- a/apps/api/src/routes/listBottles.test.ts +++ b/apps/api/src/routes/listBottles.test.ts @@ -40,7 +40,7 @@ test("lists bottles with query", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(bottle1.id); + expect(results[0].id).toBe(`${bottle1.id}`); }); test("lists bottles with distiller", async () => { @@ -62,7 +62,7 @@ test("lists bottles with distiller", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(bottle1.id); + expect(results[0].id).toBe(`${bottle1.id}`); }); test("lists bottles with brand", async () => { @@ -84,5 +84,5 @@ test("lists bottles with brand", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(bottle1.id); + expect(results[0].id).toBe(`${bottle1.id}`); }); diff --git a/apps/api/src/routes/listBottles.ts b/apps/api/src/routes/listBottles.ts index f8e7f67f..bf6976ba 100644 --- a/apps/api/src/routes/listBottles.ts +++ b/apps/api/src/routes/listBottles.ts @@ -1,10 +1,11 @@ -import { SQL, and, asc, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"; +import { SQL, and, asc, desc, eq, ilike, or, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { Entity, bottles, bottlesToDistillers, entities } from "../db/schema"; +import { bottles, bottlesToDistillers, entities } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeBottle } from "../lib/serializers/bottle"; +import { serialize } from "../lib/serializers"; +import { BottleSerializer } from "../lib/serializers/bottle"; export default { method: "GET", @@ -21,6 +22,23 @@ export default { entity: { type: "number" }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/bottle", + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, handler: async (req, res) => { const page = req.query.page || 1; @@ -35,7 +53,19 @@ export default { where.push( or( ilike(bottles.name, `%${query}%`), - ilike(entities.name, `%${query}%`), + sql`EXISTS( + SELECT 1 + FROM ${entities} e + JOIN ${bottlesToDistillers} b + ON e.id = b.distiller_id AND b.bottle_id = ${bottles.id} + WHERE e.name ILIKE ${`%${query}%`} + )`, + sql`EXISTS( + SELECT 1 + FROM ${entities} e + WHERE e.id = ${bottles.brandId} + AND e.name ILIKE ${`%${query}%`} + )`, ), ); } @@ -66,54 +96,18 @@ export default { } const results = await db - .select({ - bottle: bottles, - brand: entities, - }) + .select() .from(bottles) - .innerJoin(entities, eq(entities.id, bottles.brandId)) .where(where ? and(...where) : undefined) .limit(limit + 1) .offset(offset) .orderBy(orderBy); - const distillers = results.length - ? await db - .select({ - bottleId: bottlesToDistillers.bottleId, - distiller: entities, - }) - .from(entities) - .innerJoin( - bottlesToDistillers, - eq(bottlesToDistillers.distillerId, entities.id), - ) - .where( - inArray( - bottlesToDistillers.bottleId, - results.map(({ bottle: b }) => b.id), - ), - ) - : []; - const distillersByBottleId: { - [bottleId: number]: Entity[]; - } = {}; - distillers.forEach((d) => { - if (!distillersByBottleId[d.bottleId]) - distillersByBottleId[d.bottleId] = [d.distiller]; - else distillersByBottleId[d.bottleId].push(d.distiller); - }); - res.send({ - results: results.slice(0, limit).map(({ bottle, brand }) => - serializeBottle( - { - ...bottle, - brand, - distillers: distillersByBottleId[bottle.id], - }, - req.user, - ), + results: await serialize( + BottleSerializer, + results.slice(0, limit), + req.user, ), rel: { nextPage: results.length > limit ? page + 1 : null, diff --git a/apps/api/src/routes/listCollections.ts b/apps/api/src/routes/listCollections.ts index 4e4fc7c3..59f7aca0 100644 --- a/apps/api/src/routes/listCollections.ts +++ b/apps/api/src/routes/listCollections.ts @@ -4,6 +4,8 @@ import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { collections } from "../db/schema"; import { buildPageLink } from "../lib/paging"; +import { serialize } from "../lib/serializers"; +import { CollectionSerializer } from "../lib/serializers/collection"; import { requireAuth } from "../middleware/auth"; export default { @@ -17,6 +19,23 @@ export default { user: { oneOf: [{ type: "number" }, { const: "me" }] }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/collection", + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { @@ -43,10 +62,11 @@ export default { .orderBy(asc(collections.name)); res.send({ - results: results.slice(0, limit).map((collection) => ({ - id: collection.id, - name: collection.name, - })), + results: await serialize( + CollectionSerializer, + results.slice(0, limit), + req.user, + ), rel: { nextPage: results.length > limit ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, diff --git a/apps/api/src/routes/listComments.ts b/apps/api/src/routes/listComments.ts index 20128a47..58efeb5e 100644 --- a/apps/api/src/routes/listComments.ts +++ b/apps/api/src/routes/listComments.ts @@ -2,9 +2,10 @@ import { and, asc, eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { comments, users } from "../db/schema"; +import { comments } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { CommentSerializer } from "../lib/serializers/comment"; import { requireAuth } from "../middleware/auth"; export default { @@ -19,6 +20,23 @@ export default { tasting: { type: "number" }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/comment", + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { @@ -53,22 +71,19 @@ export default { } const results = await db - .select({ - comment: comments, - createdBy: users, - }) + .select() .from(comments) .where(and(...where)) - .innerJoin(users, eq(comments.createdById, users.id)) .limit(limit + 1) .offset(offset) .orderBy(asc(comments.createdAt)); res.send({ - results: results.slice(0, limit).map(({ comment, createdBy }) => ({ - ...comment, - createdBy: serializeUser(createdBy, req.user), - })), + results: await serialize( + CommentSerializer, + results.slice(0, limit), + req.user, + ), rel: { nextPage: results.length > limit ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, diff --git a/apps/api/src/routes/listEntities.ts b/apps/api/src/routes/listEntities.ts index 826a2a94..f8522e63 100644 --- a/apps/api/src/routes/listEntities.ts +++ b/apps/api/src/routes/listEntities.ts @@ -4,6 +4,8 @@ import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { EntityType, entities } from "../db/schema"; import { buildPageLink } from "../lib/paging"; +import { serialize } from "../lib/serializers"; +import { EntitySerializer } from "../lib/serializers/entity"; export default { method: "GET", @@ -18,6 +20,22 @@ export default { type: { type: "string", enum: ["distiller", "brand", "bottler"] }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/entity", + }, + }, + rel: { + $ref: "/schemas/paging", + }, + }, + }, + }, }, handler: async (req, res) => { const page = req.query.page || 1; @@ -52,7 +70,11 @@ export default { .orderBy(orderBy); res.send({ - results: results.slice(0, limit), + results: await serialize( + EntitySerializer, + results.slice(0, limit), + req.user, + ), rel: { nextPage: results.length > limit ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, diff --git a/apps/api/src/routes/listFollowers.test.ts b/apps/api/src/routes/listFollowers.test.ts index d693778c..42cd79be 100644 --- a/apps/api/src/routes/listFollowers.test.ts +++ b/apps/api/src/routes/listFollowers.test.ts @@ -18,10 +18,11 @@ test("lists followers", async () => { const follow2 = await Fixtures.Follow({ toUserId: DefaultFixtures.user.id, }); + await Fixtures.Follow(); const response = await app.inject({ method: "GET", - url: "/users/me/followers", + url: "/followers", headers: DefaultFixtures.authHeaders, }); @@ -33,21 +34,8 @@ test("lists followers", async () => { test("lists follow requests requires auth", async () => { const response = await app.inject({ method: "GET", - url: "/users/me/followers", + url: "/followers", }); expect(response).toRespondWith(401); }); - -test("lists follow requests cannot query others", async () => { - const user = await Fixtures.User(); - const otherUser = await Fixtures.User(); - - const response = await app.inject({ - method: "GET", - url: `/users/${otherUser.id}/followers`, - headers: await Fixtures.AuthenticatedHeaders({ user }), - }); - - expect(response).toRespondWith(403); -}); diff --git a/apps/api/src/routes/listFollowers.ts b/apps/api/src/routes/listFollowers.ts index 63a53114..00d9e7c0 100644 --- a/apps/api/src/routes/listFollowers.ts +++ b/apps/api/src/routes/listFollowers.ts @@ -1,24 +1,17 @@ import { and, asc, desc, eq } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { follows, users } from "../db/schema"; +import { follows } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeFollow } from "../lib/serializers/follow"; +import { serialize } from "../lib/serializers"; +import { FollowerSerializer } from "../lib/serializers/follow"; import { requireAuth } from "../middleware/auth"; export default { method: "GET", - url: "/users/:userId/followers", + url: "/followers", schema: { - params: { - type: "object", - required: ["userId"], - properties: { - userId: { oneOf: [{ type: "number" }, { const: "me" }] }, - }, - }, querystring: { type: "object", properties: { @@ -27,52 +20,48 @@ export default { status: { type: "string", enum: ["pending", "following"] }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/follow", + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { - const userId = req.params.userId === "me" ? req.user.id : req.params.userId; - - const [user] = await db.select().from(users).where(eq(users.id, userId)); - - if (!user) { - return res.status(404).send({ error: "Not found" }); - } - - if (user.id !== req.user.id && !req.user.admin) { - return res.status(403).send({ error: "Forbidden" }); - } - const page = req.query.page || 1; const limit = 100; const offset = (page - 1) * limit; - const where = [eq(follows.toUserId, user.id)]; + const where = [eq(follows.toUserId, req.user.id)]; if (req.query.status) { where.push(eq(follows.status, req.query.status)); } - const followsBack = alias(follows, "follows_back"); const results = await db - .select({ - user: users, - follow: follows, - followsBack: followsBack.status, - }) + .select() .from(follows) .where(and(...where)) - .innerJoin(users, eq(users.id, follows.fromUserId)) - .leftJoin(followsBack, eq(users.id, followsBack.toUserId)) .limit(limit + 1) .offset(offset) .orderBy(desc(follows.status), asc(follows.createdAt)); res.send({ - results: results.slice(0, limit).map(({ user, follow, followsBack }) => - serializeFollow({ - ...follow, - followsBack: followsBack, - user, - }), + results: await serialize( + FollowerSerializer, + results.slice(0, limit), + req.user, ), rel: { nextPage: results.length > limit ? page + 1 : null, diff --git a/apps/api/src/routes/listFriends.test.ts b/apps/api/src/routes/listFollowing.test.ts similarity index 84% rename from apps/api/src/routes/listFriends.test.ts rename to apps/api/src/routes/listFollowing.test.ts index 9c177087..ed16978e 100644 --- a/apps/api/src/routes/listFriends.test.ts +++ b/apps/api/src/routes/listFollowing.test.ts @@ -11,7 +11,7 @@ beforeAll(async () => { }; }); -test("lists friends", async () => { +test("lists following", async () => { const follow1 = await Fixtures.Follow({ fromUserId: DefaultFixtures.user.id, }); @@ -19,20 +19,20 @@ test("lists friends", async () => { const response = await app.inject({ method: "GET", - url: "/friends", + url: "/following", headers: DefaultFixtures.authHeaders, }); expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].user.id).toBe(follow1.toUserId); + expect(results[0].user.id).toBe(`${follow1.toUserId}`); }); test("requires auth", async () => { const response = await app.inject({ method: "GET", - url: "/friends", + url: "/following", }); expect(response).toRespondWith(401); diff --git a/apps/api/src/routes/listFriends.ts b/apps/api/src/routes/listFollowing.ts similarity index 66% rename from apps/api/src/routes/listFriends.ts rename to apps/api/src/routes/listFollowing.ts index 105bf282..7b7a08b5 100644 --- a/apps/api/src/routes/listFriends.ts +++ b/apps/api/src/routes/listFollowing.ts @@ -1,15 +1,16 @@ -import { and, asc, desc, eq, ne } from "drizzle-orm"; +import { and, asc, desc, eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { follows, users } from "../db/schema"; +import { follows } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeFriend } from "../lib/serializers/friend"; +import { serialize } from "../lib/serializers"; +import { FollowingSerializer } from "../lib/serializers/follow"; import { requireAuth } from "../middleware/auth"; export default { method: "GET", - url: "/friends", + url: "/following", schema: { querystring: { type: "object", @@ -19,6 +20,22 @@ export default { status: { type: "string", enum: ["pending", "following"] }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/follow", + }, + }, + rel: { + $ref: "/schemas/paging", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { @@ -26,32 +43,24 @@ export default { const limit = 100; const offset = (page - 1) * limit; - const where = [ - eq(follows.fromUserId, req.user.id), - ne(follows.status, "none"), - ]; + const where = [eq(follows.fromUserId, req.user.id)]; if (req.query.status) { where.push(eq(follows.status, req.query.status)); } const results = await db - .select({ - user: users, - follow: follows, - }) + .select() .from(follows) .where(and(...where)) - .innerJoin(users, eq(users.id, follows.toUserId)) .limit(limit + 1) .offset(offset) .orderBy(desc(follows.status), asc(follows.createdAt)); res.send({ - results: results.slice(0, limit).map(({ user, follow }) => - serializeFriend({ - ...follow, - user, - }), + results: await serialize( + FollowingSerializer, + results.slice(0, limit), + req.user, ), rel: { nextPage: results.length > limit ? page + 1 : null, @@ -72,6 +81,9 @@ export default { IncomingMessage, ServerResponse, { + Params: { + userId: number | "me"; + }; Querystring: { page?: number; status?: "pending" | "following"; diff --git a/apps/api/src/routes/listNotifications.ts b/apps/api/src/routes/listNotifications.ts index f048354b..72836b44 100644 --- a/apps/api/src/routes/listNotifications.ts +++ b/apps/api/src/routes/listNotifications.ts @@ -1,23 +1,11 @@ -import { SQL, and, desc, eq, inArray } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; +import { SQL, and, desc, eq } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { - Notification, - bottles, - comments, - entities, - follows, - notifications, - tastings, - toasts, - users, -} from "../db/schema"; +import { notifications } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeFollow } from "../lib/serializers/follow"; -import { serializeTastingRef } from "../lib/serializers/tasting"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { NotificationSerializer } from "../lib/serializers/notification"; import { requireAuth } from "../middleware/auth"; export default { @@ -31,6 +19,23 @@ export default { filter: { type: "string", enum: ["unread"] }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/notification", + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { @@ -47,152 +52,19 @@ export default { } const results = await db - .select({ - notification: notifications, - fromUser: users, - }) + .select() .from(notifications) - .leftJoin(users, eq(users.id, notifications.fromUserId)) .where(where ? and(...where) : undefined) .limit(limit + 1) .offset(offset) .orderBy(desc(notifications.createdAt)); - // follow requests need more details - const followFromUserIdList = results - .filter(({ notification }) => notification.objectType === "follow") - .map(({ notification }) => notification.objectId); - const followsBack = alias(follows, "follows_back"); - const followResults = followFromUserIdList.length - ? await db - .select({ - follow: follows, - followsBack: followsBack.status, - user: users, - }) - .from(follows) - .where( - and( - eq(follows.toUserId, req.user.id), - inArray(follows.fromUserId, followFromUserIdList), - ), - ) - .innerJoin(users, eq(users.id, follows.fromUserId)) - .leftJoin(followsBack, eq(follows.fromUserId, followsBack.toUserId)) - : []; - - const followResultsByObjectId = Object.fromEntries( - followResults.map((r) => [ - r.follow.fromUserId, - { - ...r.follow, - followsBack: r.followsBack, - user: r.user, - }, - ]), - ); - - // toasts need more details - const toastIdList = results - .filter(({ notification }) => notification.objectType === "toast") - .map(({ notification }) => notification.objectId); - const toastResults = toastIdList.length - ? ( - await db - .select({ - toastId: toasts.id, - tasting: tastings, - bottle: bottles, - brand: entities, - }) - .from(tastings) - .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) - .innerJoin(entities, eq(bottles.brandId, entities.id)) - .innerJoin(toasts, eq(tastings.id, toasts.tastingId)) - .where(inArray(toasts.id, toastIdList)) - ).map((r) => ({ - toastId: r.toastId, - ...r.tasting, - bottle: { - ...r.bottle, - brand: r.brand, - }, - })) - : []; - - const toastResultsByObjectId = Object.fromEntries( - toastResults.map((r) => [r.toastId, r]), - ); - - // comments need more details - const commentIdList = results - .filter(({ notification }) => notification.objectType === "comment") - .map(({ notification }) => notification.objectId); - const commentResults = commentIdList.length - ? ( - await db - .select({ - commentId: comments.id, - tasting: tastings, - bottle: bottles, - brand: entities, - }) - .from(tastings) - .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) - .innerJoin(entities, eq(bottles.brandId, entities.id)) - .innerJoin(comments, eq(tastings.id, comments.tastingId)) - .where(inArray(comments.id, commentIdList)) - ).map((r) => ({ - commentId: r.commentId, - ...r.tasting, - bottle: { - ...r.bottle, - brand: r.brand, - }, - })) - : []; - - const commentResultsByObjectId = Object.fromEntries( - commentResults.map((r) => [r.commentId, r]), - ); - - const serializeRef = (notification: Notification) => { - switch (notification.objectType) { - case "follow": - return serializeFollow( - followResultsByObjectId[notification.objectId], - req.user, - ); - case "toast": - return serializeTastingRef( - toastResultsByObjectId[notification.objectId], - ); - case "comment": - return serializeTastingRef( - commentResultsByObjectId[notification.objectId], - ); - default: - return undefined; - } - }; - - const finalResults = results.map(({ fromUser, notification }) => { - let ref: any; - try { - ref = serializeRef(notification); - } catch (err) { - return null; - } - return { - ...notification, - fromUser: fromUser ? serializeUser(fromUser, req.user) : null, - ref, - }; - }); - res.send({ - // remove results which are broken (e.g. we failed to delete a row) - results: finalResults.filter((r) => !!r), + results: await serialize( + NotificationSerializer, + results.slice(0, limit), + req.user, + ), rel: { nextPage: results.length > limit ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, diff --git a/apps/api/src/routes/listTastings.test.ts b/apps/api/src/routes/listTastings.test.ts index 9dba1f46..2829461d 100644 --- a/apps/api/src/routes/listTastings.test.ts +++ b/apps/api/src/routes/listTastings.test.ts @@ -41,7 +41,7 @@ test("lists tastings with bottle", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(tasting.id); + expect(results[0].id).toEqual(`${tasting.id}`); }); test("lists tastings with user", async () => { @@ -61,7 +61,7 @@ test("lists tastings with user", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(tasting.id); + expect(results[0].id).toEqual(`${tasting.id}`); }); test("lists tastings filter friends unauthenticated", async () => { @@ -103,5 +103,5 @@ test("lists tastings filter friends", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(lastTasting.id); + expect(results[0].id).toEqual(`${lastTasting.id}`); }); diff --git a/apps/api/src/routes/listTastings.ts b/apps/api/src/routes/listTastings.ts index 7b8e3098..a1320ac0 100644 --- a/apps/api/src/routes/listTastings.ts +++ b/apps/api/src/routes/listTastings.ts @@ -1,20 +1,11 @@ -import { SQL, and, desc, eq, inArray, sql } from "drizzle-orm"; +import { SQL, and, desc, eq, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { - Entity, - bottles, - bottlesToDistillers, - editions, - entities, - follows, - tastings, - toasts, - users, -} from "../db/schema"; +import { follows, tastings } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeTasting } from "../lib/serializers/tasting"; +import { serialize } from "../lib/serializers"; +import { TastingSerializer } from "../lib/serializers/tasting"; import { injectAuth } from "../middleware/auth"; export default { @@ -62,88 +53,19 @@ export default { } const results = await db - .select({ - tasting: tastings, - bottle: bottles, - brand: entities, - createdBy: users, - edition: editions, - }) + .select() .from(tastings) - .innerJoin(bottles, eq(tastings.bottleId, bottles.id)) - .innerJoin(entities, eq(entities.id, bottles.brandId)) - .innerJoin(users, eq(tastings.createdById, users.id)) - .leftJoin(editions, eq(tastings.editionId, editions.id)) .where(where ? and(...where) : undefined) .limit(limit + 1) .offset(offset) .orderBy(desc(tastings.createdAt)); - const distillers = results.length - ? await db - .select({ - bottleId: bottlesToDistillers.bottleId, - distiller: entities, - }) - .from(entities) - .innerJoin( - bottlesToDistillers, - eq(bottlesToDistillers.distillerId, entities.id), - ) - .where( - inArray( - bottlesToDistillers.bottleId, - results.map(({ bottle: b }) => b.id), - ), - ) - : []; - - const distillersByBottleId: { - [bottleId: number]: Entity[]; - } = {}; - distillers.forEach((d) => { - if (!distillersByBottleId[d.bottleId]) - distillersByBottleId[d.bottleId] = [d.distiller]; - else distillersByBottleId[d.bottleId].push(d.distiller); - }); - - const userToastsList: number[] = - req.user && results.length - ? ( - await db - .select({ tastingId: toasts.tastingId }) - .from(toasts) - .where( - and( - inArray( - toasts.tastingId, - results.map((t) => t.tasting.id), - ), - eq(toasts.createdById, req.user.id), - ), - ) - ).map((t) => t.tastingId) - : []; - res.send({ - results: results - .slice(0, limit) - .map(({ tasting, bottle, brand, createdBy, edition }) => - serializeTasting( - { - ...tasting, - createdBy, - edition, - bottle: { - ...bottle, - brand, - distillers: distillersByBottleId[bottle.id], - }, - hasToasted: userToastsList.indexOf(tasting.id) !== -1, - }, - req.user, - ), - ), + results: await serialize( + TastingSerializer, + results.slice(0, limit), + req.user, + ), rel: { nextPage: results.length > limit ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, diff --git a/apps/api/src/routes/listUsers.test.ts b/apps/api/src/routes/listUsers.test.ts index e45d7b87..6473c4a2 100644 --- a/apps/api/src/routes/listUsers.test.ts +++ b/apps/api/src/routes/listUsers.test.ts @@ -40,7 +40,7 @@ test("lists users needs a query", async () => { expect(response).toRespondWith(200); const { results } = JSON.parse(response.payload); expect(results.length).toBe(1); - expect(results[0].id).toBe(user2.id); + expect(results[0].id).toBe(`${user2.id}`); }); test("lists users requires auth", async () => { diff --git a/apps/api/src/routes/listUsers.ts b/apps/api/src/routes/listUsers.ts index 56214867..2c0b5220 100644 --- a/apps/api/src/routes/listUsers.ts +++ b/apps/api/src/routes/listUsers.ts @@ -4,7 +4,8 @@ import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { users } from "../db/schema"; import { buildPageLink } from "../lib/paging"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { UserSerializer } from "../lib/serializers/user"; import { requireAuth } from "../middleware/auth"; export default { @@ -18,6 +19,23 @@ export default { page: { type: "number" }, }, }, + response: { + 200: { + type: "object", + properties: { + results: { + type: "array", + items: { + $ref: "/schemas/user", + }, + }, + rel: { + type: "object", + $ref: "/schemas/paging", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { @@ -53,7 +71,11 @@ export default { .orderBy(asc(users.displayName)); res.send({ - results: results.map((u) => serializeUser(u, req.user)), + results: await serialize( + UserSerializer, + results.slice(0, limit), + req.user, + ), rel: { nextPage: results.length > limit ? page + 1 : null, prevPage: page > 1 ? page - 1 : null, diff --git a/apps/api/src/routes/updateBottle.ts b/apps/api/src/routes/updateBottle.ts index a54cdcba..5eb36688 100644 --- a/apps/api/src/routes/updateBottle.ts +++ b/apps/api/src/routes/updateBottle.ts @@ -10,6 +10,8 @@ import { entities, } from "../db/schema"; import { EntityInput, upsertEntity } from "../lib/db"; +import { serialize } from "../lib/serializers"; +import { BottleSerializer } from "../lib/serializers/bottle"; import { requireMod } from "../middleware/auth"; type BottleInput = { @@ -32,8 +34,12 @@ export default { }, }, body: { - type: "object", - $ref: "bottleSchema", + $ref: "/schemas/updateBottle", + }, + response: { + 200: { + $ref: "/schemas/bottle", + }, }, }, preHandler: [requireMod], @@ -179,7 +185,9 @@ export default { return newBottle; }); - res.status(200).send(newBottle); + res + .status(200) + .send(await serialize(BottleSerializer, newBottle, req.user)); }, } as RouteOptions< Server, diff --git a/apps/api/src/routes/updateEntity.ts b/apps/api/src/routes/updateEntity.ts index 6911c872..50aab861 100644 --- a/apps/api/src/routes/updateEntity.ts +++ b/apps/api/src/routes/updateEntity.ts @@ -32,8 +32,12 @@ export default { }, }, body: { - type: "object", - $ref: "entitySchema", + $ref: "/schemas/updateEntity", + }, + response: { + 200: { + $ref: "/schemas/entity", + }, }, }, preHandler: [requireMod], diff --git a/apps/api/src/routes/updateFollower.test.ts b/apps/api/src/routes/updateFollower.test.ts index 88797e88..f9aa1ef0 100644 --- a/apps/api/src/routes/updateFollower.test.ts +++ b/apps/api/src/routes/updateFollower.test.ts @@ -19,20 +19,20 @@ test("cannot act others requests", async () => { const otherUser = await Fixtures.User(); const follow = await Fixtures.Follow({ - fromUserId: otherUser.id, - toUserId: user.id, + fromUserId: user.id, + toUserId: otherUser.id, }); const response = await app.inject({ method: "PUT", - url: `/users/${otherUser.id}/followers/${user.id}`, + url: `/followers/${follow.id}`, payload: { action: "accept", }, headers: await Fixtures.AuthenticatedHeaders({ user }), }); - expect(response).toRespondWith(403); + expect(response).toRespondWith(404); }); test("can accept request", async () => { @@ -46,7 +46,7 @@ test("can accept request", async () => { const response = await app.inject({ method: "PUT", - url: `/users/${user.id}/followers/${otherUser.id}`, + url: `/followers/${follow.id}`, payload: { action: "accept", }, diff --git a/apps/api/src/routes/updateFollower.ts b/apps/api/src/routes/updateFollower.ts index 24f33c0b..d1db913f 100644 --- a/apps/api/src/routes/updateFollower.ts +++ b/apps/api/src/routes/updateFollower.ts @@ -1,21 +1,20 @@ import { and, eq } from "drizzle-orm"; -import { alias } from "drizzle-orm/pg-core"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; -import { db, first } from "../db"; -import { follows, users } from "../db/schema"; -import { serializeFollow } from "../lib/serializers/follow"; +import { db } from "../db"; +import { follows } from "../db/schema"; +import { serialize } from "../lib/serializers"; +import { FollowerSerializer } from "../lib/serializers/follow"; import { requireAuth } from "../middleware/auth"; export default { method: "PUT", - url: "/users/:userId/followers/:fromUserId", + url: "/followers/:followId", schema: { params: { type: "object", - required: ["userId", "fromUserId"], + required: ["followId"], properties: { - userId: { oneOf: [{ type: "number" }, { const: "me" }] }, fromUserId: { type: "number" }, }, }, @@ -26,44 +25,27 @@ export default { action: { type: "string", enum: ["accept"] }, }, }, + response: { + 200: { + $ref: "/schemas/follow", + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { - const userId = req.params.userId === "me" ? req.user.id : req.params.userId; - - const [user] = await db.select().from(users).where(eq(users.id, userId)); - - if (!user) { - return res.status(404).send({ error: "Not found" }); - } - - if (user.id !== req.user.id && !req.user.admin) { - return res.status(403).send({ error: "Forbidden" }); - } - - const followsBackTable = alias(follows, "follows_back"); - const result = first( - await db - .select({ - followUser: users, - follow: follows, - followsBack: followsBackTable.status, - }) - .from(follows) - .innerJoin(users, eq(users.id, follows.fromUserId)) - .leftJoin(followsBackTable, eq(users.id, followsBackTable.toUserId)) - .where( - and( - eq(follows.fromUserId, req.params.fromUserId), - eq(follows.toUserId, user.id), - ), + const [follow] = await db + .select() + .from(follows) + .where( + and( + eq(follows.id, req.params.followId), + eq(follows.toUserId, req.user.id), ), - ); + ); - if (!result) { + if (!follow) { return res.status(404).send({ error: "Not found" }); } - const { follow, followUser, followsBack } = result; const [newFollow] = await db .update(follows) @@ -72,22 +54,13 @@ export default { }) .where( and( - eq(follows.fromUserId, req.params.fromUserId), - eq(follows.toUserId, user.id), + eq(follows.id, req.params.followId), + eq(follows.toUserId, req.user.id), ), ) .returning(); - res.send( - serializeFollow( - { - ...newFollow, - user: followUser, - followsBack: followsBack || "none", - }, - req.user, - ), - ); + res.send(await serialize(FollowerSerializer, newFollow, req.user)); }, } as RouteOptions< Server, @@ -96,7 +69,7 @@ export default { { Params: { userId: number | "me"; - fromUserId: number; + followId: number; }; Body: { action: "accept"; diff --git a/apps/api/src/routes/updateTastingImage.ts b/apps/api/src/routes/updateTastingImage.ts index 6618ae20..b0e7e67a 100644 --- a/apps/api/src/routes/updateTastingImage.ts +++ b/apps/api/src/routes/updateTastingImage.ts @@ -18,6 +18,17 @@ export default { tastingId: { type: "number" }, }, }, + response: { + 200: { + type: "object", + required: ["imageUrl"], + properties: { + imageUrl: { + type: "string", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { diff --git a/apps/api/src/routes/updateUser.ts b/apps/api/src/routes/updateUser.ts index 5b6db067..319fbb25 100644 --- a/apps/api/src/routes/updateUser.ts +++ b/apps/api/src/routes/updateUser.ts @@ -3,7 +3,8 @@ import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; import { User, users } from "../db/schema"; -import { serializeUser } from "../lib/serializers/user"; +import { serialize } from "../lib/serializers"; +import { UserSerializer } from "../lib/serializers/user"; import { requireAuth } from "../middleware/auth"; export default { @@ -18,12 +19,11 @@ export default { }, }, body: { - type: "object", - properties: { - displayName: { type: "string" }, - admin: { type: "boolean" }, - mod: { type: "boolean" }, - username: { type: "string" }, + $ref: "/schemas/updateUser", + }, + response: { + 200: { + $ref: "/schemas/user", }, }, }, @@ -71,7 +71,7 @@ export default { } if (!Object.values(data).length) { - return res.send(serializeUser(user, req.user)); + return res.send(await serialize(UserSerializer, user, req.user)); } try { @@ -80,7 +80,8 @@ export default { .set(data) .where(eq(users.id, userId)) .returning(); - res.send(serializeUser(newUser, req.user)); + + res.send(await serialize(UserSerializer, newUser, req.user)); } catch (err: any) { if (err?.code === "23505" && err?.constraint === "user_username_unq") { return res.status(400).send({ error: "Username already in use" }); diff --git a/apps/api/src/routes/updateUserAvatar.ts b/apps/api/src/routes/updateUserAvatar.ts index 98e85970..8632103b 100644 --- a/apps/api/src/routes/updateUserAvatar.ts +++ b/apps/api/src/routes/updateUserAvatar.ts @@ -18,6 +18,17 @@ export default { userId: { oneOf: [{ type: "number" }, { const: "me" }] }, }, }, + response: { + 200: { + type: "object", + required: ["pictureUrl"], + properties: { + pictureUrl: { + type: "string", + }, + }, + }, + }, }, preHandler: [requireAuth], handler: async (req, res) => { diff --git a/apps/api/src/schemas/bottle.ts b/apps/api/src/schemas/bottle.ts new file mode 100644 index 00000000..dcd5cc66 --- /dev/null +++ b/apps/api/src/schemas/bottle.ts @@ -0,0 +1,129 @@ +export const bottleSchema = { + $id: "/schemas/bottle", + type: "object", + required: ["id", "name", "brand", "distillers", "category", "statedAge"], + properties: { + id: { type: "string" }, + name: { type: "string" }, + brand: { + $ref: "/schemas/entity", + }, + distillers: { + type: "array", + items: { + $ref: "/schemas/entity", + }, + }, + category: { + type: "string", + nullable: true, + enum: [ + null, + "blend", + "bourbon", + "rye", + "single_grain", + "single_malt", + "spirit", + ], + }, + statedAge: { + type: "number", + nullable: true, + }, + + createdAt: { type: "string" }, + createdBy: { $ref: "/schemas/user" }, + }, +}; + +export const newBottleSchema = { + $id: "/schemas/newBottle", + type: "object", + required: ["name", "brand"], + properties: { + name: { type: "string" }, + brand: { + oneOf: [ + { type: "number" }, + { + $ref: "/schemas/newEntity", + }, + ], + }, + distillers: { + type: "array", + items: { + oneOf: [ + { type: "number" }, + { + $ref: "/schemas/newEntity", + }, + ], + }, + }, + category: { + type: "string", + nullable: true, + enum: [ + null, + "", + "blend", + "bourbon", + "rye", + "single_grain", + "single_malt", + "spirit", + ], + }, + statedAge: { + type: "number", + nullable: true, + }, + }, +}; + +export const updateBottleSchema = { + $id: "/schemas/updateBottle", + type: "object", + properties: { + name: { type: "string" }, + brand: { + oneOf: [ + { type: "number" }, + { + $ref: "/schemas/newEntity", + }, + ], + }, + distillers: { + type: "array", + items: { + oneOf: [ + { type: "number" }, + { + $ref: "/schemas/newEntity", + }, + ], + }, + }, + category: { + type: "string", + nullable: true, + enum: [ + null, + "", + "blend", + "bourbon", + "rye", + "single_grain", + "single_malt", + "spirit", + ], + }, + statedAge: { + type: "number", + nullable: true, + }, + }, +}; diff --git a/apps/api/src/schemas/bottle.tsx b/apps/api/src/schemas/bottle.tsx deleted file mode 100644 index a48b464e..00000000 --- a/apps/api/src/schemas/bottle.tsx +++ /dev/null @@ -1,73 +0,0 @@ -export default { - $id: "bottleSchema", - type: "object", - properties: { - name: { type: "string" }, - brand: { - oneOf: [ - { type: "number" }, - { - type: "object", - required: ["name"], - properties: { - id: { - type: "number", - }, - name: { - type: "string", - }, - country: { - type: "string", - }, - region: { - type: "string", - }, - }, - }, - ], - }, - distillers: { - type: "array", - items: { - oneOf: [ - { type: "number" }, - { - type: "object", - required: ["name"], - properties: { - id: { - type: "number", - }, - name: { - type: "string", - }, - country: { - type: "string", - }, - region: { - type: "string", - }, - }, - }, - ], - }, - }, - category: { - type: "string", - nullable: true, - enum: [ - null, - "", - "blend", - "bourbon", - "rye", - "single_grain", - "single_malt", - "spirit", - ], - }, - statedAge: { - oneOf: [{ type: "number" }, { type: "null" }], - }, - }, -}; diff --git a/apps/api/src/schemas/collection.ts b/apps/api/src/schemas/collection.ts new file mode 100644 index 00000000..dc385ab5 --- /dev/null +++ b/apps/api/src/schemas/collection.ts @@ -0,0 +1,11 @@ +export const collectionSchema = { + $id: "/schemas/collection", + type: "object", + required: ["id", "name", "createdAt"], + properties: { + id: { type: "string" }, + name: { type: "string" }, + createdBy: { $ref: "/schemas/user" }, + createdAt: { type: "string" }, + }, +}; diff --git a/apps/api/src/schemas/comment.ts b/apps/api/src/schemas/comment.ts new file mode 100644 index 00000000..7d71bff4 --- /dev/null +++ b/apps/api/src/schemas/comment.ts @@ -0,0 +1,21 @@ +export const commentSchema = { + $id: "/schemas/comment", + type: "object", + required: ["id", "comment", "createdBy", "createdAt"], + properties: { + id: { type: "string" }, + comment: { type: "string" }, + createdBy: { $ref: "/schemas/user" }, + createdAt: { type: "string" }, + }, +}; + +export const newCommentSchema = { + $id: "/schemas/newComment", + type: "object", + required: ["comment", "createdAt"], + properties: { + comment: { type: "string" }, + createdAt: { type: "string" }, + }, +}; diff --git a/apps/api/src/schemas/edition.ts b/apps/api/src/schemas/edition.ts new file mode 100644 index 00000000..37f5f72b --- /dev/null +++ b/apps/api/src/schemas/edition.ts @@ -0,0 +1,23 @@ +export const editionSchema = { + $id: "/schemas/edition", + type: "object", + required: ["id", "name", "barrel", "vintageYear"], + properties: { + id: { type: "string" }, + name: { type: "string", nullable: true }, + barrel: { type: "number", nullable: true }, + vintageYear: { type: "number", nullable: true }, + createdAt: { type: "string" }, + createdBy: { $ref: "/schemas/user" }, + }, +}; + +export const newEditionSchema = { + $id: "/schemas/newEdition", + type: "object", + properties: { + name: { type: "string", nullable: true }, + barrel: { type: "number", nullable: true }, + vintageYear: { type: "number", nullable: true }, + }, +}; diff --git a/apps/api/src/schemas/entity.ts b/apps/api/src/schemas/entity.ts new file mode 100644 index 00000000..ee4056cf --- /dev/null +++ b/apps/api/src/schemas/entity.ts @@ -0,0 +1,65 @@ +export const entitySchema = { + $id: "/schemas/entity", + type: "object", + required: [ + "id", + "name", + "type", + "country", + "region", + "totalTastings", + "totalBottles", + ], + properties: { + id: { type: "string" }, + name: { type: "string" }, + type: { + $ref: "#/$defs/type", + }, + country: { type: "string", nullable: true }, + region: { type: "string", nullable: true }, + + totalTastings: { type: "number" }, + totalBottles: { type: "number" }, + + createdAt: { type: "string" }, + createdBy: { $ref: "/schemas/user" }, + }, + + $defs: { + type: { + type: "array", + items: { + type: "string", + enum: ["brand", "distiller", "bottler"], + }, + }, + }, +}; + +export const newEntitySchema = { + $id: "/schemas/newEntity", + type: "object", + required: ["name"], + properties: { + name: { type: "string" }, + type: { + $ref: "/schemas/entity#/$defs/type", + }, + country: { type: "string", nullable: true }, + region: { type: "string", nullable: true }, + }, +}; + +export const updateEntitySchema = { + $id: "/schemas/updateEntity", + type: "object", + properties: { + name: { type: "string" }, + type: { + $ref: "/schemas/entity#/$defs/type", + }, + country: { type: "string", nullable: true }, + region: { type: "string", nullable: true }, + }, +}; diff --git a/apps/api/src/schemas/entity.tsx b/apps/api/src/schemas/entity.tsx deleted file mode 100644 index 9c320b92..00000000 --- a/apps/api/src/schemas/entity.tsx +++ /dev/null @@ -1,16 +0,0 @@ -export default { - $id: "entitySchema", - type: "object", - properties: { - name: { type: "string" }, - type: { - type: "array", - items: { - type: "string", - enum: ["brand", "distiller", "bottler"], - }, - }, - country: { type: "string" }, - region: { type: "string" }, - }, -}; diff --git a/apps/api/src/schemas/errors.ts b/apps/api/src/schemas/errors.ts new file mode 100644 index 00000000..10c6eaf5 --- /dev/null +++ b/apps/api/src/schemas/errors.ts @@ -0,0 +1,9 @@ +export const error401Schema = { + $id: "/errors/401", + type: "object", + required: ["error"], + properties: { + error: { type: "string" }, + name: { type: "string" }, + }, +}; diff --git a/apps/api/src/schemas/follow.ts b/apps/api/src/schemas/follow.ts new file mode 100644 index 00000000..4521f4f5 --- /dev/null +++ b/apps/api/src/schemas/follow.ts @@ -0,0 +1,19 @@ +export const followingSchema = { + $id: "/schemas/follow", + type: "object", + required: ["id", "status", "createdAt", "user", "followsBack"], + properties: { + id: { type: "string" }, + status: { $ref: "#/$defs/status" }, + createdAt: { type: "string" }, + user: { $ref: "/schemas/user" }, + followsBack: { $ref: "#/$defs/status" }, + }, + + $defs: { + status: { + type: "string", + enum: ["pending", "following", "none"], + }, + }, +}; diff --git a/apps/api/src/schemas/notification.ts b/apps/api/src/schemas/notification.ts new file mode 100644 index 00000000..cf46387e --- /dev/null +++ b/apps/api/src/schemas/notification.ts @@ -0,0 +1,19 @@ +export const notificationSchema = { + $id: "/schemas/notification", + type: "object", + required: ["id", "objectId", "objectType", "fromUser", "createdAt", "ref"], + properties: { + id: { type: "string" }, + objectType: { type: "string", enum: ["follow", "toast", "comment"] }, + objectId: { type: "string" }, + createdAt: { type: "string" }, + fromUser: { $ref: "/schemas/user" }, + ref: { + anyOf: [ + { $ref: "/schemas/follow" }, + { $ref: "/schemas/tasting" }, + { type: "null" }, + ], + }, + }, +}; diff --git a/apps/api/src/schemas/paging.ts b/apps/api/src/schemas/paging.ts new file mode 100644 index 00000000..fc671468 --- /dev/null +++ b/apps/api/src/schemas/paging.ts @@ -0,0 +1,18 @@ +export default { + $id: "/schemas/paging", + type: "object", + properties: { + next: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + nextPage: { + anyOf: [{ type: "number" }, { type: "null" }], + }, + prev: { + anyOf: [{ type: "string" }, { type: "null" }], + }, + prevPage: { + anyOf: [{ type: "number" }, { type: "null" }], + }, + }, +}; diff --git a/apps/api/src/schemas/tasting.ts b/apps/api/src/schemas/tasting.ts new file mode 100644 index 00000000..4edd60e2 --- /dev/null +++ b/apps/api/src/schemas/tasting.ts @@ -0,0 +1,48 @@ +export const newTastingSchema = { + $id: "/schemas/newTasting", + type: "object", + required: ["bottle", "rating"], + properties: { + bottle: { type: "number" }, + rating: { type: "number", minimum: 0, maximum: 5 }, + notes: { type: "string", nullable: true }, + tags: { type: "array", items: { type: "string" } }, + edition: { type: "string", nullable: true }, + vintageYear: { type: "number", nullable: true }, + barrel: { type: "number", nullable: true }, + }, +}; + +export const tastingSchema = { + $id: "/schemas/tasting", + type: "object", + required: [ + "id", + "imageUrl", + "notes", + "tags", + "rating", + "createdAt", + "comments", + "toasts", + "bottle", + "createdBy", + ], + properties: { + id: { type: "string" }, + imageUrl: { type: "string", nullable: true }, + notes: { type: "string", nullable: true }, + bottle: { $ref: "/schemas/bottle" }, + rating: { type: "number", minimum: 0, maximum: 5 }, + tags: { type: "array", items: { type: "string" } }, + + comments: { type: "number" }, + toasts: { type: "number" }, + hasToasted: { type: "boolean" }, + edition: { + anyOf: [{ $ref: "/schemas/edition" }, { type: "null" }], + }, + createdAt: { type: "string" }, + createdBy: { $ref: "/schemas/user" }, + }, +}; diff --git a/apps/api/src/schemas/user.ts b/apps/api/src/schemas/user.ts new file mode 100644 index 00000000..532155d9 --- /dev/null +++ b/apps/api/src/schemas/user.ts @@ -0,0 +1,25 @@ +export const userSchema = { + $id: "/schemas/user", + type: "object", + required: ["id", "displayName", "username"], + properties: { + id: { type: "string" }, + displayName: { type: "string" }, + email: { type: "string", format: "email" }, + username: { type: "string" }, + admin: { type: "boolean" }, + mod: { type: "boolean" }, + createdAt: { type: "string" }, + }, +}; + +export const updateUserSchema = { + $id: "/schemas/updateUser", + type: "object", + properties: { + displayName: { type: "string" }, + username: { type: "string" }, + admin: { type: "boolean" }, + mod: { type: "boolean" }, + }, +}; diff --git a/apps/web/src/components/notifications/followEntry.tsx b/apps/web/src/components/notifications/followEntry.tsx index 4d5f02aa..03515315 100644 --- a/apps/web/src/components/notifications/followEntry.tsx +++ b/apps/web/src/components/notifications/followEntry.tsx @@ -18,7 +18,7 @@ export default ({ ); const acceptRequest = async (id: string) => { - const data = await api.put(`/users/me/followers/${id}`, { + const data = await api.put(`/followers/${id}`, { data: { action: "accept" }, }); setTheirFollowStatus(data.status); @@ -47,7 +47,7 @@ export default ({ return (