Skip to content

Commit 441c5cf

Browse files
committed
feat/summerInternshipsAPI - manually link internships
1 parent 1537358 commit 441c5cf

6 files changed

Lines changed: 807 additions & 121 deletions

File tree

backend/app/graphql/resolvers/companyApplicationInternship.ts

Lines changed: 239 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import {
77
Field,
88
FieldResolver,
99
Info,
10+
InputType,
11+
Int,
1012
Mutation,
1113
ObjectType,
1214
Query,
@@ -61,6 +63,57 @@ export const transformSelect = transformSelectFor<CompanyApplicationInternshipFi
6163
});
6264

6365

66+
@ObjectType()
67+
abstract class InternshipPayloadObjectBase {
68+
@Field()
69+
externalCompany!: string;
70+
71+
@Field()
72+
position!: string;
73+
74+
@Field()
75+
description!: string;
76+
77+
@Field()
78+
workingPeriodStart!: Date;
79+
80+
@Field()
81+
workingPeriodEnd!: Date;
82+
83+
@Field(() => Int, { nullable: true })
84+
places!: number | null;
85+
86+
@Field(() => Boolean, { nullable: true })
87+
signed!: boolean | null;
88+
}
89+
90+
@InputType()
91+
abstract class InternshipPayloadInputBase {
92+
@Field()
93+
externalCompany!: string;
94+
95+
@Field()
96+
position!: string;
97+
98+
@Field()
99+
description!: string;
100+
101+
@Field()
102+
workingPeriodStart!: Date;
103+
104+
@Field()
105+
workingPeriodEnd!: Date;
106+
107+
@Field(() => Int, { nullable: true })
108+
places!: number | null;
109+
110+
@Field(() => Boolean, { nullable: true })
111+
signed!: boolean | null;
112+
}
113+
114+
@ObjectType()
115+
class UnmatchedInternship extends InternshipPayloadObjectBase {}
116+
64117
@ObjectType()
65118
class SyncResult {
66119
@Field(() => [ String ])
@@ -72,10 +125,29 @@ class SyncResult {
72125
@Field(() => [ String ])
73126
deletedCompanies: string[] = [];
74127

75-
@Field(() => [ String ])
76-
unmatched: string[] = [];
128+
@Field(() => [ UnmatchedInternship ])
129+
unmatched: UnmatchedInternship[] = [];
77130
}
78131

132+
@InputType()
133+
class LinkUnmatchedInternshipInput extends InternshipPayloadInputBase {
134+
@Field()
135+
companyUid!: string;
136+
137+
@Field()
138+
seasonUid!: string;
139+
}
140+
141+
type RawExternalInternship = {
142+
company: string;
143+
position: string;
144+
description: string;
145+
places: string;
146+
begins: string;
147+
ends: string;
148+
signed: string;
149+
};
150+
79151
@Resolver(() => ApplicationInternship)
80152
export class CompanyApplicationInternshipResolver {
81153
@Query(() => [ ApplicationInternship ])
@@ -126,17 +198,9 @@ export class CompanyApplicationInternshipResolver {
126198
): Promise<SyncResult> {
127199
const url = process.env.SUMMER_INTERNSHIPS_URL;
128200
if (!url) {
129-
throw new Error("SUMMER_INTERNSHIPS_URL not configured");
201+
throw new Error("SUMMER_INTERNSHIPS_URL nije konfiguriran.");
130202
}
131-
const { data: externalInternships } = await axios.get<Array<{
132-
company: string;
133-
position: string;
134-
description: string;
135-
places: string;
136-
begins: string;
137-
ends: string;
138-
signed: string;
139-
}>>(url, { timeout: 15000 });
203+
const { data: externalInternships } = await axios.get<RawExternalInternship[]>(url, { timeout: 15000 });
140204

141205
const season = await ctx.prisma.season.findUnique({
142206
where: { uid: seasonUid },
@@ -146,6 +210,10 @@ export class CompanyApplicationInternshipResolver {
146210
throw new Error(`Season "${ seasonUid }" not found`);
147211
}
148212

213+
if (externalInternships.length === 0) {
214+
throw new Error("Vanjski izvor nije vratio nijednu praksu — sinkronizacija je prekinuta kako se postojeći podaci ne bi obrisali.");
215+
}
216+
149217
const applications = await ctx.prisma.companyApplication.findMany({
150218
where: { forSeasonId: season.id },
151219
select: {
@@ -156,13 +224,27 @@ export class CompanyApplicationInternshipResolver {
156224
},
157225
});
158226

159-
const appMap = new Map(applications.map((a) => [ normalizeCompanyName(a.forCompany.legalName), a ]));
227+
const appMap = new Map<string, typeof applications[number]>();
228+
for (const a of applications) {
229+
const key = normalizeCompanyName(a.forCompany.legalName);
230+
if (appMap.has(key)) {
231+
console.warn(`Multiple CompanyApplications in season "${ seasonUid }" normalize to "${ key }": "${ appMap.get(key)!.forCompany.legalName }" and "${ a.forCompany.legalName }"`);
232+
}
233+
appMap.set(key, a);
234+
}
160235

161236
const appById = new Map(applications.map((a) => [ a.id, a ]));
162237

163238
const existingInSeason = await ctx.prisma.applicationInternship.findMany({
164239
where: { forApplicationId: { in: applications.map((a) => a.id) } },
165-
select: { id: true, forApplicationId: true, position: true, workingPeriodStart: true, workingPeriodEnd: true },
240+
select: {
241+
id: true,
242+
forApplicationId: true,
243+
position: true,
244+
workingPeriodStart: true,
245+
workingPeriodEnd: true,
246+
externalCompany: true,
247+
},
166248
});
167249

168250
const existingMap = new Map(
@@ -172,71 +254,166 @@ export class CompanyApplicationInternshipResolver {
172254
]),
173255
);
174256

257+
const memoryMap = new Map<string, { id: number; forApplicationId: number }>();
258+
for (const e of existingInSeason) {
259+
if (e.externalCompany == null) {
260+
continue;
261+
}
262+
const key = `${ normalizeCompanyName(e.externalCompany) }|${ e.position }|${ e.workingPeriodStart.getTime() }|${ e.workingPeriodEnd.getTime() }`;
263+
memoryMap.set(key, { id: e.id, forApplicationId: e.forApplicationId });
264+
}
265+
175266
const createdCompanies: string[] = [];
176267
const updatedCompanies: string[] = [];
177-
const unmatched: string[] = [];
268+
const unmatched: UnmatchedInternship[] = [];
178269
const syncedIds = new Set<number>();
179270

180-
for (const item of externalInternships) {
181-
const app = appMap.get(normalizeCompanyName(item.company));
271+
const deletedCompanies = await ctx.prisma.$transaction(async (tx) => {
272+
for (const item of externalInternships) {
273+
const { company: externalCompany, position, description } = item;
274+
const workingPeriodStart = new Date(item.begins);
275+
const workingPeriodEnd = new Date(item.ends);
276+
if (Number.isNaN(workingPeriodStart.getTime()) || Number.isNaN(workingPeriodEnd.getTime())) {
277+
console.warn(`Skipping internship with invalid date(s): company="${ externalCompany }", position="${ position }"`);
278+
continue;
279+
}
280+
const placesParsed = item.places ? parseInt(item.places, 10) : NaN;
281+
const places = Number.isNaN(placesParsed) ? null : placesParsed;
282+
const signed = item.signed === "1" ? true : item.signed === "0" ? false : null;
182283

183-
if (!app) {
184-
unmatched.push(item.company);
185-
continue;
186-
}
284+
let app = appMap.get(normalizeCompanyName(externalCompany));
187285

188-
const workingPeriodStart = new Date(item.begins);
189-
const workingPeriodEnd = new Date(item.ends);
190-
const existingKey = `${ app.id }:${ item.position }:${ workingPeriodStart.getTime() }:${ workingPeriodEnd.getTime() }`;
191-
const existing = existingMap.get(existingKey);
286+
if (!app) {
287+
const memoryKey = `${ normalizeCompanyName(externalCompany) }|${ position }|${ workingPeriodStart.getTime() }|${ workingPeriodEnd.getTime() }`;
288+
const memory = memoryMap.get(memoryKey);
289+
if (memory) {
290+
app = appById.get(memory.forApplicationId);
291+
}
292+
}
192293

193-
const upserted = await ctx.prisma.applicationInternship.upsert({
194-
where: {
195-
forApplicationId_position_workingPeriodStart_workingPeriodEnd: {
294+
if (!app) {
295+
unmatched.push({ externalCompany, position, description, workingPeriodStart, workingPeriodEnd, places, signed });
296+
continue;
297+
}
298+
299+
const existingKey = `${ app.id }:${ position }:${ workingPeriodStart.getTime() }:${ workingPeriodEnd.getTime() }`;
300+
const existing = existingMap.get(existingKey);
301+
302+
const upserted = await tx.applicationInternship.upsert({
303+
where: {
304+
forApplicationId_position_workingPeriodStart_workingPeriodEnd: {
305+
forApplicationId: app.id,
306+
position,
307+
workingPeriodStart,
308+
workingPeriodEnd,
309+
},
310+
},
311+
create: {
196312
forApplicationId: app.id,
197-
position: item.position,
313+
position,
314+
description,
198315
workingPeriodStart,
199316
workingPeriodEnd,
317+
places,
318+
signed,
319+
externalCompany,
200320
},
201-
},
202-
create: {
203-
forApplicationId: app.id,
204-
position: item.position,
205-
description: item.description,
206-
workingPeriodStart,
207-
workingPeriodEnd,
208-
places: item.places ? parseInt(item.places, 10) : null,
209-
signed: item.signed === "1",
210-
externalCompany: item.company,
211-
},
212-
update: {
213-
description: item.description,
214-
places: item.places ? parseInt(item.places, 10) : null,
215-
signed: item.signed === "1",
216-
externalCompany: item.company,
217-
},
218-
select: { id: true },
219-
});
321+
update: {
322+
description,
323+
places,
324+
signed,
325+
externalCompany,
326+
},
327+
select: { id: true },
328+
});
329+
330+
syncedIds.add(upserted.id);
220331

221-
syncedIds.add(upserted.id);
332+
if (existing) {
333+
updatedCompanies.push(app.forCompany.brandName);
334+
} else {
335+
createdCompanies.push(app.forCompany.brandName);
336+
}
337+
}
222338

223-
if (existing) {
224-
updatedCompanies.push(app.forCompany.brandName);
225-
} else {
226-
createdCompanies.push(app.forCompany.brandName);
339+
const toDelete = existingInSeason.filter(
340+
(e) =>
341+
!syncedIds.has(e.id)
342+
&& e.externalCompany != null,
343+
);
344+
const deletedCompaniesInner = toDelete.map(
345+
(e) => appById.get(e.forApplicationId)?.forCompany.brandName ?? e.forApplicationId.toString(),
346+
);
347+
if (toDelete.length > 0) {
348+
await tx.applicationInternship.deleteMany({
349+
where: { id: { in: toDelete.map((e) => e.id) } },
350+
});
227351
}
352+
353+
return deletedCompaniesInner;
354+
});
355+
356+
return { createdCompanies, updatedCompanies, deletedCompanies, unmatched };
357+
}
358+
359+
@Mutation(() => ApplicationInternship)
360+
@Authorized(Role.Admin)
361+
async linkUnmatchedInternship(
362+
@Arg("input") input: LinkUnmatchedInternshipInput,
363+
@Ctx() ctx: Context,
364+
): Promise<ApplicationInternship> {
365+
const application = await ctx.prisma.companyApplication.findFirst({
366+
where: {
367+
forCompany: { uid: input.companyUid },
368+
forSeason: { uid: input.seasonUid },
369+
},
370+
select: { id: true },
371+
});
372+
373+
if (!application) {
374+
throw new Error("Nije pronađena prijava za odabranu firmu u ovoj sezoni.");
228375
}
229376

230-
const toDelete = existingInSeason.filter((e) => !syncedIds.has(e.id));
231-
const deletedCompanies = toDelete.map(
232-
(e) => appById.get(e.forApplicationId)?.forCompany.brandName ?? e.forApplicationId.toString(),
233-
);
234-
if (toDelete.length > 0) {
235-
await ctx.prisma.applicationInternship.deleteMany({
236-
where: { id: { in: toDelete.map((e) => e.id) } },
377+
return ctx.prisma.$transaction(async (tx) => {
378+
const existing = await tx.applicationInternship.findFirst({
379+
where: {
380+
externalCompany: input.externalCompany,
381+
position: input.position,
382+
workingPeriodStart: input.workingPeriodStart,
383+
workingPeriodEnd: input.workingPeriodEnd,
384+
forApplication: { forSeason: { uid: input.seasonUid } },
385+
},
237386
});
238-
}
387+
if (existing && existing.forApplicationId !== application.id) {
388+
await tx.applicationInternship.delete({ where: { id: existing.id } });
389+
}
239390

240-
return { createdCompanies, updatedCompanies, deletedCompanies, unmatched };
391+
return tx.applicationInternship.upsert({
392+
where: {
393+
forApplicationId_position_workingPeriodStart_workingPeriodEnd: {
394+
forApplicationId: application.id,
395+
position: input.position,
396+
workingPeriodStart: input.workingPeriodStart,
397+
workingPeriodEnd: input.workingPeriodEnd,
398+
},
399+
},
400+
create: {
401+
forApplicationId: application.id,
402+
position: input.position,
403+
description: input.description,
404+
workingPeriodStart: input.workingPeriodStart,
405+
workingPeriodEnd: input.workingPeriodEnd,
406+
places: input.places ?? null,
407+
signed: input.signed ?? null,
408+
externalCompany: input.externalCompany,
409+
},
410+
update: {
411+
description: input.description,
412+
places: input.places ?? null,
413+
signed: input.signed ?? null,
414+
externalCompany: input.externalCompany,
415+
},
416+
});
417+
});
241418
}
242419
}

0 commit comments

Comments
 (0)