@tatara/cli 0.10.0 → 0.11.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/bin.mjs +1161 -312
- package/package.json +1 -1
- package/templates/features/base/files/AGENTS.md +17 -0
- package/templates/features/base/files/biome.template.json +1 -1
- package/templates/features/base/files/src/apps/api/server.ts +7 -1
- package/templates/features/base/files/src/apps/cli/index.ts +4 -1
- package/templates/features/base/files/src/apps/http.ts +6 -0
- package/templates/features/base/files/src/core/logging/logger.ts +5 -3
- package/templates/features/cache/files/src/core/cache/index.ts +23 -0
- package/templates/features/cache/files/src/core/http/rate-limit.ts +15 -0
- package/templates/features/cache/files/tests/tatara/cache.test.ts +22 -0
- package/templates/features/cache/files/tests/tatara/rate-limit.test.ts +27 -0
- package/templates/features/cache/files+postgres/src/core/cache/index.ts +36 -0
- package/templates/features/db/files+mysql+drizzle/src/core/commands/db/command-runs.schema.ts +10 -0
- package/templates/features/db/files+mysql+drizzle/src/core/db/admin.ts +6 -1
- package/templates/features/db/files+mysql+prisma/prisma/models/command-runs.prisma +9 -0
- package/templates/features/db/files+mysql+prisma/src/core/db/admin.ts +1 -0
- package/templates/features/db/files+postgres/src/core/db/admin.ts +6 -1
- package/templates/features/email/files/src/core/email/send.ts +22 -2
- package/templates/features/email/files/src/modules/example/emails/welcome.ts +11 -0
- package/templates/features/jobs/files/src/apps/cron/index.ts +2 -3
- package/templates/features/jobs/files/src/apps/cron/runner.ts +11 -0
- package/templates/features/jobs/files/src/apps/worker/index.ts +2 -9
- package/templates/features/jobs/files/src/core/jobs/index.ts +37 -6
- package/templates/features/jobs/files/src/modules/example/tests/jobs.test.ts +7 -10
- package/templates/features/jobs/files+drizzle+postgres/src/core/jobs/outbox.ts +23 -0
- package/templates/features/jobs/files+drizzle+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
- package/templates/features/jobs/files+postgres/src/core/jobs/index.ts +47 -0
- package/templates/features/jobs/files+prisma+postgres/src/core/jobs/outbox.ts +17 -0
- package/templates/features/jobs/files+prisma+postgres/tests/tatara/jobs-outbox.test.ts +34 -0
- package/templates/features/notifications/files/src/modules/example/notifications/welcome.ts +14 -0
- package/templates/features/notifications/files/src/modules/notifications/consumers/deliver.ts +6 -0
- package/templates/features/notifications/files/src/modules/notifications/consumers/fanout.ts +6 -0
- package/templates/features/notifications/files/src/modules/notifications/routes/notifications.routes.ts +168 -0
- package/templates/features/notifications/files/src/modules/notifications/services/notifier.ts +59 -0
- package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts +72 -0
- package/templates/features/notifications/files+drizzle/src/modules/notifications/services/store.ts +5 -0
- package/templates/features/notifications/files+drizzle+mysql/src/modules/notifications/db/notifications.schema.ts +40 -0
- package/templates/features/notifications/files+drizzle+postgres/src/modules/notifications/db/notifications.schema.ts +41 -0
- package/templates/features/notifications/files+prisma/prisma/models/notifications.prisma +34 -0
- package/templates/features/notifications/files+prisma/src/modules/notifications/services/store.ts +4 -0
- package/templates/features/observability/files/src/apps/instrument.ts +15 -0
- package/templates/features/realtime/files/src/core/realtime/index.ts +29 -0
- package/templates/features/realtime/files/src/modules/realtime/routes/realtime.routes.ts +38 -0
- package/templates/features/realtime/files/tests/tatara/realtime.test.ts +37 -0
- package/templates/versions.json +26 -6
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FanoutConsumer } from "@tatara/notifications";
|
|
2
|
+
import { consumers } from "#modules/notifications/services/notifier";
|
|
3
|
+
|
|
4
|
+
/** Resolves a notification's recipients and queues one delivery per user. */
|
|
5
|
+
const fanout: FanoutConsumer = consumers[0];
|
|
6
|
+
export default fanout;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
+
import { currentPrincipal, requireAuth } from "@tatara/auth";
|
|
3
|
+
import {
|
|
4
|
+
cursorPage,
|
|
5
|
+
cursorPageSchema,
|
|
6
|
+
cursorQuerySchema,
|
|
7
|
+
decodeCursor,
|
|
8
|
+
errorResponses,
|
|
9
|
+
jsonBody,
|
|
10
|
+
jsonContent,
|
|
11
|
+
success,
|
|
12
|
+
successSchema,
|
|
13
|
+
} from "@tatara/http";
|
|
14
|
+
import type { Context } from "hono";
|
|
15
|
+
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
16
|
+
import { notificationStore as store } from "#modules/notifications/services/store";
|
|
17
|
+
// tatara:slot imports
|
|
18
|
+
|
|
19
|
+
const guard = [
|
|
20
|
+
requireAuth(),
|
|
21
|
+
// tatara:slot guard
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/** The org the inbox belongs to; apps without organizations have one, "default". */
|
|
25
|
+
function inbox(c: Context) {
|
|
26
|
+
const principal = currentPrincipal(c);
|
|
27
|
+
return { orgId: principal.orgId ?? "default", userId: principal.userId };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const notificationSchema = z
|
|
31
|
+
.object({
|
|
32
|
+
id: z.string(),
|
|
33
|
+
name: z.string(),
|
|
34
|
+
title: z.string(),
|
|
35
|
+
body: z.string().nullable(),
|
|
36
|
+
url: z.string().nullable(),
|
|
37
|
+
readAt: z.string().nullable(),
|
|
38
|
+
createdAt: z.string(),
|
|
39
|
+
})
|
|
40
|
+
.openapi("Notification");
|
|
41
|
+
|
|
42
|
+
const position = z.object({ createdAt: z.iso.datetime(), id: z.string() });
|
|
43
|
+
const security = [{ bearerAuth: [] }];
|
|
44
|
+
const router = createRouter();
|
|
45
|
+
|
|
46
|
+
router.openapi(
|
|
47
|
+
createRoute({
|
|
48
|
+
method: "get",
|
|
49
|
+
path: "/",
|
|
50
|
+
tags: ["Notifications"],
|
|
51
|
+
summary: "The caller's notifications, newest first",
|
|
52
|
+
middleware: guard,
|
|
53
|
+
security,
|
|
54
|
+
request: { query: cursorQuerySchema.extend({ unread: z.stringbool().optional() }) },
|
|
55
|
+
responses: {
|
|
56
|
+
200: jsonContent(cursorPageSchema(notificationSchema), "A page of notifications"),
|
|
57
|
+
...errorResponses(400, 401),
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
async (c) => {
|
|
61
|
+
const { cursor, limit, unread } = c.req.valid("query");
|
|
62
|
+
const before = cursor ? decodeCursor(position, cursor) : undefined;
|
|
63
|
+
const rows = await store.list(inbox(c).orgId, inbox(c).userId, {
|
|
64
|
+
limit: limit + 1,
|
|
65
|
+
unreadOnly: unread,
|
|
66
|
+
...(before ? { before: { createdAt: new Date(before.createdAt), id: before.id } } : {}),
|
|
67
|
+
});
|
|
68
|
+
const page = cursorPage(rows, limit, (r) => ({ createdAt: r.createdAt.toISOString(), id: r.id }));
|
|
69
|
+
return c.json(
|
|
70
|
+
{
|
|
71
|
+
...page,
|
|
72
|
+
data: page.data.map((r) => ({
|
|
73
|
+
...r,
|
|
74
|
+
readAt: r.readAt?.toISOString() ?? null,
|
|
75
|
+
createdAt: r.createdAt.toISOString(),
|
|
76
|
+
})),
|
|
77
|
+
},
|
|
78
|
+
200,
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
router.openapi(
|
|
84
|
+
createRoute({
|
|
85
|
+
method: "get",
|
|
86
|
+
path: "/unread-count",
|
|
87
|
+
tags: ["Notifications"],
|
|
88
|
+
summary: "How many of the caller's notifications are unread",
|
|
89
|
+
middleware: guard,
|
|
90
|
+
security,
|
|
91
|
+
responses: {
|
|
92
|
+
200: jsonContent(successSchema(z.object({ count: z.number().int() })), "The unread count"),
|
|
93
|
+
...errorResponses(401),
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
async (c) => {
|
|
97
|
+
const { orgId, userId } = inbox(c);
|
|
98
|
+
return c.json(success({ count: await store.unreadCount(orgId, userId) }), 200);
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
router.openapi(
|
|
103
|
+
createRoute({
|
|
104
|
+
method: "post",
|
|
105
|
+
path: "/read",
|
|
106
|
+
tags: ["Notifications"],
|
|
107
|
+
summary: "Mark the caller's notifications read: some, or all",
|
|
108
|
+
middleware: guard,
|
|
109
|
+
security,
|
|
110
|
+
request: {
|
|
111
|
+
body: jsonBody(
|
|
112
|
+
z.union([z.object({ ids: z.array(z.string()).min(1).max(100) }), z.object({ all: z.literal(true) })]),
|
|
113
|
+
"Which to mark read",
|
|
114
|
+
),
|
|
115
|
+
},
|
|
116
|
+
responses: {
|
|
117
|
+
200: jsonContent(successSchema(z.object({ updated: z.number().int() })), "How many changed"),
|
|
118
|
+
...errorResponses(400, 401),
|
|
119
|
+
},
|
|
120
|
+
}),
|
|
121
|
+
async (c) => {
|
|
122
|
+
const body = c.req.valid("json");
|
|
123
|
+
const { orgId, userId } = inbox(c);
|
|
124
|
+
const updated = await store.markRead(orgId, userId, "all" in body ? "all" : body.ids);
|
|
125
|
+
return c.json(success({ updated }), 200);
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
|
|
129
|
+
const tokenSchema = z.object({ token: z.string().min(1).max(512), device: z.string().max(255).optional() });
|
|
130
|
+
|
|
131
|
+
router.openapi(
|
|
132
|
+
createRoute({
|
|
133
|
+
method: "post",
|
|
134
|
+
path: "/push-tokens",
|
|
135
|
+
tags: ["Notifications"],
|
|
136
|
+
summary: "Register this device for push",
|
|
137
|
+
middleware: guard,
|
|
138
|
+
security,
|
|
139
|
+
request: { body: jsonBody(tokenSchema, "The device's FCM token") },
|
|
140
|
+
responses: { 204: { description: "Registered" }, ...errorResponses(400, 401) },
|
|
141
|
+
}),
|
|
142
|
+
async (c) => {
|
|
143
|
+
const { token, device } = c.req.valid("json");
|
|
144
|
+
await store.addPushToken(inbox(c).userId, token, device ?? null);
|
|
145
|
+
return c.body(null, 204);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
router.openapi(
|
|
150
|
+
createRoute({
|
|
151
|
+
method: "delete",
|
|
152
|
+
path: "/push-tokens",
|
|
153
|
+
tags: ["Notifications"],
|
|
154
|
+
summary: "Stop push to this device (e.g. on sign-out)",
|
|
155
|
+
middleware: guard,
|
|
156
|
+
security,
|
|
157
|
+
request: { body: jsonBody(tokenSchema.pick({ token: true }), "The device's FCM token") },
|
|
158
|
+
responses: { 204: { description: "Removed" }, ...errorResponses(400, 401) },
|
|
159
|
+
}),
|
|
160
|
+
async (c) => {
|
|
161
|
+
const { token } = c.req.valid("json");
|
|
162
|
+
// only the caller's own device
|
|
163
|
+
if ((await store.pushTokens(inbox(c).userId)).includes(token)) await store.removePushTokens([token]);
|
|
164
|
+
return c.body(null, 204);
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
export default defineRoutes({ app: "api", basePath: "/notifications", router });
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import {
|
|
2
|
+
consolePush,
|
|
3
|
+
consoleSms,
|
|
4
|
+
createNotifier,
|
|
5
|
+
fcmPush,
|
|
6
|
+
type NotificationsOf,
|
|
7
|
+
type PushSender,
|
|
8
|
+
type SmsSender,
|
|
9
|
+
twilioSms,
|
|
10
|
+
} from "@tatara/notifications";
|
|
11
|
+
import { env } from "#core/config/env";
|
|
12
|
+
import { enqueue } from "#core/jobs/index";
|
|
13
|
+
import { logger } from "#core/logging/logger";
|
|
14
|
+
import { notifications } from "#generated/notifications";
|
|
15
|
+
import { notificationStore } from "#modules/notifications/services/store";
|
|
16
|
+
import { auth } from "#modules/users/services/auth";
|
|
17
|
+
// tatara:slot imports
|
|
18
|
+
|
|
19
|
+
function sms(): SmsSender {
|
|
20
|
+
if (env.SMS_DRIVER !== "twilio") return consoleSms(logger);
|
|
21
|
+
if (!env.TWILIO_ACCOUNT_SID || !env.TWILIO_AUTH_TOKEN || !env.TWILIO_FROM)
|
|
22
|
+
throw new Error("SMS_DRIVER=twilio needs TWILIO_ACCOUNT_SID, TWILIO_AUTH_TOKEN and TWILIO_FROM");
|
|
23
|
+
return twilioSms({ accountSid: env.TWILIO_ACCOUNT_SID, authToken: env.TWILIO_AUTH_TOKEN, from: env.TWILIO_FROM });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function push(): PushSender {
|
|
27
|
+
if (env.PUSH_DRIVER !== "fcm") return consolePush(logger);
|
|
28
|
+
if (!env.FCM_PROJECT_ID || !env.FCM_CLIENT_EMAIL || !env.FCM_PRIVATE_KEY)
|
|
29
|
+
throw new Error("PUSH_DRIVER=fcm needs FCM_PROJECT_ID, FCM_CLIENT_EMAIL and FCM_PRIVATE_KEY");
|
|
30
|
+
return fcmPush({ projectId: env.FCM_PROJECT_ID, clientEmail: env.FCM_CLIENT_EMAIL, privateKey: env.FCM_PRIVATE_KEY });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// tatara:slot helpers
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* `notify({ orgId, to, notification, data })`: stores the inbox entry and sends each channel through
|
|
37
|
+
* jobs. Notifications are `notifications/*.ts` in any module (`defineNotification`).
|
|
38
|
+
*/
|
|
39
|
+
export const { notify, consumers } = createNotifier<NotificationsOf<typeof notifications>>({
|
|
40
|
+
notifications,
|
|
41
|
+
store: notificationStore,
|
|
42
|
+
enqueue: enqueue as never,
|
|
43
|
+
// tatara:slot recipients
|
|
44
|
+
async contact(userId) {
|
|
45
|
+
const { adapter } = await auth.$context;
|
|
46
|
+
const user = await adapter.findOne<{ email?: string; phoneNumber?: string | null; language?: string | null }>({
|
|
47
|
+
model: "user",
|
|
48
|
+
where: [{ field: "id", value: userId }],
|
|
49
|
+
});
|
|
50
|
+
// language: add it as a user field (better-auth additionalFields) to notify in each user's own
|
|
51
|
+
return user
|
|
52
|
+
? { email: user.email ?? null, phone: user.phoneNumber ?? null, language: user.language ?? null }
|
|
53
|
+
: null;
|
|
54
|
+
},
|
|
55
|
+
sms: sms(),
|
|
56
|
+
push: push(),
|
|
57
|
+
// tatara:slot senders
|
|
58
|
+
logger,
|
|
59
|
+
});
|
package/templates/features/notifications/files/src/modules/notifications/tests/notifications.test.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import type { InlineDriver } from "@tatara/jobs";
|
|
2
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
3
|
+
import { jobs } from "#core/jobs/index";
|
|
4
|
+
import { notify } from "#modules/notifications/services/notifier";
|
|
5
|
+
import { notificationStore } from "#modules/notifications/services/store";
|
|
6
|
+
import { api } from "#tests/helpers/api";
|
|
7
|
+
import { authenticate } from "#tests/helpers/auth";
|
|
8
|
+
import { resetDatabase } from "#tests/helpers/db";
|
|
9
|
+
|
|
10
|
+
const drain = () => (jobs.driver as InlineDriver).drain();
|
|
11
|
+
|
|
12
|
+
beforeEach(async () => {
|
|
13
|
+
await resetDatabase();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe("notifications", () => {
|
|
17
|
+
it("puts a notification in the user's inbox, once per idempotency key", async () => {
|
|
18
|
+
const user = await authenticate();
|
|
19
|
+
const orgId = user.orgId ?? "default";
|
|
20
|
+
for (let i = 0; i < 2; i++)
|
|
21
|
+
await notify({
|
|
22
|
+
orgId,
|
|
23
|
+
to: { user: user.userId },
|
|
24
|
+
notification: "example.welcome",
|
|
25
|
+
data: { name: "Ada" },
|
|
26
|
+
idempotencyKey: "welcome",
|
|
27
|
+
});
|
|
28
|
+
await drain();
|
|
29
|
+
const list = await api.get<{ data: { id: string; title: string }[]; meta: { nextCursor: string | null } }>(
|
|
30
|
+
"/api/notifications",
|
|
31
|
+
{ headers: user.headers },
|
|
32
|
+
);
|
|
33
|
+
expect(list.status).toBe(200);
|
|
34
|
+
expect(list.body.data.map((n) => n.title)).toEqual(["Welcome, Ada"]);
|
|
35
|
+
expect(list.body.meta.nextCursor).toBeNull();
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("marks only the caller's notifications read", async () => {
|
|
39
|
+
const [me, other] = [await authenticate(), await authenticate()];
|
|
40
|
+
for (const user of [me, other])
|
|
41
|
+
await notify({ orgId: user.orgId ?? "default", to: { user: user.userId }, notification: "example.welcome", data: { name: "x" } });
|
|
42
|
+
await drain();
|
|
43
|
+
const count = (h: Record<string, string>) =>
|
|
44
|
+
api.get<{ data: { count: number } }>("/api/notifications/unread-count", { headers: h });
|
|
45
|
+
expect((await count(me.headers)).body.data.count).toBe(1);
|
|
46
|
+
const read = await api.post<{ data: { updated: number } }>("/api/notifications/read", { all: true }, { headers: me.headers });
|
|
47
|
+
expect(read.body.data.updated).toBe(1);
|
|
48
|
+
expect((await count(me.headers)).body.data.count).toBe(0);
|
|
49
|
+
expect((await count(other.headers)).body.data.count).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("lets one delivery take a channel at a time, in the database", async () => {
|
|
53
|
+
const { id } = await notificationStore.claim({
|
|
54
|
+
id: crypto.randomUUID(),
|
|
55
|
+
orgId: "default",
|
|
56
|
+
userId: "u1",
|
|
57
|
+
name: "example.welcome",
|
|
58
|
+
inApp: null,
|
|
59
|
+
idempotencyKey: crypto.randomUUID(),
|
|
60
|
+
});
|
|
61
|
+
const takes = await Promise.all([1, 2, 3].map(() => notificationStore.claimChannel(id, "email")));
|
|
62
|
+
expect(takes.filter(Boolean)).toHaveLength(1);
|
|
63
|
+
expect(await notificationStore.claimChannel(id, "sms")).toBe(true);
|
|
64
|
+
await notificationStore.releaseChannel(id, "email");
|
|
65
|
+
expect(await notificationStore.claimChannel(id, "email")).toBe(true);
|
|
66
|
+
expect(await notificationStore.claimChannel(id, "sms")).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("needs a signed-in user", async () => {
|
|
70
|
+
expect((await api.get("/api/notifications")).status).toBe(401);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { boolean, index, mysqlTable, text, timestamp, varchar } from "drizzle-orm/mysql-core";
|
|
2
|
+
|
|
3
|
+
const ts = () => timestamp({ mode: "date", fsp: 3 });
|
|
4
|
+
|
|
5
|
+
/** One row per user per notification; `in_app` false rows only record what was sent. */
|
|
6
|
+
export const notifications = mysqlTable(
|
|
7
|
+
"notifications",
|
|
8
|
+
{
|
|
9
|
+
id: varchar({ length: 36 }).primaryKey(),
|
|
10
|
+
orgId: varchar({ length: 255 }).notNull(),
|
|
11
|
+
userId: varchar({ length: 255 }).notNull(),
|
|
12
|
+
name: varchar({ length: 255 }).notNull(),
|
|
13
|
+
title: text().notNull(),
|
|
14
|
+
body: text(),
|
|
15
|
+
url: text(),
|
|
16
|
+
inApp: boolean().notNull().default(true),
|
|
17
|
+
idempotencyKey: varchar({ length: 255 }).notNull().unique(),
|
|
18
|
+
// each channel is taken once, by a conditional update on its column
|
|
19
|
+
sentInApp: boolean().notNull().default(false),
|
|
20
|
+
sentEmail: boolean().notNull().default(false),
|
|
21
|
+
sentSms: boolean().notNull().default(false),
|
|
22
|
+
sentPush: boolean().notNull().default(false),
|
|
23
|
+
readAt: ts(),
|
|
24
|
+
createdAt: ts().notNull().defaultNow(),
|
|
25
|
+
},
|
|
26
|
+
(t) => [index("notifications_inbox").on(t.orgId, t.userId, t.createdAt)],
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
/** Devices that receive push, by FCM token. */
|
|
30
|
+
export const pushTokens = mysqlTable(
|
|
31
|
+
"push_tokens",
|
|
32
|
+
{
|
|
33
|
+
token: varchar({ length: 512 }).primaryKey(),
|
|
34
|
+
userId: varchar({ length: 255 }).notNull(),
|
|
35
|
+
device: varchar({ length: 255 }),
|
|
36
|
+
createdAt: ts().notNull().defaultNow(),
|
|
37
|
+
lastSeenAt: ts().notNull().defaultNow(),
|
|
38
|
+
},
|
|
39
|
+
(t) => [index("push_tokens_user").on(t.userId)],
|
|
40
|
+
);
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { boolean, index, pgTable, text, timestamp, varchar } from "drizzle-orm/pg-core";
|
|
2
|
+
|
|
3
|
+
// milliseconds, like the Date a cursor carries: finer and rows would fall between pages
|
|
4
|
+
const ts = () => timestamp({ withTimezone: true, mode: "date", precision: 3 });
|
|
5
|
+
|
|
6
|
+
/** One row per user per notification; `in_app` false rows only record what was sent. */
|
|
7
|
+
export const notifications = pgTable(
|
|
8
|
+
"notifications",
|
|
9
|
+
{
|
|
10
|
+
id: varchar({ length: 36 }).primaryKey(),
|
|
11
|
+
orgId: varchar({ length: 255 }).notNull(),
|
|
12
|
+
userId: varchar({ length: 255 }).notNull(),
|
|
13
|
+
name: varchar({ length: 255 }).notNull(),
|
|
14
|
+
title: text().notNull(),
|
|
15
|
+
body: text(),
|
|
16
|
+
url: text(),
|
|
17
|
+
inApp: boolean().notNull().default(true),
|
|
18
|
+
idempotencyKey: varchar({ length: 255 }).notNull().unique(),
|
|
19
|
+
// each channel is taken once, by a conditional update on its column
|
|
20
|
+
sentInApp: boolean().notNull().default(false),
|
|
21
|
+
sentEmail: boolean().notNull().default(false),
|
|
22
|
+
sentSms: boolean().notNull().default(false),
|
|
23
|
+
sentPush: boolean().notNull().default(false),
|
|
24
|
+
readAt: ts(),
|
|
25
|
+
createdAt: ts().notNull().defaultNow(),
|
|
26
|
+
},
|
|
27
|
+
(t) => [index("notifications_inbox").on(t.orgId, t.userId, t.createdAt)],
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
/** Devices that receive push, by FCM token. */
|
|
31
|
+
export const pushTokens = pgTable(
|
|
32
|
+
"push_tokens",
|
|
33
|
+
{
|
|
34
|
+
token: varchar({ length: 512 }).primaryKey(),
|
|
35
|
+
userId: varchar({ length: 255 }).notNull(),
|
|
36
|
+
device: varchar({ length: 255 }),
|
|
37
|
+
createdAt: ts().notNull().defaultNow(),
|
|
38
|
+
lastSeenAt: ts().notNull().defaultNow(),
|
|
39
|
+
},
|
|
40
|
+
(t) => [index("push_tokens_user").on(t.userId)],
|
|
41
|
+
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// One row per user per notification; in_app false rows only record what was sent.
|
|
2
|
+
|
|
3
|
+
model Notification {
|
|
4
|
+
id String @id @db.VarChar(36)
|
|
5
|
+
orgId String @map("org_id") @db.VarChar(255)
|
|
6
|
+
userId String @map("user_id") @db.VarChar(255)
|
|
7
|
+
name String @db.VarChar(255)
|
|
8
|
+
title String @db.Text
|
|
9
|
+
body String? @db.Text
|
|
10
|
+
url String? @db.Text
|
|
11
|
+
inApp Boolean @default(true) @map("in_app")
|
|
12
|
+
idempotencyKey String @unique @map("idempotency_key") @db.VarChar(255)
|
|
13
|
+
sentInApp Boolean @default(false) @map("sent_in_app")
|
|
14
|
+
sentEmail Boolean @default(false) @map("sent_email")
|
|
15
|
+
sentSms Boolean @default(false) @map("sent_sms")
|
|
16
|
+
sentPush Boolean @default(false) @map("sent_push")
|
|
17
|
+
readAt DateTime? @map("read_at")
|
|
18
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
19
|
+
|
|
20
|
+
@@index([orgId, userId, createdAt], map: "notifications_inbox")
|
|
21
|
+
@@map("notifications")
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Devices that receive push, by FCM token.
|
|
25
|
+
model PushToken {
|
|
26
|
+
token String @id @db.VarChar(512)
|
|
27
|
+
userId String @map("user_id") @db.VarChar(255)
|
|
28
|
+
device String? @db.VarChar(255)
|
|
29
|
+
createdAt DateTime @default(now()) @map("created_at")
|
|
30
|
+
lastSeenAt DateTime @default(now()) @map("last_seen_at")
|
|
31
|
+
|
|
32
|
+
@@index([userId], map: "push_tokens_user")
|
|
33
|
+
@@map("push_tokens")
|
|
34
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Loaded before the app (`node --import ./dist/apps/instrument.mjs …`, set in the Dockerfile):
|
|
2
|
+
// instrumentation can only patch libraries imported after it, so app code comes in dynamically.
|
|
3
|
+
import { register } from "node:module";
|
|
4
|
+
|
|
5
|
+
const { env } = await import("#core/config/env");
|
|
6
|
+
|
|
7
|
+
if (env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
8
|
+
register("@opentelemetry/instrumentation/hook.mjs", import.meta.url);
|
|
9
|
+
const { startTelemetry } = await import("@tatara/observability");
|
|
10
|
+
startTelemetry({
|
|
11
|
+
serviceName: env.OTEL_SERVICE_NAME,
|
|
12
|
+
endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT,
|
|
13
|
+
sampleRatio: env.OTEL_TRACES_SAMPLE_RATIO,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { corsOrigin } from "@tatara/http";
|
|
2
|
+
import { createRealtime } from "@tatara/realtime";
|
|
3
|
+
import { Redis } from "ioredis";
|
|
4
|
+
import { cache } from "#core/cache/index";
|
|
5
|
+
import { env } from "#core/config/env";
|
|
6
|
+
import { logger } from "#core/logging/logger";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The app's websockets: the api attaches them, every process can publish (through Redis). A client
|
|
10
|
+
* gets a ticket from POST /api/realtime/ticket and connects with `auth: { ticket }`.
|
|
11
|
+
*/
|
|
12
|
+
export const realtime = createRealtime({
|
|
13
|
+
driver: env.REALTIME_DRIVER,
|
|
14
|
+
adapter:
|
|
15
|
+
env.REALTIME_ADAPTER === "redis"
|
|
16
|
+
? { kind: "redis", redis: () => new Redis(env.REDIS_URL) }
|
|
17
|
+
: { kind: "memory" },
|
|
18
|
+
// single-use tickets, kept in the cache
|
|
19
|
+
tickets: cache,
|
|
20
|
+
ticketTtlMs: env.REALTIME_TICKET_TTL_SECONDS * 1000,
|
|
21
|
+
path: env.REALTIME_PATH,
|
|
22
|
+
allowOrigin: (() => {
|
|
23
|
+
const allow = corsOrigin(env.CORS_ORIGINS);
|
|
24
|
+
return (origin: string) => allow(origin) !== null;
|
|
25
|
+
})(),
|
|
26
|
+
logger,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
export const { publishToUser, publishToOrg, publishTo, disconnectUser, disconnectOrg } = realtime;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { createRoute, z } from "@hono/zod-openapi";
|
|
2
|
+
import { currentPrincipal, requireAuth } from "@tatara/auth";
|
|
3
|
+
import { errorResponses, jsonContent, success, successSchema } from "@tatara/http";
|
|
4
|
+
import { env } from "#core/config/env";
|
|
5
|
+
import { createRouter, defineRoutes } from "#core/http/routes";
|
|
6
|
+
import { realtime } from "#core/realtime/index";
|
|
7
|
+
// tatara:slot imports
|
|
8
|
+
|
|
9
|
+
const router = createRouter();
|
|
10
|
+
|
|
11
|
+
router.openapi(
|
|
12
|
+
createRoute({
|
|
13
|
+
method: "post",
|
|
14
|
+
path: "/ticket",
|
|
15
|
+
tags: ["Realtime"],
|
|
16
|
+
summary: "A single-use ticket to open a websocket as the caller",
|
|
17
|
+
middleware: [
|
|
18
|
+
requireAuth(),
|
|
19
|
+
// tatara:slot guard
|
|
20
|
+
],
|
|
21
|
+
security: [{ bearerAuth: [] }],
|
|
22
|
+
responses: {
|
|
23
|
+
200: jsonContent(
|
|
24
|
+
successSchema(z.object({ ticket: z.string(), expiresInMs: z.number().int(), path: z.string() })),
|
|
25
|
+
"Connect with io(url, { path, auth: { ticket } })",
|
|
26
|
+
),
|
|
27
|
+
...errorResponses(401),
|
|
28
|
+
},
|
|
29
|
+
}),
|
|
30
|
+
async (c) => {
|
|
31
|
+
const principal = currentPrincipal(c);
|
|
32
|
+
// apps without organizations have one, "default"
|
|
33
|
+
const ticket = await realtime.issueTicket({ userId: principal.userId, orgId: principal.orgId ?? "default" });
|
|
34
|
+
return c.json(success({ ...ticket, path: env.REALTIME_PATH }), 200);
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
export default defineRoutes({ app: "api", basePath: "/realtime", router });
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { serve } from "@hono/node-server";
|
|
2
|
+
import type { Server } from "node:http";
|
|
3
|
+
import type { AddressInfo } from "node:net";
|
|
4
|
+
import { io } from "socket.io-client";
|
|
5
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
6
|
+
import { app } from "#apps/api/app";
|
|
7
|
+
import { env } from "#core/config/env";
|
|
8
|
+
import { publishToUser, realtime } from "#core/realtime/index";
|
|
9
|
+
import { authenticate } from "#tests/helpers/auth";
|
|
10
|
+
|
|
11
|
+
let server: Server | undefined;
|
|
12
|
+
afterAll(() => new Promise((r) => (server ? server.close(r) : r(undefined))));
|
|
13
|
+
|
|
14
|
+
describe("realtime", () => {
|
|
15
|
+
it("connects with a ticket from the API and receives what the app publishes to the user", async () => {
|
|
16
|
+
server = await new Promise<Server>((resolve) => {
|
|
17
|
+
const started = serve({ fetch: app.fetch, port: 0 }, () => resolve(started as Server));
|
|
18
|
+
});
|
|
19
|
+
await realtime.attach(server);
|
|
20
|
+
const url = `http://localhost:${(server.address() as AddressInfo).port}`;
|
|
21
|
+
|
|
22
|
+
const user = await authenticate();
|
|
23
|
+
const res = await fetch(`${url}/api/realtime/ticket`, { method: "POST", headers: user.headers });
|
|
24
|
+
expect(res.status).toBe(200);
|
|
25
|
+
const { ticket } = ((await res.json()) as { data: { ticket: string } }).data;
|
|
26
|
+
|
|
27
|
+
const socket = io(url, { path: env.REALTIME_PATH, transports: ["websocket"], auth: { ticket }, reconnection: false });
|
|
28
|
+
await new Promise<void>((resolve, reject) => {
|
|
29
|
+
socket.on("connect", resolve);
|
|
30
|
+
socket.on("connect_error", reject);
|
|
31
|
+
});
|
|
32
|
+
const got = new Promise((r) => socket.once("hello", r));
|
|
33
|
+
await publishToUser(user.orgId ?? "default", user.userId, "hello", { n: 1 });
|
|
34
|
+
expect(await got).toEqual({ n: 1 });
|
|
35
|
+
socket.close();
|
|
36
|
+
});
|
|
37
|
+
});
|
package/templates/versions.json
CHANGED
|
@@ -31,15 +31,35 @@
|
|
|
31
31
|
"@aws-sdk/s3-request-presigner": "3.1152.0",
|
|
32
32
|
"nodemailer": "10.1.1",
|
|
33
33
|
"@types/nodemailer": "8.0.2",
|
|
34
|
-
"@tatara/core": "0.1.
|
|
35
|
-
"@tatara/http": "0.1.
|
|
36
|
-
"@tatara/db": "0.2.
|
|
34
|
+
"@tatara/core": "0.1.2",
|
|
35
|
+
"@tatara/http": "0.1.2",
|
|
36
|
+
"@tatara/db": "0.2.2",
|
|
37
37
|
"@tatara/testing": "0.1.1",
|
|
38
38
|
"@tatara/auth": "0.2.0",
|
|
39
39
|
"@tatara/keycloak": "0.1.2",
|
|
40
|
-
"@tatara/authz": "0.3.
|
|
40
|
+
"@tatara/authz": "0.3.1",
|
|
41
41
|
"@tatara/storage": "0.2.0",
|
|
42
|
-
"@tatara/email": "0.
|
|
42
|
+
"@tatara/email": "0.3.0",
|
|
43
43
|
"@tatara/i18n": "0.1.0",
|
|
44
|
-
"@tatara/jobs": "0.
|
|
44
|
+
"@tatara/jobs": "0.3.0",
|
|
45
|
+
"@tatara/cache": "0.1.0",
|
|
46
|
+
"openapi-typescript": "7.13.0",
|
|
47
|
+
"kafkajs": "2.2.4",
|
|
48
|
+
"@tatara/notifications": "0.1.0",
|
|
49
|
+
"@tatara/realtime": "0.1.0",
|
|
50
|
+
"socket.io": "4.8.4",
|
|
51
|
+
"socket.io-client": "4.8.4",
|
|
52
|
+
"@socket.io/redis-adapter": "8.3.0",
|
|
53
|
+
"@socket.io/redis-emitter": "5.1.0",
|
|
54
|
+
"@tatara/observability": "0.1.0",
|
|
55
|
+
"@opentelemetry/api": "1.10.0",
|
|
56
|
+
"@opentelemetry/sdk-node": "0.300.0",
|
|
57
|
+
"@opentelemetry/sdk-trace": "3.0.0",
|
|
58
|
+
"@opentelemetry/sdk-metrics": "3.0.0",
|
|
59
|
+
"@opentelemetry/resources": "3.0.0",
|
|
60
|
+
"@opentelemetry/exporter-trace-otlp-http": "0.300.0",
|
|
61
|
+
"@opentelemetry/exporter-metrics-otlp-http": "0.300.0",
|
|
62
|
+
"@opentelemetry/auto-instrumentations-node": "0.82.0",
|
|
63
|
+
"@opentelemetry/instrumentation": "0.300.0",
|
|
64
|
+
"@hono/otel": "1.3.0"
|
|
45
65
|
}
|