Skip to content

Commit a65d445

Browse files
parth0025claude
andauthored
feat(stickies): add personal sticky notes (#239)
A per-user quick-capture scratchpad opened from a header icon: a slide-over panel of colored notes with a bold title and body, debounced auto-save, a per-note ... menu (pin, expand to a large view, color, delete), and drag-to-reorder. Notes are private to the user and scoped to the company database. Backend: Modules/Stickies (CRUD + reorder) with a pure stickyRules layer and unit tests; the stickies collection is wired through all five schema registration points; every query is scoped by company + userId with content/title length caps and a per-user count cap. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent b9a26c4 commit a65d445

15 files changed

Lines changed: 888 additions & 0 deletions

File tree

Config/collections.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ const dbCollections = {
5959
WEBHOOKS: "webhooks",
6060
WEBHOOK_LOGS: "webhookLogs",
6161
RECENTVISITS: "recentVisits",
62+
STICKIES: "stickies",
6263
API_TOKENS: "apiTokens",
6364
API_ACTIVITY_LOGS: "apiActivityLogs",
6465
EXPORT_JOBS: "exportJobs",

Config/schemaType.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ const SCHEMA_TYPE = {
5858
WEBHOOKS: "webhooks",
5959
WEBHOOK_LOGS: "webhookLogs",
6060
RECENTVISITS: "recentVisits",
61+
STICKIES: "stickies",
6162
API_TOKENS: "apiTokens",
6263
API_ACTIVITY_LOGS: "apiActivityLogs",
6364
EXPORT_JOBS: "exportJobs",

Modules/Stickies/controller.js

Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
const { SCHEMA_TYPE } = require("../../Config/schemaType");
2+
const { MongoDbCrudOpration } = require("../../utils/mongo-handler/mongoQueries");
3+
const mongoose = require("mongoose");
4+
const logger = require("../../Config/loggerConfig");
5+
const {
6+
MAX_NOTES_PER_USER,
7+
isObjectId,
8+
validateStickyPayload,
9+
validateReorder,
10+
} = require("./helpers/stickyRules");
11+
12+
// Personal sticky notes — one document per note, scoped to the company
13+
// database AND the owning userId. Every query filters on userId, so a user
14+
// can only ever read or mutate their own notes. Content is stored as plain
15+
// text and rendered as text on the client (no HTML).
16+
17+
// Resolve the acting user from the various shapes the clients send.
18+
const getUserId = (req) => {
19+
const fromBody = req.body && (req.body.userId || (req.body.userData && (req.body.userData.id || req.body.userData._id)));
20+
const fromQuery = req.query && req.query.uid;
21+
return String(fromBody || fromQuery || '');
22+
};
23+
24+
/* GET /api/v2/stickies?uid=<userId> — the user's notes, pinned first. */
25+
exports.listStickies = async (req, res) => {
26+
try {
27+
const companyId = req.headers['companyid'] || '';
28+
const userId = getUserId(req);
29+
if (!companyId || !userId) {
30+
return res.send({ status: false, statusText: 'companyId and uid are required.' });
31+
}
32+
33+
const stickies = await MongoDbCrudOpration(companyId, {
34+
type: SCHEMA_TYPE.STICKIES,
35+
data: [{ userId }, null, { sort: { isPinned: -1, sortIndex: 1, updatedAt: -1 } }],
36+
}, 'find');
37+
38+
return res.send({ status: true, statusText: 'Stickies fetched.', data: stickies || [] });
39+
} catch (error) {
40+
logger.error(`ERROR in list stickies: ${error.message}`);
41+
return res.send({ status: false, statusText: error.message });
42+
}
43+
};
44+
45+
/* POST /api/v2/stickies body: { content?, color?, userData } */
46+
exports.createSticky = async (req, res) => {
47+
try {
48+
const companyId = req.headers['companyid'] || '';
49+
const userId = getUserId(req);
50+
if (!companyId || !userId) {
51+
return res.send({ status: false, statusText: 'companyId and userId are required.' });
52+
}
53+
54+
const check = validateStickyPayload(req.body || {}, { partial: false });
55+
if (!check.valid) {
56+
return res.send({ status: false, statusText: check.reason });
57+
}
58+
59+
// Per-user count cap (abuse guard).
60+
const count = await MongoDbCrudOpration(companyId, {
61+
type: SCHEMA_TYPE.STICKIES,
62+
data: [{ userId }],
63+
}, 'countDocuments');
64+
if (count >= MAX_NOTES_PER_USER) {
65+
return res.send({ status: false, statusText: `You can keep at most ${MAX_NOTES_PER_USER} sticky notes.` });
66+
}
67+
68+
const created = await MongoDbCrudOpration(companyId, {
69+
type: SCHEMA_TYPE.STICKIES,
70+
data: {
71+
userId,
72+
title: check.data.title,
73+
content: check.data.content,
74+
color: check.data.color,
75+
sortIndex: 0,
76+
isPinned: false,
77+
},
78+
}, 'save');
79+
80+
return res.send({ status: true, statusText: 'Sticky created.', data: created });
81+
} catch (error) {
82+
logger.error(`ERROR in create sticky: ${error.message}`);
83+
return res.send({ status: false, statusText: error.message });
84+
}
85+
};
86+
87+
/* PUT /api/v2/stickies/:id body: { content?, color?, isPinned?, userData } */
88+
exports.updateSticky = async (req, res) => {
89+
try {
90+
const companyId = req.headers['companyid'] || '';
91+
const userId = getUserId(req);
92+
const { id } = req.params;
93+
if (!companyId || !userId || !isObjectId(id)) {
94+
return res.send({ status: false, statusText: 'companyId, userId and a valid id are required.' });
95+
}
96+
97+
const check = validateStickyPayload(req.body || {}, { partial: true });
98+
if (!check.valid) {
99+
return res.send({ status: false, statusText: check.reason });
100+
}
101+
102+
const updated = await MongoDbCrudOpration(companyId, {
103+
type: SCHEMA_TYPE.STICKIES,
104+
data: [
105+
{ _id: new mongoose.Types.ObjectId(id), userId },
106+
{ $set: check.data },
107+
{ returnDocument: 'after' },
108+
],
109+
}, 'findOneAndUpdate');
110+
111+
if (!updated) {
112+
return res.send({ status: false, statusText: 'Sticky not found.' });
113+
}
114+
return res.send({ status: true, statusText: 'Sticky updated.', data: updated });
115+
} catch (error) {
116+
logger.error(`ERROR in update sticky: ${error.message}`);
117+
return res.send({ status: false, statusText: error.message });
118+
}
119+
};
120+
121+
/* DELETE /api/v2/stickies/:id?uid=<userId> */
122+
exports.deleteSticky = async (req, res) => {
123+
try {
124+
const companyId = req.headers['companyid'] || '';
125+
const userId = getUserId(req);
126+
const { id } = req.params;
127+
if (!companyId || !userId || !isObjectId(id)) {
128+
return res.send({ status: false, statusText: 'companyId, userId and a valid id are required.' });
129+
}
130+
131+
const deleted = await MongoDbCrudOpration(companyId, {
132+
type: SCHEMA_TYPE.STICKIES,
133+
data: [{ _id: new mongoose.Types.ObjectId(id), userId }],
134+
}, 'findOneAndDelete');
135+
136+
if (!deleted) {
137+
return res.send({ status: false, statusText: 'Sticky not found.' });
138+
}
139+
return res.send({ status: true, statusText: 'Sticky deleted.', data: { _id: id } });
140+
} catch (error) {
141+
logger.error(`ERROR in delete sticky: ${error.message}`);
142+
return res.send({ status: false, statusText: error.message });
143+
}
144+
};
145+
146+
/* PUT /api/v2/stickies/reorder body: { ids: [..], userData } */
147+
exports.reorderStickies = async (req, res) => {
148+
try {
149+
const companyId = req.headers['companyid'] || '';
150+
const userId = getUserId(req);
151+
if (!companyId || !userId) {
152+
return res.send({ status: false, statusText: 'companyId and userId are required.' });
153+
}
154+
155+
const check = validateReorder(req.body && req.body.ids);
156+
if (!check.valid) {
157+
return res.send({ status: false, statusText: check.reason });
158+
}
159+
160+
// Each note is updated only when it belongs to the user (userId in
161+
// the filter), so a forged id for someone else's note is a no-op.
162+
for (let index = 0; index < check.ids.length; index += 1) {
163+
await MongoDbCrudOpration(companyId, {
164+
type: SCHEMA_TYPE.STICKIES,
165+
data: [
166+
{ _id: new mongoose.Types.ObjectId(check.ids[index]), userId },
167+
{ $set: { sortIndex: index } },
168+
],
169+
}, 'updateOne');
170+
}
171+
172+
return res.send({ status: true, statusText: 'Stickies reordered.' });
173+
} catch (error) {
174+
logger.error(`ERROR in reorder stickies: ${error.message}`);
175+
return res.send({ status: false, statusText: error.message });
176+
}
177+
};
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Pure validation/normalization rules for sticky notes — NO I/O, so the
2+
// jest suite can import this file directly without dragging in mongoose,
3+
// controllers, or any module that trips the Babel parser on legacy files.
4+
5+
const STICKY_COLORS = ['yellow', 'green', 'blue', 'pink', 'orange', 'purple', 'gray'];
6+
const DEFAULT_COLOR = 'yellow';
7+
const MAX_TITLE_LENGTH = 200;
8+
const MAX_CONTENT_LENGTH = 2000;
9+
const MAX_NOTES_PER_USER = 200;
10+
const OBJECT_ID_PATTERN = /^[0-9a-fA-F]{24}$/;
11+
12+
const isObjectId = (value) => OBJECT_ID_PATTERN.test(String(value || ''));
13+
14+
// Notes are plain text rendered as text (never HTML) on the client, so we
15+
// only bound the length and coerce to a string here — no markup stripping.
16+
const normalizeContent = (content) => String(content == null ? '' : content);
17+
18+
const normalizeColor = (color) => (STICKY_COLORS.includes(String(color)) ? String(color) : DEFAULT_COLOR);
19+
20+
// Validate a create/update payload. `partial: true` (update) only checks the
21+
// fields that are present; create requires the note to be within limits.
22+
const validateStickyPayload = (payload = {}, { partial = false } = {}) => {
23+
const out = {};
24+
25+
if (!partial || payload.title !== undefined) {
26+
const title = normalizeContent(payload.title);
27+
if (title.length > MAX_TITLE_LENGTH) {
28+
return { valid: false, reason: `Title exceeds ${MAX_TITLE_LENGTH} characters.` };
29+
}
30+
out.title = title;
31+
}
32+
33+
if (!partial || payload.content !== undefined) {
34+
const content = normalizeContent(payload.content);
35+
if (content.length > MAX_CONTENT_LENGTH) {
36+
return { valid: false, reason: `Content exceeds ${MAX_CONTENT_LENGTH} characters.` };
37+
}
38+
out.content = content;
39+
}
40+
41+
if (!partial || payload.color !== undefined) {
42+
out.color = normalizeColor(payload.color);
43+
}
44+
45+
if (payload.isPinned !== undefined) {
46+
out.isPinned = Boolean(payload.isPinned);
47+
}
48+
49+
if (payload.sortIndex !== undefined) {
50+
const n = Number(payload.sortIndex);
51+
if (!Number.isFinite(n)) {
52+
return { valid: false, reason: 'sortIndex must be a number.' };
53+
}
54+
out.sortIndex = n;
55+
}
56+
57+
if (partial && Object.keys(out).length === 0) {
58+
return { valid: false, reason: 'No valid fields to update.' };
59+
}
60+
61+
return { valid: true, data: out };
62+
};
63+
64+
// Validate a reorder request: an array of sticky ids in their new order.
65+
const validateReorder = (ids) => {
66+
if (!Array.isArray(ids) || !ids.length) {
67+
return { valid: false, reason: 'ids must be a non-empty array.' };
68+
}
69+
if (ids.length > MAX_NOTES_PER_USER) {
70+
return { valid: false, reason: 'Too many ids.' };
71+
}
72+
if (!ids.every(isObjectId)) {
73+
return { valid: false, reason: 'Every id must be a valid ObjectId.' };
74+
}
75+
return { valid: true, ids: ids.map(String) };
76+
};
77+
78+
module.exports = {
79+
STICKY_COLORS,
80+
DEFAULT_COLOR,
81+
MAX_TITLE_LENGTH,
82+
MAX_CONTENT_LENGTH,
83+
MAX_NOTES_PER_USER,
84+
isObjectId,
85+
normalizeContent,
86+
normalizeColor,
87+
validateStickyPayload,
88+
validateReorder,
89+
};

Modules/Stickies/init.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const routes = require('./routes');
2+
3+
exports.init = (app) => {
4+
routes.init(app);
5+
}

Modules/Stickies/routes.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
const ctrl = require('./controller');
2+
3+
exports.init = (app) => {
4+
app.get('/api/v2/stickies', ctrl.listStickies);
5+
app.post('/api/v2/stickies', ctrl.createSticky);
6+
app.put('/api/v2/stickies/reorder', ctrl.reorderStickies);
7+
app.put('/api/v2/stickies/:id', ctrl.updateSticky);
8+
app.delete('/api/v2/stickies/:id', ctrl.deleteSticky);
9+
}
Lines changed: 5 additions & 0 deletions
Loading

0 commit comments

Comments
 (0)