Skip to content

Commit 74180c5

Browse files
author
Ruby Engelhart
committed
lay the ground work for auto-opt ins
1 parent efdba41 commit 74180c5

File tree

8 files changed

+328
-0
lines changed

8 files changed

+328
-0
lines changed
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* @param { import("knex").Knex } knex
3+
* @returns { Promise<void> }
4+
*/
5+
exports.up = async knex => {
6+
await knex.schema.hasTable("opt_in").then(async exists => {
7+
if (exists) return;
8+
await knex.schema.createTable("opt_in", table => {
9+
table.increments("id")
10+
table.text("cell").notNullable();
11+
table.integer("assignment_id");
12+
table.integer("organization_id").notNullable();
13+
// Not in love with "reason_code", but doing so to match
14+
// opt_out table
15+
table.text("reason_code").notNullable().defaultTo("");
16+
table.timestamp("created_at").notNullable().defaultTo(knex.fn.now());
17+
18+
table.index("cell");
19+
table.index("assignment_id");
20+
table.foreign("assignment_id").references("assignment.id");
21+
table.index("organization_id");
22+
table.foreign("organization_id").references("organization.id")
23+
});
24+
});
25+
};
26+
27+
/**
28+
* @param { import("knex").Knex } knex
29+
*/
30+
exports.down = async function(knex) {
31+
await knex.schema.dropTable("opt_in");
32+
};
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/**
2+
* @param { import("knex").Knex } knex
3+
* @returns { Promise<void> }
4+
*/
5+
exports.up = async knex => {
6+
await knex.schema.alterTable("campaign_contact", table => {
7+
table
8+
.boolean("is_opted_in")
9+
.defaultTo(false);
10+
});
11+
};
12+
13+
/**
14+
* @param { import("knex").Knex } knex
15+
* @returns { Promise<void> }
16+
*/
17+
exports.down = async knex => {
18+
const isSqlite = /sqlite/.test(knex.client.config.client);
19+
if (!isSqlite) {
20+
await knex.schema.alterTable("campaign_contact", table => {
21+
table
22+
.boolean("is_opted_in")
23+
.defaultTo(false);
24+
});
25+
}
26+
};

src/containers/AssignmentTexterContact.jsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export class AssignmentTexterContact extends React.Component {
200200
console.log("sentMessage", contact.id);
201201
});
202202
} else {
203+
console.log("here2", message, contact, cannedResponseId);
203204
await this.props.mutations.sendMessage(
204205
message,
205206
contact.id,
@@ -210,6 +211,7 @@ export class AssignmentTexterContact extends React.Component {
210211
this.props.onFinishContact(contact.id);
211212
} catch (e) {
212213
this.handleSendMessageError(e);
214+
console.log("here maybe: ", e);
213215
setTimeout(() => {
214216
this.props.onFinishContact(contact.id);
215217
}, 750);
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import { getConfig, getFeatures } from "../../../server/api/lib/config";
2+
import { cacheableData } from "../../../server/models";
3+
4+
const DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64 =
5+
"W3sicmVnZXgiOiAiXlNUQVJUJH1d";
6+
7+
// DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64 decodes to:
8+
// [{"regex": "^START$, "reason": "start"}]
9+
10+
export const serverAdministratorInstructions = () => {
11+
return {
12+
description: ``,
13+
setupInstructions: ``,
14+
environmentVariables: []
15+
}
16+
}
17+
18+
export const available = organization => {
19+
const config = getConfig("AUTO_OPTIN_REGEX_LIST_BASE64", organization) ||
20+
DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64;
21+
22+
if (!config) return false;
23+
try {
24+
JSON.parse(Buffer.from(conf, "base64").toString());
25+
return true;
26+
} catch (exception) {
27+
console.log(
28+
"message-handler/auto-optin JSON parse of AUTO_OPTIN_REGEX_LIST_BASE64 failed",
29+
e
30+
);
31+
return false;
32+
}
33+
}
34+
35+
export const preMessageSave = async ({ messageToSave, organization }) => {
36+
if (messageToSave.is_from_contact) {
37+
const config = Buffer.from(
38+
getConfig("AUTO_OPTIN_REGEX_LIST_BASE64", organization) ||
39+
DEFAULT_AUTO_OPTIN_REGEX_LIST_BASE64
40+
);
41+
const regexList = JSON.parse(config || "[]");
42+
const matches = regexList.filter(matcher => {
43+
const re = new RegExp(matcher.regex); // Want case sensitivity, probably?
44+
return String(messageToSave.text).match(re);
45+
})
46+
if (matches.length) {
47+
console.log(
48+
`auto-optin MATCH ${messageToSave.campaign_contact_id}`
49+
);
50+
const reason = matches[0].reason || "auto_optin";
51+
52+
// UNSURE OF THIS RETURN
53+
return {
54+
contactUpdates: {
55+
is_opted_in: true
56+
},
57+
handlerContext: {
58+
autoOptInReason: reason,
59+
},
60+
messageToSave
61+
};
62+
}
63+
}
64+
}
65+
66+
export const postMessageSave = async ({
67+
message,
68+
organization,
69+
handlerContext,
70+
campaign
71+
}) => {
72+
if (!message.is_from_contact || !handlerContext.autoOptInReason) return;
73+
74+
console.log(
75+
`auto-optin.postMessageSave ${message.campaign_contact_id}`
76+
);
77+
78+
let contact = await cacheableData.campaignContact.load(
79+
message.campaign_contact_id,
80+
{ cahceOnly: true}
81+
);
82+
campaign = campaign || { organization_id: organization.id};
83+
84+
// Save to DB
85+
await cacheableData.optIn.save({
86+
cell: message.contact_number,
87+
campaign,
88+
assignmentId: (contact && contact.assignment_id) || null,
89+
reason: handlerContext.autoOptInReason
90+
});
91+
}

src/server/models/cacheable_queries/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import cannedResponse from "./canned-response";
55
import organizationContact from "./organization-contact";
66
import message from "./message";
77
import optOut from "./opt-out";
8+
import optIn from "./opt-in";
89
import organization from "./organization";
910
import questionResponse from "./question-response";
1011
import { tagCampaignContactCache as tagCampaignContact } from "./tag-campaign-contact";
@@ -18,6 +19,7 @@ const cacheableData = {
1819
organizationContact,
1920
message,
2021
optOut,
22+
optIn,
2123
organization,
2224
questionResponse,
2325
tagCampaignContact,
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { r } from "../../models";
2+
3+
// TODO: Add OPTINS_SHARE_ALL_ORGS to env doc
4+
5+
const orgCacheKey = orgId =>
6+
!!process.env.OPTINS_SHARE_ALL_ORGS
7+
? `${process.env.CAHCE_PREFIX || ""}:optins`
8+
: `${process.env.CACHE_PREFIS || ""}:optins-${orgId}`;
9+
10+
const sharingOptIns = !!process.env.OPTINS_SHARE_ALL_ORGS
11+
12+
// Probably not needed, but good to offload stress from
13+
// db.
14+
const loadMany = async organizationId => {
15+
if (r.redis) {
16+
let dbQuery = r
17+
.knex("opt_in")
18+
.select("cell")
19+
.orderBy("id", "desc")
20+
.limit(process.env.OPTINS_CACHE_MAX || 1000000);
21+
22+
if (!sharingOptIns) {
23+
dbQuery = dbQuery.where("organization_id", organizationId);
24+
}
25+
const dbResult = await dbQuery;
26+
const cellOptIns = dbResult.map(rec => rec.cell);
27+
const hashKey = orgCacheKey(organizationId);
28+
29+
await r.redis.SADD(hashKey, ["0"]);
30+
await r.redis.expire(hashKey, 43200);
31+
32+
for (
33+
let i100 = 0, l100 = Math.ceil(cellOptOuts.length / 100);
34+
i100 < l100;
35+
i100++
36+
) {
37+
await r.redis.SADD(
38+
hashKey,
39+
cellOptIns.slice(100 * i100, 100 * i100 + 100)
40+
);
41+
}
42+
return cellOptIns.length;
43+
}
44+
}
45+
46+
const updateIsOptedIn = async queryModifier => {
47+
// update all organization/instance's active campaigns
48+
const optInContactQuery = r
49+
.knex("campaign_contact")
50+
.join("campaign", "campaign_contact.id", "campaign.id")
51+
.where("campaign.is_archived", false)
52+
.select("campaign_contact.id");
53+
54+
return await r
55+
.knex("campaign_contact")
56+
.whereIn(
57+
"id",
58+
queryModifier ? queryModifier(optInContactQuery) : optInContactQuery
59+
)
60+
.update("is_opted_in", true)
61+
.update({
62+
is_opted_in: true
63+
})
64+
}
65+
66+
const optInCache = {
67+
clearQuery: async ({ cell, organizationId }) => {
68+
if (r.redis) {
69+
if (cell) {
70+
await r.redis.sdel(orgCacheKey(organizationId), cell);
71+
} else {
72+
await r.redis.DEL(orgCacheKey(organizationId));
73+
}
74+
}
75+
},
76+
query: async ({ cell, organizationId }) => {
77+
const accountingForOrgSharing = !sharingOptIns
78+
? { cell, organization_id: organizationId }
79+
: { cell };
80+
81+
if (r.redis) {
82+
const hashKey = orgCacheKey(organizationId);
83+
const [exists, isMember] = await r.redis
84+
.MULTI()
85+
.EXISTS(hashKey)
86+
.SISMEMBER(hashKey, cell)
87+
.exec();
88+
if (exists) {
89+
return isMember;
90+
}
91+
92+
loadMany(organizationId)
93+
.then(optInCount => {
94+
if (!global.TEST_ENVIRONMENT) {
95+
console.log(
96+
"optInCache loaded for organization",
97+
organizationId,
98+
optInCount
99+
);
100+
}
101+
})
102+
.catch(err => {
103+
console.log(
104+
"optInCache Error for organization",
105+
organizationId,
106+
err
107+
);
108+
});
109+
}
110+
const dbResult = await r
111+
.knex("opt_in")
112+
.select("cell")
113+
.where(accountingForOrgSharing)
114+
.limit(1);
115+
return dbResult.length > 0
116+
},
117+
save: async ({
118+
cell,
119+
campaign,
120+
assignmentId,
121+
reason
122+
}) => {
123+
const organizationId = campaign.organization_id;
124+
if (r.redis) {
125+
const hashKey = orgCacheKey(organizationId);
126+
const exists = await r.redis.exists(hashKey);
127+
if (exists) {
128+
await r.redis.SADD(hashKey, cell);
129+
}
130+
}
131+
132+
// place into db
133+
await r.knex("opt_in").insert({
134+
assignment_id: assignmentId,
135+
organization_id: organizationId,
136+
reason_code: reason,
137+
cell
138+
});
139+
},
140+
loadMany,
141+
updateIsOptedIn
142+
}
143+
144+
export default optInCache;

src/server/models/index.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import CampaignContact from "./campaign-contact";
1111
import InteractionStep from "./interaction-step";
1212
import QuestionResponse from "./question-response";
1313
import OptOut from "./opt-out";
14+
import OptIn from "./opt-in";
1415
import JobRequest from "./job-request";
1516
import Invite from "./invite";
1617
import CannedResponse from "./canned-response";
@@ -131,6 +132,7 @@ const createLoaders = () => ({
131132
jobRequest: createLoader(JobRequest),
132133
message: createLoader(Message),
133134
optOut: createLoader(OptOut),
135+
optIn: createLoader(OptIn),
134136
pendingMessagePart: createLoader(PendingMessagePart),
135137
questionResponse: createLoader(QuestionResponse),
136138
userCell: createLoader(UserCell),
@@ -165,6 +167,7 @@ export {
165167
JobRequest,
166168
Message,
167169
OptOut,
170+
OptIn,
168171
Organization,
169172
PendingMessagePart,
170173
CannedResponse,

src/server/models/opt-in.js

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import thinky from "./thinky";
2+
const type = thinky.type;
3+
import { optionalString, requiredString, timestamp } from "./custom-types";
4+
5+
import Organization from "./organization";
6+
import Assignment from "./assignment";
7+
8+
const OptIn = thinky.createModel(
9+
"opt_in",
10+
type
11+
.object()
12+
.schema({
13+
id: type.string(),
14+
cell: requiredString(),
15+
assignment_id: optionalString(),
16+
organization_id: requiredString(),
17+
reason_code: requiredString(),
18+
created_at: timestamp()
19+
})
20+
.allowExtra(false),
21+
{ noAutoCreation: true, dependencies: [Organization, Assignment] }
22+
);
23+
24+
OptIn.ensureIndex("cell");
25+
OptIn.ensureIndex("assignment_id");
26+
OptIn.ensureIndex("organization_id");
27+
28+
export default OptIn;

0 commit comments

Comments
 (0)