diff --git a/apps/api/src/bin/mocks.ts b/apps/api/src/bin/mocks.ts index 871e7272..f8078814 100644 --- a/apps/api/src/bin/mocks.ts +++ b/apps/api/src/bin/mocks.ts @@ -1,6 +1,6 @@ import { eq, ne, sql } from "drizzle-orm"; import { db } from "../db"; -import { bottles, follows, users } from "../db/schema"; +import { bottles, follows, tastings, toasts, users } from "../db/schema"; import * as Fixtures from "../lib/test/fixtures"; import { program } from "commander"; @@ -15,13 +15,13 @@ program "--bottles ", "number of bottles", (v: string) => parseInt(v, 10), - 25, + 5, ) .option( "--tastings ", "number of tastings", (v: string) => parseInt(v, 10), - 25, + 5, ) .action(async (email, options) => { for (let i = 0; i < options.bottles; i++) { @@ -69,6 +69,35 @@ program }); console.log(`Created follow request from ${fromUserId} -> ${toUserId}`); } + + const [lastTasting] = await db + .insert(tastings) + .values({ + bottleId: ( + await db + .select() + .from(bottles) + .orderBy(sql`RANDOM()`) + .limit(1) + )[0].id, + rating: 4.5, + createdById: toUserId, + }) + .returning(); + for (const { id: fromUserId } of userList) { + const toast = await Fixtures.Toast({ + tastingId: lastTasting.id, + createdById: fromUserId, + }); + await createNotification(db, { + fromUserId: fromUserId, + objectType: objectTypeFromSchema(toasts), + objectId: toast.id, + userId: toUserId, + createdAt: toast.createdAt, + }); + console.log(`Created toast from ${fromUserId}`); + } } }); diff --git a/apps/api/src/lib/serializers/tasting.ts b/apps/api/src/lib/serializers/tasting.ts index d3a7b379..bb343f84 100644 --- a/apps/api/src/lib/serializers/tasting.ts +++ b/apps/api/src/lib/serializers/tasting.ts @@ -31,3 +31,29 @@ export const serializeTasting = ( }; return data; }; + +export const serializeTastingRef = ( + tasting: { + id: number; + bottle: { + id: number; + name: string; + brand: { + id: number; + name: string; + }; + }; + }, + 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/routes/deleteTasting.ts b/apps/api/src/routes/deleteTasting.ts index 573c477a..ef2a5dc0 100644 --- a/apps/api/src/routes/deleteTasting.ts +++ b/apps/api/src/routes/deleteTasting.ts @@ -1,8 +1,9 @@ -import { eq } from "drizzle-orm"; +import { and, eq, inArray, sql } from "drizzle-orm"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { tastings } from "../db/schema"; +import { notifications, tastings, toasts } from "../db/schema"; +import { objectTypeFromSchema } from "../lib/notifications"; import { requireAuth } from "../middleware/auth"; export default { @@ -32,8 +33,21 @@ export default { return res.status(403).send({ error: "Forbidden" }); } - await db.delete(tastings).where(eq(tastings.id, tasting.id)); - + await db.transaction(async (tx) => { + await tx + .delete(notifications) + .where( + and( + eq(notifications.objectType, objectTypeFromSchema(toasts)), + inArray( + notifications.objectId, + sql`(SELECT ${toasts.id} FROM ${toasts} WHERE ${toasts.tastingId} = ${tasting.id})`, + ), + ), + ); + await tx.delete(toasts).where(eq(toasts.tastingId, tasting.id)); + await tx.delete(tastings).where(eq(tastings.id, tasting.id)); + }); res.status(204).send(); }, } as RouteOptions< diff --git a/apps/api/src/routes/listNotifications.ts b/apps/api/src/routes/listNotifications.ts index 5e41f93e..10db1015 100644 --- a/apps/api/src/routes/listNotifications.ts +++ b/apps/api/src/routes/listNotifications.ts @@ -3,9 +3,18 @@ import { alias } from "drizzle-orm/pg-core"; import type { RouteOptions } from "fastify"; import { IncomingMessage, Server, ServerResponse } from "http"; import { db } from "../db"; -import { follows, notifications, tastings, toasts, users } from "../db/schema"; +import { + bottles, + entities, + follows, + notifications, + tastings, + toasts, + users, +} from "../db/schema"; import { buildPageLink } from "../lib/paging"; import { serializeFollow } from "../lib/serializers/follow"; +import { serializeTastingRef } from "../lib/serializers/tasting"; import { requireAuth } from "../middleware/auth"; export default { @@ -85,18 +94,31 @@ export default { .filter(({ notification }) => notification.objectType === "toast") .map(({ notification }) => notification.objectId); const toastResults = toastIdList.length - ? await db - .select({ - toastId: toasts.id, - tasting: tastings, - }) - .from(tastings) - .innerJoin(toasts, eq(tastings.id, toasts.tastingId)) - .where(inArray(toasts.id, toastIdList)) + ? ( + 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.tasting]), + toastResults.map((r) => [r.toastId, r]), ); res.send({ @@ -110,7 +132,7 @@ export default { req.user, ) : notification.objectType === "toast" - ? toastResultsByObjectId[notification.objectId] + ? serializeTastingRef(toastResultsByObjectId[notification.objectId]) : undefined, })), rel: { diff --git a/apps/api/src/routes/listTastings.ts b/apps/api/src/routes/listTastings.ts index b4ce5528..c38e46b4 100644 --- a/apps/api/src/routes/listTastings.ts +++ b/apps/api/src/routes/listTastings.ts @@ -102,22 +102,23 @@ export default { else distillersByBottleId[d.bottleId].push(d.distiller); }); - const userToastsList: number[] = req.user - ? ( - await db - .select({ tastingId: toasts.tastingId }) - .from(toasts) - .where( - and( - inArray( - toasts.tastingId, - results.map((t) => t.tasting.id), + 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), ), - eq(toasts.createdById, req.user.id), - ), - ) - ).map((t) => t.tastingId) - : []; + ) + ).map((t) => t.tastingId) + : []; res.send({ results: results diff --git a/apps/web/src/components/notifications/entry.tsx b/apps/web/src/components/notifications/entry.tsx index cf6caeee..bf9bdb91 100644 --- a/apps/web/src/components/notifications/entry.tsx +++ b/apps/web/src/components/notifications/entry.tsx @@ -1,3 +1,6 @@ +import { XMarkIcon } from "@heroicons/react/20/solid"; +import { Link, useNavigate } from "react-router-dom"; +import classNames from "../../lib/classNames"; import { Notification } from "../../types"; import UserAvatar from "../userAvatar"; import FollowEntry from "./followEntry"; @@ -9,46 +12,85 @@ export default function NotificationEntry({ notification: Notification; onArchive: () => void; }) { + const navigate = useNavigate(); + const link = getLink({ notification }); return ( -
+
{ + navigate(link); + } + : undefined + } + >
-
- +
+
-
+
-

- {notification.fromUser?.displayName || "Unknown User"} -

-

+

+ {notification.fromUser && ( + + {notification.fromUser.displayName} + + )} {getStatusMessage({ notification })}

+
-
- -
); } +const getLink = ({ notification }: { notification: Notification }) => { + switch (notification.objectType) { + case "follow": + return `/users/${notification.objectId}`; + case "toast": + return `/users/${notification.ref.id}`; + default: + return null; + } +}; + const getStatusMessage = ({ notification }: { notification: Notification }) => { switch (notification.objectType) { case "follow": - return <>Wants to follow you; + return <>wants to follow you; case "toast": - return <>Toasted your tasting; + return ( + <> + toasted your + + {notification.ref.bottle.brand.name} + + tasting + + ); default: return null; } diff --git a/apps/web/src/components/notifications/followEntry.tsx b/apps/web/src/components/notifications/followEntry.tsx index cf373695..55ccc1fa 100644 --- a/apps/web/src/components/notifications/followEntry.tsx +++ b/apps/web/src/components/notifications/followEntry.tsx @@ -42,18 +42,23 @@ export default ({ }; return ( - +
+ +
); }; diff --git a/apps/web/src/components/notifications/list.tsx b/apps/web/src/components/notifications/list.tsx index 2e32bb05..939c0937 100644 --- a/apps/web/src/components/notifications/list.tsx +++ b/apps/web/src/components/notifications/list.tsx @@ -18,7 +18,7 @@ export default function NotificationList({ const activeValues = values.filter((n) => archiveList.indexOf(n.id) === -1); return ( -
    +
      {activeValues.map((n) => { return ( {unreadNotificationCount > 0 && ( -
      +
      {unreadNotificationCount.toLocaleString()}
      )} - -
      + +
      {loading ? ( ) : notificationList.length ? ( <> View All Notifications diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index fa2174bb..03cd2abb 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -107,9 +107,21 @@ export type FollowNotification = BaseNotification & { ref: FollowRequest; }; +export type TastingSummary = { + id: string; + bottle: { + id: string; + name: string; + brand: { + id: string; + name: string; + }; + }; +}; + export type ToastNotification = BaseNotification & { objectType: "toast"; - ref: never; + ref: TastingRef; }; export type Notification = FollowNotification | ToastNotification;