@rpcbase/server 0.475.0 → 0.477.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/email-DEw8keax.js +8041 -0
- package/dist/{handler-xi0XKR-Y.js → handler-BOTZftAB.js} +29 -29
- package/dist/handler-B_mMDLBO.js +437 -0
- package/dist/{handler-BYVnU9H-.js → handler-Cl-0-832.js} +1 -1
- package/dist/{handler-CTL2iQCj.js → handler-Dd20DHyz.js} +15 -11
- package/dist/index.d.ts +0 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +169 -8191
- package/dist/notifications/api/notifications/handler.d.ts +4 -0
- package/dist/notifications/api/notifications/handler.d.ts.map +1 -0
- package/dist/notifications/api/notifications/index.d.ts +168 -0
- package/dist/notifications/api/notifications/index.d.ts.map +1 -0
- package/dist/notifications/api/notifications/shared.d.ts +6 -0
- package/dist/notifications/api/notifications/shared.d.ts.map +1 -0
- package/dist/notifications/createNotification.d.ts +13 -0
- package/dist/notifications/createNotification.d.ts.map +1 -0
- package/dist/notifications/digest.d.ts +13 -0
- package/dist/notifications/digest.d.ts.map +1 -0
- package/dist/notifications/routes.d.ts +2 -0
- package/dist/notifications/routes.d.ts.map +1 -0
- package/dist/notifications.d.ts +4 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +126 -0
- package/dist/rts/api/changes/handler.d.ts.map +1 -1
- package/dist/rts/index.d.ts +3 -1
- package/dist/rts/index.d.ts.map +1 -1
- package/dist/{index-Ckx0UHs6.js → rts/index.js} +99 -32
- package/dist/{schemas-CyxqObur.js → schemas-D5T9tDtI.js} +712 -4
- package/dist/{shared-Chfrv8o6.js → shared-UGuDRAKK.js} +16 -30
- package/dist/uploads/api/file-uploads/handlers/completeUpload.d.ts.map +1 -1
- package/dist/uploads/api/file-uploads/handlers/getStatus.d.ts.map +1 -1
- package/dist/uploads/api/file-uploads/handlers/uploadChunk.d.ts.map +1 -1
- package/dist/uploads/api/file-uploads/shared.d.ts +3 -0
- package/dist/uploads/api/file-uploads/shared.d.ts.map +1 -1
- package/dist/uploads.js +1 -1
- package/package.json +9 -4
- package/dist/passwordHashStorage.test.d.ts +0 -2
- package/dist/passwordHashStorage.test.d.ts.map +0 -1
- package/dist/rts/api/changes/handler.test.d.ts +0 -2
- package/dist/rts/api/changes/handler.test.d.ts.map +0 -1
- package/dist/rts/index.ws.test.d.ts +0 -2
- package/dist/rts/index.ws.test.d.ts.map +0 -1
- package/dist/rts.d.ts +0 -3
- package/dist/rts.d.ts.map +0 -1
- package/dist/rts.js +0 -13
- package/dist/uploads/api/files/handlers/getFile.test.d.ts +0 -2
- package/dist/uploads/api/files/handlers/getFile.test.d.ts.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { loadModel, getTenantFilesystemDb } from "@rpcbase/db";
|
|
2
2
|
import { GridFSBucket, ObjectId } from "mongodb";
|
|
3
|
-
import { g as getTenantId, a as getModelCtx, b as
|
|
3
|
+
import { g as getTenantId, a as getModelCtx, b as buildUploadsAbility, c as getUploadSessionAccessQuery, e as ensureUploadIndexes, d as getBucketName, f as getUserId, h as getChunkSizeBytes, i as getSessionTtlMs, j as computeSha256Hex, t as toBufferPayload, n as normalizeSha256Hex, k as getMaxClientUploadBytesPerSecond, l as getRawBodyLimitBytes } from "./shared-UGuDRAKK.js";
|
|
4
4
|
import { randomBytes } from "node:crypto";
|
|
5
|
-
import { o as object, n as number, s as string, b as boolean, a as array, _ as _enum } from "./schemas-
|
|
5
|
+
import { o as object, n as number, s as string, b as boolean, a as array, _ as _enum } from "./schemas-D5T9tDtI.js";
|
|
6
6
|
const waitForStreamFinished = async (stream) => new Promise((resolve, reject) => {
|
|
7
7
|
stream.once("finish", resolve);
|
|
8
8
|
stream.once("error", reject);
|
|
@@ -37,7 +37,8 @@ const abortUploadStream = async (stream) => {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
try {
|
|
40
|
-
|
|
40
|
+
;
|
|
41
|
+
stream.destroy?.();
|
|
41
42
|
} catch {
|
|
42
43
|
}
|
|
43
44
|
};
|
|
@@ -57,21 +58,21 @@ const completeUpload = async (_payload, ctx) => {
|
|
|
57
58
|
loadModel("RBUploadSession", modelCtx),
|
|
58
59
|
loadModel("RBUploadChunk", modelCtx)
|
|
59
60
|
]);
|
|
60
|
-
const
|
|
61
|
+
const ability = buildUploadsAbility(ctx, tenantId);
|
|
62
|
+
if (!ability.can("update", "RBUploadSession")) {
|
|
63
|
+
ctx.res.status(401);
|
|
64
|
+
return { ok: false, error: "unauthorized" };
|
|
65
|
+
}
|
|
66
|
+
const existing = await UploadSession.findOne({ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "read")] }).lean();
|
|
61
67
|
if (!existing) {
|
|
62
68
|
ctx.res.status(404);
|
|
63
69
|
return { ok: false, error: "not_found" };
|
|
64
70
|
}
|
|
65
|
-
const ownershipSelector = getOwnershipSelector(ctx, existing);
|
|
66
|
-
if (!ownershipSelector) {
|
|
67
|
-
ctx.res.status(401);
|
|
68
|
-
return { ok: false, error: "unauthorized" };
|
|
69
|
-
}
|
|
70
71
|
if (existing.status === "done" && existing.fileId) {
|
|
71
72
|
return { ok: true, fileId: existing.fileId };
|
|
72
73
|
}
|
|
73
74
|
const locked = await UploadSession.findOneAndUpdate(
|
|
74
|
-
{ _id: uploadId
|
|
75
|
+
{ $and: [{ _id: uploadId }, { status: "uploading" }, getUploadSessionAccessQuery(ability, "update")] },
|
|
75
76
|
{ $set: { status: "assembling" }, $unset: { error: "" } },
|
|
76
77
|
{ new: true }
|
|
77
78
|
).lean();
|
|
@@ -84,7 +85,7 @@ const completeUpload = async (_payload, ctx) => {
|
|
|
84
85
|
const nativeDb = fsDb.db;
|
|
85
86
|
if (!nativeDb) {
|
|
86
87
|
await UploadSession.updateOne(
|
|
87
|
-
{ _id: uploadId,
|
|
88
|
+
{ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "update")] },
|
|
88
89
|
{ $set: { status: "error", error: "filesystem_db_unavailable" } }
|
|
89
90
|
);
|
|
90
91
|
ctx.res.status(500);
|
|
@@ -106,8 +107,7 @@ const completeUpload = async (_payload, ctx) => {
|
|
|
106
107
|
const cursor = UploadChunk.find({ uploadId }).sort({ index: 1 }).cursor();
|
|
107
108
|
let expectedIndex = 0;
|
|
108
109
|
try {
|
|
109
|
-
for await (const
|
|
110
|
-
const chunkDoc = doc;
|
|
110
|
+
for await (const chunkDoc of cursor) {
|
|
111
111
|
if (chunkDoc.index !== expectedIndex) {
|
|
112
112
|
throw new Error("missing_chunks");
|
|
113
113
|
}
|
|
@@ -131,7 +131,7 @@ const completeUpload = async (_payload, ctx) => {
|
|
|
131
131
|
throw new Error("missing_file_id");
|
|
132
132
|
}
|
|
133
133
|
await UploadSession.updateOne(
|
|
134
|
-
{ _id: uploadId,
|
|
134
|
+
{ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "update")] },
|
|
135
135
|
{ $set: { status: "done", fileId }, $unset: { error: "" } }
|
|
136
136
|
);
|
|
137
137
|
try {
|
|
@@ -144,14 +144,14 @@ const completeUpload = async (_payload, ctx) => {
|
|
|
144
144
|
await abortUploadStream(uploadStream);
|
|
145
145
|
if (message === "missing_chunks") {
|
|
146
146
|
await UploadSession.updateOne(
|
|
147
|
-
{ _id: uploadId,
|
|
147
|
+
{ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "update")] },
|
|
148
148
|
{ $set: { status: "uploading" } }
|
|
149
149
|
);
|
|
150
150
|
ctx.res.status(409);
|
|
151
151
|
return { ok: false, error: "missing_chunks" };
|
|
152
152
|
}
|
|
153
153
|
await UploadSession.updateOne(
|
|
154
|
-
{ _id: uploadId,
|
|
154
|
+
{ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "update")] },
|
|
155
155
|
{ $set: { status: "error", error: message } }
|
|
156
156
|
);
|
|
157
157
|
ctx.res.status(500);
|
|
@@ -174,21 +174,21 @@ const getStatus = async (_payload, ctx) => {
|
|
|
174
174
|
loadModel("RBUploadSession", modelCtx),
|
|
175
175
|
loadModel("RBUploadChunk", modelCtx)
|
|
176
176
|
]);
|
|
177
|
-
const
|
|
177
|
+
const ability = buildUploadsAbility(ctx, tenantId);
|
|
178
|
+
if (!ability.can("read", "RBUploadSession")) {
|
|
179
|
+
ctx.res.status(401);
|
|
180
|
+
return { ok: false, error: "unauthorized" };
|
|
181
|
+
}
|
|
182
|
+
const session = await UploadSession.findOne({ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "read")] }).lean();
|
|
178
183
|
if (!session) {
|
|
179
184
|
ctx.res.status(404);
|
|
180
185
|
return { ok: false, error: "not_found" };
|
|
181
186
|
}
|
|
182
|
-
const ownershipSelector = getOwnershipSelector(ctx, session);
|
|
183
|
-
if (!ownershipSelector) {
|
|
184
|
-
ctx.res.status(401);
|
|
185
|
-
return { ok: false, error: "unauthorized" };
|
|
186
|
-
}
|
|
187
187
|
const receivedDocs = await UploadChunk.find(
|
|
188
188
|
{ uploadId },
|
|
189
189
|
{ index: 1, _id: 0 }
|
|
190
190
|
).sort({ index: 1 }).lean();
|
|
191
|
-
const received = receivedDocs.map((
|
|
191
|
+
const received = receivedDocs.map((doc) => typeof doc.index === "number" ? doc.index : -1).filter((n) => Number.isInteger(n) && n >= 0);
|
|
192
192
|
return {
|
|
193
193
|
ok: true,
|
|
194
194
|
status: session.status,
|
|
@@ -294,16 +294,16 @@ const uploadChunk = async (payload, ctx) => {
|
|
|
294
294
|
loadModel("RBUploadSession", modelCtx),
|
|
295
295
|
loadModel("RBUploadChunk", modelCtx)
|
|
296
296
|
]);
|
|
297
|
-
const
|
|
297
|
+
const ability = buildUploadsAbility(ctx, tenantId);
|
|
298
|
+
if (!ability.can("update", "RBUploadSession")) {
|
|
299
|
+
ctx.res.status(401);
|
|
300
|
+
return { ok: false, error: "unauthorized" };
|
|
301
|
+
}
|
|
302
|
+
const session = await UploadSession.findOne({ $and: [{ _id: uploadId }, getUploadSessionAccessQuery(ability, "update")] }).lean();
|
|
298
303
|
if (!session) {
|
|
299
304
|
ctx.res.status(404);
|
|
300
305
|
return { ok: false, error: "not_found" };
|
|
301
306
|
}
|
|
302
|
-
const ownershipSelector = getOwnershipSelector(ctx, session);
|
|
303
|
-
if (!ownershipSelector) {
|
|
304
|
-
ctx.res.status(401);
|
|
305
|
-
return { ok: false, error: "unauthorized" };
|
|
306
|
-
}
|
|
307
307
|
if (session.status !== "uploading") {
|
|
308
308
|
ctx.res.status(409);
|
|
309
309
|
return { ok: false, error: "not_uploading" };
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { loadModel } from "@rpcbase/db";
|
|
2
|
+
import { buildAbilityFromSession, getAccessibleByQuery } from "@rpcbase/db/acl";
|
|
3
|
+
import { createNotification, sendNotificationsDigestForUser } from "./notifications.js";
|
|
4
|
+
import { o as object, b as boolean, n as number, a as array, s as string, r as record, u as unknown, _ as _enum } from "./schemas-D5T9tDtI.js";
|
|
5
|
+
const getSessionUser = (ctx) => {
|
|
6
|
+
const rawSessionUser = ctx.req.session?.user;
|
|
7
|
+
const userId = typeof rawSessionUser?.id === "string" ? rawSessionUser.id.trim() : "";
|
|
8
|
+
const tenantId = typeof rawSessionUser?.currentTenantId === "string" ? rawSessionUser.currentTenantId.trim() : "";
|
|
9
|
+
if (!userId) {
|
|
10
|
+
ctx.res.status(401);
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
if (!tenantId) {
|
|
14
|
+
ctx.res.status(400);
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
return { userId, tenantId };
|
|
18
|
+
};
|
|
19
|
+
const ListRoute = "/api/rb/notifications";
|
|
20
|
+
const CreateRoute = "/api/rb/notifications/create";
|
|
21
|
+
const MarkReadRoute = "/api/rb/notifications/:notificationId/read";
|
|
22
|
+
const ArchiveRoute = "/api/rb/notifications/:notificationId/archive";
|
|
23
|
+
const MarkAllReadRoute = "/api/rb/notifications/mark-all-read";
|
|
24
|
+
const SettingsRoute = "/api/rb/notifications/settings";
|
|
25
|
+
const DigestRunRoute = "/api/rb/notifications/digest/run";
|
|
26
|
+
const listRequestSchema = object({
|
|
27
|
+
includeArchived: boolean().optional(),
|
|
28
|
+
unreadOnly: boolean().optional(),
|
|
29
|
+
limit: number().int().min(1).max(200).optional(),
|
|
30
|
+
markSeen: boolean().optional()
|
|
31
|
+
});
|
|
32
|
+
const createRequestSchema = object({
|
|
33
|
+
topic: string().trim().min(1).optional(),
|
|
34
|
+
title: string().trim().min(1),
|
|
35
|
+
body: string().trim().optional(),
|
|
36
|
+
url: string().trim().optional(),
|
|
37
|
+
metadata: record(string(), unknown()).optional()
|
|
38
|
+
});
|
|
39
|
+
const notificationSchema = object({
|
|
40
|
+
id: string(),
|
|
41
|
+
topic: string().optional(),
|
|
42
|
+
title: string(),
|
|
43
|
+
body: string().optional(),
|
|
44
|
+
url: string().optional(),
|
|
45
|
+
createdAt: string(),
|
|
46
|
+
seenAt: string().optional(),
|
|
47
|
+
readAt: string().optional(),
|
|
48
|
+
archivedAt: string().optional(),
|
|
49
|
+
metadata: record(string(), unknown()).optional()
|
|
50
|
+
});
|
|
51
|
+
const listResponseSchema = object({
|
|
52
|
+
ok: boolean(),
|
|
53
|
+
error: string().optional(),
|
|
54
|
+
notifications: array(notificationSchema).optional(),
|
|
55
|
+
unreadCount: number().int().min(0).optional(),
|
|
56
|
+
unseenCount: number().int().min(0).optional()
|
|
57
|
+
});
|
|
58
|
+
const createResponseSchema = object({
|
|
59
|
+
ok: boolean(),
|
|
60
|
+
error: string().optional(),
|
|
61
|
+
id: string().optional()
|
|
62
|
+
});
|
|
63
|
+
object({
|
|
64
|
+
ok: boolean(),
|
|
65
|
+
error: string().optional()
|
|
66
|
+
});
|
|
67
|
+
object({
|
|
68
|
+
ok: boolean(),
|
|
69
|
+
error: string().optional()
|
|
70
|
+
});
|
|
71
|
+
object({
|
|
72
|
+
ok: boolean(),
|
|
73
|
+
error: string().optional()
|
|
74
|
+
});
|
|
75
|
+
const digestFrequencySchema = _enum(["off", "daily", "weekly"]);
|
|
76
|
+
const topicPreferenceSchema = object({
|
|
77
|
+
topic: string(),
|
|
78
|
+
inApp: boolean(),
|
|
79
|
+
emailDigest: boolean(),
|
|
80
|
+
push: boolean()
|
|
81
|
+
});
|
|
82
|
+
const settingsSchema = object({
|
|
83
|
+
digestFrequency: digestFrequencySchema,
|
|
84
|
+
topicPreferences: array(topicPreferenceSchema),
|
|
85
|
+
lastDigestSentAt: string().optional()
|
|
86
|
+
});
|
|
87
|
+
const settingsResponseSchema = object({
|
|
88
|
+
ok: boolean(),
|
|
89
|
+
error: string().optional(),
|
|
90
|
+
settings: settingsSchema.optional()
|
|
91
|
+
});
|
|
92
|
+
const updateSettingsRequestSchema = object({
|
|
93
|
+
digestFrequency: digestFrequencySchema.optional(),
|
|
94
|
+
topicPreferences: array(topicPreferenceSchema).optional()
|
|
95
|
+
});
|
|
96
|
+
const updateSettingsResponseSchema = object({
|
|
97
|
+
ok: boolean(),
|
|
98
|
+
error: string().optional(),
|
|
99
|
+
settings: settingsSchema.optional()
|
|
100
|
+
});
|
|
101
|
+
const digestRunRequestSchema = object({
|
|
102
|
+
force: boolean().optional()
|
|
103
|
+
});
|
|
104
|
+
object({
|
|
105
|
+
ok: boolean(),
|
|
106
|
+
error: string().optional(),
|
|
107
|
+
sent: boolean().optional(),
|
|
108
|
+
skippedReason: string().optional()
|
|
109
|
+
});
|
|
110
|
+
const toIso = (value) => value instanceof Date ? value.toISOString() : void 0;
|
|
111
|
+
const buildDisabledTopics = (settings, key) => {
|
|
112
|
+
const raw = settings?.topicPreferences;
|
|
113
|
+
if (!Array.isArray(raw) || raw.length === 0) return [];
|
|
114
|
+
return raw.map((pref) => {
|
|
115
|
+
if (!pref || typeof pref !== "object") return null;
|
|
116
|
+
const topic = typeof pref.topic === "string" ? pref.topic.trim() : "";
|
|
117
|
+
if (!topic) return null;
|
|
118
|
+
const enabled = pref[key] === true;
|
|
119
|
+
return enabled ? null : topic;
|
|
120
|
+
}).filter((topic) => Boolean(topic));
|
|
121
|
+
};
|
|
122
|
+
const listNotifications = async (payload, ctx) => {
|
|
123
|
+
const session = getSessionUser(ctx);
|
|
124
|
+
if (!session) {
|
|
125
|
+
return { ok: false, error: "unauthorized" };
|
|
126
|
+
}
|
|
127
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
128
|
+
if (!ability.can("read", "RBNotification")) {
|
|
129
|
+
ctx.res.status(403);
|
|
130
|
+
return { ok: false, error: "forbidden" };
|
|
131
|
+
}
|
|
132
|
+
const parsed = listRequestSchema.safeParse(payload);
|
|
133
|
+
if (!parsed.success) {
|
|
134
|
+
ctx.res.status(400);
|
|
135
|
+
return { ok: false, error: "invalid_payload" };
|
|
136
|
+
}
|
|
137
|
+
const { userId } = session;
|
|
138
|
+
const includeArchived = parsed.data.includeArchived === true;
|
|
139
|
+
const unreadOnly = parsed.data.unreadOnly === true;
|
|
140
|
+
const limit = parsed.data.limit ?? 50;
|
|
141
|
+
const markSeen = parsed.data.markSeen === true;
|
|
142
|
+
const SettingsModel = await loadModel("RBNotificationSettings", ctx);
|
|
143
|
+
const settings = await SettingsModel.findOne({ userId }).lean();
|
|
144
|
+
const disabledTopics = buildDisabledTopics(settings, "inApp");
|
|
145
|
+
const NotificationModel = await loadModel("RBNotification", ctx);
|
|
146
|
+
const queryFilters = [
|
|
147
|
+
{ userId },
|
|
148
|
+
getAccessibleByQuery(ability, "read", "RBNotification")
|
|
149
|
+
];
|
|
150
|
+
if (!includeArchived) queryFilters.push({ archivedAt: { $exists: false } });
|
|
151
|
+
if (unreadOnly) queryFilters.push({ readAt: { $exists: false } });
|
|
152
|
+
if (disabledTopics.length > 0) queryFilters.push({ topic: { $nin: disabledTopics } });
|
|
153
|
+
const query = { $and: queryFilters };
|
|
154
|
+
const notifications = await NotificationModel.find(query).sort({ createdAt: -1 }).limit(limit).lean();
|
|
155
|
+
const unseenQueryFilters = [
|
|
156
|
+
{ userId },
|
|
157
|
+
{ archivedAt: { $exists: false } },
|
|
158
|
+
{ seenAt: { $exists: false } },
|
|
159
|
+
getAccessibleByQuery(ability, "read", "RBNotification")
|
|
160
|
+
];
|
|
161
|
+
if (disabledTopics.length > 0) unseenQueryFilters.push({ topic: { $nin: disabledTopics } });
|
|
162
|
+
const unseenQuery = { $and: unseenQueryFilters };
|
|
163
|
+
const unreadQueryFilters = [
|
|
164
|
+
{ userId },
|
|
165
|
+
{ archivedAt: { $exists: false } },
|
|
166
|
+
{ readAt: { $exists: false } },
|
|
167
|
+
getAccessibleByQuery(ability, "read", "RBNotification")
|
|
168
|
+
];
|
|
169
|
+
if (disabledTopics.length > 0) unreadQueryFilters.push({ topic: { $nin: disabledTopics } });
|
|
170
|
+
const unreadQuery = { $and: unreadQueryFilters };
|
|
171
|
+
const [unreadCount, unseenCount] = await Promise.all([
|
|
172
|
+
NotificationModel.countDocuments(unreadQuery),
|
|
173
|
+
NotificationModel.countDocuments(unseenQuery)
|
|
174
|
+
]);
|
|
175
|
+
const now = markSeen ? /* @__PURE__ */ new Date() : null;
|
|
176
|
+
if (now && unseenCount > 0) {
|
|
177
|
+
await NotificationModel.updateMany(unseenQuery, { $set: { seenAt: now } });
|
|
178
|
+
}
|
|
179
|
+
return listResponseSchema.parse({
|
|
180
|
+
ok: true,
|
|
181
|
+
notifications: notifications.map((n) => ({
|
|
182
|
+
id: String(n._id),
|
|
183
|
+
topic: typeof n.topic === "string" ? n.topic : void 0,
|
|
184
|
+
title: typeof n.title === "string" ? n.title : "",
|
|
185
|
+
body: typeof n.body === "string" ? n.body : void 0,
|
|
186
|
+
url: typeof n.url === "string" ? n.url : void 0,
|
|
187
|
+
createdAt: toIso(n.createdAt) ?? (/* @__PURE__ */ new Date()).toISOString(),
|
|
188
|
+
seenAt: toIso(n.seenAt) ?? (now && !n.archivedAt && !n.seenAt ? now.toISOString() : void 0),
|
|
189
|
+
readAt: toIso(n.readAt),
|
|
190
|
+
archivedAt: toIso(n.archivedAt),
|
|
191
|
+
metadata: typeof n.metadata === "object" && n.metadata !== null ? n.metadata : void 0
|
|
192
|
+
})),
|
|
193
|
+
unreadCount,
|
|
194
|
+
unseenCount: now ? 0 : unseenCount
|
|
195
|
+
});
|
|
196
|
+
};
|
|
197
|
+
const createNotificationForCurrentUser = async (payload, ctx) => {
|
|
198
|
+
const session = getSessionUser(ctx);
|
|
199
|
+
if (!session) {
|
|
200
|
+
return { ok: false, error: "unauthorized" };
|
|
201
|
+
}
|
|
202
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
203
|
+
if (!ability.can("create", "RBNotification")) {
|
|
204
|
+
ctx.res.status(403);
|
|
205
|
+
return { ok: false, error: "forbidden" };
|
|
206
|
+
}
|
|
207
|
+
const parsed = createRequestSchema.safeParse(payload);
|
|
208
|
+
if (!parsed.success) {
|
|
209
|
+
ctx.res.status(400);
|
|
210
|
+
return { ok: false, error: "invalid_payload" };
|
|
211
|
+
}
|
|
212
|
+
const created = await createNotification(ctx, {
|
|
213
|
+
userId: session.userId,
|
|
214
|
+
topic: parsed.data.topic,
|
|
215
|
+
title: parsed.data.title,
|
|
216
|
+
body: parsed.data.body,
|
|
217
|
+
url: parsed.data.url,
|
|
218
|
+
metadata: parsed.data.metadata
|
|
219
|
+
});
|
|
220
|
+
return createResponseSchema.parse({ ok: true, id: created.id });
|
|
221
|
+
};
|
|
222
|
+
const markRead = async (_payload, ctx) => {
|
|
223
|
+
const session = getSessionUser(ctx);
|
|
224
|
+
if (!session) {
|
|
225
|
+
return { ok: false, error: "unauthorized" };
|
|
226
|
+
}
|
|
227
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
228
|
+
if (!ability.can("update", "RBNotification")) {
|
|
229
|
+
ctx.res.status(403);
|
|
230
|
+
return { ok: false, error: "forbidden" };
|
|
231
|
+
}
|
|
232
|
+
const notificationId = typeof ctx.req.params.notificationId === "string" ? ctx.req.params.notificationId.trim() : "";
|
|
233
|
+
if (!notificationId) {
|
|
234
|
+
ctx.res.status(400);
|
|
235
|
+
return { ok: false, error: "missing_notification_id" };
|
|
236
|
+
}
|
|
237
|
+
const NotificationModel = await loadModel("RBNotification", ctx);
|
|
238
|
+
const now = /* @__PURE__ */ new Date();
|
|
239
|
+
try {
|
|
240
|
+
await NotificationModel.updateOne(
|
|
241
|
+
{ $and: [{ _id: notificationId }, { archivedAt: { $exists: false } }, getAccessibleByQuery(ability, "update", "RBNotification")] },
|
|
242
|
+
{ $set: { readAt: now, seenAt: now } }
|
|
243
|
+
);
|
|
244
|
+
} catch {
|
|
245
|
+
ctx.res.status(400);
|
|
246
|
+
return { ok: false, error: "invalid_notification_id" };
|
|
247
|
+
}
|
|
248
|
+
return { ok: true };
|
|
249
|
+
};
|
|
250
|
+
const markAllRead = async (_payload, ctx) => {
|
|
251
|
+
const session = getSessionUser(ctx);
|
|
252
|
+
if (!session) {
|
|
253
|
+
return { ok: false, error: "unauthorized" };
|
|
254
|
+
}
|
|
255
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
256
|
+
if (!ability.can("update", "RBNotification")) {
|
|
257
|
+
ctx.res.status(403);
|
|
258
|
+
return { ok: false, error: "forbidden" };
|
|
259
|
+
}
|
|
260
|
+
const SettingsModel = await loadModel("RBNotificationSettings", ctx);
|
|
261
|
+
const settings = await SettingsModel.findOne({ userId: session.userId }).lean();
|
|
262
|
+
const disabledTopics = buildDisabledTopics(settings, "inApp");
|
|
263
|
+
const NotificationModel = await loadModel("RBNotification", ctx);
|
|
264
|
+
const queryFilters = [
|
|
265
|
+
{ userId: session.userId },
|
|
266
|
+
{ archivedAt: { $exists: false } },
|
|
267
|
+
{ readAt: { $exists: false } },
|
|
268
|
+
getAccessibleByQuery(ability, "update", "RBNotification")
|
|
269
|
+
];
|
|
270
|
+
if (disabledTopics.length > 0) queryFilters.push({ topic: { $nin: disabledTopics } });
|
|
271
|
+
const query = { $and: queryFilters };
|
|
272
|
+
const now = /* @__PURE__ */ new Date();
|
|
273
|
+
await NotificationModel.updateMany(query, { $set: { readAt: now, seenAt: now } });
|
|
274
|
+
return { ok: true };
|
|
275
|
+
};
|
|
276
|
+
const archiveNotification = async (_payload, ctx) => {
|
|
277
|
+
const session = getSessionUser(ctx);
|
|
278
|
+
if (!session) {
|
|
279
|
+
return { ok: false, error: "unauthorized" };
|
|
280
|
+
}
|
|
281
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
282
|
+
if (!ability.can("update", "RBNotification")) {
|
|
283
|
+
ctx.res.status(403);
|
|
284
|
+
return { ok: false, error: "forbidden" };
|
|
285
|
+
}
|
|
286
|
+
const notificationId = typeof ctx.req.params.notificationId === "string" ? ctx.req.params.notificationId.trim() : "";
|
|
287
|
+
if (!notificationId) {
|
|
288
|
+
ctx.res.status(400);
|
|
289
|
+
return { ok: false, error: "missing_notification_id" };
|
|
290
|
+
}
|
|
291
|
+
const NotificationModel = await loadModel("RBNotification", ctx);
|
|
292
|
+
try {
|
|
293
|
+
await NotificationModel.updateOne(
|
|
294
|
+
{ $and: [{ _id: notificationId }, { archivedAt: { $exists: false } }, getAccessibleByQuery(ability, "update", "RBNotification")] },
|
|
295
|
+
{ $set: { archivedAt: /* @__PURE__ */ new Date() } }
|
|
296
|
+
);
|
|
297
|
+
} catch {
|
|
298
|
+
ctx.res.status(400);
|
|
299
|
+
return { ok: false, error: "invalid_notification_id" };
|
|
300
|
+
}
|
|
301
|
+
return { ok: true };
|
|
302
|
+
};
|
|
303
|
+
const getSettings = async (_payload, ctx) => {
|
|
304
|
+
const session = getSessionUser(ctx);
|
|
305
|
+
if (!session) {
|
|
306
|
+
return { ok: false, error: "unauthorized" };
|
|
307
|
+
}
|
|
308
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
309
|
+
if (!ability.can("read", "RBNotificationSettings")) {
|
|
310
|
+
ctx.res.status(403);
|
|
311
|
+
return { ok: false, error: "forbidden" };
|
|
312
|
+
}
|
|
313
|
+
const SettingsModel = await loadModel("RBNotificationSettings", ctx);
|
|
314
|
+
const settings = await SettingsModel.findOne(
|
|
315
|
+
{ $and: [{ userId: session.userId }, getAccessibleByQuery(ability, "read", "RBNotificationSettings")] }
|
|
316
|
+
).lean();
|
|
317
|
+
const digestFrequencyRaw = typeof settings?.digestFrequency === "string" ? settings.digestFrequency : "weekly";
|
|
318
|
+
const digestFrequency = digestFrequencyRaw === "off" || digestFrequencyRaw === "daily" || digestFrequencyRaw === "weekly" ? digestFrequencyRaw : "weekly";
|
|
319
|
+
const topicPreferences = Array.isArray(settings?.topicPreferences) ? settings.topicPreferences.map((pref) => ({
|
|
320
|
+
topic: typeof pref.topic === "string" ? pref.topic : "",
|
|
321
|
+
inApp: pref.inApp === true,
|
|
322
|
+
emailDigest: pref.emailDigest === true,
|
|
323
|
+
push: pref.push === true
|
|
324
|
+
})).filter((pref) => pref.topic.length > 0) : [];
|
|
325
|
+
return settingsResponseSchema.parse({
|
|
326
|
+
ok: true,
|
|
327
|
+
settings: {
|
|
328
|
+
digestFrequency,
|
|
329
|
+
topicPreferences,
|
|
330
|
+
lastDigestSentAt: toIso(settings?.lastDigestSentAt)
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
};
|
|
334
|
+
const updateSettings = async (payload, ctx) => {
|
|
335
|
+
const session = getSessionUser(ctx);
|
|
336
|
+
if (!session) {
|
|
337
|
+
return { ok: false, error: "unauthorized" };
|
|
338
|
+
}
|
|
339
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
340
|
+
if (!ability.can("update", "RBNotificationSettings")) {
|
|
341
|
+
ctx.res.status(403);
|
|
342
|
+
return { ok: false, error: "forbidden" };
|
|
343
|
+
}
|
|
344
|
+
const parsed = updateSettingsRequestSchema.safeParse(payload);
|
|
345
|
+
if (!parsed.success) {
|
|
346
|
+
ctx.res.status(400);
|
|
347
|
+
return { ok: false, error: "invalid_payload" };
|
|
348
|
+
}
|
|
349
|
+
const SettingsModel = await loadModel("RBNotificationSettings", ctx);
|
|
350
|
+
const nextValues = {};
|
|
351
|
+
if (parsed.data.digestFrequency) {
|
|
352
|
+
nextValues.digestFrequency = parsed.data.digestFrequency;
|
|
353
|
+
}
|
|
354
|
+
if (parsed.data.topicPreferences) {
|
|
355
|
+
const seen = /* @__PURE__ */ new Set();
|
|
356
|
+
const next = parsed.data.topicPreferences.map((pref) => ({
|
|
357
|
+
topic: pref.topic.trim(),
|
|
358
|
+
inApp: pref.inApp,
|
|
359
|
+
emailDigest: pref.emailDigest,
|
|
360
|
+
push: pref.push
|
|
361
|
+
})).filter((pref) => pref.topic.length > 0).filter((pref) => {
|
|
362
|
+
if (seen.has(pref.topic)) return false;
|
|
363
|
+
seen.add(pref.topic);
|
|
364
|
+
return true;
|
|
365
|
+
});
|
|
366
|
+
nextValues.topicPreferences = next;
|
|
367
|
+
}
|
|
368
|
+
const ops = {
|
|
369
|
+
$setOnInsert: { userId: session.userId }
|
|
370
|
+
};
|
|
371
|
+
if (Object.keys(nextValues).length > 0) {
|
|
372
|
+
ops.$set = nextValues;
|
|
373
|
+
}
|
|
374
|
+
const settings = await SettingsModel.findOneAndUpdate(
|
|
375
|
+
{ $and: [{ userId: session.userId }, getAccessibleByQuery(ability, "update", "RBNotificationSettings")] },
|
|
376
|
+
ops,
|
|
377
|
+
{ upsert: true, new: true, setDefaultsOnInsert: true }
|
|
378
|
+
).lean();
|
|
379
|
+
const digestFrequencyRaw = typeof settings?.digestFrequency === "string" ? settings.digestFrequency : "weekly";
|
|
380
|
+
const digestFrequency = digestFrequencyRaw === "off" || digestFrequencyRaw === "daily" || digestFrequencyRaw === "weekly" ? digestFrequencyRaw : "weekly";
|
|
381
|
+
const topicPreferences = Array.isArray(settings?.topicPreferences) ? settings.topicPreferences.map((pref) => ({
|
|
382
|
+
topic: typeof pref.topic === "string" ? pref.topic : "",
|
|
383
|
+
inApp: pref.inApp === true,
|
|
384
|
+
emailDigest: pref.emailDigest === true,
|
|
385
|
+
push: pref.push === true
|
|
386
|
+
})).filter((pref) => pref.topic.length > 0) : [];
|
|
387
|
+
return updateSettingsResponseSchema.parse({
|
|
388
|
+
ok: true,
|
|
389
|
+
settings: {
|
|
390
|
+
digestFrequency,
|
|
391
|
+
topicPreferences,
|
|
392
|
+
lastDigestSentAt: toIso(settings?.lastDigestSentAt)
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
};
|
|
396
|
+
const runDigest = async (payload, ctx) => {
|
|
397
|
+
const session = getSessionUser(ctx);
|
|
398
|
+
if (!session) {
|
|
399
|
+
return { ok: false, error: "unauthorized" };
|
|
400
|
+
}
|
|
401
|
+
const ability = buildAbilityFromSession({ tenantId: session.tenantId, session: ctx.req.session });
|
|
402
|
+
if (!ability.can("read", "RBNotification")) {
|
|
403
|
+
ctx.res.status(403);
|
|
404
|
+
return { ok: false, error: "forbidden" };
|
|
405
|
+
}
|
|
406
|
+
const parsed = digestRunRequestSchema.safeParse(payload);
|
|
407
|
+
if (!parsed.success) {
|
|
408
|
+
ctx.res.status(400);
|
|
409
|
+
return { ok: false, error: "invalid_payload" };
|
|
410
|
+
}
|
|
411
|
+
const result = await sendNotificationsDigestForUser(ctx, {
|
|
412
|
+
userId: session.userId,
|
|
413
|
+
force: parsed.data.force === true
|
|
414
|
+
});
|
|
415
|
+
if (!result.ok) {
|
|
416
|
+
ctx.res.status(500);
|
|
417
|
+
return { ok: false, error: result.error };
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
ok: true,
|
|
421
|
+
sent: result.sent,
|
|
422
|
+
...result.skippedReason ? { skippedReason: result.skippedReason } : {}
|
|
423
|
+
};
|
|
424
|
+
};
|
|
425
|
+
const handler = (api) => {
|
|
426
|
+
api.post(ListRoute, listNotifications);
|
|
427
|
+
api.post(CreateRoute, createNotificationForCurrentUser);
|
|
428
|
+
api.post(MarkReadRoute, markRead);
|
|
429
|
+
api.post(MarkAllReadRoute, markAllRead);
|
|
430
|
+
api.post(ArchiveRoute, archiveNotification);
|
|
431
|
+
api.get(SettingsRoute, getSettings);
|
|
432
|
+
api.put(SettingsRoute, updateSettings);
|
|
433
|
+
api.post(DigestRunRoute, runDigest);
|
|
434
|
+
};
|
|
435
|
+
export {
|
|
436
|
+
handler as default
|
|
437
|
+
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { getTenantFilesystemDb } from "@rpcbase/db";
|
|
2
2
|
import { ObjectId, GridFSBucket } from "mongodb";
|
|
3
|
-
import { g as getTenantId,
|
|
3
|
+
import { g as getTenantId, d as getBucketName } from "./shared-UGuDRAKK.js";
|
|
4
4
|
const deleteFile = async (_payload, ctx) => {
|
|
5
5
|
const tenantId = getTenantId(ctx);
|
|
6
6
|
if (!tenantId) {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { loadModel, ZRBRtsChangeOp } from "@rpcbase/db";
|
|
2
|
-
import {
|
|
2
|
+
import { buildAbilityFromSession } from "@rpcbase/db/acl";
|
|
3
|
+
import { o as object, a as array, s as string, n as number, b as boolean, _ as _enum } from "./schemas-D5T9tDtI.js";
|
|
3
4
|
const Route = "/api/rb/rts/changes";
|
|
4
5
|
const requestSchema = object({
|
|
5
6
|
sinceSeq: number().int().min(0).default(0),
|
|
@@ -68,6 +69,7 @@ const changesHandler = async (payload, ctx) => {
|
|
|
68
69
|
ctx.res.status(401);
|
|
69
70
|
return { ok: false, latestSeq: 0, changes: [] };
|
|
70
71
|
}
|
|
72
|
+
const ability = buildAbilityFromSession({ tenantId, session: ctx.req.session });
|
|
71
73
|
const modelCtx = getModelCtx(ctx, tenantId);
|
|
72
74
|
const [RtsChange, RtsCounter] = await Promise.all([
|
|
73
75
|
loadModel("RBRtsChange", modelCtx),
|
|
@@ -76,24 +78,26 @@ const changesHandler = async (payload, ctx) => {
|
|
|
76
78
|
const counter = await RtsCounter.findOne({ _id: "rts" }, { seq: 1 }).lean();
|
|
77
79
|
const latestSeq = Number(counter?.seq ?? 0) || 0;
|
|
78
80
|
const { sinceSeq, limit, modelNames } = parsed.data;
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
81
|
+
const requestedModelNames = Array.isArray(modelNames) && modelNames.length ? modelNames.map((m) => String(m)).filter(Boolean) : null;
|
|
82
|
+
const allowedModelNames = requestedModelNames ? requestedModelNames.filter((m) => ability.can("read", m)) : Array.from(
|
|
83
|
+
new Set(
|
|
84
|
+
(await RtsChange.distinct("modelName")).map((m) => String(m)).filter(Boolean).filter((m) => ability.can("read", m))
|
|
85
|
+
)
|
|
86
|
+
);
|
|
87
|
+
let earliestSeq;
|
|
88
|
+
if (allowedModelNames.length) {
|
|
89
|
+
const earliest = await RtsChange.findOne({ modelName: { $in: allowedModelNames } }, { seq: 1 }).sort({ seq: 1 }).lean();
|
|
90
|
+
earliestSeq = earliest?.seq ? Number(earliest.seq) : void 0;
|
|
82
91
|
}
|
|
83
|
-
const earliest = await RtsChange.findOne(earliestSelector, { seq: 1 }).sort({ seq: 1 }).lean();
|
|
84
|
-
const earliestSeq = earliest?.seq ? Number(earliest.seq) : void 0;
|
|
85
92
|
const needsFullResync = typeof earliestSeq === "number" && sinceSeq < earliestSeq - 1;
|
|
86
|
-
const selector = { seq: { $gt: sinceSeq } };
|
|
87
|
-
if (Array.isArray(modelNames) && modelNames.length) {
|
|
88
|
-
selector.modelName = { $in: modelNames };
|
|
89
|
-
}
|
|
93
|
+
const selector = { seq: { $gt: sinceSeq }, modelName: { $in: allowedModelNames } };
|
|
90
94
|
const changes = await RtsChange.find(selector, { _id: 0, seq: 1, modelName: 1, op: 1, docId: 1 }).sort({ seq: 1 }).limit(limit).lean();
|
|
91
95
|
return {
|
|
92
96
|
ok: true,
|
|
93
97
|
needsFullResync: needsFullResync || void 0,
|
|
94
98
|
earliestSeq,
|
|
95
99
|
latestSeq,
|
|
96
|
-
changes: Array.isArray(changes) ? changes.filter(isRtsChangeRecord).map((c) => ({
|
|
100
|
+
changes: Array.isArray(changes) ? changes.filter(isRtsChangeRecord).filter((c) => ability.can("read", c.modelName)).map((c) => ({
|
|
97
101
|
seq: Number(c.seq),
|
|
98
102
|
modelName: String(c.modelName),
|
|
99
103
|
op: c.op,
|
package/dist/index.d.ts
CHANGED
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,uBAAuB,CAAA;AACrC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,SAAS,CAAA
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,cAAc,CAAA;AAC5B,cAAc,iBAAiB,CAAA;AAC/B,cAAc,gBAAgB,CAAA;AAC9B,cAAc,uBAAuB,CAAA;AACrC,cAAc,iBAAiB,CAAA;AAC/B,cAAc,SAAS,CAAA"}
|