@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.
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/dist/client/index.d.ts +135 -0
- package/dist/client/index.d.ts.map +1 -0
- package/dist/client/index.js +131 -0
- package/dist/client/index.js.map +1 -0
- package/dist/client/types.d.ts +70 -0
- package/dist/client/types.d.ts.map +1 -0
- package/dist/client/types.js +3 -0
- package/dist/client/types.js.map +1 -0
- package/dist/component/_generated/api.d.ts +38 -0
- package/dist/component/_generated/api.d.ts.map +1 -0
- package/dist/component/_generated/api.js +31 -0
- package/dist/component/_generated/api.js.map +1 -0
- package/dist/component/_generated/component.d.ts +67 -0
- package/dist/component/_generated/component.d.ts.map +1 -0
- package/dist/component/_generated/component.js +11 -0
- package/dist/component/_generated/component.js.map +1 -0
- package/dist/component/_generated/dataModel.d.ts +46 -0
- package/dist/component/_generated/dataModel.d.ts.map +1 -0
- package/dist/component/_generated/dataModel.js +11 -0
- package/dist/component/_generated/dataModel.js.map +1 -0
- package/dist/component/_generated/server.d.ts +121 -0
- package/dist/component/_generated/server.d.ts.map +1 -0
- package/dist/component/_generated/server.js +78 -0
- package/dist/component/_generated/server.js.map +1 -0
- package/dist/component/convex.config.d.ts +3 -0
- package/dist/component/convex.config.d.ts.map +1 -0
- package/dist/component/convex.config.js +7 -0
- package/dist/component/convex.config.js.map +1 -0
- package/dist/component/mutations.d.ts +49 -0
- package/dist/component/mutations.d.ts.map +1 -0
- package/dist/component/mutations.js +110 -0
- package/dist/component/mutations.js.map +1 -0
- package/dist/component/queries.d.ts +46 -0
- package/dist/component/queries.d.ts.map +1 -0
- package/dist/component/queries.js +112 -0
- package/dist/component/queries.js.map +1 -0
- package/dist/component/schema.d.ts +51 -0
- package/dist/component/schema.d.ts.map +1 -0
- package/dist/component/schema.js +39 -0
- package/dist/component/schema.js.map +1 -0
- package/dist/component/validators.d.ts +50 -0
- package/dist/component/validators.d.ts.map +1 -0
- package/dist/component/validators.js +40 -0
- package/dist/component/validators.js.map +1 -0
- package/dist/shared.d.ts +22 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +26 -0
- package/dist/shared.js.map +1 -0
- package/package.json +101 -0
- package/src/client/index.ts +271 -0
- package/src/client/types.ts +82 -0
- package/src/component/_generated/api.ts +54 -0
- package/src/component/_generated/component.ts +102 -0
- package/src/component/_generated/dataModel.ts +60 -0
- package/src/component/_generated/server.ts +156 -0
- package/src/component/convex.config.ts +9 -0
- package/src/component/mutations.ts +118 -0
- package/src/component/queries.ts +128 -0
- package/src/component/schema.ts +40 -0
- package/src/component/validators.ts +49 -0
- package/src/shared.ts +31 -0
- 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
|
+
}
|