Skip to content

Commit

Permalink
Merge pull request #63 from casesandberg/feature/notifications
Browse files Browse the repository at this point in the history
Notifications V1
  • Loading branch information
casesandberg authored Jul 19, 2024
2 parents f9803b6 + 896729e commit 780eb3e
Show file tree
Hide file tree
Showing 45 changed files with 1,280 additions and 9 deletions.
48 changes: 48 additions & 0 deletions apps/api/app/api/users/me/notifications/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { NextResponse } from 'next/server'
import type { SchemaResponse } from '@play-money/api-helpers'
import { auth } from '@play-money/auth'
import { getNotifications } from '@play-money/notifications/lib/getNotifications'
import { getUnreadNotificationCount } from '@play-money/notifications/lib/getUnreadNotificationCount'
import { updateNotificationsRead } from '@play-money/notifications/lib/updateNotificationsRead'
import type schema from './schema'

export const dynamic = 'force-dynamic'

export async function GET(_req: Request): Promise<SchemaResponse<typeof schema.get.responses>> {
try {
const session = await auth()

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const [unreadCount, notifications] = await Promise.all([
getUnreadNotificationCount({ userId: session.user.id }),
getNotifications({ userId: session.user.id }),
])

return NextResponse.json({ notifications, unreadCount })
} catch (error) {
console.log(error) // eslint-disable-line no-console -- Log error for debugging

return NextResponse.json({ error: 'Failed to retrieve user session' }, { status: 500 })
}
}

export async function POST(_req: Request): Promise<SchemaResponse<typeof schema.post.responses>> {
try {
const session = await auth()

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

await updateNotificationsRead({ userId: session.user.id })

return NextResponse.json({ success: true })
} catch (error) {
console.log(error) // eslint-disable-line no-console -- Log error for debugging

return NextResponse.json({ error: 'Failed to retrieve user session' }, { status: 500 })
}
}
20 changes: 20 additions & 0 deletions apps/api/app/api/users/me/notifications/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { z } from 'zod'
import { ServerErrorSchema, createSchema } from '@play-money/api-helpers'
import { NotificationGroupSchema } from '@play-money/database'

export default createSchema({
get: {
responses: {
200: z.object({ notifications: z.array(NotificationGroupSchema), unreadCount: z.number() }),
404: ServerErrorSchema,
500: ServerErrorSchema,
},
},
post: {
responses: {
200: z.object({ success: z.boolean() }),
404: ServerErrorSchema,
500: ServerErrorSchema,
},
},
})
30 changes: 30 additions & 0 deletions apps/api/app/api/users/me/resource-viewed/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from 'next/server'
import { type SchemaResponse } from '@play-money/api-helpers'
import { auth } from '@play-money/auth'
import { updateNotificationsRead } from '@play-money/notifications/lib/updateNotificationsRead'
import schema from './schema'

export const dynamic = 'force-dynamic'

export async function POST(req: Request): Promise<SchemaResponse<typeof schema.post.responses>> {
try {
const session = await auth()

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const body = (await req.json()) as unknown
const { resourceId, resourceType } = schema.post.requestBody.parse(body)

if (resourceType === 'MARKET') {
await updateNotificationsRead({ userId: session.user.id, marketId: resourceId })
}

return NextResponse.json({ success: true })
} catch (error: unknown) {
console.log(error) // eslint-disable-line no-console -- Log error for debugging

return NextResponse.json({ error: 'Failed to update user' }, { status: 500 })
}
}
13 changes: 13 additions & 0 deletions apps/api/app/api/users/me/resource-viewed/schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { z } from 'zod'
import { ServerErrorSchema, createSchema } from '@play-money/api-helpers'

export default createSchema({
post: {
requestBody: z.object({ resourceType: z.string(), resourceId: z.string(), timestamp: z.string() }),
responses: {
200: z.object({ success: z.boolean() }),
404: ServerErrorSchema,
500: ServerErrorSchema,
},
},
})
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"@play-money/currencies": "*",
"@play-money/database": "*",
"@play-money/markets": "*",
"@play-money/notifications": "*",
"@play-money/search": "*",
"@play-money/transactions": "*",
"@play-money/users": "*",
Expand Down
2 changes: 2 additions & 0 deletions apps/web/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { MenuIcon } from 'lucide-react'
import { cookies } from 'next/headers'
import Link from 'next/link'
import { ActiveUserBalance } from '@play-money/accounts/components/ActiveUserBalance'
import { NotificationDropdown } from '@play-money/notifications/components/NotificationDropdown'
import { GlobalSearchTrigger } from '@play-money/search/components/GlobalSearchTrigger'
import { Badge } from '@play-money/ui/badge'
import { Button } from '@play-money/ui/button'
Expand Down Expand Up @@ -86,6 +87,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<div className="ml-auto flex items-center space-x-4">
<ActiveUserBalance initialBalance={balance} />
<GlobalSearchTrigger className="hidden md:flex" />
<NotificationDropdown />
<UserNav />
</div>
</div>
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"@play-money/database": "*",
"@play-money/economy": "*",
"@play-money/markets": "*",
"@play-money/notifications": "*",
"@play-money/search": "*",
"@play-money/transactions": "*",
"@play-money/ui": "*",
Expand Down
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion packages/comments/components/CommentItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export function CommentItem({
<div
id={comment.id}
className={cn(
isHighlighted && 'bg-primary/10 ring-2 ring-primary ring-offset-2',
isHighlighted && 'bg-primary/10 ring-2 ring-primary ring-offset-2 dark:ring-offset-black',
'group flex flex-row gap-4 rounded-md px-6 py-2 hover:bg-muted/50',
(isReplyOpen || isPortalOpen) && 'bg-muted/50'
)}
Expand Down
41 changes: 41 additions & 0 deletions packages/comments/lib/createComment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import db, { Comment } from '@play-money/database'
import { getMarket } from '@play-money/markets/lib/getMarket'
import { createNotification } from '@play-money/notifications/lib/createNotification'
import { getUniqueLiquidityProviderIds } from '@play-money/transactions/lib/getUniqueLiquidityProviderIds'

export async function createComment({
content,
Expand All @@ -15,7 +18,45 @@ export async function createComment({
entityType,
entityId,
},
include: {
parent: true,
},
})

// TODO: Comment mentions.

const market = await getMarket({ id: entityId })

if (parentId && comment.parent && authorId !== comment.parent?.authorId) {
await createNotification({
type: 'COMMENT_REPLY',
actorId: authorId,
marketId: market.id,
commentId: comment.id,
parentCommentId: parentId ?? undefined,
groupKey: market.id,
userId: comment.parent.authorId,
actionUrl: `/questions/${market.id}/${market.slug}#${comment.id}`,
})
}

// TODO switch this to watchers of the market.
const recipientIds = await getUniqueLiquidityProviderIds(market.id, [authorId, comment.parent?.authorId])

await Promise.all(
recipientIds.map((recipientId) =>
createNotification({
type: 'MARKET_COMMENT',
actorId: authorId,
marketId: market.id,
commentId: comment.id,
parentCommentId: parentId ?? undefined,
groupKey: market.id,
userId: recipientId,
actionUrl: `/questions/${market.id}/${market.slug}#${comment.id}`,
})
)
)

return comment
}
20 changes: 20 additions & 0 deletions packages/comments/lib/reactToComment.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import db, { CommentReaction } from '@play-money/database'
import { getMarket } from '@play-money/markets/lib/getMarket'
import { createNotification } from '@play-money/notifications/lib/createNotification'

export async function reactToComment({
emoji,
Expand Down Expand Up @@ -29,7 +31,25 @@ export async function reactToComment({
userId,
commentId,
},
include: {
comment: true,
},
})

if (userId !== reaction.comment.authorId) {
const market = await getMarket({ id: reaction.comment.entityId })

await createNotification({
type: 'COMMENT_REACTION',
actorId: userId,
marketId: market.id,
commentId: reaction.comment.id,
commentReactionId: reaction.id,
groupKey: market.id,
userId: reaction.comment.authorId,
actionUrl: `/questions/${market.id}/${market.slug}#${reaction.comment.id}`,
})
}

return reaction
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
-- CreateEnum
CREATE TYPE "NotificationType" AS ENUM ('MARKET_RESOLVED', 'MARKET_TRADE', 'MARKET_LIQUIDITY_ADDED', 'MARKET_COMMENT', 'COMMENT_REPLY', 'COMMENT_MENTION', 'COMMENT_REACTION');

-- CreateTable
CREATE TABLE "Notification" (
"id" TEXT NOT NULL,
"recipientId" TEXT NOT NULL,
"actorId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"content" JSONB NOT NULL,
"marketId" TEXT,
"marketOptionId" TEXT,
"marketResolutionId" TEXT,
"transactionId" TEXT,
"commentId" TEXT,
"parentCommentId" TEXT,
"commentReactionId" TEXT,
"actionUrl" TEXT NOT NULL,
"readAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "Notification_pkey" PRIMARY KEY ("id")
);

-- CreateTable
CREATE TABLE "NotificationGroup" (
"id" TEXT NOT NULL,
"recipientId" TEXT NOT NULL,
"type" "NotificationType" NOT NULL,
"count" INTEGER NOT NULL DEFAULT 1,
"lastNotificationId" TEXT NOT NULL,
"groupWindowEnd" TIMESTAMP(3) NOT NULL,
"groupKey" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "NotificationGroup_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "Notification_recipientId_createdAt_idx" ON "Notification"("recipientId", "createdAt");

-- CreateIndex
CREATE INDEX "NotificationGroup_recipientId_type_groupWindowEnd_groupKey_idx" ON "NotificationGroup"("recipientId", "type", "groupWindowEnd", "groupKey");

-- CreateIndex
CREATE UNIQUE INDEX "NotificationGroup_recipientId_type_groupWindowEnd_groupKey_key" ON "NotificationGroup"("recipientId", "type", "groupWindowEnd", "groupKey");

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_actorId_fkey" FOREIGN KEY ("actorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_marketId_fkey" FOREIGN KEY ("marketId") REFERENCES "Market"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_marketOptionId_fkey" FOREIGN KEY ("marketOptionId") REFERENCES "MarketOption"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_marketResolutionId_fkey" FOREIGN KEY ("marketResolutionId") REFERENCES "MarketResolution"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_transactionId_fkey" FOREIGN KEY ("transactionId") REFERENCES "Transaction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_commentId_fkey" FOREIGN KEY ("commentId") REFERENCES "Comment"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_parentCommentId_fkey" FOREIGN KEY ("parentCommentId") REFERENCES "Comment"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_commentReactionId_fkey" FOREIGN KEY ("commentReactionId") REFERENCES "CommentReaction"("id") ON DELETE SET NULL ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "NotificationGroup" ADD CONSTRAINT "NotificationGroup_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

-- AddForeignKey
ALTER TABLE "NotificationGroup" ADD CONSTRAINT "NotificationGroup_lastNotificationId_fkey" FOREIGN KEY ("lastNotificationId") REFERENCES "Notification"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
Loading

0 comments on commit 780eb3e

Please sign in to comment.