better-inbox 0.0.1 → 0.1.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/README.md CHANGED
@@ -1,5 +1,133 @@
1
1
  # better-inbox
2
2
 
3
- In-app inbox and notification-center components backed by your own database. Part of the [better-notify](https://www.npmjs.com/package/better-notify) project.
3
+ In-app notifications for [better-auth](https://better-auth.com) apps. One plugin, one migration, one component — notifications live in **your** database, addressed to **your** users.
4
4
 
5
- **Not released yet.** This name is reserved for active development.
5
+ ```
6
+ npm install better-inbox
7
+ ```
8
+
9
+ - **A better-auth plugin, not a platform.** No services to deploy, no dashboard SaaS. The `notification` table lives in your own database via better-auth's adapter (drizzle, prisma, kysely — whatever you already use).
10
+ - **Addressed to users, not email addresses.** `notify({ userId })` uses your better-auth user ids. Delete a user, their notifications cascade.
11
+ - **Organization-aware.** Notify a whole org — optionally filtered by role — via the better-auth organization plugin: `notify({ organizationId, roles: ["owner", "admin"] })`.
12
+ - **One React component.** `<InboxButton />` gives you the bell, unread badge, and inbox panel, styled with shadcn CSS variables (works with any shadcn theme, zero runtime dependencies, no shadcn required).
13
+
14
+ > Community plugin — not affiliated with the better-auth team.
15
+
16
+ ## Quickstart
17
+
18
+ **1. Add the plugin** to your better-auth config:
19
+
20
+ ```ts
21
+ // lib/auth.ts
22
+ import { betterAuth } from "better-auth";
23
+ import { inbox } from "better-inbox";
24
+
25
+ export const auth = betterAuth({
26
+ // ...your existing config
27
+ plugins: [inbox()],
28
+ });
29
+ ```
30
+
31
+ **2. Migrate.** The plugin declares the `notification` table; your existing better-auth workflow creates it:
32
+
33
+ ```
34
+ npx @better-auth/cli migrate --config lib/auth.ts # or: generate, for drizzle/prisma
35
+ ```
36
+
37
+ (Point `--config` at your better-auth config if the CLI doesn't auto-detect it.)
38
+
39
+ **3. Add the client plugin:**
40
+
41
+ ```ts
42
+ // lib/auth-client.ts
43
+ import { createAuthClient } from "better-auth/react";
44
+ import { inboxClient } from "better-inbox/client";
45
+
46
+ export const authClient = createAuthClient({
47
+ plugins: [inboxClient()],
48
+ });
49
+ ```
50
+
51
+ **4. Drop in the component** (any client component, e.g. your navbar):
52
+
53
+ ```tsx
54
+ "use client";
55
+ import { InboxButton } from "better-inbox/react";
56
+ import { useRouter } from "next/navigation";
57
+ import { authClient } from "@/lib/auth-client";
58
+
59
+ export function Navbar() {
60
+ const router = useRouter();
61
+ return <InboxButton client={authClient} onNavigate={(href) => router.push(href)} />;
62
+ }
63
+ ```
64
+
65
+ **5. Send notifications** from any server code — a server action, a webhook handler, a cron job:
66
+
67
+ ```ts
68
+ await auth.api.notify({
69
+ body: {
70
+ userId: comment.authorId,
71
+ type: "comment.reply",
72
+ title: `${user.name} replied to your comment`,
73
+ href: `/posts/${post.id}#comment-${comment.id}`,
74
+ },
75
+ });
76
+ ```
77
+
78
+ `notify` is server-only: callable through `auth.api`, never exposed as an HTTP route.
79
+
80
+ ## Notify a whole organization
81
+
82
+ With the better-auth [organization plugin](https://better-auth.com/docs/plugins/organization) enabled, address an org instead of a user. One notification is created per member (fan-out on write), each stamped with `organizationId`:
83
+
84
+ ```ts
85
+ // Stripe webhook: payment failed → tell the org's admins
86
+ await auth.api.notify({
87
+ body: {
88
+ organizationId: subscription.metadata.orgId,
89
+ roles: ["owner", "admin"], // optional — omit to notify every member
90
+ type: "billing.payment_failed",
91
+ title: "Payment failed — update your card",
92
+ href: "/settings/billing",
93
+ },
94
+ });
95
+ ```
96
+
97
+ Fan-out is capped at 1,000 members by default (`inbox({ maxFanout })` to change). Over the cap, `notify` throws instead of hammering your database.
98
+
99
+ ## API
100
+
101
+ ### Server (`better-inbox`)
102
+
103
+ | Endpoint | Access | Description |
104
+ |----------|--------|-------------|
105
+ | `auth.api.notify({ body })` | server-only | Create notification(s). Exactly one of `userId` / `organizationId`; optional `roles`, `body`, `href`, `data` (JSON) |
106
+ | `auth.api.listNotifications({ query })` | session | `{ filter?: "unread"\|"all", limit? (≤100), offset?, organizationId? }` → `{ notifications, hasMore }` |
107
+ | `auth.api.markRead({ body: { id } })` | session | Marks one of the caller's notifications read |
108
+ | `auth.api.markAllRead({ body })` | session | Optional `{ organizationId }` scope |
109
+ | `auth.api.unreadCount({ query })` | session | → `{ count }` |
110
+
111
+ Every session endpoint is scoped to the caller — users can only ever see and mutate their own notifications.
112
+
113
+ ### React (`better-inbox/react`)
114
+
115
+ - `<InboxButton client={authClient} />` — bell + badge + panel. Props: `onNavigate`, `renderItem`, `pollInterval` (default 30s; unread count only, full refresh on window focus and panel open), `organizationId`, `className`.
116
+ - `useInbox(client, options)` — build your own UI: `{ notifications, unreadCount, isLoading, hasMore, loadMore, markRead, markAllRead, refresh }`.
117
+ - `<InboxPanel inbox={useInbox(...)} />` — the panel without the bell.
118
+
119
+ ## Performance note
120
+
121
+ Add an index for the list query once you have real traffic — plugin schemas can't declare indexes, so it's one manual line, e.g. drizzle:
122
+
123
+ ```ts
124
+ index("notification_user_created_idx").on(table.userId, table.createdAt)
125
+ ```
126
+
127
+ ## What this is not (yet)
128
+
129
+ Email/push channels, preferences, digests, realtime websockets — out of scope for v0.1 by design. Polling + focus refetch covers the badge honestly. If you need multi-channel delivery pipelines, look at [Novu](https://novu.co) or [betternotify](https://github.com/better-notify/better-notify); better-inbox is the thin, DB-owned layer for the in-app half.
130
+
131
+ ## License
132
+
133
+ MIT
@@ -0,0 +1,41 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/client.ts
21
+ var client_exports = {};
22
+ __export(client_exports, {
23
+ inboxClient: () => inboxClient
24
+ });
25
+ module.exports = __toCommonJS(client_exports);
26
+ var inboxClient = () => {
27
+ return {
28
+ id: "inbox",
29
+ $InferServerPlugin: {},
30
+ pathMethods: {
31
+ "/inbox/list": "GET",
32
+ "/inbox/unread-count": "GET",
33
+ "/inbox/mark-read": "POST",
34
+ "/inbox/mark-all-read": "POST"
35
+ }
36
+ };
37
+ };
38
+ // Annotate the CommonJS export names for ESM import in node:
39
+ 0 && (module.exports = {
40
+ inboxClient
41
+ });
@@ -0,0 +1,19 @@
1
+ import { inbox } from './index.cjs';
2
+ export { Notification } from './index.cjs';
3
+ import 'better-auth';
4
+ import 'better-call';
5
+ import 'zod/v4/core';
6
+ import 'zod';
7
+
8
+ declare const inboxClient: () => {
9
+ id: "inbox";
10
+ $InferServerPlugin: ReturnType<typeof inbox>;
11
+ pathMethods: {
12
+ "/inbox/list": "GET";
13
+ "/inbox/unread-count": "GET";
14
+ "/inbox/mark-read": "POST";
15
+ "/inbox/mark-all-read": "POST";
16
+ };
17
+ };
18
+
19
+ export { inboxClient };
@@ -0,0 +1,19 @@
1
+ import { inbox } from './index.js';
2
+ export { Notification } from './index.js';
3
+ import 'better-auth';
4
+ import 'better-call';
5
+ import 'zod/v4/core';
6
+ import 'zod';
7
+
8
+ declare const inboxClient: () => {
9
+ id: "inbox";
10
+ $InferServerPlugin: ReturnType<typeof inbox>;
11
+ pathMethods: {
12
+ "/inbox/list": "GET";
13
+ "/inbox/unread-count": "GET";
14
+ "/inbox/mark-read": "POST";
15
+ "/inbox/mark-all-read": "POST";
16
+ };
17
+ };
18
+
19
+ export { inboxClient };
package/dist/client.js ADDED
@@ -0,0 +1,16 @@
1
+ // src/client.ts
2
+ var inboxClient = () => {
3
+ return {
4
+ id: "inbox",
5
+ $InferServerPlugin: {},
6
+ pathMethods: {
7
+ "/inbox/list": "GET",
8
+ "/inbox/unread-count": "GET",
9
+ "/inbox/mark-read": "POST",
10
+ "/inbox/mark-all-read": "POST"
11
+ }
12
+ };
13
+ };
14
+ export {
15
+ inboxClient
16
+ };
package/dist/index.cjs ADDED
@@ -0,0 +1,299 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ INBOX_ERROR_CODES: () => INBOX_ERROR_CODES,
34
+ inbox: () => inbox
35
+ });
36
+ module.exports = __toCommonJS(src_exports);
37
+ var import_db = require("better-auth/db");
38
+
39
+ // src/error-codes.ts
40
+ var import_better_auth = require("better-auth");
41
+ var INBOX_ERROR_CODES = (0, import_better_auth.defineErrorCodes)({
42
+ NOTIFICATION_NOT_FOUND: "Notification not found",
43
+ USER_OR_ORGANIZATION_REQUIRED: "Provide exactly one of userId or organizationId",
44
+ ORGANIZATION_PLUGIN_REQUIRED: "organizationId requires the organization plugin",
45
+ ORGANIZATION_HAS_NO_MEMBERS: "Organization has no matching members",
46
+ FAN_OUT_LIMIT_EXCEEDED: "Organization member count exceeds maxFanout"
47
+ });
48
+
49
+ // src/routes.ts
50
+ var import_api = require("better-auth/api");
51
+ var z = __toESM(require("zod"), 1);
52
+ var notifyBodySchema = z.object({
53
+ userId: z.string().optional(),
54
+ organizationId: z.string().optional(),
55
+ roles: z.array(z.string()).optional(),
56
+ type: z.string(),
57
+ title: z.string(),
58
+ body: z.string().optional(),
59
+ href: z.string().optional(),
60
+ data: z.record(z.string(), z.unknown()).optional()
61
+ }).refine((body) => !!body.userId !== !!body.organizationId, {
62
+ message: INBOX_ERROR_CODES.USER_OR_ORGANIZATION_REQUIRED.message
63
+ });
64
+ var notify = (options) => import_api.createAuthEndpoint.serverOnly(
65
+ {
66
+ method: "POST",
67
+ body: notifyBodySchema
68
+ },
69
+ async (ctx) => {
70
+ const { userId, organizationId, roles, ...payload } = ctx.body;
71
+ const createFor = (targetUserId) => ctx.context.adapter.create({
72
+ model: "notification",
73
+ data: {
74
+ userId: targetUserId,
75
+ ...organizationId ? { organizationId } : {},
76
+ ...payload,
77
+ read: false,
78
+ createdAt: /* @__PURE__ */ new Date()
79
+ }
80
+ });
81
+ if (!organizationId) {
82
+ const notification = await createFor(userId);
83
+ return { count: 1, notifications: [notification] };
84
+ }
85
+ const hasOrgPlugin = ctx.context.options.plugins?.some(
86
+ (plugin) => plugin.id === "organization"
87
+ );
88
+ if (!hasOrgPlugin) {
89
+ throw import_api.APIError.from(
90
+ "BAD_REQUEST",
91
+ INBOX_ERROR_CODES.ORGANIZATION_PLUGIN_REQUIRED
92
+ );
93
+ }
94
+ const members = await ctx.context.adapter.findMany({
95
+ model: "member",
96
+ where: [{ field: "organizationId", value: organizationId }],
97
+ limit: options.maxFanout + 1
98
+ });
99
+ if (members.length > options.maxFanout) {
100
+ throw import_api.APIError.from(
101
+ "BAD_REQUEST",
102
+ INBOX_ERROR_CODES.FAN_OUT_LIMIT_EXCEEDED
103
+ );
104
+ }
105
+ const targets = roles?.length ? members.filter(
106
+ (member) => member.role.split(",").some((role) => roles.includes(role))
107
+ ) : members;
108
+ if (targets.length === 0) {
109
+ throw import_api.APIError.from(
110
+ "BAD_REQUEST",
111
+ INBOX_ERROR_CODES.ORGANIZATION_HAS_NO_MEMBERS
112
+ );
113
+ }
114
+ const notifications = [];
115
+ for (const member of targets) {
116
+ notifications.push(await createFor(member.userId));
117
+ }
118
+ return { count: notifications.length, notifications };
119
+ }
120
+ );
121
+ var listQuerySchema = z.object({
122
+ filter: z.enum(["unread", "all"]).optional(),
123
+ limit: z.coerce.number().int().min(1).max(100).optional(),
124
+ offset: z.coerce.number().int().min(0).optional(),
125
+ organizationId: z.string().optional()
126
+ }).optional();
127
+ var listNotifications = () => (0, import_api.createAuthEndpoint)(
128
+ "/inbox/list",
129
+ {
130
+ method: "GET",
131
+ query: listQuerySchema,
132
+ use: [import_api.sessionMiddleware]
133
+ },
134
+ async (ctx) => {
135
+ const { filter = "all", limit = 20, offset = 0, organizationId } = ctx.query ?? {};
136
+ const where = [
137
+ { field: "userId", value: ctx.context.session.user.id }
138
+ ];
139
+ if (filter === "unread") {
140
+ where.push({ field: "read", value: false });
141
+ }
142
+ if (organizationId) {
143
+ where.push({ field: "organizationId", value: organizationId });
144
+ }
145
+ const rows = await ctx.context.adapter.findMany({
146
+ model: "notification",
147
+ where,
148
+ limit: limit + 1,
149
+ offset,
150
+ sortBy: { field: "createdAt", direction: "desc" }
151
+ });
152
+ return {
153
+ notifications: rows.slice(0, limit),
154
+ hasMore: rows.length > limit
155
+ };
156
+ }
157
+ );
158
+ var markRead = () => (0, import_api.createAuthEndpoint)(
159
+ "/inbox/mark-read",
160
+ {
161
+ method: "POST",
162
+ body: z.object({ id: z.string() }),
163
+ use: [import_api.sessionMiddleware]
164
+ },
165
+ async (ctx) => {
166
+ const updated = await ctx.context.adapter.update({
167
+ model: "notification",
168
+ where: [
169
+ { field: "id", value: ctx.body.id },
170
+ { field: "userId", value: ctx.context.session.user.id }
171
+ ],
172
+ update: { read: true }
173
+ });
174
+ if (!updated) {
175
+ throw import_api.APIError.from(
176
+ "NOT_FOUND",
177
+ INBOX_ERROR_CODES.NOTIFICATION_NOT_FOUND
178
+ );
179
+ }
180
+ return { notification: updated };
181
+ }
182
+ );
183
+ var markAllRead = () => (0, import_api.createAuthEndpoint)(
184
+ "/inbox/mark-all-read",
185
+ {
186
+ method: "POST",
187
+ body: z.object({ organizationId: z.string().optional() }),
188
+ use: [import_api.sessionMiddleware]
189
+ },
190
+ async (ctx) => {
191
+ const where = [
192
+ { field: "userId", value: ctx.context.session.user.id },
193
+ { field: "read", value: false }
194
+ ];
195
+ if (ctx.body.organizationId) {
196
+ where.push({ field: "organizationId", value: ctx.body.organizationId });
197
+ }
198
+ const count = await ctx.context.adapter.updateMany({
199
+ model: "notification",
200
+ where,
201
+ update: { read: true }
202
+ });
203
+ return { count };
204
+ }
205
+ );
206
+ var unreadCount = () => (0, import_api.createAuthEndpoint)(
207
+ "/inbox/unread-count",
208
+ {
209
+ method: "GET",
210
+ query: z.object({ organizationId: z.string().optional() }).optional(),
211
+ use: [import_api.sessionMiddleware]
212
+ },
213
+ async (ctx) => {
214
+ const where = [
215
+ { field: "userId", value: ctx.context.session.user.id },
216
+ { field: "read", value: false }
217
+ ];
218
+ if (ctx.query?.organizationId) {
219
+ where.push({
220
+ field: "organizationId",
221
+ value: ctx.query.organizationId
222
+ });
223
+ }
224
+ const count = await ctx.context.adapter.count({
225
+ model: "notification",
226
+ where
227
+ });
228
+ return { count };
229
+ }
230
+ );
231
+
232
+ // src/schema.ts
233
+ var schema = {
234
+ notification: {
235
+ fields: {
236
+ userId: {
237
+ type: "string",
238
+ required: true,
239
+ references: { model: "user", field: "id" }
240
+ },
241
+ organizationId: {
242
+ type: "string",
243
+ required: false
244
+ },
245
+ type: {
246
+ type: "string",
247
+ required: true
248
+ },
249
+ title: {
250
+ type: "string",
251
+ required: true
252
+ },
253
+ body: {
254
+ type: "string",
255
+ required: false
256
+ },
257
+ href: {
258
+ type: "string",
259
+ required: false
260
+ },
261
+ data: {
262
+ type: "json",
263
+ required: false
264
+ },
265
+ read: {
266
+ type: "boolean",
267
+ required: true,
268
+ defaultValue: false
269
+ },
270
+ createdAt: {
271
+ type: "date",
272
+ required: true
273
+ }
274
+ }
275
+ }
276
+ };
277
+
278
+ // src/index.ts
279
+ var inbox = (options = {}) => {
280
+ const opts = { maxFanout: options.maxFanout ?? 1e3 };
281
+ return {
282
+ id: "inbox",
283
+ schema: (0, import_db.mergeSchema)(schema, options.schema),
284
+ endpoints: {
285
+ notify: notify(opts),
286
+ listNotifications: listNotifications(),
287
+ markRead: markRead(),
288
+ markAllRead: markAllRead(),
289
+ unreadCount: unreadCount()
290
+ },
291
+ $ERROR_CODES: INBOX_ERROR_CODES,
292
+ options
293
+ };
294
+ };
295
+ // Annotate the CommonJS export names for ESM import in node:
296
+ 0 && (module.exports = {
297
+ INBOX_ERROR_CODES,
298
+ inbox
299
+ });