@basaltkit/comments 1.0.0 → 1.0.2

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/LICENSE CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 Machize Contributors
3
+ Copyright (c) 2026 Basalt Contributors
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,3 +1,9 @@
1
+ <p align="center">
2
+ <a href="https://basaltkit-docs.pages.dev">
3
+ <img src="https://basaltkit-docs.pages.dev/social-card.png" alt="Basalt" width="440">
4
+ </a>
5
+ </p>
6
+
1
7
  # @basaltkit/comments
2
8
 
3
9
  Per-resource comments for Basalt: **threads** (nested replies), **@mentions**, and **resolve/reopen**, isolated by tenant, emitting **events** that connect to [`@basaltkit/realtime`](https://www.npmjs.com/package/@basaltkit/realtime) (live discussion) and [`@basaltkit/notifications`](https://www.npmjs.com/package/@basaltkit/notifications) (notify who was mentioned). You need this module when you want collaboration — commenting on a note, a project, a task.
@@ -0,0 +1,54 @@
1
+ import { BasaltError, type HookBus } from '@basaltkit/core';
2
+ import { type Comment, type CommentStore } from './store.js';
3
+ export declare class CommentNotFoundError extends BasaltError {
4
+ readonly status = 404;
5
+ constructor();
6
+ }
7
+ export declare class CommentTenantRequiredError extends BasaltError {
8
+ readonly status = 400;
9
+ constructor();
10
+ }
11
+ /** A comment plus its nested replies. */
12
+ export interface CommentNode extends Comment {
13
+ replies: CommentNode[];
14
+ }
15
+ export interface CommentsOptions {
16
+ store?: CommentStore;
17
+ hooks?: HookBus;
18
+ /** Regex whose first capture group is a mentioned user id. Default `@([\w-]+)`. */
19
+ mentionPattern?: RegExp;
20
+ now?: () => number;
21
+ }
22
+ export interface AddCommentInput {
23
+ authorId: string;
24
+ body: string;
25
+ parentId?: string;
26
+ }
27
+ /** Everything scoped to one resource (`resourceType`:`resourceId`) of a tenant. */
28
+ export interface ResourceComments {
29
+ add(input: AddCommentInput): Promise<Comment>;
30
+ list(): Promise<Comment[]>;
31
+ tree(): Promise<CommentNode[]>;
32
+ }
33
+ /**
34
+ * Per-resource comment threads with @mentions and resolve/reopen, scoped by
35
+ * tenant. Emits hooks (`comment:created`, `comment:mentioned`, …) so live
36
+ * updates (@basaltkit/realtime) and notifications wire up without coupling.
37
+ */
38
+ export declare class Comments {
39
+ private readonly store;
40
+ private readonly hooks;
41
+ private readonly mentionPattern;
42
+ private readonly now;
43
+ constructor(options?: CommentsOptions);
44
+ on(resourceType: string, resourceId: string, tenantId?: string): ResourceComments;
45
+ get(id: string, tenantId?: string): Promise<Comment | null>;
46
+ edit(id: string, body: string, tenantId?: string): Promise<Comment>;
47
+ remove(id: string, tenantId?: string): Promise<void>;
48
+ resolve(id: string, resolvedBy: string, tenantId?: string): Promise<Comment>;
49
+ reopen(id: string, tenantId?: string): Promise<Comment>;
50
+ private add;
51
+ private mutate;
52
+ private mentions;
53
+ private tenant;
54
+ }
@@ -0,0 +1,124 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { BasaltError, tryCtx } from '@basaltkit/core';
3
+ import { MemoryCommentStore } from './store.js';
4
+ export class CommentNotFoundError extends BasaltError {
5
+ status = 404;
6
+ constructor() {
7
+ super('COMMENT_NOT_FOUND', 'Comment not found.');
8
+ }
9
+ }
10
+ export class CommentTenantRequiredError extends BasaltError {
11
+ status = 400;
12
+ constructor() {
13
+ super('COMMENT_TENANT_REQUIRED', 'A tenant is required — pass tenantId or run inside a tenant context.');
14
+ }
15
+ }
16
+ const buildTree = (comments) => {
17
+ const nodes = new Map(comments.map((c) => [c.id, { ...c, replies: [] }]));
18
+ const roots = [];
19
+ for (const node of nodes.values()) {
20
+ const parent = node.parentId ? nodes.get(node.parentId) : undefined;
21
+ if (parent)
22
+ parent.replies.push(node);
23
+ else
24
+ roots.push(node);
25
+ }
26
+ return roots;
27
+ };
28
+ /**
29
+ * Per-resource comment threads with @mentions and resolve/reopen, scoped by
30
+ * tenant. Emits hooks (`comment:created`, `comment:mentioned`, …) so live
31
+ * updates (@basaltkit/realtime) and notifications wire up without coupling.
32
+ */
33
+ export class Comments {
34
+ store;
35
+ hooks;
36
+ mentionPattern;
37
+ now;
38
+ constructor(options = {}) {
39
+ this.store = options.store ?? new MemoryCommentStore();
40
+ this.hooks = options.hooks;
41
+ this.mentionPattern = options.mentionPattern ?? /@([\w-]+)/g;
42
+ this.now = options.now ?? Date.now;
43
+ }
44
+ on(resourceType, resourceId, tenantId) {
45
+ const tenant = this.tenant(tenantId);
46
+ return {
47
+ add: (input) => this.add(tenant, resourceType, resourceId, input),
48
+ list: () => this.store.list(tenant, resourceType, resourceId),
49
+ tree: async () => buildTree(await this.store.list(tenant, resourceType, resourceId)),
50
+ };
51
+ }
52
+ get(id, tenantId) {
53
+ return this.store.find(this.tenant(tenantId), id);
54
+ }
55
+ async edit(id, body, tenantId) {
56
+ const tenant = this.tenant(tenantId);
57
+ if (!(await this.store.find(tenant, id)))
58
+ throw new CommentNotFoundError();
59
+ const updated = await this.store.update(tenant, id, { body, mentions: this.mentions(body), editedAt: this.now() });
60
+ await this.hooks?.emit('comment:updated', { comment: updated });
61
+ return updated;
62
+ }
63
+ async remove(id, tenantId) {
64
+ const tenant = this.tenant(tenantId);
65
+ const comment = await this.store.find(tenant, id);
66
+ if (!comment)
67
+ return;
68
+ await this.store.delete(tenant, id);
69
+ await this.hooks?.emit('comment:deleted', {
70
+ tenantId: tenant,
71
+ id,
72
+ resourceType: comment.resourceType,
73
+ resourceId: comment.resourceId,
74
+ });
75
+ }
76
+ async resolve(id, resolvedBy, tenantId) {
77
+ const comment = await this.mutate(id, { resolvedAt: this.now(), resolvedBy }, tenantId);
78
+ await this.hooks?.emit('comment:resolved', { comment });
79
+ return comment;
80
+ }
81
+ async reopen(id, tenantId) {
82
+ const comment = await this.mutate(id, { resolvedAt: undefined, resolvedBy: undefined }, tenantId);
83
+ await this.hooks?.emit('comment:reopened', { comment });
84
+ return comment;
85
+ }
86
+ async add(tenantId, resourceType, resourceId, input) {
87
+ const mentions = this.mentions(input.body);
88
+ const comment = {
89
+ id: randomUUID(),
90
+ tenantId,
91
+ resourceType,
92
+ resourceId,
93
+ authorId: input.authorId,
94
+ body: input.body,
95
+ mentions,
96
+ createdAt: this.now(),
97
+ ...(input.parentId !== undefined ? { parentId: input.parentId } : {}),
98
+ };
99
+ await this.store.create(comment);
100
+ await this.hooks?.emit('comment:created', { comment });
101
+ for (const userId of mentions)
102
+ await this.hooks?.emit('comment:mentioned', { comment, userId });
103
+ return comment;
104
+ }
105
+ async mutate(id, patch, tenantId) {
106
+ const tenant = this.tenant(tenantId);
107
+ if (!(await this.store.find(tenant, id)))
108
+ throw new CommentNotFoundError();
109
+ return (await this.store.update(tenant, id, patch));
110
+ }
111
+ mentions(body) {
112
+ const ids = new Set();
113
+ for (const match of body.matchAll(this.mentionPattern))
114
+ if (match[1])
115
+ ids.add(match[1]);
116
+ return [...ids];
117
+ }
118
+ tenant(explicit) {
119
+ const id = explicit ?? tryCtx()?.['tenant']?.id;
120
+ if (!id)
121
+ throw new CommentTenantRequiredError();
122
+ return id;
123
+ }
124
+ }
package/dist/index.d.ts CHANGED
@@ -1,138 +1,3 @@
1
- import * as _basaltkit_core from '@basaltkit/core';
2
- import { HookBus, BasaltError } from '@basaltkit/core';
3
- import { BasaltRoute } from '@basaltkit/fastify';
4
-
5
- /** A comment attached to a resource (`resourceType`:`resourceId`) within a tenant. */
6
- interface Comment {
7
- id: string;
8
- tenantId: string;
9
- resourceType: string;
10
- resourceId: string;
11
- /** Set when this comment is a reply to another. */
12
- parentId?: string;
13
- authorId: string;
14
- body: string;
15
- /** User ids extracted from @mentions in the body. */
16
- mentions: string[];
17
- resolvedAt?: number;
18
- resolvedBy?: string;
19
- editedAt?: number;
20
- createdAt: number;
21
- }
22
- interface CommentPatch {
23
- body?: string;
24
- mentions?: string[];
25
- editedAt?: number;
26
- /** `undefined` clears it (reopen). */
27
- resolvedAt?: number | undefined;
28
- resolvedBy?: string | undefined;
29
- }
30
- interface CommentStore {
31
- create(comment: Comment): Promise<void>;
32
- find(tenantId: string, id: string): Promise<Comment | null>;
33
- /** Every comment on a resource (the whole thread), oldest first. */
34
- list(tenantId: string, resourceType: string, resourceId: string): Promise<Comment[]>;
35
- update(tenantId: string, id: string, patch: CommentPatch): Promise<Comment | null>;
36
- delete(tenantId: string, id: string): Promise<void>;
37
- }
38
- declare class MemoryCommentStore implements CommentStore {
39
- private readonly records;
40
- private key;
41
- create(comment: Comment): Promise<void>;
42
- find(tenantId: string, id: string): Promise<Comment | null>;
43
- list(tenantId: string, resourceType: string, resourceId: string): Promise<Comment[]>;
44
- update(tenantId: string, id: string, patch: CommentPatch): Promise<Comment | null>;
45
- delete(tenantId: string, id: string): Promise<void>;
46
- }
47
-
48
- declare class CommentNotFoundError extends BasaltError {
49
- readonly status = 404;
50
- constructor();
51
- }
52
- declare class CommentTenantRequiredError extends BasaltError {
53
- readonly status = 400;
54
- constructor();
55
- }
56
- /** A comment plus its nested replies. */
57
- interface CommentNode extends Comment {
58
- replies: CommentNode[];
59
- }
60
- interface CommentsOptions {
61
- store?: CommentStore;
62
- hooks?: HookBus;
63
- /** Regex whose first capture group is a mentioned user id. Default `@([\w-]+)`. */
64
- mentionPattern?: RegExp;
65
- now?: () => number;
66
- }
67
- interface AddCommentInput {
68
- authorId: string;
69
- body: string;
70
- parentId?: string;
71
- }
72
- /** Everything scoped to one resource (`resourceType`:`resourceId`) of a tenant. */
73
- interface ResourceComments {
74
- add(input: AddCommentInput): Promise<Comment>;
75
- list(): Promise<Comment[]>;
76
- tree(): Promise<CommentNode[]>;
77
- }
78
- /**
79
- * Per-resource comment threads with @mentions and resolve/reopen, scoped by
80
- * tenant. Emits hooks (`comment:created`, `comment:mentioned`, …) so live
81
- * updates (@basaltkit/realtime) and notifications wire up without coupling.
82
- */
83
- declare class Comments {
84
- private readonly store;
85
- private readonly hooks;
86
- private readonly mentionPattern;
87
- private readonly now;
88
- constructor(options?: CommentsOptions);
89
- on(resourceType: string, resourceId: string, tenantId?: string): ResourceComments;
90
- get(id: string, tenantId?: string): Promise<Comment | null>;
91
- edit(id: string, body: string, tenantId?: string): Promise<Comment>;
92
- remove(id: string, tenantId?: string): Promise<void>;
93
- resolve(id: string, resolvedBy: string, tenantId?: string): Promise<Comment>;
94
- reopen(id: string, tenantId?: string): Promise<Comment>;
95
- private add;
96
- private mutate;
97
- private mentions;
98
- private tenant;
99
- }
100
-
101
- declare module '@basaltkit/core' {
102
- interface BasaltHooks {
103
- 'comment:created': {
104
- comment: Comment;
105
- };
106
- 'comment:updated': {
107
- comment: Comment;
108
- };
109
- 'comment:deleted': {
110
- tenantId: string;
111
- id: string;
112
- resourceType: string;
113
- resourceId: string;
114
- };
115
- 'comment:resolved': {
116
- comment: Comment;
117
- };
118
- 'comment:reopened': {
119
- comment: Comment;
120
- };
121
- /** One per mentioned user — wire to notifications. */
122
- 'comment:mentioned': {
123
- comment: Comment;
124
- userId: string;
125
- };
126
- }
127
- }
128
- declare const COMMENTS: _basaltkit_core.Token<Comments>;
129
- type CommentsPluginOptions = Omit<CommentsOptions, 'hooks'>;
130
- declare function commentsPlugin(options?: CommentsPluginOptions): _basaltkit_core.BasaltPlugin<unknown>;
131
- /**
132
- * REST routes for the current tenant's comments, all requiring a logged-in
133
- * user. The author is taken from `ctx().user`; editing and deleting are
134
- * restricted to the comment's author.
135
- */
136
- declare function commentRoutes(): BasaltRoute[];
137
-
138
- export { type AddCommentInput, COMMENTS, type Comment, type CommentNode, CommentNotFoundError, type CommentPatch, type CommentStore, CommentTenantRequiredError, Comments, type CommentsOptions, type CommentsPluginOptions, MemoryCommentStore, type ResourceComments, commentRoutes, commentsPlugin };
1
+ export { Comments, CommentNotFoundError, CommentTenantRequiredError, type CommentsOptions, type AddCommentInput, type ResourceComments, type CommentNode, } from './comments.js';
2
+ export { MemoryCommentStore, type Comment, type CommentStore, type CommentPatch } from './store.js';
3
+ export { commentsPlugin, commentRoutes, COMMENTS, type CommentsPluginOptions } from './plugin.js';
package/dist/index.js CHANGED
@@ -1,251 +1,3 @@
1
- // src/comments.ts
2
- import { randomUUID } from "crypto";
3
- import { BasaltError, tryCtx } from "@basaltkit/core";
4
-
5
- // src/store.ts
6
- var MemoryCommentStore = class {
7
- records = /* @__PURE__ */ new Map();
8
- key(tenantId, id) {
9
- return `${tenantId} ${id}`;
10
- }
11
- async create(comment) {
12
- this.records.set(this.key(comment.tenantId, comment.id), comment);
13
- }
14
- async find(tenantId, id) {
15
- return this.records.get(this.key(tenantId, id)) ?? null;
16
- }
17
- async list(tenantId, resourceType, resourceId) {
18
- const out = [];
19
- for (const c of this.records.values()) {
20
- if (c.tenantId === tenantId && c.resourceType === resourceType && c.resourceId === resourceId) out.push(c);
21
- }
22
- return out.sort((a, b) => a.createdAt - b.createdAt);
23
- }
24
- async update(tenantId, id, patch) {
25
- const record = this.records.get(this.key(tenantId, id));
26
- if (!record) return null;
27
- Object.assign(record, patch);
28
- return record;
29
- }
30
- async delete(tenantId, id) {
31
- this.records.delete(this.key(tenantId, id));
32
- }
33
- };
34
-
35
- // src/comments.ts
36
- var CommentNotFoundError = class extends BasaltError {
37
- status = 404;
38
- constructor() {
39
- super("COMMENT_NOT_FOUND", "Comment not found.");
40
- }
41
- };
42
- var CommentTenantRequiredError = class extends BasaltError {
43
- status = 400;
44
- constructor() {
45
- super("COMMENT_TENANT_REQUIRED", "A tenant is required \u2014 pass tenantId or run inside a tenant context.");
46
- }
47
- };
48
- var buildTree = (comments2) => {
49
- const nodes = new Map(comments2.map((c) => [c.id, { ...c, replies: [] }]));
50
- const roots = [];
51
- for (const node of nodes.values()) {
52
- const parent = node.parentId ? nodes.get(node.parentId) : void 0;
53
- if (parent) parent.replies.push(node);
54
- else roots.push(node);
55
- }
56
- return roots;
57
- };
58
- var Comments = class {
59
- store;
60
- hooks;
61
- mentionPattern;
62
- now;
63
- constructor(options = {}) {
64
- this.store = options.store ?? new MemoryCommentStore();
65
- this.hooks = options.hooks;
66
- this.mentionPattern = options.mentionPattern ?? /@([\w-]+)/g;
67
- this.now = options.now ?? Date.now;
68
- }
69
- on(resourceType, resourceId, tenantId) {
70
- const tenant = this.tenant(tenantId);
71
- return {
72
- add: (input) => this.add(tenant, resourceType, resourceId, input),
73
- list: () => this.store.list(tenant, resourceType, resourceId),
74
- tree: async () => buildTree(await this.store.list(tenant, resourceType, resourceId))
75
- };
76
- }
77
- get(id, tenantId) {
78
- return this.store.find(this.tenant(tenantId), id);
79
- }
80
- async edit(id, body, tenantId) {
81
- const tenant = this.tenant(tenantId);
82
- if (!await this.store.find(tenant, id)) throw new CommentNotFoundError();
83
- const updated = await this.store.update(tenant, id, { body, mentions: this.mentions(body), editedAt: this.now() });
84
- await this.hooks?.emit("comment:updated", { comment: updated });
85
- return updated;
86
- }
87
- async remove(id, tenantId) {
88
- const tenant = this.tenant(tenantId);
89
- const comment = await this.store.find(tenant, id);
90
- if (!comment) return;
91
- await this.store.delete(tenant, id);
92
- await this.hooks?.emit("comment:deleted", {
93
- tenantId: tenant,
94
- id,
95
- resourceType: comment.resourceType,
96
- resourceId: comment.resourceId
97
- });
98
- }
99
- async resolve(id, resolvedBy, tenantId) {
100
- const comment = await this.mutate(id, { resolvedAt: this.now(), resolvedBy }, tenantId);
101
- await this.hooks?.emit("comment:resolved", { comment });
102
- return comment;
103
- }
104
- async reopen(id, tenantId) {
105
- const comment = await this.mutate(id, { resolvedAt: void 0, resolvedBy: void 0 }, tenantId);
106
- await this.hooks?.emit("comment:reopened", { comment });
107
- return comment;
108
- }
109
- async add(tenantId, resourceType, resourceId, input) {
110
- const mentions = this.mentions(input.body);
111
- const comment = {
112
- id: randomUUID(),
113
- tenantId,
114
- resourceType,
115
- resourceId,
116
- authorId: input.authorId,
117
- body: input.body,
118
- mentions,
119
- createdAt: this.now(),
120
- ...input.parentId !== void 0 ? { parentId: input.parentId } : {}
121
- };
122
- await this.store.create(comment);
123
- await this.hooks?.emit("comment:created", { comment });
124
- for (const userId of mentions) await this.hooks?.emit("comment:mentioned", { comment, userId });
125
- return comment;
126
- }
127
- async mutate(id, patch, tenantId) {
128
- const tenant = this.tenant(tenantId);
129
- if (!await this.store.find(tenant, id)) throw new CommentNotFoundError();
130
- return await this.store.update(tenant, id, patch);
131
- }
132
- mentions(body) {
133
- const ids = /* @__PURE__ */ new Set();
134
- for (const match of body.matchAll(this.mentionPattern)) if (match[1]) ids.add(match[1]);
135
- return [...ids];
136
- }
137
- tenant(explicit) {
138
- const id = explicit ?? tryCtx()?.["tenant"]?.id;
139
- if (!id) throw new CommentTenantRequiredError();
140
- return id;
141
- }
142
- };
143
-
144
- // src/plugin.ts
145
- import { createToken, ctx, definePlugin, BasaltError as BasaltError2 } from "@basaltkit/core";
146
- import { route } from "@basaltkit/fastify";
147
- import { z } from "zod";
148
- var COMMENTS = createToken("comments");
149
- function commentsPlugin(options = {}) {
150
- return definePlugin({
151
- name: "basalt:comments",
152
- register({ container, hooks }) {
153
- container.singleton(COMMENTS, () => new Comments({ ...options, hooks }));
154
- }
155
- });
156
- }
157
- var CommentForbiddenError = class extends BasaltError2 {
158
- status = 403;
159
- constructor() {
160
- super("COMMENT_FORBIDDEN", "You can only modify your own comments.");
161
- }
162
- };
163
- var UserRequiredError = class extends BasaltError2 {
164
- status = 401;
165
- constructor() {
166
- super("AUTH_REQUIRED", "Authentication required.");
167
- }
168
- };
169
- var comments = () => ctx().container.get(COMMENTS);
170
- var currentUser = () => {
171
- const id = ctx().user?.id;
172
- if (!id) throw new UserRequiredError();
173
- return id;
174
- };
175
- var assertAuthor = async (id) => {
176
- const comment = await comments().get(id);
177
- if (!comment || comment.authorId !== currentUser()) throw new CommentForbiddenError();
178
- };
179
- function commentRoutes() {
180
- const resource = z.object({ resourceType: z.string().min(1), resourceId: z.string().min(1) });
181
- return [
182
- route({
183
- method: "GET",
184
- url: "/comments",
185
- meta: { auth: true },
186
- query: resource,
187
- async handler({ query }) {
188
- return comments().on(query.resourceType, query.resourceId).tree();
189
- }
190
- }),
191
- route({
192
- method: "POST",
193
- url: "/comments",
194
- meta: { auth: true },
195
- body: resource.extend({ body: z.string().min(1), parentId: z.string().optional() }),
196
- async handler({ body, reply }) {
197
- const created = await comments().on(body.resourceType, body.resourceId).add({ authorId: currentUser(), body: body.body, ...body.parentId ? { parentId: body.parentId } : {} });
198
- return reply.code(201).send(created);
199
- }
200
- }),
201
- route({
202
- method: "PATCH",
203
- url: "/comments/:id",
204
- meta: { auth: true },
205
- params: z.object({ id: z.string() }),
206
- body: z.object({ body: z.string().min(1) }),
207
- async handler({ params, body }) {
208
- await assertAuthor(params.id);
209
- return comments().edit(params.id, body.body);
210
- }
211
- }),
212
- route({
213
- method: "DELETE",
214
- url: "/comments/:id",
215
- meta: { auth: true },
216
- params: z.object({ id: z.string() }),
217
- async handler({ params, reply }) {
218
- await assertAuthor(params.id);
219
- await comments().remove(params.id);
220
- return reply.code(204).send();
221
- }
222
- }),
223
- route({
224
- method: "POST",
225
- url: "/comments/:id/resolve",
226
- meta: { auth: true },
227
- params: z.object({ id: z.string() }),
228
- async handler({ params }) {
229
- return comments().resolve(params.id, currentUser());
230
- }
231
- }),
232
- route({
233
- method: "POST",
234
- url: "/comments/:id/reopen",
235
- meta: { auth: true },
236
- params: z.object({ id: z.string() }),
237
- async handler({ params }) {
238
- return comments().reopen(params.id);
239
- }
240
- })
241
- ];
242
- }
243
- export {
244
- COMMENTS,
245
- CommentNotFoundError,
246
- CommentTenantRequiredError,
247
- Comments,
248
- MemoryCommentStore,
249
- commentRoutes,
250
- commentsPlugin
251
- };
1
+ export { Comments, CommentNotFoundError, CommentTenantRequiredError, } from './comments.js';
2
+ export { MemoryCommentStore } from './store.js';
3
+ export { commentsPlugin, commentRoutes, COMMENTS } from './plugin.js';
@@ -0,0 +1,39 @@
1
+ import { type BasaltRoute } from '@basaltkit/http';
2
+ import { Comments, type CommentsOptions } from './comments.js';
3
+ import type { Comment } from './store.js';
4
+ declare module '@basaltkit/core' {
5
+ interface BasaltHooks {
6
+ 'comment:created': {
7
+ comment: Comment;
8
+ };
9
+ 'comment:updated': {
10
+ comment: Comment;
11
+ };
12
+ 'comment:deleted': {
13
+ tenantId: string;
14
+ id: string;
15
+ resourceType: string;
16
+ resourceId: string;
17
+ };
18
+ 'comment:resolved': {
19
+ comment: Comment;
20
+ };
21
+ 'comment:reopened': {
22
+ comment: Comment;
23
+ };
24
+ /** One per mentioned user — wire to notifications. */
25
+ 'comment:mentioned': {
26
+ comment: Comment;
27
+ userId: string;
28
+ };
29
+ }
30
+ }
31
+ export declare const COMMENTS: import("@basaltkit/core").Token<Comments>;
32
+ export type CommentsPluginOptions = Omit<CommentsOptions, 'hooks'>;
33
+ export declare function commentsPlugin(options?: CommentsPluginOptions): import("@basaltkit/core").BasaltPlugin<unknown>;
34
+ /**
35
+ * REST routes for the current tenant's comments, all requiring a logged-in
36
+ * user. The author is taken from `ctx().user`; editing and deleting are
37
+ * restricted to the comment's author.
38
+ */
39
+ export declare function commentRoutes(): BasaltRoute[];
package/dist/plugin.js ADDED
@@ -0,0 +1,109 @@
1
+ import { createToken, ctx, definePlugin, BasaltError } from '@basaltkit/core';
2
+ import { route } from '@basaltkit/http';
3
+ import { z } from 'zod';
4
+ import { Comments } from './comments.js';
5
+ export const COMMENTS = createToken('comments');
6
+ export function commentsPlugin(options = {}) {
7
+ return definePlugin({
8
+ name: 'basalt:comments',
9
+ register({ container, hooks }) {
10
+ container.singleton(COMMENTS, () => new Comments({ ...options, hooks }));
11
+ },
12
+ });
13
+ }
14
+ class CommentForbiddenError extends BasaltError {
15
+ status = 403;
16
+ constructor() {
17
+ super('COMMENT_FORBIDDEN', 'You can only modify your own comments.');
18
+ }
19
+ }
20
+ class UserRequiredError extends BasaltError {
21
+ status = 401;
22
+ constructor() {
23
+ super('AUTH_REQUIRED', 'Authentication required.');
24
+ }
25
+ }
26
+ const comments = () => ctx().container.get(COMMENTS);
27
+ const currentUser = () => {
28
+ // `user` is set by @basaltkit/auth; read it without a hard dependency on it.
29
+ const id = ctx().user?.id;
30
+ if (!id)
31
+ throw new UserRequiredError();
32
+ return id;
33
+ };
34
+ const assertAuthor = async (id) => {
35
+ const comment = await comments().get(id);
36
+ if (!comment || comment.authorId !== currentUser())
37
+ throw new CommentForbiddenError();
38
+ };
39
+ /**
40
+ * REST routes for the current tenant's comments, all requiring a logged-in
41
+ * user. The author is taken from `ctx().user`; editing and deleting are
42
+ * restricted to the comment's author.
43
+ */
44
+ export function commentRoutes() {
45
+ const resource = z.object({ resourceType: z.string().min(1), resourceId: z.string().min(1) });
46
+ return [
47
+ route({
48
+ method: 'GET',
49
+ url: '/comments',
50
+ meta: { auth: true },
51
+ query: resource,
52
+ async handler({ query }) {
53
+ return comments().on(query.resourceType, query.resourceId).tree();
54
+ },
55
+ }),
56
+ route({
57
+ method: 'POST',
58
+ url: '/comments',
59
+ meta: { auth: true },
60
+ body: resource.extend({ body: z.string().min(1), parentId: z.string().optional() }),
61
+ async handler({ body, reply }) {
62
+ const created = await comments()
63
+ .on(body.resourceType, body.resourceId)
64
+ .add({ authorId: currentUser(), body: body.body, ...(body.parentId ? { parentId: body.parentId } : {}) });
65
+ return reply.code(201).send(created);
66
+ },
67
+ }),
68
+ route({
69
+ method: 'PATCH',
70
+ url: '/comments/:id',
71
+ meta: { auth: true },
72
+ params: z.object({ id: z.string() }),
73
+ body: z.object({ body: z.string().min(1) }),
74
+ async handler({ params, body }) {
75
+ await assertAuthor(params.id);
76
+ return comments().edit(params.id, body.body);
77
+ },
78
+ }),
79
+ route({
80
+ method: 'DELETE',
81
+ url: '/comments/:id',
82
+ meta: { auth: true },
83
+ params: z.object({ id: z.string() }),
84
+ async handler({ params, reply }) {
85
+ await assertAuthor(params.id);
86
+ await comments().remove(params.id);
87
+ return reply.code(204).send();
88
+ },
89
+ }),
90
+ route({
91
+ method: 'POST',
92
+ url: '/comments/:id/resolve',
93
+ meta: { auth: true },
94
+ params: z.object({ id: z.string() }),
95
+ async handler({ params }) {
96
+ return comments().resolve(params.id, currentUser());
97
+ },
98
+ }),
99
+ route({
100
+ method: 'POST',
101
+ url: '/comments/:id/reopen',
102
+ meta: { auth: true },
103
+ params: z.object({ id: z.string() }),
104
+ async handler({ params }) {
105
+ return comments().reopen(params.id);
106
+ },
107
+ }),
108
+ ];
109
+ }
@@ -0,0 +1,42 @@
1
+ /** A comment attached to a resource (`resourceType`:`resourceId`) within a tenant. */
2
+ export interface Comment {
3
+ id: string;
4
+ tenantId: string;
5
+ resourceType: string;
6
+ resourceId: string;
7
+ /** Set when this comment is a reply to another. */
8
+ parentId?: string;
9
+ authorId: string;
10
+ body: string;
11
+ /** User ids extracted from @mentions in the body. */
12
+ mentions: string[];
13
+ resolvedAt?: number;
14
+ resolvedBy?: string;
15
+ editedAt?: number;
16
+ createdAt: number;
17
+ }
18
+ export interface CommentPatch {
19
+ body?: string;
20
+ mentions?: string[];
21
+ editedAt?: number;
22
+ /** `undefined` clears it (reopen). */
23
+ resolvedAt?: number | undefined;
24
+ resolvedBy?: string | undefined;
25
+ }
26
+ export interface CommentStore {
27
+ create(comment: Comment): Promise<void>;
28
+ find(tenantId: string, id: string): Promise<Comment | null>;
29
+ /** Every comment on a resource (the whole thread), oldest first. */
30
+ list(tenantId: string, resourceType: string, resourceId: string): Promise<Comment[]>;
31
+ update(tenantId: string, id: string, patch: CommentPatch): Promise<Comment | null>;
32
+ delete(tenantId: string, id: string): Promise<void>;
33
+ }
34
+ export declare class MemoryCommentStore implements CommentStore {
35
+ private readonly records;
36
+ private key;
37
+ create(comment: Comment): Promise<void>;
38
+ find(tenantId: string, id: string): Promise<Comment | null>;
39
+ list(tenantId: string, resourceType: string, resourceId: string): Promise<Comment[]>;
40
+ update(tenantId: string, id: string, patch: CommentPatch): Promise<Comment | null>;
41
+ delete(tenantId: string, id: string): Promise<void>;
42
+ }
package/dist/store.js ADDED
@@ -0,0 +1,30 @@
1
+ export class MemoryCommentStore {
2
+ records = new Map();
3
+ key(tenantId, id) {
4
+ return `${tenantId} ${id}`;
5
+ }
6
+ async create(comment) {
7
+ this.records.set(this.key(comment.tenantId, comment.id), comment);
8
+ }
9
+ async find(tenantId, id) {
10
+ return this.records.get(this.key(tenantId, id)) ?? null;
11
+ }
12
+ async list(tenantId, resourceType, resourceId) {
13
+ const out = [];
14
+ for (const c of this.records.values()) {
15
+ if (c.tenantId === tenantId && c.resourceType === resourceType && c.resourceId === resourceId)
16
+ out.push(c);
17
+ }
18
+ return out.sort((a, b) => a.createdAt - b.createdAt);
19
+ }
20
+ async update(tenantId, id, patch) {
21
+ const record = this.records.get(this.key(tenantId, id));
22
+ if (!record)
23
+ return null;
24
+ Object.assign(record, patch);
25
+ return record;
26
+ }
27
+ async delete(tenantId, id) {
28
+ this.records.delete(this.key(tenantId, id));
29
+ }
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basaltkit/comments",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Comments for Basalt: per-resource threads with @mentions and resolve/reopen, tenant-scoped, emitting events that bridge to realtime and notifications.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -14,20 +14,20 @@
14
14
  "dist"
15
15
  ],
16
16
  "dependencies": {
17
- "@basaltkit/core": "^1.0.0",
18
- "@basaltkit/fastify": "^1.0.0"
17
+ "@basaltkit/core": "^1.1.2",
18
+ "@basaltkit/http": "^1.9.1"
19
19
  },
20
20
  "peerDependencies": {
21
21
  "zod": "^3.24.0 || ^4.0.0"
22
22
  },
23
23
  "devDependencies": {
24
- "@types/node": "^22.15.0",
25
- "tsup": "^8.4.0",
26
- "typescript": "^5.8.0",
27
- "vitest": "^3.1.0",
28
- "zod": "^3.24.0",
29
- "@basaltkit/auth": "^1.0.0",
30
- "@basaltkit/tenancy": "^1.0.0",
24
+ "@types/node": "^26.3.0",
25
+ "typescript": "^7.0.2",
26
+ "vitest": "^4.1.11",
27
+ "zod": "^3.24.0 || ^4.0.0",
28
+ "@basaltkit/auth": "^1.6.3",
29
+ "@basaltkit/tenancy": "^1.3.3",
30
+ "@basaltkit/fastify": "^1.6.1",
31
31
  "@basaltkit/tsconfig": "^0.24.0"
32
32
  },
33
33
  "publishConfig": {
@@ -35,11 +35,11 @@
35
35
  },
36
36
  "repository": {
37
37
  "type": "git",
38
- "url": "git+https://github.com/Zebedeu/basalt.git",
38
+ "url": "git+https://github.com/basaltkit/basalt.git",
39
39
  "directory": "packages/comments"
40
40
  },
41
- "homepage": "https://github.com/Zebedeu/basalt/tree/main/packages/comments#readme",
42
- "bugs": "https://github.com/Zebedeu/basalt/issues",
41
+ "homepage": "https://github.com/basaltkit/basalt/tree/main/packages/comments#readme",
42
+ "bugs": "https://github.com/basaltkit/basalt/issues",
43
43
  "keywords": [
44
44
  "basalt",
45
45
  "typescript",
@@ -49,7 +49,7 @@
49
49
  "mentions"
50
50
  ],
51
51
  "scripts": {
52
- "build": "tsup src/index.ts --format esm --dts --clean",
52
+ "build": "tsc -p tsconfig.build.json",
53
53
  "test": "vitest run",
54
54
  "typecheck": "tsc --noEmit"
55
55
  }