@house-rules/notifications 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,119 @@
1
+ import { defineOperation, NoInput } from "@house-rules/domain/contract";
2
+ import { Schema } from "effect";
3
+ import { DueDelivery, MyPreferences, NotificationForbidden, NotificationInputInvalid, NotificationList, NotificationPage, NotificationStoreUnavailable, NotifyInput, NotifyResult, Preference, QuietHours, ReadResult, TimeZone, } from "./notification.js";
4
+ import { NotificationPermissions } from "./permissions.js";
5
+ const failure = Schema.Union([
6
+ NotificationForbidden,
7
+ NotificationInputInvalid,
8
+ NotificationStoreUnavailable,
9
+ ]);
10
+ const notify = defineOperation("Notifications.notify", {
11
+ description: "Create inbox notifications and channel ledger rows for app-selected recipients. System callers only.",
12
+ input: NotifyInput,
13
+ output: NotifyResult,
14
+ failure,
15
+ permission: NotificationPermissions.write,
16
+ transactional: true,
17
+ });
18
+ const listMine = defineOperation("Notifications.listMine", {
19
+ description: "Read the caller's inbox, newest first.",
20
+ input: Schema.Struct({ page: Schema.optionalKey(NotificationPage) }),
21
+ output: NotificationList,
22
+ failure,
23
+ permission: NotificationPermissions.read,
24
+ readOnly: true,
25
+ });
26
+ const unreadCount = defineOperation("Notifications.unreadCount", {
27
+ description: "Count the caller's unread notifications.",
28
+ input: NoInput,
29
+ output: Schema.Int,
30
+ failure,
31
+ permission: NotificationPermissions.read,
32
+ readOnly: true,
33
+ });
34
+ const markRead = defineOperation("Notifications.markRead", {
35
+ description: "Mark one owned notification read and return its event fact once.",
36
+ input: Schema.Struct({ notificationId: Schema.NonEmptyString }),
37
+ output: ReadResult,
38
+ failure,
39
+ permission: NotificationPermissions.write,
40
+ transactional: true,
41
+ });
42
+ const markAllRead = defineOperation("Notifications.markAllRead", {
43
+ description: "Mark the caller's unread inbox read and return event facts.",
44
+ input: NoInput,
45
+ output: ReadResult,
46
+ failure,
47
+ permission: NotificationPermissions.write,
48
+ transactional: true,
49
+ });
50
+ const getMyPreferences = defineOperation("Notifications.getMyPreferences", {
51
+ description: "Read effective channel preferences, time zone and quiet hours for the caller.",
52
+ input: NoInput,
53
+ output: MyPreferences,
54
+ failure,
55
+ permission: NotificationPermissions.read,
56
+ readOnly: true,
57
+ });
58
+ const setMyPreference = defineOperation("Notifications.setMyPreference", {
59
+ description: "Set a caller-owned category and channel preference.",
60
+ input: Preference,
61
+ output: Schema.Void,
62
+ failure,
63
+ permission: NotificationPermissions.write,
64
+ transactional: true,
65
+ });
66
+ const setMyQuietHours = defineOperation("Notifications.setMyQuietHours", {
67
+ description: "Set or clear the caller's local quiet window and refresh queued deliveries.",
68
+ input: Schema.Struct({ quietHours: Schema.NullOr(QuietHours) }),
69
+ output: Schema.Void,
70
+ failure,
71
+ permission: NotificationPermissions.write,
72
+ transactional: true,
73
+ });
74
+ const setMyTimeZone = defineOperation("Notifications.setMyTimeZone", {
75
+ description: "Set or clear the caller's IANA time zone and refresh queued deliveries.",
76
+ input: Schema.Struct({ timeZone: Schema.NullOr(TimeZone) }),
77
+ output: Schema.Void,
78
+ failure,
79
+ permission: NotificationPermissions.write,
80
+ transactional: true,
81
+ });
82
+ const dueDeliveries = defineOperation("Notifications.dueDeliveries", {
83
+ description: "Select bounded complete due groups by recipient, channel and mode. System callers only.",
84
+ input: NoInput,
85
+ output: Schema.Array(DueDelivery),
86
+ failure,
87
+ permission: NotificationPermissions.deliver,
88
+ readOnly: true,
89
+ });
90
+ const markSent = defineOperation("Notifications.markSent", {
91
+ description: "Settle sent ledger rows idempotently. System callers only.",
92
+ input: Schema.Struct({ deliveryIds: Schema.Array(Schema.NonEmptyString) }),
93
+ output: Schema.Void,
94
+ failure,
95
+ permission: NotificationPermissions.deliver,
96
+ transactional: true,
97
+ });
98
+ const markFailed = defineOperation("Notifications.markFailed", {
99
+ description: "Record a failed send without changing the inbox. System callers only.",
100
+ input: Schema.Struct({ deliveryId: Schema.NonEmptyString, error: Schema.String }),
101
+ output: Schema.Void,
102
+ failure,
103
+ permission: NotificationPermissions.deliver,
104
+ transactional: true,
105
+ });
106
+ export const NotificationsOperations = {
107
+ notify,
108
+ listMine,
109
+ unreadCount,
110
+ markRead,
111
+ markAllRead,
112
+ getMyPreferences,
113
+ setMyPreference,
114
+ setMyQuietHours,
115
+ setMyTimeZone,
116
+ dueDeliveries,
117
+ markSent,
118
+ markFailed,
119
+ };
@@ -0,0 +1,23 @@
1
+ import { Schema } from "effect";
2
+ export declare const NotificationCategory: Schema.Struct<{
3
+ readonly name: Schema.NonEmptyString;
4
+ readonly defaultChannels: Schema.$Array<Schema.Literals<readonly ["inapp", "push", "email"]>>;
5
+ readonly mandatory: Schema.Boolean;
6
+ }>;
7
+ export type NotificationCategory = typeof NotificationCategory.Type;
8
+ export declare const NotificationsOptions: Schema.Struct<{
9
+ readonly recipientKind: Schema.NonEmptyString;
10
+ readonly channels: Schema.$Array<Schema.Literals<readonly ["inapp", "push", "email"]>>;
11
+ readonly categories: Schema.$Array<Schema.Struct<{
12
+ readonly name: Schema.NonEmptyString;
13
+ readonly defaultChannels: Schema.$Array<Schema.Literals<readonly ["inapp", "push", "email"]>>;
14
+ readonly mandatory: Schema.Boolean;
15
+ }>>;
16
+ readonly dailyDigestTime: Schema.optionalKey<Schema.String>;
17
+ readonly maxPageSize: Schema.Int;
18
+ readonly maxDueBatch: Schema.optionalKey<Schema.Int>;
19
+ readonly maxAttempts: Schema.optionalKey<Schema.Int>;
20
+ readonly retryBaseMs: Schema.optionalKey<Schema.Int>;
21
+ readonly retryCapMs: Schema.optionalKey<Schema.Int>;
22
+ }>;
23
+ export type NotificationsOptions = typeof NotificationsOptions.Type;
@@ -0,0 +1,23 @@
1
+ import { Schema } from "effect";
2
+ import { Channel, LocalTime } from "./notification.js";
3
+ const positive = Schema.Int.check(Schema.isGreaterThan(0));
4
+ export const NotificationCategory = Schema.Struct({
5
+ name: Schema.NonEmptyString,
6
+ defaultChannels: Schema.Array(Channel),
7
+ mandatory: Schema.Boolean,
8
+ });
9
+ export const NotificationsOptions = Schema.Struct({
10
+ recipientKind: Schema.NonEmptyString,
11
+ channels: Schema.Array(Channel).check(Schema.isMinLength(1)),
12
+ categories: Schema.Array(NotificationCategory).check(Schema.isMinLength(1)),
13
+ dailyDigestTime: Schema.optionalKey(LocalTime),
14
+ maxPageSize: positive,
15
+ maxDueBatch: Schema.optionalKey(positive),
16
+ maxAttempts: Schema.optionalKey(positive),
17
+ retryBaseMs: Schema.optionalKey(positive),
18
+ retryCapMs: Schema.optionalKey(positive),
19
+ }).check(Schema.makeFilter((options) => new Set(options.channels).size === options.channels.length &&
20
+ new Set(options.categories.map((category) => category.name)).size ===
21
+ options.categories.length &&
22
+ options.categories.every((category) => new Set(category.defaultChannels).size === category.defaultChannels.length &&
23
+ category.defaultChannels.every((channel) => options.channels.includes(channel)))));
@@ -0,0 +1,6 @@
1
+ export declare const NotificationPermissions: {
2
+ readonly read: "notifications:read";
3
+ readonly write: "notifications:write";
4
+ readonly deliver: "notifications:deliver";
5
+ };
6
+ export type NotificationPermission = (typeof NotificationPermissions)[keyof typeof NotificationPermissions];
@@ -0,0 +1,5 @@
1
+ export const NotificationPermissions = {
2
+ read: "notifications:read",
3
+ write: "notifications:write",
4
+ deliver: "notifications:deliver",
5
+ };
@@ -0,0 +1,4 @@
1
+ import type { Delivery, QuietHours } from "./notification.js";
2
+ export declare const isQuiet: (instant: number, hours: QuietHours, timeZone: string) => boolean;
3
+ export declare const releaseAt: (instant: number, hours: QuietHours, timeZone: string) => number;
4
+ export declare const digestAt: (instant: number, mode: Delivery["mode"], zone: string, dailyTime: string) => number;
@@ -0,0 +1,54 @@
1
+ const minute = 60_000;
2
+ const formatter = (timeZone) => new Intl.DateTimeFormat("en-CA", {
3
+ timeZone,
4
+ year: "numeric",
5
+ month: "2-digit",
6
+ day: "2-digit",
7
+ hour: "2-digit",
8
+ minute: "2-digit",
9
+ hourCycle: "h23",
10
+ });
11
+ const local = (format, instant) => {
12
+ const parts = format.formatToParts(instant);
13
+ const part = (name) => parts.find((p) => p.type === name)?.value ?? "";
14
+ return {
15
+ date: `${part("year")}-${part("month")}-${part("day")}`,
16
+ time: `${part("hour")}:${part("minute")}`,
17
+ };
18
+ };
19
+ export const isQuiet = (instant, hours, timeZone) => {
20
+ const { time } = local(formatter(timeZone), instant);
21
+ return hours.start < hours.end
22
+ ? time >= hours.start && time < hours.end
23
+ : time >= hours.start || time < hours.end;
24
+ };
25
+ export const releaseAt = (instant, hours, timeZone) => {
26
+ if (!isQuiet(instant, hours, timeZone))
27
+ return instant;
28
+ const format = formatter(timeZone);
29
+ // Walk real minutes so skipped and repeated DST hours cannot invent an instant.
30
+ for (let next = Math.floor(instant / minute) * minute + minute; next <= instant + 49 * 60 * minute; next += minute) {
31
+ const { time } = local(format, next);
32
+ const quiet = hours.start < hours.end
33
+ ? time >= hours.start && time < hours.end
34
+ : time >= hours.start || time < hours.end;
35
+ if (!quiet)
36
+ return next;
37
+ }
38
+ return instant + 49 * 60 * minute;
39
+ };
40
+ export const digestAt = (instant, mode, zone, dailyTime) => {
41
+ if (mode === "immediate")
42
+ return instant;
43
+ const format = formatter(zone);
44
+ const current = local(format, instant);
45
+ const sameDay = current.time < dailyTime;
46
+ for (let next = Math.floor(instant / minute) * minute + minute; next <= instant + 49 * 60 * minute; next += minute) {
47
+ const candidate = local(format, next);
48
+ if (mode === "hourly"
49
+ ? candidate.time.endsWith(":00")
50
+ : (sameDay || candidate.date > current.date) && candidate.time >= dailyTime)
51
+ return next;
52
+ }
53
+ return instant + 49 * 60 * minute;
54
+ };
@@ -0,0 +1,36 @@
1
+ import type { NoOpenUnit } from "@house-rules/domain";
2
+ import { Context, type Effect } from "effect";
3
+ import type { Delivery, DeliveryItem, Notification, NotificationCursor, NotificationStoreUnavailable, Preference, QuietHours, Recipient } from "./notification/notification.js";
4
+ type Read<A> = Effect.Effect<A, NotificationStoreUnavailable>;
5
+ type Write<A = void> = Effect.Effect<A, NotificationStoreUnavailable | NoOpenUnit>;
6
+ declare const NotificationStore_base: Context.ServiceClass<NotificationStore, "@house-rules/notifications/NotificationStore", {
7
+ readonly insert: (notification: Notification, deliveries: readonly Delivery[]) => Write;
8
+ readonly list: (recipient: Recipient, page: {
9
+ readonly size: number;
10
+ readonly after?: NotificationCursor;
11
+ }) => Read<readonly Notification[]>;
12
+ readonly unreadCount: (recipient: Recipient) => Read<number>;
13
+ readonly markRead: (recipient: Recipient, at: number, id?: string) => Write<readonly Notification[]>;
14
+ readonly preferences: (recipient: Recipient) => Read<readonly Preference[]>;
15
+ readonly setPreference: (recipient: Recipient, preference: Preference) => Write;
16
+ readonly quietHours: (recipient: Recipient) => Read<QuietHours | null>;
17
+ readonly setQuietHours: (recipient: Recipient, hours: QuietHours | null) => Write;
18
+ readonly timeZone: (recipient: Recipient) => Read<string | null>;
19
+ readonly setTimeZone: (recipient: Recipient, timeZone: string | null) => Write;
20
+ readonly deliveries: (recipient: Recipient, query?: {
21
+ readonly unsettledOnly: boolean;
22
+ }) => Read<readonly DeliveryItem[]>;
23
+ readonly delivery: (id: string) => Read<DeliveryItem | null>;
24
+ readonly reschedule: (id: string, schedule: Pick<Delivery, "mode" | "state" | "dueAt">) => Write;
25
+ readonly due: (at: number, limit: number) => Read<readonly DeliveryItem[]>;
26
+ readonly markSent: (ids: readonly string[], at: number) => Write;
27
+ readonly markFailed: (id: string, error: string, at: number, retry: {
28
+ readonly maxAttempts: number;
29
+ readonly baseMs: number;
30
+ readonly capMs: number;
31
+ }) => Write;
32
+ readonly erase: (recipient: Recipient) => Write;
33
+ }>;
34
+ export declare class NotificationStore extends NotificationStore_base {
35
+ }
36
+ export {};
@@ -0,0 +1,3 @@
1
+ import { Context } from "effect";
2
+ export class NotificationStore extends Context.Service()("@house-rules/notifications/NotificationStore") {
3
+ }
@@ -0,0 +1,61 @@
1
+ CREATE TABLE notifications (
2
+ id text PRIMARY KEY,
3
+ recipient_kind text NOT NULL,
4
+ recipient_id text NOT NULL,
5
+ category text NOT NULL,
6
+ title text NOT NULL,
7
+ body text NOT NULL,
8
+ data jsonb NOT NULL,
9
+ created_at bigint NOT NULL,
10
+ read_at bigint,
11
+ UNIQUE (id, recipient_kind, recipient_id)
12
+ );
13
+ --> statement-breakpoint
14
+ CREATE INDEX notifications_inbox ON notifications (recipient_kind, recipient_id, created_at DESC, id COLLATE "C" DESC);
15
+ --> statement-breakpoint
16
+ CREATE TABLE notification_preferences (
17
+ recipient_kind text NOT NULL,
18
+ recipient_id text NOT NULL,
19
+ category text NOT NULL,
20
+ channel text NOT NULL CHECK (channel IN ('inapp', 'push', 'email')),
21
+ mode text NOT NULL CHECK (mode IN ('immediate', 'hourly', 'daily', 'off')),
22
+ PRIMARY KEY (recipient_kind, recipient_id, category, channel)
23
+ );
24
+ --> statement-breakpoint
25
+ CREATE TABLE notification_quiet_hours (
26
+ recipient_kind text NOT NULL,
27
+ recipient_id text NOT NULL,
28
+ start_time text NOT NULL,
29
+ end_time text NOT NULL,
30
+ PRIMARY KEY (recipient_kind, recipient_id)
31
+ );
32
+ --> statement-breakpoint
33
+ CREATE TABLE notification_time_zones (
34
+ recipient_kind text NOT NULL,
35
+ recipient_id text NOT NULL,
36
+ time_zone text NOT NULL,
37
+ PRIMARY KEY (recipient_kind, recipient_id)
38
+ );
39
+ --> statement-breakpoint
40
+ CREATE TABLE notification_deliveries (
41
+ id text PRIMARY KEY,
42
+ notification_id text NOT NULL,
43
+ recipient_kind text NOT NULL,
44
+ recipient_id text NOT NULL,
45
+ channel text NOT NULL CHECK (channel IN ('inapp', 'push', 'email')),
46
+ mode text NOT NULL CHECK (mode IN ('immediate', 'hourly', 'daily')),
47
+ state text NOT NULL CHECK (state IN ('pending', 'held', 'sent', 'failed', 'cancelled')),
48
+ due_at bigint NOT NULL,
49
+ retry_at bigint,
50
+ sent_at bigint,
51
+ attempts integer NOT NULL DEFAULT 0 CHECK (attempts >= 0),
52
+ last_error text,
53
+ UNIQUE (notification_id, channel, recipient_kind, recipient_id),
54
+ FOREIGN KEY (notification_id, recipient_kind, recipient_id) REFERENCES notifications (id, recipient_kind, recipient_id) ON DELETE CASCADE
55
+ );
56
+ --> statement-breakpoint
57
+ CREATE INDEX notification_deliveries_due ON notification_deliveries (due_at, id COLLATE "C") WHERE state IN ('pending', 'held');
58
+ --> statement-breakpoint
59
+ CREATE INDEX notification_deliveries_unsettled ON notification_deliveries (recipient_kind, recipient_id, id COLLATE "C") WHERE state IN ('pending', 'held');
60
+ --> statement-breakpoint
61
+ CREATE VIEW notifications_published AS SELECT id, recipient_kind, recipient_id, category, title, body, data, created_at, read_at FROM notifications;
package/package.json CHANGED
@@ -1,6 +1,60 @@
1
1
  {
2
2
  "name": "@house-rules/notifications",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.1",
4
+ "description": "Inbox, channel preferences, quiet hours and a delivery ledger.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": [
8
+ "dist",
9
+ "migrations",
10
+ "AGENTS.md",
11
+ "README.md"
12
+ ],
13
+ "exports": {
14
+ ".": {
15
+ "types": "./dist/index.d.ts",
16
+ "default": "./dist/index.js"
17
+ },
18
+ "./contract": {
19
+ "types": "./dist/contract.d.ts",
20
+ "default": "./dist/contract.js"
21
+ }
22
+ },
23
+ "publishConfig": {
24
+ "access": "public"
25
+ },
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/JakubSzwajka/house-rules.git",
29
+ "directory": "packages/notifications"
30
+ },
31
+ "bugs": {
32
+ "url": "https://github.com/JakubSzwajka/house-rules/issues"
33
+ },
34
+ "homepage": "https://github.com/JakubSzwajka/house-rules",
35
+ "houseRules": {
36
+ "migrations": "migrations"
37
+ },
38
+ "peerDependencies": {
39
+ "@house-rules/application": "0.3.0",
40
+ "@house-rules/domain": "0.2.1",
41
+ "effect": "4.0.3"
42
+ },
43
+ "devDependencies": {
44
+ "@effect/platform-node": "4.0.3",
45
+ "@effect/platform-node-shared": "4.0.3",
46
+ "@effect/sql-pg": "4.0.3",
47
+ "@effect/vitest": "4.0.3",
48
+ "@house-rules/application": "0.3.0",
49
+ "@house-rules/domain": "0.2.1",
50
+ "@house-rules/migrations": "0.4.1",
51
+ "effect": "4.0.3",
52
+ "typescript": "7.0.2",
53
+ "vitest": "5.0.1"
54
+ },
55
+ "scripts": {
56
+ "build": "tsc --project tsconfig.build.json",
57
+ "typecheck": "tsc --project tsconfig.json",
58
+ "test": "varlock run --path ../../ -- vitest run"
59
+ }
6
60
  }