Mirror of https://github.com/roostorg/coop
github.com/roostorg/coop
1import { type Exception } from '@opentelemetry/api';
2import { DataSource } from 'apollo-datasource';
3import { uid } from 'uid';
4import { v1 as uuidV1 } from 'uuid';
5
6import { inject, type Dependencies } from '../../iocContainer/index.js';
7import { type LocationBank as TLocationBank } from '../../models/banks/LocationBankModel.js';
8import { isUniqueConstraintError } from '../../models/errors.js';
9import { type LocationArea } from '../../models/types/locationArea.js';
10import { type User } from '../../models/UserModel.js';
11// TODO: delete the import below when we move the location bank mutation logic
12// into the moderation config service, which is where it should be.
13// eslint-disable-next-line import/no-restricted-paths
14import { makeLocationBankNameExistsError } from '../../services/moderationConfigService/moderationConfigService.js';
15import { type PlacesApiService } from '../../services/placesApiService/index.js';
16import { patchInPlace, safePick } from '../../utils/misc.js';
17import {
18 type GQLCreateLocationBankInput,
19 type GQLLocationAreaInput,
20 type GQLUpdateLocationBankInput,
21} from '../generated.js';
22
23// NB: this is the type that our GQL resolvers for a location bank rely on
24// getting as the parent object. (I.e., we don't promise the location bank field
25// resolvers that they'll be able to see the fullPlacesApiResponse).
26export type LocationBankWithoutFullPlacesAPIResponse = Omit<
27 TLocationBank,
28 'fullPlacesApiResponse'
29>;
30
31class LocationBankAPI extends DataSource {
32 private lookupPlaceId: PlacesApiService['lookupPlaceId'];
33 constructor(
34 placesApiService: PlacesApiService,
35 private readonly sequelize: Dependencies['Sequelize'],
36 private readonly tracer: Dependencies['Tracer'],
37 ) {
38 super();
39 this.lookupPlaceId = placesApiService.lookupPlaceId.bind(placesApiService);
40 }
41
42 async getGraphQLLocationBankFromId(opts: { id: string; orgId: string }) {
43 const { id, orgId } = opts;
44 return this.sequelize.LocationBank.findOne({
45 where: { id, orgId },
46 rejectOnEmpty: true,
47 attributes: { exclude: ['fullPlacesApiResponses'] },
48 }) as Promise<LocationBankWithoutFullPlacesAPIResponse>;
49 }
50
51 async getGraphQLLocationBanksForOrg(orgId: string) {
52 return this.sequelize.LocationBank.findAll({
53 where: { orgId },
54 attributes: { exclude: ['fullPlacesApiResponses'] },
55 }) as Promise<LocationBankWithoutFullPlacesAPIResponse[]>;
56 }
57
58 async createLocationBank(input: GQLCreateLocationBankInput, user: User) {
59 const { name, description, locations: locationInputs } = input;
60 const { orgId, id: ownerId } = user;
61
62 const newBankId = uid();
63 const locations = this.sequelize.LocationBankLocation.bulkBuild(
64 await this.expandLocationAreaInputs(newBankId, locationInputs),
65 );
66
67 try {
68 return await this.sequelize.transactionWithRetry(async () => {
69 const bank = this.sequelize.LocationBank.build(
70 {
71 id: newBankId,
72 name,
73 description,
74 ownerId,
75 orgId,
76 locations,
77 },
78 {
79 include: [
80 { model: this.sequelize.LocationBankLocation, as: 'locations' },
81 ],
82 },
83 );
84
85 await bank.save();
86
87 return bank;
88 });
89 } catch (e: unknown) {
90 throw isUniqueConstraintError(e)
91 ? makeLocationBankNameExistsError({ shouldErrorSpan: true })
92 : e;
93 }
94 }
95
96 async updateLocationBank(input: GQLUpdateLocationBankInput, orgId: string) {
97 const { id, name, description, locationsToAdd, locationsToDelete } = input;
98
99 const expandedLocationsToAdd = locationsToAdd?.length
100 ? await this.expandLocationAreaInputs(id, locationsToAdd)
101 : undefined;
102
103 const bank = await this.sequelize.LocationBank.findOne({
104 where: { id, orgId },
105 rejectOnEmpty: true,
106 });
107
108 // Name can be missing in the input object (in which case it'll be
109 // undefined), but it can't be present + null (which would normally have the
110 // semantic of trying to unset the name, which is invalid b/c name is
111 // required).
112 if (name === null) {
113 throw new Error('Cannot clear bank name.');
114 }
115
116 patchInPlace(bank, {
117 name,
118 description: description ?? undefined,
119 });
120
121 try {
122 return await this.sequelize.transactionWithRetry(async () => {
123 await bank.save();
124 await Promise.all([
125 locationsToDelete?.length &&
126 this.sequelize.LocationBankLocation.destroy({
127 where: {
128 id: locationsToDelete,
129 bankId: bank.id,
130 },
131 }),
132 expandedLocationsToAdd
133 ? this.sequelize.LocationBankLocation.bulkCreate(
134 expandedLocationsToAdd,
135 )
136 : null,
137 ]);
138 return bank;
139 });
140 } catch (e: unknown) {
141 throw isUniqueConstraintError(e)
142 ? makeLocationBankNameExistsError({ shouldErrorSpan: true })
143 : e;
144 }
145 }
146
147 async deleteLocationBank(opts: { id: string; orgId: string }) {
148 const { id, orgId } = opts;
149
150 try {
151 const bank = await this.sequelize.LocationBank.findOne({
152 where: { id, orgId },
153 });
154 await bank?.destroy();
155 } catch (exception) {
156 const activeSpan = this.tracer.getActiveSpan();
157 if (activeSpan?.isRecording()) {
158 activeSpan.recordException(exception as Exception);
159 }
160
161 return false;
162 }
163 return true;
164 }
165
166 /**
167 * When a user adds a location to a location bank, we need to convert their
168 * input, which might be geographic coordinates or a google place id (for
169 * which we have to fetch more details from google), into a
170 * LocationBankLocation object that we can actually save along with the bank.
171 */
172 private async expandLocationAreaInputs(
173 locationBankId: string,
174 locations: readonly GQLLocationAreaInput[],
175 ) {
176 const locationAreas = await Promise.all(
177 locations.map(async (it) =>
178 locationAreaInputToLocationAreaWithGooglePlaceData(
179 this.lookupPlaceId,
180 it,
181 ),
182 ),
183 );
184
185 return locationAreas.map((locationArea) => ({
186 ...locationArea,
187 bankId: locationBankId,
188 }));
189 }
190}
191
192export default inject(
193 ['PlacesApiService', 'Sequelize', 'Tracer'],
194 LocationBankAPI,
195);
196export type { LocationBankAPI };
197
198/**
199 * Returns a LocationArea based on a user-provided GQLLocationAreaInput.
200 * The LocationArea returned will have a newly-generated/assigned id; since
201 * existing LocationAreas can never be edited (just deleted and recreated),
202 * we're always in the position of needing to make a new id if we're receiving
203 * new input. This id will be a uuid to ensure global uniqueness (which is
204 * helpful for apollo) regardless of where the LocationArea is stored.
205 *
206 * The returned LocationArea will not have any detailed google place info; just
207 * the id of the place submitted in the GQLLocationAreaInput, if any. If you
208 * need the detailed google info, see {@link locationAreaInputToLocationAreaWithGooglePlaceData}.
209 */
210export function locationAreaInputToLocationArea(
211 it: GQLLocationAreaInput,
212): LocationArea {
213 const { googlePlaceId } = it;
214
215 return {
216 id: uuidV1(),
217 ...safePick(it, ['bounds', 'geometry']),
218 name: it.name ?? undefined,
219 ...(googlePlaceId ? { googlePlaceInfo: { id: googlePlaceId } } : {}),
220 };
221}
222
223export async function locationAreaInputToLocationAreaWithGooglePlaceData(
224 lookupPlaceId: PlacesApiService['lookupPlaceId'],
225 locationInput: GQLLocationAreaInput,
226) {
227 const baseLocationArea = locationAreaInputToLocationArea(locationInput);
228
229 return !baseLocationArea.googlePlaceInfo
230 ? baseLocationArea
231 : {
232 ...baseLocationArea,
233 googlePlaceInfo: {
234 ...baseLocationArea.googlePlaceInfo,
235 ...safePick(
236 await lookupPlaceId(baseLocationArea.googlePlaceInfo.id),
237 ['details', 'geocode'],
238 ),
239 },
240 };
241}