Mirror of https://github.com/roostorg/coop github.com/roostorg/coop
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at 557ff54b2b435e5f1e789c6a8a4e1bebf2d7deb6 241 lines 8.0 kB view raw
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}