@vllnt/convex-suppression 0.1.0-canary.261f634

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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +138 -0
  3. package/dist/client/index.d.ts +135 -0
  4. package/dist/client/index.d.ts.map +1 -0
  5. package/dist/client/index.js +131 -0
  6. package/dist/client/index.js.map +1 -0
  7. package/dist/client/types.d.ts +70 -0
  8. package/dist/client/types.d.ts.map +1 -0
  9. package/dist/client/types.js +3 -0
  10. package/dist/client/types.js.map +1 -0
  11. package/dist/component/_generated/api.d.ts +38 -0
  12. package/dist/component/_generated/api.d.ts.map +1 -0
  13. package/dist/component/_generated/api.js +31 -0
  14. package/dist/component/_generated/api.js.map +1 -0
  15. package/dist/component/_generated/component.d.ts +67 -0
  16. package/dist/component/_generated/component.d.ts.map +1 -0
  17. package/dist/component/_generated/component.js +11 -0
  18. package/dist/component/_generated/component.js.map +1 -0
  19. package/dist/component/_generated/dataModel.d.ts +46 -0
  20. package/dist/component/_generated/dataModel.d.ts.map +1 -0
  21. package/dist/component/_generated/dataModel.js +11 -0
  22. package/dist/component/_generated/dataModel.js.map +1 -0
  23. package/dist/component/_generated/server.d.ts +121 -0
  24. package/dist/component/_generated/server.d.ts.map +1 -0
  25. package/dist/component/_generated/server.js +78 -0
  26. package/dist/component/_generated/server.js.map +1 -0
  27. package/dist/component/convex.config.d.ts +3 -0
  28. package/dist/component/convex.config.d.ts.map +1 -0
  29. package/dist/component/convex.config.js +7 -0
  30. package/dist/component/convex.config.js.map +1 -0
  31. package/dist/component/mutations.d.ts +49 -0
  32. package/dist/component/mutations.d.ts.map +1 -0
  33. package/dist/component/mutations.js +110 -0
  34. package/dist/component/mutations.js.map +1 -0
  35. package/dist/component/queries.d.ts +46 -0
  36. package/dist/component/queries.d.ts.map +1 -0
  37. package/dist/component/queries.js +112 -0
  38. package/dist/component/queries.js.map +1 -0
  39. package/dist/component/schema.d.ts +51 -0
  40. package/dist/component/schema.d.ts.map +1 -0
  41. package/dist/component/schema.js +39 -0
  42. package/dist/component/schema.js.map +1 -0
  43. package/dist/component/validators.d.ts +50 -0
  44. package/dist/component/validators.d.ts.map +1 -0
  45. package/dist/component/validators.js +40 -0
  46. package/dist/component/validators.js.map +1 -0
  47. package/dist/shared.d.ts +22 -0
  48. package/dist/shared.d.ts.map +1 -0
  49. package/dist/shared.js +26 -0
  50. package/dist/shared.js.map +1 -0
  51. package/package.json +101 -0
  52. package/src/client/index.ts +271 -0
  53. package/src/client/types.ts +82 -0
  54. package/src/component/_generated/api.ts +54 -0
  55. package/src/component/_generated/component.ts +102 -0
  56. package/src/component/_generated/dataModel.ts +60 -0
  57. package/src/component/_generated/server.ts +156 -0
  58. package/src/component/convex.config.ts +9 -0
  59. package/src/component/mutations.ts +118 -0
  60. package/src/component/queries.ts +128 -0
  61. package/src/component/schema.ts +40 -0
  62. package/src/component/validators.ts +49 -0
  63. package/src/shared.ts +31 -0
  64. package/src/test.ts +15 -0
@@ -0,0 +1,156 @@
1
+ /* eslint-disable */
2
+ /**
3
+ * Generated utilities for implementing server-side Convex query and mutation functions.
4
+ *
5
+ * THIS CODE IS AUTOMATICALLY GENERATED.
6
+ *
7
+ * To regenerate, run `npx convex dev`.
8
+ * @module
9
+ */
10
+
11
+ import type {
12
+ ActionBuilder,
13
+ HttpActionBuilder,
14
+ MutationBuilder,
15
+ QueryBuilder,
16
+ GenericActionCtx,
17
+ GenericMutationCtx,
18
+ GenericQueryCtx,
19
+ GenericDatabaseReader,
20
+ GenericDatabaseWriter,
21
+ } from "convex/server";
22
+ import {
23
+ actionGeneric,
24
+ httpActionGeneric,
25
+ queryGeneric,
26
+ mutationGeneric,
27
+ internalActionGeneric,
28
+ internalMutationGeneric,
29
+ internalQueryGeneric,
30
+ } from "convex/server";
31
+ import type { DataModel } from "./dataModel.js";
32
+
33
+ /**
34
+ * Define a query in this Convex app's public API.
35
+ *
36
+ * This function will be allowed to read your Convex database and will be accessible from the client.
37
+ *
38
+ * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
39
+ * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
40
+ */
41
+ export const query: QueryBuilder<DataModel, "public"> = queryGeneric;
42
+
43
+ /**
44
+ * Define a query that is only accessible from other Convex functions (but not from the client).
45
+ *
46
+ * This function will be allowed to read from your Convex database. It will not be accessible from the client.
47
+ *
48
+ * @param func - The query function. It receives a {@link QueryCtx} as its first argument.
49
+ * @returns The wrapped query. Include this as an `export` to name it and make it accessible.
50
+ */
51
+ export const internalQuery: QueryBuilder<DataModel, "internal"> =
52
+ internalQueryGeneric;
53
+
54
+ /**
55
+ * Define a mutation in this Convex app's public API.
56
+ *
57
+ * This function will be allowed to modify your Convex database and will be accessible from the client.
58
+ *
59
+ * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
60
+ * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
61
+ */
62
+ export const mutation: MutationBuilder<DataModel, "public"> = mutationGeneric;
63
+
64
+ /**
65
+ * Define a mutation that is only accessible from other Convex functions (but not from the client).
66
+ *
67
+ * This function will be allowed to modify your Convex database. It will not be accessible from the client.
68
+ *
69
+ * @param func - The mutation function. It receives a {@link MutationCtx} as its first argument.
70
+ * @returns The wrapped mutation. Include this as an `export` to name it and make it accessible.
71
+ */
72
+ export const internalMutation: MutationBuilder<DataModel, "internal"> =
73
+ internalMutationGeneric;
74
+
75
+ /**
76
+ * Define an action in this Convex app's public API.
77
+ *
78
+ * An action is a function which can execute any JavaScript code, including non-deterministic
79
+ * code and code with side-effects, like calling third-party services.
80
+ * They can be run in Convex's JavaScript environment or in Node.js using the "use node" directive.
81
+ * They can interact with the database indirectly by calling queries and mutations using the {@link ActionCtx}.
82
+ *
83
+ * @param func - The action. It receives an {@link ActionCtx} as its first argument.
84
+ * @returns The wrapped action. Include this as an `export` to name it and make it accessible.
85
+ */
86
+ export const action: ActionBuilder<DataModel, "public"> = actionGeneric;
87
+
88
+ /**
89
+ * Define an action that is only accessible from other Convex functions (but not from the client).
90
+ *
91
+ * @param func - The function. It receives an {@link ActionCtx} as its first argument.
92
+ * @returns The wrapped function. Include this as an `export` to name it and make it accessible.
93
+ */
94
+ export const internalAction: ActionBuilder<DataModel, "internal"> =
95
+ internalActionGeneric;
96
+
97
+ /**
98
+ * Define an HTTP action.
99
+ *
100
+ * The wrapped function will be used to respond to HTTP requests received
101
+ * by a Convex deployment if the requests matches the path and method where
102
+ * this action is routed. Be sure to route your httpAction in `convex/http.js`.
103
+ *
104
+ * @param func - The function. It receives an {@link ActionCtx} as its first argument
105
+ * and a Fetch API `Request` object as its second.
106
+ * @returns The wrapped function. Import this function from `convex/http.js` and route it to hook it up.
107
+ */
108
+ export const httpAction: HttpActionBuilder = httpActionGeneric;
109
+
110
+ /**
111
+ * A set of services for use within Convex query functions.
112
+ *
113
+ * The query context is passed as the first argument to any Convex query
114
+ * function run on the server.
115
+ *
116
+ * If you're using code generation, use the `QueryCtx` type in `convex/_generated/server.d.ts` instead.
117
+ */
118
+ export type QueryCtx = GenericQueryCtx<DataModel>;
119
+
120
+ /**
121
+ * A set of services for use within Convex mutation functions.
122
+ *
123
+ * The mutation context is passed as the first argument to any Convex mutation
124
+ * function run on the server.
125
+ *
126
+ * If you're using code generation, use the `MutationCtx` type in `convex/_generated/server.d.ts` instead.
127
+ */
128
+ export type MutationCtx = GenericMutationCtx<DataModel>;
129
+
130
+ /**
131
+ * A set of services for use within Convex action functions.
132
+ *
133
+ * The action context is passed as the first argument to any Convex action
134
+ * function run on the server.
135
+ */
136
+ export type ActionCtx = GenericActionCtx<DataModel>;
137
+
138
+ /**
139
+ * An interface to read from the database within Convex query functions.
140
+ *
141
+ * The two entry points are {@link DatabaseReader.get}, which fetches a single
142
+ * document by its {@link Id}, or {@link DatabaseReader.query}, which starts
143
+ * building a query.
144
+ */
145
+ export type DatabaseReader = GenericDatabaseReader<DataModel>;
146
+
147
+ /**
148
+ * An interface to read from and write to the database within Convex mutation
149
+ * functions.
150
+ *
151
+ * Convex guarantees that all writes within a single mutation are
152
+ * executed atomically, so you never have to worry about partial writes leaving
153
+ * your data in an inconsistent state. See [the Convex Guide](https://docs.convex.dev/understanding/convex-fundamentals/functions#atomicity-and-optimistic-concurrency-control)
154
+ * for the guarantees Convex provides your functions.
155
+ */
156
+ export type DatabaseWriter = GenericDatabaseWriter<DataModel>;
@@ -0,0 +1,9 @@
1
+ import { defineComponent } from "convex/server";
2
+
3
+ const component = defineComponent("suppression");
4
+
5
+ // Nest official child components here, e.g.:
6
+ // import sharded from "@convex-dev/sharded-counter/convex.config";
7
+ // component.use(sharded);
8
+
9
+ export default component;
@@ -0,0 +1,118 @@
1
+ import { v } from "convex/values";
2
+ import { mutation } from "./_generated/server";
3
+ import { jsonValue, suppressionReason } from "./validators";
4
+
5
+ /**
6
+ * Suppress a `(contactHash, channel)` — add it to the do-not-contact list. The
7
+ * host hashes and normalizes the contact itself and passes the opaque
8
+ * `contactHash`; the component never sees a raw email/phone, so the entry survives
9
+ * erasure of the underlying subject. `channel` is the host channel string
10
+ * (`"email"`/`"sms"`/`"push"`/…) the suppression applies to; omit it for a global
11
+ * (all-channel) suppression. `reason` is recorded for audit.
12
+ *
13
+ * Idempotent on `(contactHash, channel)`: re-suppressing an existing entry updates
14
+ * its `reason` and refreshes `createdAt` rather than inserting a duplicate, so a
15
+ * replayed bounce/complaint webhook can never fan the table out. `createdAt` is
16
+ * stamped from the server clock (`Date.now()` inside the handler — never
17
+ * caller-supplied).
18
+ */
19
+ export const suppress = mutation({
20
+ args: {
21
+ contactHash: v.string(),
22
+ channel: v.string(),
23
+ reason: suppressionReason,
24
+ },
25
+ returns: v.null(),
26
+ handler: async (ctx, args) => {
27
+ const existing = await ctx.db
28
+ .query("suppressions")
29
+ .withIndex("by_hash_channel", (q) =>
30
+ q.eq("contactHash", args.contactHash).eq("channel", args.channel),
31
+ )
32
+ .unique();
33
+ const now = Date.now();
34
+ if (existing !== null) {
35
+ await ctx.db.patch(existing._id, { reason: args.reason, createdAt: now });
36
+ return null;
37
+ }
38
+ await ctx.db.insert("suppressions", {
39
+ contactHash: args.contactHash,
40
+ channel: args.channel,
41
+ reason: args.reason,
42
+ createdAt: now,
43
+ });
44
+ return null;
45
+ },
46
+ });
47
+
48
+ /**
49
+ * Remove a `(contactHash, channel)` from the do-not-contact list — a rare, audited
50
+ * re-subscribe. Removing a global suppression (`channel` = the sentinel) clears
51
+ * only the global row, not per-channel entries; removing a channel row clears only
52
+ * that channel. Returns `true` if an entry was removed, `false` if none matched (a
53
+ * no-op unsuppress of an address that was never suppressed is not an error).
54
+ */
55
+ export const unsuppress = mutation({
56
+ args: { contactHash: v.string(), channel: v.string() },
57
+ returns: v.boolean(),
58
+ handler: async (ctx, args) => {
59
+ const existing = await ctx.db
60
+ .query("suppressions")
61
+ .withIndex("by_hash_channel", (q) =>
62
+ q.eq("contactHash", args.contactHash).eq("channel", args.channel),
63
+ )
64
+ .unique();
65
+ if (existing === null) {
66
+ return false;
67
+ }
68
+ await ctx.db.delete(existing._id);
69
+ return true;
70
+ },
71
+ });
72
+
73
+ /**
74
+ * Record an opt-in proof for a `(contactHash, listKey)` — the legal evidence that
75
+ * this contact confirmed receiving mail/messages for one list/purpose (a double
76
+ * opt-in, an explicit checkbox, an import with consent). `listKey` scopes the
77
+ * proof to one list or holds the global sentinel; `source` tags how it was
78
+ * captured; `proof` is opaque host evidence (IP, token ref, form snapshot) narrowed
79
+ * by the host's validator at the client boundary.
80
+ *
81
+ * Idempotent on `(contactHash, listKey)`: a second confirmation updates `source`,
82
+ * `proof`, and `confirmedAt` rather than inserting a duplicate. `confirmedAt` is
83
+ * server-sourced.
84
+ */
85
+ export const recordOptIn = mutation({
86
+ args: {
87
+ contactHash: v.string(),
88
+ listKey: v.string(),
89
+ source: v.string(),
90
+ proof: v.optional(jsonValue),
91
+ },
92
+ returns: v.null(),
93
+ handler: async (ctx, args) => {
94
+ const existing = await ctx.db
95
+ .query("optInProofs")
96
+ .withIndex("by_hash_list", (q) =>
97
+ q.eq("contactHash", args.contactHash).eq("listKey", args.listKey),
98
+ )
99
+ .unique();
100
+ const now = Date.now();
101
+ if (existing !== null) {
102
+ await ctx.db.patch(existing._id, {
103
+ source: args.source,
104
+ proof: args.proof,
105
+ confirmedAt: now,
106
+ });
107
+ return null;
108
+ }
109
+ await ctx.db.insert("optInProofs", {
110
+ contactHash: args.contactHash,
111
+ listKey: args.listKey,
112
+ source: args.source,
113
+ proof: args.proof,
114
+ confirmedAt: now,
115
+ });
116
+ return null;
117
+ },
118
+ });
@@ -0,0 +1,128 @@
1
+ import { v } from "convex/values";
2
+ import { query } from "./_generated/server";
3
+ import { GLOBAL_CHANNEL } from "../shared";
4
+ import { optInProofView, suppressionView } from "./validators";
5
+ import type { Doc } from "./_generated/dataModel";
6
+
7
+ /** Project a stored suppression row to its public view (drops internal fields). */
8
+ function view(row: Doc<"suppressions">) {
9
+ return {
10
+ contactHash: row.contactHash,
11
+ channel: row.channel === GLOBAL_CHANNEL ? null : row.channel,
12
+ reason: row.reason,
13
+ createdAt: row.createdAt,
14
+ };
15
+ }
16
+
17
+ /**
18
+ * The matching suppression for `(contactHash, channel)`, or `null` if the contact
19
+ * is not suppressed on that channel. A `channel` argument matches a global
20
+ * (all-channel) suppression OR a channel-specific one — a global tombstone wins
21
+ * and is returned first. Omit `channel` (the sentinel) to check the global entry
22
+ * only. Two bounded equality reads on `by_hash_channel`; never spans contacts.
23
+ */
24
+ export const isSuppressed = query({
25
+ args: { contactHash: v.string(), channel: v.string() },
26
+ returns: v.union(v.null(), suppressionView),
27
+ handler: async (ctx, args) => {
28
+ const globalRow = await ctx.db
29
+ .query("suppressions")
30
+ .withIndex("by_hash_channel", (q) =>
31
+ q.eq("contactHash", args.contactHash).eq("channel", GLOBAL_CHANNEL),
32
+ )
33
+ .unique();
34
+ if (globalRow !== null) {
35
+ return view(globalRow);
36
+ }
37
+ if (args.channel === GLOBAL_CHANNEL) {
38
+ return null;
39
+ }
40
+ const channelRow = await ctx.db
41
+ .query("suppressions")
42
+ .withIndex("by_hash_channel", (q) =>
43
+ q.eq("contactHash", args.contactHash).eq("channel", args.channel),
44
+ )
45
+ .unique();
46
+ return channelRow === null ? null : view(channelRow);
47
+ },
48
+ });
49
+
50
+ /**
51
+ * The opt-in proof for `(contactHash, listKey)`, or `null` if none is recorded.
52
+ * `listKey` holds the global sentinel to fetch a global opt-in. `proof` is the
53
+ * opaque host evidence, narrowed by the host validator at the client boundary.
54
+ */
55
+ export const getOptInProof = query({
56
+ args: { contactHash: v.string(), listKey: v.string() },
57
+ returns: v.union(v.null(), optInProofView),
58
+ handler: async (ctx, args) => {
59
+ const row = await ctx.db
60
+ .query("optInProofs")
61
+ .withIndex("by_hash_list", (q) =>
62
+ q.eq("contactHash", args.contactHash).eq("listKey", args.listKey),
63
+ )
64
+ .unique();
65
+ if (row === null) {
66
+ return null;
67
+ }
68
+ return {
69
+ contactHash: row.contactHash,
70
+ listKey: row.listKey === GLOBAL_CHANNEL ? null : row.listKey,
71
+ source: row.source,
72
+ proof: row.proof,
73
+ confirmedAt: row.confirmedAt,
74
+ };
75
+ },
76
+ });
77
+
78
+ /**
79
+ * The send gate: `true` when the contact may be contacted. A contact is eligible
80
+ * when it is NOT suppressed on `channel` (global or channel-specific) and — when
81
+ * `requireOptIn` is set — has a recorded opt-in proof for `listKey`. This is the
82
+ * single call a sender makes before every send: `¬suppressed [∧ confirmed]`.
83
+ * Suppression always blocks; the opt-in requirement is opt-in per call (marketing
84
+ * mail sets it; a transactional send may not).
85
+ */
86
+ export const isEligible = query({
87
+ args: {
88
+ contactHash: v.string(),
89
+ channel: v.string(),
90
+ listKey: v.string(),
91
+ requireOptIn: v.boolean(),
92
+ },
93
+ returns: v.boolean(),
94
+ handler: async (ctx, args) => {
95
+ const globalRow = await ctx.db
96
+ .query("suppressions")
97
+ .withIndex("by_hash_channel", (q) =>
98
+ q.eq("contactHash", args.contactHash).eq("channel", GLOBAL_CHANNEL),
99
+ )
100
+ .unique();
101
+ if (globalRow !== null) {
102
+ return false;
103
+ }
104
+ if (args.channel !== GLOBAL_CHANNEL) {
105
+ const channelRow = await ctx.db
106
+ .query("suppressions")
107
+ .withIndex("by_hash_channel", (q) =>
108
+ q.eq("contactHash", args.contactHash).eq("channel", args.channel),
109
+ )
110
+ .unique();
111
+ if (channelRow !== null) {
112
+ return false;
113
+ }
114
+ }
115
+ if (args.requireOptIn) {
116
+ const proof = await ctx.db
117
+ .query("optInProofs")
118
+ .withIndex("by_hash_list", (q) =>
119
+ q.eq("contactHash", args.contactHash).eq("listKey", args.listKey),
120
+ )
121
+ .unique();
122
+ if (proof === null) {
123
+ return false;
124
+ }
125
+ }
126
+ return true;
127
+ },
128
+ });
@@ -0,0 +1,40 @@
1
+ import { defineSchema, defineTable } from "convex/server";
2
+ import { v } from "convex/values";
3
+ import { jsonValue, suppressionReason } from "./validators";
4
+
5
+ /**
6
+ * Two sandboxed tables — the do-not-contact gate's own concern.
7
+ *
8
+ * `suppressions` is the anti-membership: a `(contactHash, channel)` tombstone that
9
+ * says "never contact this hash on this channel". `contactHash` is the host's
10
+ * opaque `hash(normalize(contact))` — the component never stores a raw email or
11
+ * phone, so a suppression survives erasure of the underlying subject. `channel`
12
+ * holds the host channel (`"email"`/`"sms"`/…) or the `GLOBAL_CHANNEL` sentinel
13
+ * `"*"` for an all-channel suppression. `reason` is recorded for audit. Indexed
14
+ * `by_hash_channel` (the exact-channel and global lookups an `isSuppressed` check
15
+ * makes) and `by_hash` (every entry for a hash, for `unsuppress` and audit).
16
+ *
17
+ * `optInProofs` is the legal evidence (not an authz relation): a `(contactHash,
18
+ * listKey)` record of a confirmed opt-in. `listKey` scopes it to one list/purpose
19
+ * or holds `GLOBAL_CHANNEL` for a global opt-in; `source` tags the capture
20
+ * channel; `proof` is opaque host evidence narrowed by a host validator. Indexed
21
+ * `by_hash_list` (the exact lookup `getOptInProof`/`isEligible` make).
22
+ */
23
+ export default defineSchema({
24
+ suppressions: defineTable({
25
+ contactHash: v.string(),
26
+ channel: v.string(),
27
+ reason: suppressionReason,
28
+ createdAt: v.number(),
29
+ })
30
+ .index("by_hash", ["contactHash"])
31
+ .index("by_hash_channel", ["contactHash", "channel"]),
32
+
33
+ optInProofs: defineTable({
34
+ contactHash: v.string(),
35
+ listKey: v.string(),
36
+ source: v.string(),
37
+ proof: v.optional(jsonValue),
38
+ confirmedAt: v.number(),
39
+ }).index("by_hash_list", ["contactHash", "listKey"]),
40
+ });
@@ -0,0 +1,49 @@
1
+ import { v } from "convex/values";
2
+
3
+ /**
4
+ * Opaque host-owned legal evidence attached to an opt-in proof — e.g. the IP,
5
+ * user-agent, double-opt-in token ref, or form snapshot the host captured at
6
+ * confirmation. The component never inspects it; it is last-resort arbitrary data,
7
+ * aliased here rather than left bare in function signatures. The host narrows it at
8
+ * the {@link Suppression} client boundary via an optional `proofValidator` parser.
9
+ *
10
+ * This is the single documented `v.any()` escape hatch in the component; the lint
11
+ * rule `convex-rules/no-bare-v-any` is satisfied by routing the arbitrary host
12
+ * payload through this alias instead of a bare `v.any()`.
13
+ */
14
+ export const jsonValue = v.any();
15
+
16
+ /** The five standard reasons a `(contactHash, channel)` is suppressed. */
17
+ export const suppressionReason = v.union(
18
+ v.literal("unsubscribe"),
19
+ v.literal("bounce"),
20
+ v.literal("complaint"),
21
+ v.literal("manual"),
22
+ v.literal("global"),
23
+ );
24
+
25
+ /**
26
+ * Public projection of a suppression returned by {@link isSuppressed}. `channel`
27
+ * is the host-supplied channel the entry applies to, or `null` for a global
28
+ * (all-channel) tombstone. `contactHash` is the host's opaque contact hash — never
29
+ * a raw email/phone.
30
+ */
31
+ export const suppressionView = v.object({
32
+ contactHash: v.string(),
33
+ channel: v.union(v.string(), v.null()),
34
+ reason: suppressionReason,
35
+ createdAt: v.number(),
36
+ });
37
+
38
+ /**
39
+ * Public projection of an opt-in proof returned by {@link getOptInProof}.
40
+ * `listKey` scopes the proof to one list/purpose, or `null` for a global opt-in.
41
+ * `source` tags how the opt-in was captured; `proof` is opaque host evidence.
42
+ */
43
+ export const optInProofView = v.object({
44
+ contactHash: v.string(),
45
+ listKey: v.union(v.string(), v.null()),
46
+ source: v.string(),
47
+ proof: v.optional(jsonValue),
48
+ confirmedAt: v.number(),
49
+ });
package/src/shared.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** Shared constants used by both `client/` and `component/`. */
2
+
3
+ export const COMPONENT_NAME = "suppression";
4
+
5
+ /**
6
+ * The standard suppression reasons. `unsubscribe` is an explicit opt-out (a
7
+ * one-click unsubscribe or a list-removal request); `bounce` and `complaint` are
8
+ * deliverability signals fed from a mail/SMS provider's events; `manual` is an
9
+ * operator action; `global` marks a do-not-contact-anywhere tombstone. The reason
10
+ * is recorded for audit — it never changes the suppression's effect (a suppressed
11
+ * hash is suppressed regardless of why).
12
+ */
13
+ export const SUPPRESSION_REASONS = [
14
+ "unsubscribe",
15
+ "bounce",
16
+ "complaint",
17
+ "manual",
18
+ "global",
19
+ ] as const;
20
+
21
+ /** A single suppression reason. */
22
+ export type SuppressionReason = (typeof SUPPRESSION_REASONS)[number];
23
+
24
+ /**
25
+ * The sentinel stored in the `channel` index slot for a global (all-channel)
26
+ * suppression. A real channel is any other host-supplied string (`"email"`,
27
+ * `"sms"`, `"push"`, …). Storing a concrete value rather than `undefined` lets a
28
+ * channel-scoped check hit a single equality index that matches the global row and
29
+ * the channel row in two bounded reads, with no `undefined` index gap.
30
+ */
31
+ export const GLOBAL_CHANNEL = "*";
package/src/test.ts ADDED
@@ -0,0 +1,15 @@
1
+ import type { TestConvex } from "convex-test";
2
+ import schema from "./component/schema";
3
+
4
+ const modules = import.meta.glob("./component/**/*.ts");
5
+
6
+ /**
7
+ * Register this component with a `convex-test` instance so consuming apps can
8
+ * test integration: `import { register } from "@vllnt/convex-suppression/test"`.
9
+ */
10
+ export function register(
11
+ t: TestConvex<typeof schema>,
12
+ name = "suppression",
13
+ ): void {
14
+ t.registerComponent(name, schema, modules);
15
+ }