diff --git a/common/match/create.ts b/common/match/create.ts index 7f738b8ba..e5f1f9750 100644 --- a/common/match/create.ts +++ b/common/match/create.ts @@ -1,4 +1,4 @@ -import { student as Student, pupil as Pupil, match as Match } from '@prisma/client'; +import { student as Student, pupil as Pupil, match as Match, match_request as MatchRequest } from '@prisma/client'; import { prisma } from '../prisma'; import { v4 as generateUUID } from 'uuid'; import { getPupilGradeAsString } from '../pupil'; @@ -20,26 +20,26 @@ interface CreateMatchOptions { } export async function createMatch( - pupil: Pupil, - student: Student, + request: MatchRequest, + offer: MatchRequest, pool: ConcreteMatchPool, options: CreateMatchOptions = { skipChatCreation: false } ): Promise { const uuid = generateUUID(); + const freshRequest = await prisma.match_request.findUniqueOrThrow({ where: { id: request.id }, include: { pupil: true } }); + const freshOffer = await prisma.match_request.findUniqueOrThrow({ where: { id: offer.id }, include: { student: true } }); - // Refetch match request count to reduce the likelihood of race conditions - // (does not prevent it though, we would actually need a SELECT FOR UPDATE) - const freshPupil = await prisma.pupil.findUniqueOrThrow({ where: { id: pupil.id }, select: { openMatchRequestCount: true } }); - const freshStudent = await prisma.student.findUniqueOrThrow({ where: { id: student.id }, select: { openMatchRequestCount: true } }); - - if (freshPupil.openMatchRequestCount < 1) { - throw new PrerequisiteError(`Cannot create Match for Pupil without open match requests`); + if (freshRequest.status !== 'open') { + throw new PrerequisiteError(`Cannot create Match for MatchRequest(${request.id}) with status ${freshRequest.status}`); } - if (freshStudent.openMatchRequestCount < 1) { - throw new PrerequisiteError(`Cannot create Match for Student without open match request count`); + if (freshOffer.status !== 'open') { + throw new PrerequisiteError(`Cannot create Match for MatchOffer(${offer.id}) with status ${freshOffer.status}`); } + const pupil = freshRequest.pupil; + const student = freshOffer.student; + const overlappingSubjects = getOverlappingSubjects(pupil, student); const pupilGrade = pupil.grade ?? gradeAsInt(pupil.grade); @@ -62,18 +62,14 @@ export async function createMatch( }, }); - await prisma.pupil.update({ - where: { id: pupil.id }, - data: { - openMatchRequestCount: { decrement: 1 }, - }, + await prisma.match_request.update({ + where: { id: freshRequest.id }, + data: { status: 'resolved', matchId: match.id }, }); - await prisma.student.update({ - where: { id: student.id }, - data: { - openMatchRequestCount: { decrement: 1 }, - }, + await prisma.match_request.update({ + where: { id: freshOffer.id }, + data: { status: 'resolved', matchId: match.id }, }); await removeInterest(pupil); diff --git a/common/match/matching.perf.ts b/common/match/matching.perf.ts index 2edf98362..8a54481eb 100644 --- a/common/match/matching.perf.ts +++ b/common/match/matching.perf.ts @@ -320,6 +320,16 @@ describe('Real World Matching Performance', () => { subjects: parseSubjectString(pupil.subjects), requestAt: new Date(pupil.requestAt), languages: pupil.languages as pupil_languages_enum[], + matchRequest: { + id: 1, + pupilId: pupil.id, + status: 'open', + subjects: parseSubjectString(pupil.subjects), + closedAt: null, + createdAt: new Date(pupil.requestAt), + studentId: null, + matchId: null, + }, }); pupilIdx += 1; // log += ` + ${pupil.requestAt} - add pupil\n`; @@ -332,6 +342,16 @@ describe('Real World Matching Performance', () => { subjects: parseSubjectString(student.subjects), requestAt: new Date(student.requestAt), languages: student.languages as student_languages_enum[], + matchRequest: { + id: 1, + studentId: student.id, + status: 'open', + subjects: parseSubjectString(student.subjects), + closedAt: null, + createdAt: new Date(student.requestAt), + pupilId: null, + matchId: null, + }, }); studentIdx += 1; // log += ` + ${student.requestAt} - add student\n`; diff --git a/common/match/matching.spec.ts b/common/match/matching.spec.ts index 5c1c9848d..813f89760 100644 --- a/common/match/matching.spec.ts +++ b/common/match/matching.spec.ts @@ -18,34 +18,64 @@ function test(name: string, requests: MatchRequest[], offers: MatchOffer[], expe }); } -const requestOne = { +const requestOne: MatchRequest = { grade: 10, pupilId: 1, state: 'at' as const, subjects: [{ name: 'Deutsch', mandatory: false }], requestAt: TODAY_TEST_DATE, languages: [pupil_languages_enum.Englisch, pupil_languages_enum.Spanisch], + matchRequest: { + id: 1, + pupilId: 1, + subjects: [{ name: 'Deutsch', mandatory: false }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestTwo = { +const requestTwo: MatchRequest = { grade: 10, pupilId: 2, state: 'at' as const, subjects: [{ name: 'Mathematik', mandatory: false }], requestAt: TODAY_TEST_DATE, languages: [], + matchRequest: { + id: 2, + pupilId: 2, + subjects: [{ name: 'Mathematik', mandatory: false }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestThree = { +const requestThree: MatchRequest = { grade: 10, pupilId: 3, state: 'at' as const, subjects: [{ name: 'Klingonisch', mandatory: false }], requestAt: TODAY_TEST_DATE, languages: [], + matchRequest: { + id: 3, + pupilId: 3, + subjects: [{ name: 'Klingonisch', mandatory: false }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestFour = { +const requestFour: MatchRequest = { grade: 10, pupilId: 4, state: 'at' as const, @@ -55,9 +85,22 @@ const requestFour = { ], requestAt: TODAY_TEST_DATE, languages: [], + matchRequest: { + id: 4, + pupilId: 4, + subjects: [ + { name: 'Mathematik', mandatory: false }, + { name: 'Deutsch', mandatory: false }, + ], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestFive = { +const requestFive: MatchRequest = { grade: 10, pupilId: 5, state: 'at' as const, @@ -78,9 +121,22 @@ const requestFive = { }, }, languages: [], + matchRequest: { + id: 5, + pupilId: 5, + subjects: [ + { name: 'Mathematik', mandatory: true }, + { name: 'Deutsch', mandatory: false }, + ], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestSix = { +const requestSix: MatchRequest = { grade: 10, pupilId: 5, state: 'at' as const, @@ -88,9 +144,19 @@ const requestSix = { requestAt: TODAY_TEST_DATE, onlyMatchWith: 'female' as const, languages: [], + matchRequest: { + id: 6, + pupilId: 5, + subjects: [{ name: 'Mathematik' }, { name: 'Deutsch' }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestSeven = { +const requestSeven: MatchRequest = { grade: 10, pupilId: 5, state: 'at' as const, @@ -98,9 +164,19 @@ const requestSeven = { requestAt: TODAY_TEST_DATE, hasSpecialNeeds: true, languages: [], + matchRequest: { + id: 7, + pupilId: 5, + subjects: [{ name: 'Mathematik' }, { name: 'Deutsch' }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const requestEight = { +const requestEight: MatchRequest = { grade: 10, pupilId: 8, state: 'at' as const, @@ -122,64 +198,130 @@ const requestEight = { }, }, languages: [], + matchRequest: { + id: 8, + pupilId: 8, + subjects: [{ name: 'Mathematik' }, { name: 'Deutsch' }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const offerOne = { +const offerOne: MatchOffer = { studentId: 1, state: 'at' as const, subjects: [{ name: 'Deutsch', grade: { min: 1, max: 10 } }], requestAt: TODAY_TEST_DATE, languages: [], + matchRequest: { + id: 1, + pupilId: 1, + subjects: [{ name: 'Deutsch', grade: { min: 1, max: 10 } }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const offerTwo = { +const offerTwo: MatchOffer = { studentId: 2, state: 'at' as const, subjects: [{ name: 'Mathematik', grade: { min: 1, max: 10 } }], requestAt: TODAY_TEST_DATE, languages: [], + matchRequest: { + id: 2, + pupilId: 2, + subjects: [{ name: 'Mathematik', grade: { min: 1, max: 10 } }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const offerThree = { +const offerThree: MatchOffer = { studentId: 3, state: 'at' as const, subjects: [{ name: 'Klingonisch', grade: { min: 1, max: 10 } }], requestAt: TODAY_TEST_DATE, languages: [], + matchRequest: { + id: 3, + pupilId: 3, + subjects: [{ name: 'Klingonisch', grade: { min: 1, max: 10 } }], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const offerFour = { +const offerFour: MatchOffer = { studentId: 4, state: 'at' as const, subjects: [ { name: 'Deutsch', grade: { min: 1, max: 10 } }, - { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, + { name: 'Mathematik', grade: { min: 1, max: 10 } }, ], requestAt: TODAY_TEST_DATE, gender: 'male' as const, hasSpecialExperience: false, languages: [], + matchRequest: { + id: 4, + pupilId: 4, + subjects: [ + { name: 'Deutsch', grade: { min: 1, max: 10 } }, + { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, + ], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const offerFive = { +const offerFive: MatchOffer = { studentId: 4, state: 'bw' as const, subjects: [ { name: 'Deutsch', grade: { min: 1, max: 10 } }, - { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, + { name: 'Mathematik', grade: { min: 1, max: 10 } }, ], requestAt: TODAY_TEST_DATE, gender: 'female' as const, hasSpecialExperience: true, languages: [student_languages_enum.Deutsch, student_languages_enum.Spanisch], + matchRequest: { + id: 5, + pupilId: 5, + subjects: [ + { name: 'Deutsch', grade: { min: 1, max: 10 } }, + { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, + ], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; -const offerSix = { +const offerSix: MatchOffer = { studentId: 6, state: 'bw' as const, subjects: [ { name: 'Deutsch', grade: { min: 1, max: 10 } }, - { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, + { name: 'Mathematik', grade: { min: 1, max: 10 } }, ], requestAt: TODAY_TEST_DATE, calendarPreferences: { @@ -198,6 +340,19 @@ const offerSix = { }, }, languages: [student_languages_enum.Englisch], + matchRequest: { + id: 6, + pupilId: 6, + subjects: [ + { name: 'Deutsch', grade: { min: 1, max: 10 } }, + { name: 'Mathematik', mandatory: false, grade: { min: 1, max: 10 } }, + ], + closedAt: null, + createdAt: TODAY_TEST_DATE, + studentId: null, + matchId: null, + status: 'open', + }, }; describe('Matching Score Basics', () => { diff --git a/common/match/matching.ts b/common/match/matching.ts index 6f2e36417..23091fb69 100644 --- a/common/match/matching.ts +++ b/common/match/matching.ts @@ -6,6 +6,7 @@ import { gender_enum, pupil_languages_enum as PupilLanguage, student_languages_enum as StudentLanguage, + match_request as MatchRequestEntity, } from '@prisma/client'; import { maxWeightAssign } from 'munkres-algorithm'; import { getPupilGradeAsString } from '../pupil'; @@ -16,6 +17,7 @@ import { prisma } from '../prisma'; import { CalendarPreferences } from '../../graphql/types/calendarPreferences'; import { getOverlappingHoursCount } from '../util/calendarPreferences'; import { Language } from '../daz/language'; +import type { MatchPupil, MatchStudent } from './pool'; // ------- The Matching Algorithm ------------ // For a series of match requests and match offers computes @@ -35,26 +37,27 @@ export type MatchRequest = Readonly<{ onlyMatchWith?: gender_enum; hasSpecialNeeds?: boolean; calendarPreferences?: CalendarPreferences; + matchRequest: MatchRequestEntity; }>; -export function pupilsToRequests(pupils: Pupil[]): MatchRequest[] { +export function pupilsToRequests(pupils: MatchPupil[]): MatchRequest[] { const result: MatchRequest[] = []; for (const pupil of pupils) { - const request: MatchRequest = { - pupil, - pupilId: pupil.id, - grade: gradeAsInt(pupil.grade), - state: pupil.state, - subjects: parseSubjectString(pupil.subjects), - requestAt: pupil.firstMatchRequest, - onlyMatchWith: pupil.onlyMatchWith, - hasSpecialNeeds: pupil.hasSpecialNeeds, - calendarPreferences: pupil.calendarPreferences as Record as CalendarPreferences, - languages: pupil.languages, - }; - - for (let i = 0; i < pupil.openMatchRequestCount; i++) { + for (const matchRequest of pupil.match_request) { + const request: MatchRequest = { + pupil, + pupilId: pupil.id, + grade: gradeAsInt(pupil.grade), + state: pupil.state, + subjects: parseSubjectString(pupil.subjects), + requestAt: pupil.firstMatchRequest, + onlyMatchWith: pupil.onlyMatchWith, + hasSpecialNeeds: pupil.hasSpecialNeeds, + calendarPreferences: pupil.calendarPreferences as Record as CalendarPreferences, + languages: pupil.languages, + matchRequest: matchRequest, + }; result.push(request); } } @@ -73,25 +76,27 @@ export type MatchOffer = Readonly<{ gender?: gender_enum; hasSpecialExperience?: boolean; calendarPreferences?: CalendarPreferences; + matchRequest: MatchRequestEntity; }>; -export function studentsToOffers(students: Student[]): MatchOffer[] { +export function studentsToOffers(students: MatchStudent[]): MatchOffer[] { const result: MatchOffer[] = []; for (const student of students) { - const offer: MatchOffer = { - student, - studentId: student.id, - state: student.state, - subjects: parseSubjectString(student.subjects), - requestAt: student.firstMatchRequest, - gender: student.gender, - hasSpecialExperience: student.hasSpecialExperience, - calendarPreferences: student.calendarPreferences as Record as CalendarPreferences, - languages: student.languages, - }; - - for (let i = 0; i < student.openMatchRequestCount; i++) { + for (const matchRequest of student.match_request) { + const offer: MatchOffer = { + student, + studentId: student.id, + state: student.state, + subjects: parseSubjectString(student.subjects), + requestAt: student.firstMatchRequest, + gender: student.gender, + hasSpecialExperience: student.hasSpecialExperience, + calendarPreferences: student.calendarPreferences as Record as CalendarPreferences, + languages: student.languages, + matchRequest: matchRequest, + }; + result.push(offer); } } diff --git a/common/match/pool.ts b/common/match/pool.ts index bbe02a184..2eacab9d5 100644 --- a/common/match/pool.ts +++ b/common/match/pool.ts @@ -1,5 +1,5 @@ import { prisma } from '../prisma'; -import type { Prisma, pupil as Pupil, student as Student, match as Match } from '@prisma/client'; +import type { Prisma, pupil as Pupil, student as Student, match as Match, match_request } from '@prisma/client'; import { createMatch } from './create'; import { assertExists } from '../util/basic'; import { getLogger } from '../logger/logger'; @@ -9,6 +9,7 @@ import { userSearch } from '../user/search'; import { addPupilScreening } from '../pupil/screening'; import assert from 'assert'; import { computeMatchings, getMatchExclusions, MatchOffer, MatchRequest, pupilsToRequests, studentsToOffers } from './matching'; +import { match_request as MatchRequestEntity } from '@prisma/client'; const logger = getLogger('MatchingPool'); @@ -21,7 +22,7 @@ export interface MatchPool { readonly name: string; studentsToMatch: (toggles: readonly Toggle[]) => Prisma.studentWhereInput; pupilsToMatch: (toggles: readonly Toggle[]) => Prisma.pupilWhereInput; - readonly createMatch: (pupil: Pupil, student: Student, pool: MatchPool) => Promise; + readonly createMatch: (request: MatchRequestEntity, offer: MatchRequestEntity, pool: MatchPool) => Promise; readonly toggles: readonly Toggle[]; // There are a few well known toggles: // "skip-interest-confirmation" -> do not exclude pupils that have not confirmed their interest @@ -41,6 +42,14 @@ export interface MatchPool { readonly autoInviteForScreening?: boolean; } +export interface MatchPupil extends Pupil { + match_request: match_request[]; +} + +export interface MatchStudent extends Student { + match_request: match_request[]; +} + /* ---------------- UTILS ------------------------------------- */ const getViableUsers = (toggles: string[]) => { @@ -61,18 +70,19 @@ const getViableUsers = (toggles: string[]) => { return viableUsers; }; -export async function getStudents(pool: MatchPool, toggles: Toggle[], take?: number, skip?: number, search?: string) { +export async function getStudents(pool: MatchPool, toggles: Toggle[], take?: number, skip?: number, search?: string): Promise { const where = { ...getViableUsers(toggles), ...pool.studentsToMatch(toggles) }; return await prisma.student.findMany({ where: { AND: [where, userSearch(search)] }, orderBy: { createdAt: 'asc' }, + include: { match_request: { where: { status: 'open' } } }, take, skip, }); } -export async function getPupils(pool: MatchPool, toggles: Toggle[], take?: number, skip?: number, search?: string) { +export async function getPupils(pool: MatchPool, toggles: Toggle[], take?: number, skip?: number, search?: string): Promise { const where = { ...getViableUsers(toggles), ...pool.pupilsToMatch(toggles) }; return await prisma.pupil.findMany({ @@ -87,6 +97,7 @@ export async function getPupils(pool: MatchPool, toggles: Toggle[], take?: numbe }, { createdAt: 'asc' }, ], + include: { match_request: { where: { status: 'open' } } }, take, skip, }); @@ -99,12 +110,9 @@ export async function getStudentCount(pool: MatchPool, toggles: Toggle[]) { } export async function getStudentOfferCount(pool: MatchPool, toggles: Toggle[]) { - return ( - await prisma.student.aggregate({ - _sum: { openMatchRequestCount: true }, - where: { ...getViableUsers(toggles), ...pool.studentsToMatch(toggles) }, - }) - )._sum.openMatchRequestCount; + return await prisma.match_request.count({ + where: { student: { ...getViableUsers(toggles), ...pool.studentsToMatch(toggles) }, status: 'open' }, + }); } export async function getPupilCount(pool: MatchPool, toggles: Toggle[]) { @@ -114,12 +122,9 @@ export async function getPupilCount(pool: MatchPool, toggles: Toggle[]) { } export async function getPupilDemandCount(pool: MatchPool, toggles: Toggle[]) { - return ( - await prisma.pupil.aggregate({ - _sum: { openMatchRequestCount: true }, - where: { ...getViableUsers(toggles), ...pool.pupilsToMatch(toggles) }, - }) - )._sum.openMatchRequestCount; + return await prisma.match_request.count({ + where: { pupil: { ...getViableUsers(toggles), ...pool.pupilsToMatch(toggles) }, status: 'open' }, + }); } const INTEREST_CONFIRMATION_TOGGLES = ['confirmation-success', 'confirmation-pending', 'confirmation-unknown'] as const; @@ -195,17 +200,17 @@ export const TEST_POOL = { toggles: ['allow-unverified'], pupilsToMatch: (toggles): Prisma.pupilWhereInput => ({ isPupil: true, - openMatchRequestCount: { gt: 0 }, + match_request: { some: { status: 'open' } }, }), studentsToMatch: (toggles): Prisma.studentWhereInput => ({ isStudent: true, - openMatchRequestCount: { gt: 0 }, + match_request: { some: { status: 'open' } }, }), - createMatch(pupil, student) { + createMatch(request, offer) { if (!isDev) { throw new Error(`The Test Pool may not be run in production!`); } - return createMatch(pupil, student, this); + return createMatch(request, offer, this); }, } as const; @@ -218,7 +223,7 @@ const _pools = [ pupilsToMatch: (toggles: (InterestConfirmationToggle | PupilScreeningToggle)[]): Prisma.pupilWhereInput => { const query: Prisma.pupilWhereInput = { isPupil: true, - openMatchRequestCount: { gt: 0 }, + match_request: { some: { status: 'open' } }, subjects: { not: '[]' }, registrationSource: { notIn: ['plus'] }, }; @@ -248,7 +253,7 @@ const _pools = [ }, studentsToMatch: (toggles): Prisma.studentWhereInput => ({ isStudent: true, - openMatchRequestCount: { gt: 0 }, + match_request: { some: { status: 'open' } }, subjects: { not: '[]' }, screening: { status: 'success' }, registrationSource: { notIn: ['plus'] }, @@ -261,7 +266,7 @@ const _pools = [ pupilsToMatch: (toggles: PupilScreeningToggle[]): Prisma.pupilWhereInput => { const query: Prisma.pupilWhereInput = { isPupil: true, - openMatchRequestCount: { gt: 0 }, + match_request: { some: { status: 'open' } }, subjects: { not: '[]' }, registrationSource: { equals: 'plus' }, }; @@ -276,7 +281,7 @@ const _pools = [ }, studentsToMatch: (toggles): Prisma.studentWhereInput => ({ isStudent: true, - openMatchRequestCount: { gt: 0 }, + match_request: { some: { status: 'open' } }, subjects: { not: '[]' }, screening: { status: 'success' }, registrationSource: { equals: 'plus' }, @@ -357,8 +362,8 @@ export async function runMatching(poolName: string, apply: boolean, _toggles: st const result = computeMatchings(requests, offers, excludeMatchings); const matches = result.map((it) => ({ - student: assertExists(it.offer.student), - pupil: assertExists(it.request.pupil), + offer: assertExists(it.offer), + request: assertExists(it.request), })); timing.matching = Date.now() - startMatching; @@ -395,7 +400,7 @@ export async function runMatching(poolName: string, apply: boolean, _toggles: st const createdMatches: Match[] = []; for (const match of matches) { - createdMatches.push(await pool.createMatch(match.pupil, match.student, pool)); + createdMatches.push(await pool.createMatch(match.request.matchRequest, match.offer.matchRequest, pool)); } timing.commit = Date.now() - startCommit; diff --git a/common/match/request.ts b/common/match/request.ts index f8feb1cde..8ffa35d89 100644 --- a/common/match/request.ts +++ b/common/match/request.ts @@ -22,11 +22,12 @@ export async function canPupilRequestMatch(pupil: Pupil): Promise= PUPIL_MAX_REQUESTS) { + if (openMatchRequestCount >= PUPIL_MAX_REQUESTS) { return { allowed: false, reason: 'max-requests', limit: PUPIL_MAX_REQUESTS }; } @@ -47,7 +48,7 @@ export async function canPupilRequestMatch(pupil: Pupil): Promise= getMaxMatchesForUser()) { + if (openMatchRequestCount + activeMatchCount >= getMaxMatchesForUser()) { return { allowed: false, reason: 'max-matches', limit: PUPIL_MAX_MATCHES }; } @@ -64,19 +65,7 @@ export async function createPupilMatchRequest(pupil: Pupil, adminOverride = fals throw new PrerequisiteError('Subjects must be selected before creating a match request'); } - const result = await prisma.pupil.update({ - where: { id: pupil.id }, - data: { - openMatchRequestCount: { increment: 1 }, - }, - }); - - if (result.openMatchRequestCount === 1) { - await prisma.pupil.update({ - where: { id: pupil.id }, - data: { firstMatchRequest: new Date() }, - }); - } + const request = await prisma.match_request.create({ data: { pupilId: pupil.id, subjects: parseSubjectString(pupil.subjects) } }); await Notification.actionTaken(userForPupil(pupil), 'tutee_match_requested', {}); @@ -104,26 +93,28 @@ export async function createPupilMatchRequest(pupil: Pupil, adminOverride = fals } } - logger.info(`Created match request for Pupil(${pupil.id}), now has ${result.openMatchRequestCount} requests, was admin: ${adminOverride}`); + logger.info(`Created MatchRequest(${request.id}) for Pupil(${pupil.id}). Was admin: ${adminOverride}`); + return request; } -export async function deletePupilMatchRequest(pupil: Pupil) { - if (pupil.openMatchRequestCount <= 0) { - throw new RedundantError(`Cannot delete match request for Pupil(${pupil.id}) as pupil has no request left`); +export async function deletePupilMatchRequest(id: number) { + const openMatchRequest = await prisma.match_request.findFirst({ where: { id, status: 'open', pupilId: { not: null } } }); + if (!openMatchRequest) { + throw new RedundantError(`Cannot delete MatchRequest(${id}) as it is not open or does not exist`); } - const result = await prisma.pupil.update({ - where: { id: pupil.id }, - data: { - openMatchRequestCount: { decrement: 1 }, - }, + const result = await prisma.match_request.update({ + where: { id }, + data: { status: 'cancelled', closedAt: new Date() }, }); - if (result.openMatchRequestCount === 0) { + const openMatchRequestCount = await prisma.match_request.count({ where: { pupilId: result.pupilId, status: 'open' } }); + if (openMatchRequestCount === 0) { + const pupil = await prisma.pupil.findUnique({ where: { id: result.pupilId! } }); await Notification.actionTaken(userForPupil(pupil), 'tutee_match_request_revoked', {}); } - logger.info(`Deleted match request for pupil, now has ${result.openMatchRequestCount} requests`); + logger.info(`Deleted MatchRequest(${id}) for Pupil(${result.pupilId})`); } export async function canStudentRequestMatch(student: Student): Promise> { @@ -136,7 +127,8 @@ export async function canStudentRequestMatch(student: Student): Promise= STUDENT_MAX_REQUESTS) { + const openMatchRequestCount = await prisma.match_request.count({ where: { studentId: student.id, status: 'open' } }); + if (openMatchRequestCount >= STUDENT_MAX_REQUESTS) { return { allowed: false, reason: 'max-requests', limit: STUDENT_MAX_REQUESTS }; } @@ -147,37 +139,30 @@ export async function createStudentMatchRequest(student: Student, adminOverride if (!adminOverride) { assertAllowed(await canStudentRequestMatch(student)); } - - const result = await prisma.student.update({ - where: { id: student.id }, - data: { openMatchRequestCount: { increment: 1 } }, - }); - - if (result.openMatchRequestCount === 1) { - await prisma.student.update({ - where: { id: student.id }, - data: { firstMatchRequest: new Date() }, - }); - } + const request = await prisma.match_request.create({ data: { studentId: student.id, subjects: parseSubjectString(student.subjects) } }); await Notification.actionTaken(userForStudent(student), 'tutor_match_requested', {}); - logger.info(`Created match request for Student(${student.id}), now has ${result.openMatchRequestCount} requests, was admin: ${adminOverride}`); + logger.info(`Created MatchRequest(${request.id}) for Student(${student.id}). Was admin: ${adminOverride}`); + return request; } -export async function deleteStudentMatchRequest(student: Student) { - if (student.openMatchRequestCount <= 0) { - throw new RedundantError(`Cannot delete match request for Student(${student.id}) as student has no request left`); +export async function deleteStudentMatchRequest(id: number) { + const openMatchRequest = await prisma.match_request.findFirst({ where: { id, status: 'open', studentId: { not: null } } }); + if (!openMatchRequest) { + throw new RedundantError(`Cannot delete MatchRequest(${id}) as it is not open or does not exist`); } - const result = await prisma.student.update({ - where: { id: student.id }, - data: { openMatchRequestCount: { decrement: 1 } }, + const result = await prisma.match_request.update({ + where: { id }, + data: { status: 'cancelled', closedAt: new Date() }, }); - if (result.openMatchRequestCount === 0) { + const openMatchRequestCount = await prisma.match_request.count({ where: { studentId: result.studentId, status: 'open' } }); + if (openMatchRequestCount === 0) { + const student = await prisma.student.findUnique({ where: { id: result.studentId! } }); await Notification.actionTaken(userForStudent(student), 'tutor_match_request_revoked', {}); } - logger.info(`Deleted match request for student, now has ${result.openMatchRequestCount} requests`); + logger.info(`Deleted MatchRequest(${id}) for Student(${result.studentId})`); } diff --git a/common/notification/hooks.ts b/common/notification/hooks.ts index fcf715a39..9e74d02b0 100644 --- a/common/notification/hooks.ts +++ b/common/notification/hooks.ts @@ -46,8 +46,14 @@ registerStudentHook( } ); -registerPupilHook('revoke-pupil-match-request', 'Match Request is taken back, pending Pupil Screenings are invalidated', async (pupil) => { - await deletePupilMatchRequest(pupil); +registerPupilHook('revoke-pupil-match-request', 'Match Requests are taken back, pending Pupil Screenings are invalidated', async (pupil) => { + const openMatchRequests = await prisma.match_request.findMany({ where: { pupilId: pupil.id, status: 'open' } }); + if (!openMatchRequests.length) { + throw new Error(`Cannot delete match requests for Pupil(${pupil.id}) as pupil has no request left`); + } + for (const request of openMatchRequests) { + await deletePupilMatchRequest(request.id); + } }); registerPupilHook('deactivate-pupil', 'Account gets deactivated, matches are dissolved, courses are left', async (pupil) => { diff --git a/graphql/authorizations.ts b/graphql/authorizations.ts index f93e8f1b1..191da47f7 100644 --- a/graphql/authorizations.ts +++ b/graphql/authorizations.ts @@ -366,6 +366,7 @@ export const authorizationEnhanceMap: Required = { Learning_assignment: allAdmin, Learning_note: allAdmin, Learning_topic: allAdmin, + Match_request: allAdmin, }; /* Some entities are generally accessible by multiple users, however some fields of them are @@ -442,6 +443,7 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = { referredById: adminOrOwner, emailOwner: adminOrOwnerOrScreener, age: adminOrOwnerOrScreener, + match_request: adminOrOwnerOrScreener, }), }, @@ -524,6 +526,7 @@ export const authorizationModelEnhanceMap: ModelsEnhanceMap = { jobStatus: adminOrOwnerOrScreener, formalEducation: adminOrOwnerOrScreener, specialTeachingExperience: adminOrOwnerOrScreener, + match_request: adminOrOwnerOrScreener, }), }, diff --git a/graphql/match/mutations.ts b/graphql/match/mutations.ts index b4cdea8b9..c073e60ce 100644 --- a/graphql/match/mutations.ts +++ b/graphql/match/mutations.ts @@ -50,15 +50,21 @@ class MatchReportInput { export class MutateMatchResolver { @Mutation((returns) => Boolean) @Authorized(Role.ADMIN) - async matchAdd(@Arg('pupilId') pupilId: number, @Arg('studentId') studentId: number, @Arg('poolName') poolName: string): Promise { - const pupil = await getPupil(pupilId); - const student = await getStudent(studentId); + async matchAdd( + @Arg('pupilMatchRequestId') pupilMatchRequestId: number, + @Arg('studentMatchRequestId') studentMatchRequestId: number, + @Arg('poolName') poolName: string + ): Promise { + const pupilMatchRequest = await prisma.match_request.findFirst({ where: { id: pupilMatchRequestId, status: 'open', pupilId: { not: null } } }); + const studentMatchRequest = await prisma.match_request.findFirst({ where: { id: studentMatchRequestId, status: 'open', studentId: { not: null } } }); const pool = pools.find((it) => it.name === poolName); if (!pool) { throw new Error(`Unknown MatchPool(${poolName})`); } - - await createMatch(pupil, student, pool as ConcreteMatchPool); + if (!pupilMatchRequest || !studentMatchRequest) { + throw new Error(`One or both MatchRequests(${pupilMatchRequestId}, ${studentMatchRequestId}) not found or not open`); + } + await createMatch(pupilMatchRequest, studentMatchRequest, pool as ConcreteMatchPool); return true; } diff --git a/graphql/pupil/fields.ts b/graphql/pupil/fields.ts index 966d085ee..da2e572f5 100644 --- a/graphql/pupil/fields.ts +++ b/graphql/pupil/fields.ts @@ -8,6 +8,7 @@ import { Match, Pupil_screening as PupilScreening, School, + Match_request, } from '../generated'; import { Arg, Authorized, Ctx, Field, FieldResolver, Int, Query, Resolver, Root } from 'type-graphql'; import { prisma } from '../../common/prisma'; @@ -216,4 +217,16 @@ export class ExtendFieldsPupilResolver { }); return !screeningInTheLastFourMonths || hasActiveMatch; } + + @FieldResolver((type) => Int) + @Authorized(Role.ADMIN, Role.OWNER) + async openMatchRequestCount(@Root() pupil: Required) { + return await prisma.match_request.count({ where: { pupilId: pupil.id, status: 'open' } }); + } + + @FieldResolver((type) => [Match_request]) + @Authorized(Role.ADMIN, Role.OWNER) + async openMatchRequests(@Root() pupil: Required) { + return await prisma.match_request.findMany({ where: { pupilId: pupil.id, status: 'open' } }); + } } diff --git a/graphql/pupil/mutations.ts b/graphql/pupil/mutations.ts index 35bebe938..506b24b22 100644 --- a/graphql/pupil/mutations.ts +++ b/graphql/pupil/mutations.ts @@ -21,7 +21,7 @@ import { school as School, } from '@prisma/client'; import { prisma } from '../../common/prisma'; -import { PrerequisiteError } from '../../common/util/error'; +import { PrerequisiteError, RedundantError } from '../../common/util/error'; import { toPupilSubjectDatabaseFormat } from '../../common/util/subjectsutils'; import { DeactivationReason, userForPupil } from '../../common/user'; import { MaxLength } from 'class-validator'; @@ -301,9 +301,24 @@ export class MutatePupilResolver { @Mutation((returns) => Boolean) @Authorized(Role.ADMIN, Role.TUTEE, Role.PUPIL_SCREENER) - async pupilDeleteMatchRequest(@Ctx() context: GraphQLContext, @Arg('pupilId', { nullable: true }) pupilId?: number): Promise { - const pupil = await getSessionPupil(context, /* elevated override */ pupilId); - await deletePupilMatchRequest(pupil); + async pupilDeleteMatchRequest(@Ctx() context: GraphQLContext, @Arg('matchRequestId', { nullable: true }) matchRequestId?: number): Promise { + let pupil: Pupil | null = null; + const openMatchRequest = await prisma.match_request.findFirst({ where: { id: matchRequestId, status: 'open' } }); + if (!openMatchRequest) { + throw new RedundantError(`Cannot delete MatchRequest(${matchRequestId}) as it is not open or does not exist`); + } + + if (isElevated(context)) { + pupil = await prisma.pupil.findFirst({ where: { id: openMatchRequest?.pupilId } }); + } else { + pupil = await getSessionPupil(context); + } + + if (pupil.id !== openMatchRequest?.pupilId) { + throw new PrerequisiteError(`Cannot delete MatchRequest(${matchRequestId}) as the pupil does not have permission`); + } + + await deletePupilMatchRequest(openMatchRequest.id); const pendingScreeningAppointment = await prisma.lecture.findFirst({ where: { participantIds: { diff --git a/graphql/student/fields.ts b/graphql/student/fields.ts index 28106026a..5a695ffdd 100644 --- a/graphql/student/fields.ts +++ b/graphql/student/fields.ts @@ -8,6 +8,7 @@ import { Subcourse, Course, StudentWhereInput, + Match_request, } from '../generated'; import { Arg, Authorized, Ctx, FieldResolver, Int, ObjectType, Query, Resolver, Root } from 'type-graphql'; import { prisma } from '../../common/prisma'; @@ -272,4 +273,16 @@ export class ExtendFieldsStudentResolver { }, }); } + + @FieldResolver((type) => Int) + @Authorized(Role.ADMIN, Role.OWNER) + async openMatchRequestCount(@Root() student: Required) { + return await prisma.match_request.count({ where: { studentId: student.id, status: 'open' } }); + } + + @FieldResolver((type) => [Match_request]) + @Authorized(Role.ADMIN, Role.OWNER) + async openMatchRequests(@Root() student: Required) { + return await prisma.match_request.findMany({ where: { studentId: student.id, status: 'open' } }); + } } diff --git a/graphql/student/mutations.ts b/graphql/student/mutations.ts index af7baa1d0..84324ed20 100644 --- a/graphql/student/mutations.ts +++ b/graphql/student/mutations.ts @@ -413,10 +413,24 @@ export class MutateStudentResolver { @Mutation((returns) => Boolean) @Authorized(Role.ADMIN, Role.TUTOR, Role.STUDENT_SCREENER) - async studentDeleteMatchRequest(@Ctx() context: GraphQLContext, @Arg('studentId', { nullable: true }) studentId?: number): Promise { - const student = await getSessionStudent(context, /* elevated override */ studentId); - await deleteStudentMatchRequest(student); + async studentDeleteMatchRequest(@Ctx() context: GraphQLContext, @Arg('matchRequestId', { nullable: true }) matchRequestId?: number): Promise { + let student: Student | null = null; + const openMatchRequest = await prisma.match_request.findFirst({ where: { id: matchRequestId, status: 'open' } }); + if (!openMatchRequest) { + throw new RedundantError(`Cannot delete MatchRequest(${matchRequestId}) as it is not open or does not exist`); + } + + if (isElevated(context)) { + student = await prisma.student.findFirst({ where: { id: openMatchRequest?.studentId } }); + } else { + student = await getSessionStudent(context); + } + + if (student.id !== openMatchRequest?.studentId) { + throw new PrerequisiteError(`Cannot delete MatchRequest(${matchRequestId}) as the student does not have permission`); + } + await deleteStudentMatchRequest(openMatchRequest.id); return true; } diff --git a/integration-tests/01_user.ts b/integration-tests/01_user.ts index 167d0882b..552e949c6 100644 --- a/integration-tests/01_user.ts +++ b/integration-tests/01_user.ts @@ -101,6 +101,9 @@ export async function createNewPupil() { isPupil isParticipant openMatchRequestCount + openMatchRequests { + id + } } } } @@ -118,7 +121,10 @@ export async function createNewPupil() { // Ensure that E-Mails are consumed case-insensitive everywhere: pupil.email = pupil.email.toUpperCase(); - return { client, pupil: pupil as { userID: string; firstname: string; lastname: string; email: string; pupil: { id: number } } }; + return { + client, + pupil: pupil as { userID: string; firstname: string; lastname: string; email: string; pupil: { id: number; openMatchRequests: { id: number }[] } }, + }; } export const pupilTwo = test('Register Pupil', async () => { @@ -220,7 +226,10 @@ export const pupilTwo = test('Register Pupil', async () => { // Ensure that E-Mails are consumed case-insensitive everywhere: pupil.email = pupil.email.toUpperCase(); - return { client, pupil: pupil as { userID: string; firstname: string; lastname: string; email: string; pupil: { id: number } } }; + return { + client, + pupil: pupil as { userID: string; firstname: string; lastname: string; email: string; pupil: { id: number; openMatchRequests: { id: number }[] } }, + }; }); export const pupilOne = test('Register Pupil', createNewPupil); @@ -270,7 +279,12 @@ export async function createNewStudent() { firstname lastname email - student { id } + student { + id + openMatchRequests { + id + } + } } myRoles } @@ -306,7 +320,16 @@ export async function createNewStudent() { // Ensure that E-Mails are consumed case-insensitive everywhere: student.email = student.email.toUpperCase(); - return { client, student: student as { userID: string; firstname: string; lastname: string; email: string; student: { id: number } } }; + return { + client, + student: student as { + userID: string; + firstname: string; + lastname: string; + email: string; + student: { id: number; openMatchRequests: { id: number }[] }; + }, + }; } export const studentOne = test('Register Student', createNewStudent); diff --git a/integration-tests/03_matching.ts b/integration-tests/03_matching.ts index ac255e84f..516b4fad2 100644 --- a/integration-tests/03_matching.ts +++ b/integration-tests/03_matching.ts @@ -32,13 +32,16 @@ const pupilWithMR = test('Pupil Request Match', async () => { me { pupil { openMatchRequestCount + openMatchRequests { + id + } } } } `); assert.strictEqual(p1.pupil.openMatchRequestCount, 1); - return { client, pupil }; + return { client, pupil: { ...pupil, pupil: { ...pupil.pupil, openMatchRequests: p1.pupil.openMatchRequests } } }; }); const studentWithMR = test('Student Request Match', async () => { @@ -63,18 +66,21 @@ const studentWithMR = test('Student Request Match', async () => { } `); - const { me: s1 } = await client.request(` + const { me: updatedUser } = await client.request(` query GetOpenMatchRequestCount { me { student { openMatchRequestCount + openMatchRequests { + id + } } } } `); - assert.strictEqual(s1.student.openMatchRequestCount, 1); - return { client, student }; + assert.strictEqual(updatedUser.student.openMatchRequestCount, 1); + return { client, student: { ...student, student: { ...student.student, ...updatedUser.student } } }; }); export const expectMatchChatCreation = (student, pupil) => { @@ -160,7 +166,7 @@ export const match1 = test('Manual Match creation', async () => { expectMatchChatCreation(student, pupil); await adminClient.request(` mutation CreateManualMatch { - matchAdd(poolName: "lern-fair-now", studentId: ${student.student.id} pupilId: ${pupil.pupil.id}) + matchAdd(poolName: "lern-fair-now", studentMatchRequestId: ${student.student.openMatchRequests[0].id}, pupilMatchRequestId: ${pupil.pupil.openMatchRequests[0].id}) } `); diff --git a/integration-tests/15_achievements.ts b/integration-tests/15_achievements.ts index 35835b43b..5789a62bb 100644 --- a/integration-tests/15_achievements.ts +++ b/integration-tests/15_achievements.ts @@ -193,25 +193,55 @@ void test('Reward student conducted match appointment', async () => { void test('Reward pupil conducted match appointment', async () => { await adminClient.request(`mutation ResetRateLimits { _resetRateLimits }`); - const { student } = await studentOne; - const { pupil, client } = await pupilTwo; + const { student, client: studentClient } = await studentOne; + const { pupil, client: pupilClient } = await pupilTwo; - await client.request(` + await pupilClient.request(` mutation { pupilCreateMatchRequest } `); + await studentClient.request(` + mutation { + studentCreateMatchRequest + } + `); + + const { me: updatedPupil } = await pupilClient.request(` + query GetOpenMatchRequest { + me { + pupil { + openMatchRequests { + id + } + } + } + } + `); + + const { me: updatedStudent } = await studentClient.request(` + query GetOpenMatchRequest { + me { + student { + openMatchRequests { + id + } + } + } + } + `); + expectMatchChatCreation(student, pupil); await adminClient.request(` mutation CreateManualMatch { - matchAdd(poolName: "lern-fair-now", studentId: ${student.student.id} pupilId: ${pupil.pupil.id}) + matchAdd(poolName: "lern-fair-now", studentMatchRequestId: ${updatedStudent.student.openMatchRequests[0].id}, pupilMatchRequestId: ${updatedPupil.pupil.openMatchRequests[0].id}) } `); const { me: { pupil: { matches }, }, - } = await client.request(` + } = await pupilClient.request(` query PupilWithMatch { me { pupil { @@ -226,7 +256,7 @@ void test('Reward pupil conducted match appointment', async () => { const dates = createDates(); const appointments = await generateLectures(dates, match, student.userID, pupil.userID); - await client.request(` + await pupilClient.request(` mutation PupilJoinMatchMeeting { appointmentTrackJoin(appointmentId:${appointments[0].id}) } `); const pupilJoinedMatchMeetingAchievements = await prisma.user_achievement.findMany({ diff --git a/prisma/migrations/20260428125903_add_match_request_table/migration.sql b/prisma/migrations/20260428125903_add_match_request_table/migration.sql new file mode 100644 index 000000000..a7088cdad --- /dev/null +++ b/prisma/migrations/20260428125903_add_match_request_table/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "match_request_status" AS ENUM ('open', 'resolved', 'cancelled'); + +-- CreateTable +CREATE TABLE "match_request" ( + "id" SERIAL NOT NULL, + "createdAt" TIMESTAMP(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "subjects" JSON[] DEFAULT ARRAY[]::JSON[], + "status" "match_request_status" NOT NULL DEFAULT 'open', + "closedAt" TIMESTAMP(3), + "studentId" INTEGER, + "pupilId" INTEGER, + "matchId" INTEGER, + + CONSTRAINT "match_request_pkey" PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "match_request" ADD CONSTRAINT "match_request_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "student"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "match_request" ADD CONSTRAINT "match_request_pupilId_fkey" FOREIGN KEY ("pupilId") REFERENCES "pupil"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; + +-- AddForeignKey +ALTER TABLE "match_request" ADD CONSTRAINT "match_request_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "match"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d9af73143..4268bc4b4 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -482,6 +482,7 @@ model match { // Pupils never get matched to the same Student again, // if they want to continue their match we reactivate the existing match instead @@unique([studentId, pupilId], name: "UQ_MATCH", map: "UQ_MATCH") + match_request match_request[] } // A template for a Notification sent to a User @@ -644,7 +645,8 @@ model pupil { // Sometimes pupils, specially younger ones, are registered with the email of a support person (parent, teacher, etc...) // Here we specify who is the owner of this email - emailOwner pupil_email_owner_enum @default(unknown) + emailOwner pupil_email_owner_enum @default(unknown) + match_request match_request[] } // To only match active users, we sent interest confirmation requests to pupils before matching them @@ -848,6 +850,7 @@ model student { jobStatus student_jobstatus_enum? formalEducation String? specialTeachingExperience String[] @default([]) + match_request match_request[] } // A concrete course with participants, each course might have multiple subcourses with different instructors @@ -1584,3 +1587,23 @@ enum cooperation_type_enum { company university } + +enum match_request_status { + open + resolved + cancelled +} + +model match_request { + id Int @id() @default(autoincrement()) + createdAt DateTime @default(now()) @db.Timestamp(6) + subjects Json[] @default([]) @db.Json + status match_request_status @default(open) + closedAt DateTime? + studentId Int? + student student? @relation(fields: [studentId], references: [id], onDelete: NoAction, onUpdate: NoAction) + pupilId Int? + pupil pupil? @relation(fields: [pupilId], references: [id], onDelete: NoAction, onUpdate: NoAction) + matchId Int? + match match? @relation(fields: [matchId], references: [id], onDelete: NoAction, onUpdate: NoAction) +} diff --git a/seed-db.ts b/seed-db.ts index 384c29539..5fb731ec8 100644 --- a/seed-db.ts +++ b/seed-db.ts @@ -168,9 +168,9 @@ interface CreateTutoringMatchArgs { const createTutoringMatch = async (data: CreateTutoringMatchArgs) => { const { topics = [] } = data; - await createPupilMatchRequest(data.pupil, true); - await createStudentMatchRequest(data.student, true); - const match = await createMatch(await refetchPupil(data.pupil), await refetchStudent(data.student), TEST_POOL, { skipChatCreation: true }); + const pupilMatchRequest = await createPupilMatchRequest(data.pupil, true); + const studentMatchRequest = await createStudentMatchRequest(data.student, true); + const match = await createMatch(pupilMatchRequest, studentMatchRequest, TEST_POOL, { skipChatCreation: true }); if (topics.length) { for (const topic of topics) { const createdTopic = await prisma.learning_topic.create({