Skip to content

Commit 4ef50cb

Browse files
authored
Adding Journal Search Tool (#9)
* 🫙 compliance cache * 🦄 update async script and pull table info * 🔎 journal search tool * 📘changeset * 🪴 fix casing issue
1 parent e918705 commit 4ef50cb

File tree

14 files changed

+899
-42
lines changed

14 files changed

+899
-42
lines changed

.changeset/cute-results-draw.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hhmi/compliance': patch
3+
---
4+
5+
Adding an in DB caching layer for the all scientists airtable call

.changeset/green-nails-play.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@hhmi/compliance': minor
3+
---
4+
5+
Adds the Journal Search Tool for Lab Budget advice

packages/compliance/scripts/sync-airtable-schema.mts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,12 @@ interface AirtableBaseMetadata {
4747
tables: AirtableTableMetadata[];
4848
}
4949

50-
// Table IDs we care about
51-
const TARGET_TABLES = {
50+
// Table IDs or names we care about. Use table id (e.g. 'tblXXX') or { name: 'Table Name' } to resolve by name.
51+
const TARGET_TABLES: Record<string, string | { name: string }> = {
5252
publications: 'tblZw5sCSjmxfd4PC',
5353
preprints: 'tblRWSiJvlOjt2OI4',
5454
scientists: 'tblKTlRedfiVcTo36',
55+
journals: 'tblIzVg2kQxXa5Xac',
5556
};
5657

5758
/**
@@ -136,13 +137,18 @@ function generateConfigFile(metadata: AirtableBaseMetadata): string {
136137
lines.push(' tables: {');
137138

138139
// Only include the tables we care about
139-
for (const [tableName, tableId] of Object.entries(TARGET_TABLES)) {
140-
const table = metadata.tables.find((t) => t.id === tableId);
140+
for (const [configKey, idOrName] of Object.entries(TARGET_TABLES)) {
141+
const tableId =
142+
typeof idOrName === 'string'
143+
? idOrName
144+
: metadata.tables.find((t) => t.name === idOrName.name)?.id;
145+
const table = tableId ? metadata.tables.find((t) => t.id === tableId) : undefined;
141146
if (table) {
142147
console.log(` ✓ Found table: ${table.name} (${table.fields.length} fields)`);
143148
lines.push(generateTableConfig(table));
144149
} else {
145-
console.warn(` ⚠ Table ${tableName} (${tableId}) not found in base`);
150+
const desc = typeof idOrName === 'string' ? idOrName : idOrName.name;
151+
console.warn(` ⚠ Table ${configKey} (${desc}) not found in base`);
146152
}
147153
}
148154

@@ -180,7 +186,15 @@ async function main() {
180186

181187
// Summary
182188
console.log('Summary:');
183-
const tables = metadata.tables.filter((t) => Object.values(TARGET_TABLES).includes(t.id));
189+
const targetIds = new Set<string>();
190+
for (const idOrName of Object.values(TARGET_TABLES)) {
191+
if (typeof idOrName === 'string') targetIds.add(idOrName);
192+
else {
193+
const t = metadata.tables.find((tb) => tb.name === idOrName.name);
194+
if (t) targetIds.add(t.id);
195+
}
196+
}
197+
const tables = metadata.tables.filter((t) => targetIds.has(t.id));
184198
for (const table of tables) {
185199
console.log(` - ${table.name}: ${table.fields.length} fields`);
186200
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { Link } from 'react-router';
2+
import { primitives } from '@curvenote/scms-core';
3+
import { FileSearch } from 'lucide-react';
4+
5+
const JOURNAL_SEARCH_PATH = '/app/task/journal-search';
6+
7+
export function JournalSearchTaskCard() {
8+
return (
9+
<primitives.Card
10+
lift
11+
className="relative p-0 h-full bg-white transition-colors cursor-pointer border-stone-400 hover:bg-accent/50"
12+
>
13+
<Link to={JOURNAL_SEARCH_PATH} className="block px-2 py-4 w-full h-full cursor-pointer">
14+
<div className="flex gap-2 items-center mx-2 h-full">
15+
<div className="flex-shrink-0">
16+
<FileSearch className="w-20 h-20 text-green-700" strokeWidth={1.25} aria-hidden />
17+
</div>
18+
<div className="flex-1 text-left">
19+
<h3 className="text-lg font-normal">HHMI Lab Budget Policy Search</h3>
20+
<p className="text-sm text-muted-foreground">
21+
Look up a journal to see whether HHMI lab budgets can be used to pay open access or
22+
other fees.
23+
</p>
24+
</div>
25+
</div>
26+
</Link>
27+
</primitives.Card>
28+
);
29+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* HHMI compliance Airtable cache using the shared Object table.
3+
* - Webhook always upserts when it runs.
4+
* - Routes: read from cache; if object missing (cold start), may fetch and write once; if exists, never update.
5+
*/
6+
7+
import { getPrismaClient } from '@curvenote/scms-server';
8+
import type { NormalizedScientist } from './types.js';
9+
import type { NormalizedJournal } from './airtable.journals.server.js';
10+
import { fetchAllScientists } from './airtable.scientists.server.js';
11+
import { fetchAllJournals } from './airtable.journals.server.js';
12+
13+
const HHMI_COMPLIANCE_CACHE_PREFIX = 'hhmi:compliance:';
14+
15+
/** Cache slot definitions. Add new entries here when caching additional queries. */
16+
export const CACHE_KEYS = {
17+
scientists: {
18+
id: `${HHMI_COMPLIANCE_CACHE_PREFIX}scientists`,
19+
type: `${HHMI_COMPLIANCE_CACHE_PREFIX}scientists`,
20+
},
21+
journals: {
22+
id: `${HHMI_COMPLIANCE_CACHE_PREFIX}journals`,
23+
type: `${HHMI_COMPLIANCE_CACHE_PREFIX}journals`,
24+
},
25+
} as const;
26+
27+
function nowIso(): string {
28+
return new Date().toISOString();
29+
}
30+
31+
/**
32+
* Read a cached object by id. Returns the row or null.
33+
*/
34+
export async function getCached(id: string): Promise<{ data: unknown; date_modified: string } | null> {
35+
const prisma = await getPrismaClient();
36+
const row = await prisma.object.findUnique({
37+
where: { id },
38+
select: { data: true, date_modified: true },
39+
});
40+
if (!row) return null;
41+
return { data: row.data, date_modified: row.date_modified };
42+
}
43+
44+
/**
45+
* Upsert a cache row. Used by webhook (always) and by routes on cold start only.
46+
*/
47+
export async function setCached(
48+
id: string,
49+
type: string,
50+
data: unknown,
51+
): Promise<void> {
52+
const prisma = await getPrismaClient();
53+
const now = nowIso();
54+
await prisma.object.upsert({
55+
where: { id },
56+
create: {
57+
id,
58+
type,
59+
date_created: now,
60+
date_modified: now,
61+
data: data as object,
62+
occ: 0,
63+
},
64+
update: {
65+
date_modified: now,
66+
data: data as object,
67+
},
68+
});
69+
}
70+
71+
/**
72+
* Read scientists list from cache. Returns null if not present.
73+
*/
74+
export async function getScientistsFromCache(): Promise<NormalizedScientist[] | null> {
75+
const row = await getCached(CACHE_KEYS.scientists.id);
76+
if (!row || row.data == null) return null;
77+
const parsed = row.data as NormalizedScientist[];
78+
return Array.isArray(parsed) ? parsed : null;
79+
}
80+
81+
/**
82+
* Get scientists from cache, or fetch from Airtable and write to cache once (cold start).
83+
* Routes use this; they may write only when the object does not exist.
84+
*/
85+
export async function getScientistsFromCacheOrFetch(): Promise<NormalizedScientist[]> {
86+
const cached = await getScientistsFromCache();
87+
if (cached !== null) return cached;
88+
89+
const scientists = await fetchAllScientists();
90+
const prisma = await getPrismaClient();
91+
const existing = await prisma.object.findUnique({
92+
where: { id: CACHE_KEYS.scientists.id },
93+
select: { id: true },
94+
});
95+
if (!existing) {
96+
await setCached(CACHE_KEYS.scientists.id, CACHE_KEYS.scientists.type, scientists);
97+
}
98+
return scientists;
99+
}
100+
101+
/**
102+
* Read journals list from cache. Returns null if not present.
103+
*/
104+
export async function getJournalsFromCache(): Promise<NormalizedJournal[] | null> {
105+
const row = await getCached(CACHE_KEYS.journals.id);
106+
if (!row || row.data == null) return null;
107+
const parsed = row.data as NormalizedJournal[];
108+
return Array.isArray(parsed) ? parsed : null;
109+
}
110+
111+
/**
112+
* Get journals from cache, or fetch from Airtable and write to cache once (cold start).
113+
*/
114+
export async function getJournalsFromCacheOrFetch(): Promise<NormalizedJournal[]> {
115+
const cached = await getJournalsFromCache();
116+
if (cached !== null) return cached;
117+
118+
const journals = await fetchAllJournals();
119+
const prisma = await getPrismaClient();
120+
const existing = await prisma.object.findUnique({
121+
where: { id: CACHE_KEYS.journals.id },
122+
select: { id: true },
123+
});
124+
if (!existing) {
125+
await setCached(CACHE_KEYS.journals.id, CACHE_KEYS.journals.type, journals);
126+
}
127+
return journals;
128+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { AIRTABLE_CONFIG } from './airtableConfig.js';
2+
import { airtableFetchAllPages } from './airtable.common.server.js';
3+
4+
const JOURNAL_FIELDS = AIRTABLE_CONFIG.tables.journals.fields;
5+
6+
export interface NormalizedJournal {
7+
id: string;
8+
journal_name: string;
9+
type: string;
10+
payment_instruction_override: string;
11+
}
12+
13+
interface JournalRecord {
14+
id: string;
15+
fields: Record<string, unknown>;
16+
}
17+
18+
function asString(value: unknown): string {
19+
if (value == null) return '';
20+
if (Array.isArray(value)) return (value[0] != null ? String(value[0]) : '') || '';
21+
return String(value);
22+
}
23+
24+
function normalizeJournal(record: JournalRecord): NormalizedJournal {
25+
const fields = record.fields;
26+
const journalNameId = JOURNAL_FIELDS.journal_name.id;
27+
const typeId = JOURNAL_FIELDS.type.id;
28+
const overrideId = JOURNAL_FIELDS.payment_instruction_override.id;
29+
30+
return {
31+
id: record.id,
32+
journal_name: asString(fields[journalNameId]),
33+
type: asString(fields[typeId]),
34+
payment_instruction_override: asString(fields[overrideId]),
35+
};
36+
}
37+
38+
/**
39+
* Fetches all journals from Airtable (journal_name, type, payment_instruction_override).
40+
* @returns Array of normalized journal records
41+
*/
42+
export async function fetchAllJournals(): Promise<NormalizedJournal[]> {
43+
const url = new URL(
44+
`https://api.airtable.com/v0/${AIRTABLE_CONFIG.baseId}/${AIRTABLE_CONFIG.tables.journals.id}`,
45+
);
46+
url.searchParams.set('filterByFormula', '');
47+
48+
const allRecords = await airtableFetchAllPages(url, { cellFormat: 'string' });
49+
return allRecords.map((r: JournalRecord) => normalizeJournal(r));
50+
}

0 commit comments

Comments
 (0)