Skip to content

Commit b0fef53

Browse files
Merge PR: notifications & real-time post updates
2 parents 6265fe4 + ef1999f commit b0fef53

File tree

15 files changed

+806
-61
lines changed

15 files changed

+806
-61
lines changed
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
-- CreateTable
2+
CREATE TABLE "notifications_receivers" (
3+
"profile_id" UUID NOT NULL,
4+
"notification_id" UUID NOT NULL,
5+
"seen_at" TIMESTAMP(3)
6+
);
7+
8+
-- CreateTable
9+
CREATE TABLE "notifications" (
10+
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
11+
"profile_id" UUID,
12+
"profile_name" TEXT NOT NULL,
13+
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
14+
"description" TEXT,
15+
"header" TEXT NOT NULL,
16+
"url" TEXT NOT NULL,
17+
18+
CONSTRAINT "notifications_pkey" PRIMARY KEY ("id")
19+
);
20+
21+
-- CreateIndex
22+
CREATE UNIQUE INDEX "notifications_receivers_profile_id_notification_id_key" ON "notifications_receivers"("profile_id", "notification_id");
23+
24+
-- AddForeignKey
25+
ALTER TABLE "notifications_receivers" ADD CONSTRAINT "notifications_receivers_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE CASCADE ON UPDATE CASCADE;
26+
27+
-- AddForeignKey
28+
ALTER TABLE "notifications_receivers" ADD CONSTRAINT "notifications_receivers_notification_id_fkey" FOREIGN KEY ("notification_id") REFERENCES "notifications"("id") ON DELETE CASCADE ON UPDATE CASCADE;
29+
30+
-- AddForeignKey
31+
ALTER TABLE "notifications" ADD CONSTRAINT "notifications_profile_id_fkey" FOREIGN KEY ("profile_id") REFERENCES "profiles"("id") ON DELETE SET NULL ON UPDATE CASCADE;

prisma/schema.prisma

Lines changed: 38 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,19 @@ model User {
2929
}
3030

3131
model Profile {
32-
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
33-
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
34-
userId String @unique @map("user_id") @db.Uuid
35-
lastSeen DateTime @updatedAt @map("last_seen")
36-
visible Boolean @default(true)
37-
tangible Boolean @default(true)
38-
chats ProfilesChats[]
39-
managedChats ProfilesManagedChats[]
40-
sentMessages Message[]
41-
followers Follows[] @relation("profile")
42-
following Follows[] @relation("follower")
32+
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
33+
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
34+
userId String @unique @map("user_id") @db.Uuid
35+
lastSeen DateTime @updatedAt @map("last_seen")
36+
visible Boolean @default(true)
37+
tangible Boolean @default(true)
38+
chats ProfilesChats[]
39+
managedChats ProfilesManagedChats[]
40+
notifications NotificationsReceivers[]
41+
triggeredNotifications Notification[]
42+
sentMessages Message[]
43+
followers Follows[] @relation("profile")
44+
following Follows[] @relation("follower")
4345
4446
@@map("profiles")
4547
}
@@ -55,6 +57,31 @@ model Follows {
5557
@@map("follows")
5658
}
5759

60+
model NotificationsReceivers {
61+
profile Profile @relation(fields: [profileId], references: [id], onDelete: Cascade)
62+
profileId String @map("profile_id") @db.Uuid
63+
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
64+
notificationId String @map("notification_id") @db.Uuid
65+
seenAt DateTime? @map("seen_at")
66+
67+
@@unique([profileId, notificationId])
68+
@@map("notifications_receivers")
69+
}
70+
71+
model Notification {
72+
id String @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
73+
profile Profile? @relation(fields: [profileId], references: [id], onDelete: SetNull)
74+
profileId String? @map("profile_id") @db.Uuid
75+
profileName String @map("profile_name")
76+
createdAt DateTime @default(now()) @map("created_at")
77+
receivers NotificationsReceivers[]
78+
description String?
79+
header String
80+
url String
81+
82+
@@map("notifications")
83+
}
84+
5885
enum ManagerRole {
5986
MODERATOR
6087
OWNER

src/api/v1/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { profilesRouter, lastSeenUpdater } from './profiles';
2+
import { notificationsRouter } from './notifications';
23
import { charactersRouter } from './characters';
34
import { imagesRouter } from './images';
45
import { statsRouter } from './stats';
@@ -20,3 +21,4 @@ apiRouter.use('/stats', statsRouter);
2021
apiRouter.use('/images', imagesRouter);
2122
apiRouter.use('/profiles', profilesRouter);
2223
apiRouter.use('/characters', charactersRouter);
24+
apiRouter.use('/notifications', notificationsRouter);

src/api/v1/notifications/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './notifications.router';
2+
export * from './notifications.service';
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import * as Validators from '@/middlewares/validators';
2+
import * as Service from './notifications.service';
3+
import * as Utils from '@/lib/utils';
4+
import { Router } from 'express';
5+
6+
export const notificationsRouter = Router();
7+
8+
notificationsRouter.get('/', Validators.authValidator, async (req, res) => {
9+
const userId = Utils.getCurrentUserIdFromReq(req)!;
10+
const filters = Utils.getBasePaginationFiltersFromReqQuery(req);
11+
res.json(await Service.getUserNotifications(userId, filters));
12+
});
13+
14+
notificationsRouter.get('/:id', Validators.authValidator, async (req, res) => {
15+
const userId = Utils.getCurrentUserIdFromReq(req)!;
16+
res.json(await Service.getUserNotificationById(req.params.id, userId));
17+
});
18+
19+
notificationsRouter.patch('/seen', Validators.authValidator, async (req, res) => {
20+
const userId = Utils.getCurrentUserIdFromReq(req)!;
21+
await Service.markUserNotificationsAsSeen(userId);
22+
res.status(204).end();
23+
});
24+
25+
notificationsRouter.delete('/:id', Validators.authValidator, async (req, res) => {
26+
const userId = Utils.getCurrentUserIdFromReq(req)!;
27+
await Service.deleteUserNotificationById(req.params.id, userId);
28+
res.status(204).end();
29+
});
30+
31+
export default notificationsRouter;
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import * as Types from '@/types';
2+
import * as Utils from '@/lib/utils';
3+
import * as AppError from '@/lib/app-error';
4+
import { Notification, User } from '@/../prisma/client';
5+
import db from '@/lib/db';
6+
7+
export const prepareNotification = async (
8+
notificationPayload: Types.NotificationPayload,
9+
userId: User['id'],
10+
): Promise<Types.PublicNotification> => {
11+
const { receivers, profile, profileId, ...notification } = notificationPayload;
12+
const seenAt = receivers.find((r) => r.profile.userId === userId)?.seenAt ?? null;
13+
if (profile) {
14+
const followedByCurrentUser = !!(await db.follows.findFirst({
15+
where: { follower: { userId }, profile: { id: profileId ?? profile.id } },
16+
}));
17+
return { ...notification, seenAt, profileId, profile: { ...profile, followedByCurrentUser } };
18+
}
19+
return { ...notification, seenAt, profileId, profile };
20+
};
21+
22+
export const getUserNotifications = async (
23+
userId: User['id'],
24+
filters: Types.BasePaginationFilters,
25+
) => {
26+
const notifications = await Utils.handleDBKnownErrors(
27+
db.notification.findMany({
28+
where: { receivers: { some: { profile: { userId } } } },
29+
include: {
30+
profile: Utils.profileAggregation,
31+
receivers: {
32+
where: { profile: { userId } },
33+
include: { profile: Utils.profileAggregation },
34+
},
35+
},
36+
...(filters.cursor ? { cursor: { id: filters.cursor }, skip: 1 } : {}),
37+
orderBy: { createdAt: filters.sort ?? 'desc' },
38+
take: filters.limit ?? 10,
39+
}),
40+
);
41+
const preparedNotifications: Awaited<ReturnType<typeof prepareNotification>>[] = [];
42+
for (const notification of notifications) {
43+
preparedNotifications.push(await prepareNotification(notification, userId));
44+
}
45+
return preparedNotifications;
46+
};
47+
48+
export const getUserNotificationById = async (id: Notification['id'], userId: User['id']) => {
49+
let notification: Types.NotificationPayload | null = null;
50+
try {
51+
notification = await Utils.handleDBKnownErrors(
52+
db.notification.findUnique({
53+
where: { id, receivers: { some: { profile: { userId } } } },
54+
include: {
55+
profile: Utils.profileAggregation,
56+
receivers: {
57+
where: { profile: { userId } },
58+
include: { profile: Utils.profileAggregation },
59+
},
60+
},
61+
}),
62+
);
63+
} catch (error) {
64+
if (!(error instanceof AppError.AppInvalidIdError)) throw error;
65+
}
66+
if (!notification) throw new AppError.AppNotFoundError('Notification not found.');
67+
return await prepareNotification(notification, userId);
68+
};
69+
70+
export const markUserNotificationsAsSeen = async (userId: User['id']) => {
71+
await Utils.handleDBKnownErrors(
72+
db.notificationsReceivers.updateMany({
73+
where: { profile: { userId }, seenAt: null },
74+
data: { seenAt: new Date() },
75+
}),
76+
);
77+
};
78+
79+
export const deleteUserNotificationById = async (id: Notification['id'], userId: User['id']) => {
80+
try {
81+
await Utils.handleDBKnownErrors(
82+
db.notificationsReceivers.deleteMany({ where: { notificationId: id, profile: { userId } } }),
83+
);
84+
} catch (error) {
85+
if (
86+
!(error instanceof AppError.AppInvalidIdError || error instanceof AppError.AppNotFoundError)
87+
)
88+
throw error;
89+
}
90+
};

src/api/v1/posts/posts.router.ts

Lines changed: 69 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import * as Storage from '@/lib/storage';
66
import * as Service from './posts.service';
77
import * as Middlewares from '@/middlewares';
88
import { Router, Request, Response } from 'express';
9+
import io from '@/lib/io';
910

1011
export const postsRouter = Router();
1112

@@ -143,26 +144,43 @@ postsRouter.post(
143144
} else {
144145
createdPost = await Service.createPost(postData, user);
145146
}
146-
res.status(201).json(createdPost);
147+
res
148+
.status(201)
149+
.json(createdPost)
150+
.on('finish', () => {
151+
io.except(createdPost.author.profile?.id ?? []).emit('post:created', createdPost.id);
152+
});
147153
},
148154
);
149155

150156
postsRouter.post('/:id/upvote', Middlewares.authValidator, async (req, res) => {
151157
const user = req.user as Types.PublicUser;
152158
const upvotedPost = await Service.upvotePost(req.params.id, user.id);
153-
res.json(upvotedPost);
159+
res.json(upvotedPost).on('finish', () => {
160+
io.except(user.profile?.id ?? []).emit('post:upvoted', upvotedPost.id);
161+
if (upvotedPost.author.profile) {
162+
io.to(upvotedPost.author.profile.id).emit('notifications:updated');
163+
}
164+
});
154165
});
155166

156167
postsRouter.post('/:id/downvote', Middlewares.authValidator, async (req, res) => {
157168
const user = req.user as Types.PublicUser;
158169
const downvotedPost = await Service.downvotePost(req.params.id, user.id);
159-
res.json(downvotedPost);
170+
res.json(downvotedPost).on('finish', () => {
171+
io.except(user.profile?.id ?? []).emit('post:downvoted', downvotedPost.id);
172+
if (downvotedPost.author.profile) {
173+
io.to(downvotedPost.author.profile.id).emit('notifications:updated');
174+
}
175+
});
160176
});
161177

162178
postsRouter.post('/:id/unvote', Middlewares.authValidator, async (req, res) => {
163179
const user = req.user as Types.PublicUser;
164180
const unvotedPost = await Service.unvotePost(req.params.id, user.id);
165-
res.json(unvotedPost);
181+
res.json(unvotedPost).on('finish', () => {
182+
io.except(user.profile?.id ?? []).emit('post:unvoted', unvotedPost.id);
183+
});
166184
});
167185

168186
postsRouter.post(
@@ -171,12 +189,22 @@ postsRouter.post(
171189
async (req: Request<{ id: string }, unknown, { content: string }>, res) => {
172190
const user = req.user as Types.PublicUser;
173191
const commentData = Schema.commentSchema.parse(req.body);
174-
const newComment = await Service.findPostByIdAndCreateComment(
192+
const { createdComment, post } = await Service.findPostByIdAndCreateComment(
175193
req.params.id,
176194
user.id,
177195
commentData,
178196
);
179-
res.status(200).json(newComment);
197+
res
198+
.status(200)
199+
.json(createdComment)
200+
.on('finish', () => {
201+
if (post.author.profile) {
202+
io.to(post.author.profile.id).emit('notifications:updated');
203+
}
204+
const { postId } = createdComment;
205+
const commentProfileId = createdComment.author.profile?.id;
206+
io.except(commentProfileId ?? []).emit('post:comment:created', postId, createdComment.id);
207+
});
180208
},
181209
);
182210

@@ -207,7 +235,9 @@ postsRouter.put(
207235
} else {
208236
updatedPost = await Service.updatePost(post, user, postData, imagedata);
209237
}
210-
res.json(updatedPost);
238+
res.json(updatedPost).on('finish', () => {
239+
io.except(updatedPost.author.profile?.id ?? []).emit('post:updated', updatedPost.id);
240+
});
211241
},
212242
);
213243

@@ -218,8 +248,20 @@ postsRouter.put(
218248
async (req, res) => {
219249
const user = req.user as Types.PublicUser;
220250
const commentData = Schema.commentSchema.parse(req.body);
221-
const updatedComment = await Service.findCommentAndUpdate(req.params.cId, user.id, commentData);
222-
res.json(updatedComment);
251+
const { updatedComment, post } = await Service.findCommentAndUpdate(
252+
req.params.pId,
253+
req.params.cId,
254+
user.id,
255+
commentData,
256+
);
257+
res.json(updatedComment).on('finish', () => {
258+
if (post.author.profile) {
259+
io.to(post.author.profile.id).emit('notifications:updated');
260+
}
261+
const { postId } = updatedComment;
262+
const commentProfileId = updatedComment.author.profile?.id;
263+
io.except(commentProfileId ?? []).emit('post:comment:updated', postId, updatedComment.id);
264+
});
223265
},
224266
);
225267

@@ -230,9 +272,15 @@ postsRouter.delete(
230272
async (req, res) => await getPostAuthorIdAndInjectPostInResLocals(req, res),
231273
),
232274
async (req, res: Response<unknown, { post: Service._PostFullData }>) => {
275+
const deletedPost = res.locals.post;
233276
const userId = Utils.getCurrentUserIdFromReq(req);
234-
await Service.deletePost(res.locals.post, userId);
235-
res.status(204).end();
277+
await Service.deletePost(deletedPost, userId);
278+
res
279+
.status(204)
280+
.end()
281+
.on('finish', () => {
282+
io.except(deletedPost.author.profile?.id ?? []).emit('post:deleted', deletedPost.id);
283+
});
236284
},
237285
);
238286

@@ -246,8 +294,15 @@ postsRouter.delete(
246294
return postAuthorId === userId ? postAuthorId : await getCommentAuthorId(req);
247295
}),
248296
async (req, res) => {
249-
const userId = Utils.getCurrentUserIdFromReq(req);
250-
await Service.findCommentAndDelete(req.params.cId, userId);
251-
res.status(204).end();
297+
const user = req.user as Types.PublicUser;
298+
await Service.findCommentAndDelete(req.params.cId, user.id);
299+
res
300+
.status(204)
301+
.end()
302+
.on('finish', () => {
303+
const postId = req.params.pId;
304+
const commentId = req.params.cId;
305+
io.except(user.profile?.id ?? []).emit('post:comment:deleted', postId, commentId);
306+
});
252307
},
253308
);

0 commit comments

Comments
 (0)