Skip to content

Commit 7d820be

Browse files
committed
feat: remove isOrganizer field from speakers and compute status from conference organizers
1 parent e4e9687 commit 7d820be

8 files changed

Lines changed: 126 additions & 31 deletions

File tree

__tests__/lib/speaker/sanity.test.ts

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ vi.mock('uuid', () => ({
2424
v4: () => 'mock-uuid-1234',
2525
}))
2626

27-
import { updateSpeaker, getOrCreateSpeaker } from '@/lib/speaker/sanity'
27+
import {
28+
updateSpeaker,
29+
getOrCreateSpeaker,
30+
getSpeaker,
31+
} from '@/lib/speaker/sanity'
2832
import type { Speaker } from '@/lib/speaker/types'
2933
import type { Account, User } from 'next-auth'
3034

@@ -225,3 +229,86 @@ describe('getOrCreateSpeaker', () => {
225229
expect(createArg.image).toBeUndefined()
226230
})
227231
})
232+
233+
describe('isOrganizer computed from conference.organizers', () => {
234+
beforeEach(() => {
235+
vi.clearAllMocks()
236+
})
237+
238+
it('should propagate isOrganizer: true from Sanity response through getOrCreateSpeaker', async () => {
239+
const organizer = {
240+
...baseSpeaker,
241+
isOrganizer: true,
242+
providers: ['github:12345'],
243+
}
244+
mockFetch.mockResolvedValue(organizer)
245+
246+
const { speaker, err } = await getOrCreateSpeaker(
247+
{ email: organizer.email, name: organizer.name },
248+
{ provider: 'github', providerAccountId: '12345', type: 'oauth' },
249+
)
250+
251+
expect(err).toBeNull()
252+
expect(speaker.isOrganizer).toBe(true)
253+
})
254+
255+
it('should propagate isOrganizer: false from Sanity response through getOrCreateSpeaker', async () => {
256+
const regular = {
257+
...baseSpeaker,
258+
isOrganizer: false,
259+
providers: ['github:67890'],
260+
}
261+
mockFetch.mockResolvedValue(regular)
262+
263+
const { speaker, err } = await getOrCreateSpeaker(
264+
{ email: regular.email, name: regular.name },
265+
{ provider: 'github', providerAccountId: '67890', type: 'oauth' },
266+
)
267+
268+
expect(err).toBeNull()
269+
expect(speaker.isOrganizer).toBe(false)
270+
})
271+
272+
it('should propagate isOrganizer through getSpeaker', async () => {
273+
mockFetch.mockResolvedValue({ ...baseSpeaker, isOrganizer: true })
274+
275+
const { speaker, err } = await getSpeaker('speaker-1')
276+
277+
expect(err).toBeNull()
278+
expect(speaker.isOrganizer).toBe(true)
279+
})
280+
281+
it('should handle undefined isOrganizer as falsy', async () => {
282+
const speakerWithoutFlag = { ...baseSpeaker }
283+
delete (speakerWithoutFlag as Record<string, unknown>).isOrganizer
284+
mockFetch.mockResolvedValue(speakerWithoutFlag)
285+
286+
const { speaker, err } = await getSpeaker('speaker-1')
287+
288+
expect(err).toBeNull()
289+
expect(speaker.isOrganizer).toBeFalsy()
290+
})
291+
292+
it('should include isOrganizer in GROQ query for findSpeakerByProvider', async () => {
293+
mockFetch.mockResolvedValue(null)
294+
295+
await getOrCreateSpeaker(
296+
{ email: 'test@test.com', name: 'Test' },
297+
{ provider: 'github', providerAccountId: '99', type: 'oauth' },
298+
)
299+
300+
const query = mockFetch.mock.calls[0][0] as string
301+
expect(query).toContain('isOrganizer')
302+
expect(query).toContain('conference')
303+
})
304+
305+
it('should include isOrganizer in GROQ query for getSpeaker', async () => {
306+
mockFetch.mockResolvedValue(baseSpeaker)
307+
308+
await getSpeaker('speaker-1')
309+
310+
const query = mockFetch.mock.calls[0][0] as string
311+
expect(query).toContain('isOrganizer')
312+
expect(query).toContain('conference')
313+
})
314+
})
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { defineMigration, at, unset } from 'sanity/migrate'
2+
3+
export default defineMigration({
4+
title: 'Remove isOrganizer field from speakers',
5+
description:
6+
'Removes the isOrganizer boolean field from speaker documents. ' +
7+
'Organizer status is now computed from conference.organizers[] references.',
8+
documentTypes: ['speaker'],
9+
10+
migrate: {
11+
document(doc) {
12+
const d = doc as Record<string, unknown>
13+
14+
if (d.isOrganizer !== undefined) {
15+
return [at('isOrganizer', unset())]
16+
}
17+
18+
return []
19+
},
20+
},
21+
})

sanity/schemaTypes/speaker.ts

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,6 @@ export default defineType({
107107
type: 'text',
108108
description: 'Speaker biography displayed on the public profile',
109109
}),
110-
defineField({
111-
name: 'isOrganizer',
112-
title: 'Is this a organizer?',
113-
type: 'boolean',
114-
description: 'Grants access to the admin interface',
115-
}),
116110
defineField({
117111
title: 'Flags',
118112
description: 'Meta information about the speaker',

sanity/schemaTypes/sponsorActivity.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ export default defineType({
7474
description:
7575
'The organizer who performed this action. Omitted for system-generated activities.',
7676
options: {
77-
filter: 'isOrganizer == true',
77+
filter: '_id in *[_type == \"conference\"].organizers[]._ref',
7878
},
7979
}),
8080
defineField({

sanity/schemaTypes/sponsorForConference.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ export default defineType({
204204
filter: ({ document }: { document: any }) => {
205205
if (!document?.conference?._ref) {
206206
return {
207-
filter: 'isOrganizer == true',
207+
filter: '_id in *[_type == \"conference\"].organizers[]._ref',
208208
}
209209
}
210210

src/lib/auth.ts

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,21 +57,6 @@ const config = {
5757
return {}
5858
}
5959

60-
// Migrate legacy snake_case JWT tokens from before migration 028.
61-
// Old tokens stored speaker.is_organizer; new code expects isOrganizer.
62-
// This block can be removed once all active sessions have expired.
63-
if (
64-
!trigger &&
65-
token.speaker &&
66-
typeof token.speaker === 'object' &&
67-
'is_organizer' in token.speaker &&
68-
!('isOrganizer' in token.speaker)
69-
) {
70-
const sp = token.speaker as Record<string, unknown>
71-
sp.isOrganizer = sp.is_organizer
72-
delete sp.is_organizer
73-
}
74-
7560
if (trigger === 'signIn') {
7661
if (!token || !token.email || !token.name) {
7762
console.error('Invalid auth token', token)

src/lib/sanity/speaker.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export async function getSpeakerByEmail(
1111
_id,
1212
name,
1313
email,
14-
isOrganizer,
14+
"isOrganizer": _id in *[_type == "conference"].organizers[]._ref,
1515
"image": coalesce(image.asset->url, imageURL),
1616
"slug": slug.current
1717
}

src/lib/speaker/sanity.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,10 @@ import { v4 as randomUUID } from 'uuid'
99
import { Account, User } from 'next-auth'
1010
import { ProposalExisting, Status } from '../proposal/types'
1111
import { cacheLife, cacheTag } from 'next/cache'
12+
13+
// Computed field: speaker is an organizer if referenced in any conference's organizers array
14+
const IS_ORGANIZER_FIELD =
15+
'"isOrganizer": _id in *[_type == "conference"].organizers[]._ref'
1216
export function providerAccount(
1317
provider: string,
1418
providerAccountId: string,
@@ -43,7 +47,8 @@ async function findSpeakerByProvider(
4347
`*[ _type == "speaker" && $id in providers][0]{
4448
...,
4549
"slug": slug.current,
46-
"image": coalesce(image.asset->url, imageURL)
50+
"image": coalesce(image.asset->url, imageURL),
51+
${IS_ORGANIZER_FIELD}
4752
}`,
4853
{ id },
4954
)
@@ -65,7 +70,8 @@ async function findSpeakerByEmail(
6570
`*[ _type == "speaker" && email == $email][0]{
6671
...,
6772
"slug": slug.current,
68-
"image": coalesce(image.asset->url, imageURL)
73+
"image": coalesce(image.asset->url, imageURL),
74+
${IS_ORGANIZER_FIELD}
6975
}`,
7076
{ email },
7177
)
@@ -165,7 +171,8 @@ export async function getSpeaker(
165171
`*[ _type == "speaker" && _id == $speakerId][0]{
166172
...,
167173
"slug": slug.current,
168-
"image": coalesce(image.asset->url, imageURL)
174+
"image": coalesce(image.asset->url, imageURL),
175+
${IS_ORGANIZER_FIELD}
169176
}`,
170177
{ speakerId },
171178
{ cache: 'no-store' },
@@ -376,7 +383,7 @@ export async function getOrganizerCount(): Promise<{
376383
let err = null
377384

378385
try {
379-
const query = groq`count(*[_type == "speaker" && isOrganizer == true])`
386+
const query = groq`count(*[_type == "conference"].organizers[]._ref)`
380387
count = await clientRead.fetch(query, {}, { cache: 'no-store' })
381388
} catch (error) {
382389
err = error as Error
@@ -393,10 +400,11 @@ export async function getOrganizers(): Promise<{
393400
let err = null
394401

395402
try {
396-
const query = groq`*[_type == "speaker" && isOrganizer == true] {
403+
const query = groq`*[_type == "speaker" && _id in *[_type == "conference"].organizers[]._ref] {
397404
...,
398405
"slug": slug.current,
399-
"image": coalesce(image.asset->url, imageURL)
406+
"image": coalesce(image.asset->url, imageURL),
407+
"isOrganizer": true
400408
} | order(name asc)`
401409

402410
speakers = await clientRead.fetch(query, {}, { cache: 'no-store' })

0 commit comments

Comments
 (0)