Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import {
2 ContainerTypes,
3 getScalarType,
4 ScalarTypes,
5 type Field,
6 type ScalarType,
7} from '@roostorg/types';
8import _ from 'lodash';
9
10import { inject } from '../../iocContainer/index.js';
11import { assertUnreachable } from '../../utils/misc.js';
12import {
13 CoopInput,
14 type ItemSchema,
15} from '../moderationConfigService/index.js';
16import {
17 type SignalInputType,
18 type SignalOutputType,
19} from '../signalsService/index.js';
20import {
21 derivedFieldRecipes,
22 getDerivedFieldInputTypes,
23 getDerivedFieldIsEnabled,
24 getDerivedFieldOutputType,
25 type DerivedFieldRecipe,
26 type DerivedFieldSpec,
27 type DerivedFieldType,
28} from './helpers.js';
29
30type AnnotatedDerivedFieldRecipe = {
31 type: DerivedFieldType;
32 recipe: DerivedFieldRecipe;
33 outputType: SignalOutputType;
34};
35
36type DerivedField = Pick<Field, 'type' | 'container' | 'name'> & {
37 spec: DerivedFieldSpec;
38};
39
40export const makeDerivedFieldsService = inject(
41 ['SignalsService'],
42 function (signalsService) {
43 const getSignal = signalsService.getSignalOrThrow.bind(signalsService);
44 const getSignalDisabled =
45 signalsService.getSignalDisabledForOrg.bind(signalsService);
46
47 const recipesByFieldTypeEntries = Object.entries(derivedFieldRecipes) as [
48 DerivedFieldType,
49 DerivedFieldRecipe,
50 ][];
51
52 const getAnnotatedDerivedFieldRecipesByInputType = async (
53 orgId: string,
54 ): Promise<{
55 [K in SignalInputType]?: AnnotatedDerivedFieldRecipe[];
56 }> => {
57 const annotatedDerivedFieldRecipes = await Promise.all(
58 recipesByFieldTypeEntries.map(async ([fieldType, recipe]) => {
59 const enabled = await getDerivedFieldIsEnabled(
60 getSignalDisabled,
61 fieldType,
62 orgId,
63 );
64 if (!enabled) {
65 return [];
66 }
67 const inputTypes = await getDerivedFieldInputTypes(
68 getSignal,
69 fieldType,
70 orgId,
71 );
72 return Promise.all(
73 inputTypes.map(async (inputType) => ({
74 type: fieldType,
75 recipe,
76 inputType,
77 outputType: await getDerivedFieldOutputType(
78 getSignal,
79 fieldType,
80 orgId,
81 ),
82 })),
83 );
84 }),
85 ).then((it) => it.flat());
86
87 return _.groupBy(annotatedDerivedFieldRecipes, (it) => {
88 // _.groupBy will only work faithfully if the inputTypes ae strings,
89 // as it's gonna use inputType as an object key. Flag that so we don't
90 // forget if we expand the valid input types later.
91 if (typeof it.inputType !== 'string') {
92 throw new Error('Code expected inputType to be a string');
93 }
94 return it.inputType;
95 });
96 };
97
98 const getAnnotatedRecipesForInputType = async (
99 it: SignalInputType,
100 orgId: string,
101 ) => {
102 return (
103 (await getAnnotatedDerivedFieldRecipesByInputType(orgId))[it] ?? []
104 );
105 };
106
107 const hasFieldOfScalarType = (schema: ItemSchema, type: ScalarType) =>
108 schema.some((field) => getScalarType(field) === type);
109
110 return {
111 async getDerivedFields(
112 contentTypeId: string,
113 schema: ItemSchema,
114 orgId: string,
115 ): Promise<DerivedField[]> {
116 const derivedFieldsFromIndividualFields = await Promise.all(
117 schema.map(async (field) => {
118 const fieldScalarType = getScalarType(field);
119 const applicableRecipes = await getAnnotatedRecipesForInputType(
120 fieldScalarType,
121 orgId,
122 );
123
124 return applicableRecipes.map((it): DerivedField => {
125 const spec = {
126 derivationType: it.type,
127 source: {
128 type: 'CONTENT_FIELD' as const,
129 name: field.name,
130 contentTypeId,
131 },
132 };
133 return {
134 type: it.outputType.scalarType,
135 container: field.container,
136 name: getNameForDerivedField(spec),
137 spec,
138 };
139 });
140 }),
141 ).then((it) => it.flat());
142
143 const derivedFieldsFromCoopInputs = await Promise.all(
144 Object.values(CoopInput).map(
145 async (it): Promise<DerivedField[]> => {
146 // using a switch here is a good way to get exhaustiveness
147 // checking for when we inevitably add new CoopInputs.
148 switch (it) {
149 case CoopInput.AUTHOR_USER:
150 return (
151 await getAnnotatedRecipesForInputType(
152 ScalarTypes.USER_ID,
153 orgId,
154 )
155 ).map((recipe) =>
156 makeCoopInputDerivedFieldSpec(
157 recipe,
158 CoopInput.AUTHOR_USER,
159 ),
160 );
161
162 case CoopInput.ALL_TEXT:
163 return hasFieldOfScalarType(schema, ScalarTypes.STRING)
164 ? (
165 await getAnnotatedRecipesForInputType(
166 ScalarTypes.STRING,
167 orgId,
168 )
169 ).map((recipe) =>
170 makeCoopInputDerivedFieldSpec(
171 recipe,
172 CoopInput.ALL_TEXT,
173 ),
174 )
175 : [];
176
177 case CoopInput.ANY_GEOHASH:
178 return hasFieldOfScalarType(schema, ScalarTypes.GEOHASH)
179 ? (
180 await getAnnotatedRecipesForInputType(
181 ScalarTypes.GEOHASH,
182 orgId,
183 )
184 ).map((recipe) =>
185 makeCoopInputDerivedFieldSpec(
186 recipe,
187 CoopInput.ANY_GEOHASH,
188 ),
189 )
190 : [];
191
192 case CoopInput.ANY_IMAGE:
193 return hasFieldOfScalarType(schema, ScalarTypes.IMAGE)
194 ? (
195 await getAnnotatedRecipesForInputType(
196 ScalarTypes.IMAGE,
197 orgId,
198 )
199 ).map((recipe) =>
200 makeCoopInputDerivedFieldSpec(
201 recipe,
202 CoopInput.ANY_IMAGE,
203 ),
204 )
205 : [];
206
207 case CoopInput.ANY_VIDEO:
208 return hasFieldOfScalarType(schema, ScalarTypes.VIDEO)
209 ? (
210 await getAnnotatedRecipesForInputType(
211 ScalarTypes.VIDEO,
212 orgId,
213 )
214 ).map((recipe) =>
215 makeCoopInputDerivedFieldSpec(
216 recipe,
217 CoopInput.ANY_VIDEO,
218 ),
219 )
220 : [];
221 case CoopInput.POLICY_ID:
222 case CoopInput.SOURCE:
223 return [];
224 default:
225 assertUnreachable(it);
226 }
227 },
228 ),
229 ).then((it) => it.flat());
230
231 return [
232 ...derivedFieldsFromIndividualFields,
233 ...derivedFieldsFromCoopInputs,
234 ];
235 },
236 };
237 },
238);
239
240export type DerivedFieldsService = ReturnType<typeof makeDerivedFieldsService>;
241
242function getDisplayStringForDerivationType(derivationType: DerivedFieldType) {
243 switch (derivationType) {
244 case 'VIDEO_TRANSCRIPTION':
245 return 'Transcription';
246 case 'ENGLISH_TRANSLATION':
247 return 'English Translation';
248 default:
249 assertUnreachable(derivationType);
250 }
251}
252
253/**
254 * This returns a human-readable name (which may eventually be localized) for
255 * the field defined by a derived field's spec.
256 */
257export function getNameForDerivedField(spec: DerivedFieldSpec) {
258 const { source, derivationType } = spec;
259 const derivationTypeName = getDisplayStringForDerivationType(derivationType);
260
261 switch (source.type) {
262 case 'CONTENT_FIELD':
263 case 'CONTENT_COOP_INPUT':
264 return `${source.name}'s ${derivationTypeName}`;
265 case 'FULL_ITEM':
266 return "Content's " + derivationTypeName;
267 default:
268 assertUnreachable(source);
269 }
270}
271
272function makeCoopInputDerivedFieldSpec(
273 recipe: AnnotatedDerivedFieldRecipe,
274 source: CoopInput,
275): DerivedField {
276 const isScalar = (() => {
277 switch (source) {
278 case CoopInput.ALL_TEXT:
279 case CoopInput.AUTHOR_USER:
280 case CoopInput.POLICY_ID:
281 case CoopInput.SOURCE:
282 return true;
283 case CoopInput.ANY_GEOHASH:
284 case CoopInput.ANY_VIDEO:
285 case CoopInput.ANY_IMAGE:
286 return false;
287 default:
288 assertUnreachable(source);
289 }
290 })();
291
292 const spec = {
293 derivationType: recipe.type,
294 source: { type: 'CONTENT_COOP_INPUT' as const, name: source },
295 };
296
297 return {
298 ...(isScalar
299 ? { type: recipe.outputType.scalarType, container: null }
300 : {
301 type: ContainerTypes.ARRAY,
302 container: {
303 containerType: ContainerTypes.ARRAY,
304 valueScalarType: recipe.outputType.scalarType,
305 keyScalarType: null,
306 },
307 }),
308 name: getNameForDerivedField(spec),
309 spec,
310 };
311}