Skip to content

Commit d60ca11

Browse files
committed
Injest recurly data
1 parent 3f418e3 commit d60ca11

File tree

17 files changed

+248
-74
lines changed

17 files changed

+248
-74
lines changed

bun.lockb

311 Bytes
Binary file not shown.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@
8080
"pino-pretty": "^11.0.0",
8181
"pubsub-js": "^1.9.4",
8282
"qs": "^6.13.1",
83+
"recurly": "^4.61.0",
8384
"sanitize-html": "^2.13.0",
8485
"uuid": "^9.0.0",
8586
"validator": "^13.7.0"

src/configuration.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const Config = t.strict({
3232
'file:/tmp/makespace-member-app.db'
3333
),
3434
TURSO_TOKEN: t.union([t.undefined, t.string]),
35+
RECURLY_TOKEN: t.union([t.undefined, t.string]),
3536
TURSO_SYNC_URL: t.union([t.undefined, t.string]),
3637
LOG_LEVEL: withDefaultIfEmpty(LogLevel, 'debug'),
3738
GOOGLE_RATELIMIT_MS: withDefaultIfEmpty(

src/init-dependencies/init-dependencies.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,8 @@ export const initDependencies = (
9292
conf.GOOGLE_RATELIMIT_MS,
9393
O.fromNullable(conf.TROUBLE_TICKET_SHEET),
9494
_cacheSheetData,
95-
_cacheTroubleTicketData
95+
_cacheTroubleTicketData,
96+
O.fromNullable(conf.RECURLY_TOKEN)
9697
);
9798

9899
const deps: Dependencies = {

src/queries/member/render.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {getGravatarProfile, getGravatarThumbnail} from '../../templates/avatar';
22
import {Html, html, sanitizeOption, sanitizeString} from '../../types/html';
33
import {ViewModel} from './view-model';
44
import {renderMemberNumber} from '../../templates/member-number';
5+
import {memberStatusTag} from '../../templates/member-status';
56
import {renderOwnerAgreementStatus} from '../shared-render/owner-agreement';
67
import {renderOwnerStatus} from '../shared-render/owner-status';
78
import {renderTrainerStatus} from '../shared-render/trainer-status';
@@ -58,6 +59,10 @@ export const render = (viewModel: ViewModel) => html`
5859
${viewModel.isSuperUser ? html`${editName(viewModel)}` : html``}
5960
</td>
6061
</tr>
62+
<tr>
63+
<th scope="row">Status</th>
64+
<td>${memberStatusTag(viewModel.member.status)}</td>
65+
</tr>
6166
<tr>
6267
<th scope="row">
6368
<p>Form of address</p>

src/queries/members/render.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {ViewModel} from './view-model';
77
import {getGravatarThumbnail} from '../../templates/avatar';
88
import {renderMemberNumber} from '../../templates/member-number';
99
import {Member} from '../../read-models/members';
10+
import {memberStatusTag} from '../../templates/member-status';
1011

1112
const ordByMemberNumber: Ord<Member> = pipe(
1213
N.Ord,
@@ -27,6 +28,7 @@ const renderMembers = (viewModel: ViewModel) =>
2728
<td>${sanitizeOption(member.name)}</td>
2829
<td>${sanitizeOption(member.formOfAddress)}</td>
2930
<td>${sanitizeString(member.emailAddress)}</td>
31+
<td>${memberStatusTag(member.status)}</td>
3032
</tr>
3133
`
3234
),
@@ -41,6 +43,7 @@ const renderMembers = (viewModel: ViewModel) =>
4143
<th>Full Name</th>
4244
<th>Preferred form of address</th>
4345
<th>Email</th>
46+
<th>Status</th>
4447
</tr>
4548
</thead>
4649
<tbody>

src/read-models/members/return-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export type Member = {
1616
agreementSigned: O.Option<Date>;
1717
isSuperUser: boolean;
1818
gravatarHash: GravatarHash;
19+
status: string;
1920
};
2021

2122
export type MultipleMembers = Map<number, Member>;

src/read-models/shared-state/async-apply-external-event-sources.ts

Lines changed: 148 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import * as O from 'fp-ts/Option';
44
import * as A from 'fp-ts/Array';
55
import * as t from 'io-ts';
66
import * as tt from 'io-ts-types';
7+
import recurly from 'recurly';
78
import {DomainEvent} from '../../types';
89
import {BetterSQLite3Database} from 'drizzle-orm/better-sqlite3';
10+
import {EmailAddressCodec} from '../../types/email-address';
911

1012
import {constructEvent, EventOfType} from '../../types/domain-event';
1113
import {
@@ -366,94 +368,172 @@ export const pullTroubleTicketResponses = async (
366368

367369
let lastTroubleTicketSync: O.Option<DateTime> = O.none; // FIXME - Temporary for POC.
368370

369-
export const asyncApplyExternalEventSources = (
371+
async function asyncApplyGoogleEvents(
370372
logger: Logger,
371373
currentState: BetterSQLite3Database,
372-
googleHelpers: O.Option<GoogleHelpers>,
374+
googleHelpers: GoogleHelpers,
373375
updateState: (event: DomainEvent) => void,
374376
googleRefreshIntervalMs: number,
375377
troubleTicketSheetId: O.Option<string>,
376378
cacheSheetData: Dependencies['cacheSheetData'],
377379
cacheTroubleTicketData: Dependencies['cacheTroubleTicketData']
378-
) => {
379-
return () => async () => {
380-
logger.info('Applying external event sources...');
381-
if (O.isNone(googleHelpers)) {
382-
logger.info('Google external event source disabled');
383-
return;
380+
) {
381+
if (O.isSome(troubleTicketSheetId)) {
382+
logger.info('Pulling latest trouble ticket reports...');
383+
if (
384+
O.isNone(lastTroubleTicketSync) ||
385+
lastTroubleTicketSync.value.diffNow() > TROUBLE_TICKET_SYNC_INTERVAL
386+
) {
387+
await pullTroubleTicketResponses(
388+
logger,
389+
googleHelpers,
390+
troubleTicketSheetId.value,
391+
updateState,
392+
cacheTroubleTicketData
393+
);
394+
lastTroubleTicketSync = O.some(DateTime.now());
395+
} else {
396+
logger.info(
397+
'%s since last trouble ticket sync - not resyncing yet',
398+
lastTroubleTicketSync.value.diffNow().toHuman()
399+
);
384400
}
401+
logger.info('...done');
402+
}
385403

386-
if (O.isSome(troubleTicketSheetId)) {
387-
logger.info('Pulling latest trouble ticket reports...');
388-
if (
389-
O.isNone(lastTroubleTicketSync) ||
390-
lastTroubleTicketSync.value.diffNow() > TROUBLE_TICKET_SYNC_INTERVAL
391-
) {
392-
await pullTroubleTicketResponses(
393-
logger,
394-
googleHelpers.value,
395-
troubleTicketSheetId.value,
396-
updateState,
397-
cacheTroubleTicketData
398-
);
399-
lastTroubleTicketSync = O.some(DateTime.now());
400-
} else {
401-
logger.info(
402-
'%s since last trouble ticket sync - not resyncing yet',
403-
lastTroubleTicketSync.value.diffNow().toHuman()
404-
);
405-
}
404+
logger.info('Pulling google training sheet data...');
405+
for (const equipment of getAllEquipmentMinimal(currentState)) {
406+
const equipmentLogger = logger.child({equipment});
407+
if (
408+
O.isNone(equipment.trainingSheetId) ||
409+
(O.isSome(equipment.lastQuizSync) &&
410+
Date.now() - equipment.lastQuizSync.value < googleRefreshIntervalMs)
411+
) {
412+
equipmentLogger.info('No google training sheet refresh required');
413+
continue;
406414
}
407415

408-
logger.info(
409-
'Finished pulling trouble ticket reports, getting google training sheet data...'
416+
equipmentLogger.info(
417+
'Triggering event update from google training sheets...'
410418
);
411-
for (const equipment of getAllEquipmentMinimal(currentState)) {
412-
const equipmentLogger = logger.child({equipment});
413-
if (
414-
O.isNone(equipment.trainingSheetId) ||
415-
(O.isSome(equipment.lastQuizSync) &&
416-
Date.now() - equipment.lastQuizSync.value < googleRefreshIntervalMs)
417-
) {
418-
equipmentLogger.info('No google training sheet refresh required');
419-
continue;
420-
}
421419

422-
equipmentLogger.info(
423-
'Triggering event update from google training sheets...'
424-
);
425-
426-
const events: (
420+
const events: (
421+
| EventOfType<'EquipmentTrainingQuizSync'>
422+
| EventOfType<'EquipmentTrainingQuizResult'>
423+
)[] = [];
424+
const collectEvents = (
425+
event:
427426
| EventOfType<'EquipmentTrainingQuizSync'>
428427
| EventOfType<'EquipmentTrainingQuizResult'>
429-
)[] = [];
430-
const collectEvents = (
431-
event:
432-
| EventOfType<'EquipmentTrainingQuizSync'>
433-
| EventOfType<'EquipmentTrainingQuizResult'>
434-
) => {
435-
events.push(event);
436-
updateState(event);
437-
};
428+
) => {
429+
events.push(event);
430+
updateState(event);
431+
};
438432

439-
await pullNewEquipmentQuizResults(
440-
equipmentLogger,
433+
await pullNewEquipmentQuizResults(
434+
equipmentLogger,
435+
googleHelpers,
436+
equipment.id,
437+
equipment.trainingSheetId.value,
438+
collectEvents
439+
);
440+
equipmentLogger.info(
441+
'Finished pulling %s events from google training sheet, caching...',
442+
events.length
443+
);
444+
await cacheSheetData(
445+
new Date(),
446+
equipment.trainingSheetId.value,
447+
equipmentLogger,
448+
events
449+
);
450+
}
451+
logger.info('...done');
452+
}
453+
454+
async function asyncApplyRecurlyEvents(
455+
logger: Logger,
456+
currentState: BetterSQLite3Database,
457+
updateState: (event: DomainEvent) => void,
458+
recurlyToken: string
459+
) {
460+
logger.info('Fetching recurly events...');
461+
const client = new recurly.Client(recurlyToken);
462+
463+
const accounts = client.listAccounts();
464+
for await (const account of accounts.each()) {
465+
const {
466+
email,
467+
hasActiveSubscription,
468+
hasFutureSubscription,
469+
hasCanceledSubscription,
470+
hasPausedSubscription,
471+
hasPastDueInvoice,
472+
} = account;
473+
474+
const maybeEmail = E.getOrElseW(() => undefined)(
475+
EmailAddressCodec.decode(email)
476+
);
477+
478+
if (maybeEmail === undefined) {
479+
continue;
480+
}
481+
482+
const event = constructEvent('RecurlySubscriptionUpdated')({
483+
email: maybeEmail,
484+
hasActiveSubscription: hasActiveSubscription ?? false,
485+
hasFutureSubscription: hasFutureSubscription ?? false,
486+
hasCanceledSubscription: hasCanceledSubscription ?? false,
487+
hasPausedSubscription: hasPausedSubscription ?? false,
488+
hasPastDueInvoice: hasPastDueInvoice ?? false,
489+
});
490+
491+
updateState(event);
492+
}
493+
494+
logger.info('...done');
495+
}
496+
497+
export const asyncApplyExternalEventSources = (
498+
logger: Logger,
499+
currentState: BetterSQLite3Database,
500+
googleHelpers: O.Option<GoogleHelpers>,
501+
updateState: (event: DomainEvent) => void,
502+
googleRefreshIntervalMs: number,
503+
troubleTicketSheetId: O.Option<string>,
504+
cacheSheetData: Dependencies['cacheSheetData'],
505+
cacheTroubleTicketData: Dependencies['cacheTroubleTicketData'],
506+
recurlyToken: O.Option<string>
507+
) => {
508+
return () => async () => {
509+
logger.info('Applying external event sources...');
510+
511+
if (O.isNone(googleHelpers)) {
512+
logger.info('Google external event source disabled');
513+
} else {
514+
await asyncApplyGoogleEvents(
515+
logger,
516+
currentState,
441517
googleHelpers.value,
442-
equipment.id,
443-
equipment.trainingSheetId.value,
444-
collectEvents
518+
updateState,
519+
googleRefreshIntervalMs,
520+
troubleTicketSheetId,
521+
cacheSheetData,
522+
cacheTroubleTicketData
445523
);
446-
equipmentLogger.info(
447-
'Finished pulling %s events from google training sheet, caching...',
448-
events.length
449-
);
450-
await cacheSheetData(
451-
new Date(),
452-
equipment.trainingSheetId.value,
453-
equipmentLogger,
454-
events
524+
}
525+
526+
if (O.isNone(recurlyToken)) {
527+
logger.info('Recurly external event source disabled');
528+
} else {
529+
await asyncApplyRecurlyEvents(
530+
logger,
531+
currentState,
532+
updateState,
533+
recurlyToken.value
455534
);
456535
}
536+
457537
logger.info('Finished applying external event sources');
458538
};
459539
};

src/read-models/shared-state/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,8 @@ export const initSharedReadModel = (
5858
googleRateLimitMs: number,
5959
troubleTicketSheetId: O.Option<string>,
6060
cacheSheetData: Dependencies['cacheSheetData'],
61-
cacheTroubleTicketData: Dependencies['cacheTroubleTicketData']
61+
cacheTroubleTicketData: Dependencies['cacheTroubleTicketData'],
62+
recurlyToken: O.Option<string>
6263
): SharedReadModel => {
6364
const _underlyingReadModelDb = new Database();
6465
const readModelDb = drizzle(_underlyingReadModelDb);
@@ -78,7 +79,8 @@ export const initSharedReadModel = (
7879
googleRateLimitMs,
7980
troubleTicketSheetId,
8081
cacheSheetData,
81-
cacheTroubleTicketData
82+
cacheTroubleTicketData,
83+
recurlyToken
8284
),
8385
members: {
8486
get: getMemberFull(readModelDb),

src/read-models/shared-state/return-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ export type MemberCoreInfo = {
8787
isSuperUser: boolean;
8888
superUserSince: O.Option<Date>;
8989
gravatarHash: GravatarHash;
90+
status: string;
9091
};
9192

9293
export type MemberAwaitingTraining = MemberCoreInfo & {

0 commit comments

Comments
 (0)