Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
25066aa
add partner-config.read/update privileges, map to PartnerAdmin
edandylytics May 28, 2026
9022825
add failing tests for GET /partners/config
edandylytics May 28, 2026
cdddc1e
add GET /partners/config
edandylytics May 28, 2026
2b328bb
add PUT /partners/config tests
edandylytics May 28, 2026
24fa08b
add CrossYearMatchingSection to Admin page
edandylytics May 28, 2026
c341976
gate PUT /partners/config on EDU creds when enabling
edandylytics May 28, 2026
93100c2
rework cross-year matching admin UX
edandylytics May 29, 2026
29ea7f3
clear save error banner on retry and edit
edandylytics Jun 1, 2026
10e2795
tighten partners-config tests
edandylytics Jun 1, 2026
b9dd73c
collapse cross-year help text and wire toggle a11y via form components
edandylytics Jun 1, 2026
47328db
rename CrossYearMatchingSection to PartnerConfig
edandylytics Jun 1, 2026
b6a7b3e
add optimistic concurrency to partner config
edandylytics Jun 1, 2026
850fd26
back out partner-config stale-write check, leave a note
edandylytics Jun 1, 2026
f277a37
consolidate GET /partners/config tests
edandylytics Jun 1, 2026
3d0a1b4
consolidate PUT /partners/config tests
edandylytics Jun 1, 2026
d97fd71
tidy PartnerConfig state and naming
edandylytics Jun 1, 2026
8a8b310
clarify partners-config test mocks and scoping assertion
edandylytics Jun 2, 2026
b49d93b
split confirm-changes modal into purpose-named dialog plus shell
edandylytics Jun 2, 2026
372ac28
match confirm-modal change label to the toggle label
edandylytics Jun 2, 2026
d530687
distinguish discarding edits from leaving the page
edandylytics Jun 2, 2026
0a289cf
rename eduCredsExist to canConnectToEdu
edandylytics Jun 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions app/api/integration/tests/partners-config.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import request from 'supertest';
import { userA, userX } from '../fixtures/user-fixtures';
import { tenantA, tenantX } from '../fixtures/context-fixtures/tenant-fixtures';
import { partnerA } from '../fixtures/context-fixtures/partner-fixtures';
import { idpA, idpX } from '../fixtures/context-fixtures/idp-fixtures';
import { authHelper } from '../helpers/oidc/auth-flow';
import { EduSnowflakePoolService } from 'api/src/earthbeam/api/edu-snowflake-pool.service';

describe('GET /partners/config', () => {
const endpoint = '/partners/config';
const userRoleA = 'runway.test.user';
const partnerAdminRoleA = 'runway.test.partneradmin';

it('should reject unauthenticated requests', async () => {
const res = await request(app.getHttpServer()).get(endpoint);
expect(res.status).toBe(401);
});

it('should reject non-PartnerAdmin users', async () => {
const cookieA = (await authHelper.login(idpA, userA, tenantA, userRoleA)).cookies;
const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [cookieA]);
expect(res.status).toBe(403);
});

describe('as PartnerAdmin', () => {
let adminCookieA: string;
let canConnectSpy: jest.SpyInstance;

beforeEach(async () => {
adminCookieA = (await authHelper.login(idpA, userA, tenantA, partnerAdminRoleA)).cookies;
// Each test sets the canConnect result it exercises.
canConnectSpy = jest.spyOn(app.get(EduSnowflakePoolService), 'canConnect');
});

afterEach(() => {
canConnectSpy.mockRestore();
});

// The body mirrors the partner's toggle column and canConnect, and is
// exactly { crossYearMatchingEnabled, canConnectToEdu } — asserting the full
// shape guards against leaking extra partner fields. One case per boolean.
it('returns the toggle column and EDU creds state (both true)', async () => {
canConnectSpy.mockResolvedValue(true);
await global.prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: true },
});
const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [adminCookieA]);
expect(res.status).toBe(200);
expect(res.body).toEqual({ crossYearMatchingEnabled: true, canConnectToEdu: true });
expect(canConnectSpy).toHaveBeenCalledWith(partnerA.id);
});

it('returns the toggle column and EDU creds state (both false)', async () => {
canConnectSpy.mockResolvedValue(false);
await global.prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: false },
});
const res = await request(app.getHttpServer()).get(endpoint).set('Cookie', [adminCookieA]);
expect(res.status).toBe(200);
expect(res.body).toEqual({ crossYearMatchingEnabled: false, canConnectToEdu: false });
});
});
});

describe('PUT /partners/config', () => {
const endpoint = '/partners/config';
const userRoleA = 'runway.test.user';
const partnerAdminRoleA = 'runway.test.partneradmin';
const partnerAdminRoleX = 'Runway.PartnerAdmin';

it('should reject unauthenticated requests', async () => {
const res = await request(app.getHttpServer())
.put(endpoint)
.send({ crossYearMatchingEnabled: true });
expect(res.status).toBe(401);
});

it('should reject non-PartnerAdmin users', async () => {
const cookieA = (await authHelper.login(idpA, userA, tenantA, userRoleA)).cookies;
const res = await request(app.getHttpServer())
.put(endpoint)
.set('Cookie', [cookieA])
.send({ crossYearMatchingEnabled: true });
expect(res.status).toBe(403);
});

describe('as PartnerAdmin', () => {
let adminCookieA: string;
let canConnectSpy: jest.SpyInstance;

beforeEach(async () => {
adminCookieA = (await authHelper.login(idpA, userA, tenantA, partnerAdminRoleA)).cookies;
canConnectSpy = jest
.spyOn(app.get(EduSnowflakePoolService), 'canConnect')
.mockResolvedValue(true);
await global.prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: false },
});
});

afterEach(() => {
canConnectSpy.mockRestore();
});

it('rejects enabling when EDU creds are missing', async () => {
canConnectSpy.mockResolvedValue(false);
const res = await request(app.getHttpServer())
.put(endpoint)
.set('Cookie', [adminCookieA])
.send({ crossYearMatchingEnabled: true });
expect(res.status).toBe(400);

const row = await global.prisma.partner.findUniqueOrThrow({ where: { id: partnerA.id } });
expect(row.crossYearMatchingEnabled).toBe(false);
});

it('allows disabling even when EDU creds are missing', async () => {
await global.prisma.partner.update({
where: { id: partnerA.id },
data: { crossYearMatchingEnabled: true },
});
canConnectSpy.mockResolvedValue(false);

const res = await request(app.getHttpServer())
.put(endpoint)
.set('Cookie', [adminCookieA])
.send({ crossYearMatchingEnabled: false });
expect(res.status).toBe(200);

const row = await global.prisma.partner.findUniqueOrThrow({ where: { id: partnerA.id } });
expect(row.crossYearMatchingEnabled).toBe(false);
});

it('rejects invalid body', async () => {
const res = await request(app.getHttpServer())
.put(endpoint)
.set('Cookie', [adminCookieA])
.send({ crossYearMatchingEnabled: 'nope' });
expect(res.status).toBe(400);
});

it("updates only the session partner's row", async () => {
const adminCookieX = (await authHelper.login(idpX, userX, tenantX, partnerAdminRoleX)).cookies;
await global.prisma.partner.update({
where: { id: 'partner-x' },
data: { crossYearMatchingEnabled: false },
});

const res = await request(app.getHttpServer())
.put(endpoint)
.set('Cookie', [adminCookieX])
.send({ crossYearMatchingEnabled: true });
expect(res.status).toBe(200);
expect(res.body).toEqual({ status: 'ok' });

const partnerXRow = await global.prisma.partner.findUniqueOrThrow({
where: { id: 'partner-x' },
});
expect(partnerXRow.crossYearMatchingEnabled).toBe(true);

// Load-bearing: partner A must be left untouched, proving the update is
// scoped to the session's partnerId rather than affecting other partners.
const partnerARow = await global.prisma.partner.findUniqueOrThrow({
where: { id: partnerA.id },
});
expect(partnerARow.crossYearMatchingEnabled).toBe(false);
});
});
});
2 changes: 1 addition & 1 deletion app/api/src/earthbeam/api/earthbeam-api.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ import { EventEmitterModule } from 'api/src/event-emitter/event-emitter.module';
],
providers: [EarthbeamApiService, EduSnowflakePoolService],
controllers: [EarthbeamApiController],
exports: [],
exports: [EduSnowflakePoolService],
})
export class EarthbeamApiModule {}
43 changes: 41 additions & 2 deletions app/api/src/partners/partners.controller.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,52 @@
import { Controller, Post } from '@nestjs/common';
import { BadRequestException, Body, Controller, Get, Inject, Post, Put } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { PrismaClient, Tenant } from '@prisma/client';
import { PutPartnerConfigDto, toGetPartnerConfigDto } from '@edanalytics/models';
import { PRISMA_APP_USER } from '../database';
import { Authorize } from '../auth/helpers/authorize.decorator';
import { Tenant as TenantDecorator } from '../auth/helpers/tenant.decorator';
import { EduSnowflakePoolService } from '../earthbeam/api/edu-snowflake-pool.service';

@Controller()
@ApiTags('Partners')
export class PartnersController {
constructor() {}
constructor(
@Inject(PRISMA_APP_USER) private prisma: PrismaClient,
private eduPool: EduSnowflakePoolService
) {}

@Authorize('partner-earthmover-bundle.create')
@Post(':type/:bundleKey')
async enableBundle() {}

@Authorize('partner-config.read')
@Get('config')
async getConfig(@TenantDecorator() tenant: Tenant) {
const partner = await this.prisma.partner.findUniqueOrThrow({
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm trying to move away from findUniqueOrThrow generally, but leaving this in since if we don't find the partner for the session tenant, that's rightly a 500, not a 404.

where: { id: tenant.partnerId },
select: { crossYearMatchingEnabled: true },
});
const canConnectToEdu = await this.eduPool.canConnect(tenant.partnerId);
return toGetPartnerConfigDto({
crossYearMatchingEnabled: partner.crossYearMatchingEnabled,
canConnectToEdu,
});
}

// TODO: add an optimistic-concurrency / stale-write check before this
// section grows beyond the single cross-year toggle. With one boolean a
// last-write-wins clobber is harmless, but once multiple settings share this
// endpoint, concurrent admin edits could silently overwrite each other.
@Authorize('partner-config.update')
@Put('config')
async updateConfig(@TenantDecorator() tenant: Tenant, @Body() body: PutPartnerConfigDto) {
if (body.crossYearMatchingEnabled && !(await this.eduPool.canConnect(tenant.partnerId))) {
throw new BadRequestException('EDU credentials are not configured for this partner.');
}
await this.prisma.partner.update({
where: { id: tenant.partnerId },
data: { crossYearMatchingEnabled: body.crossYearMatchingEnabled },
});
return { status: 'ok' };
}
}
3 changes: 2 additions & 1 deletion app/api/src/partners/partners.module.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { PartnersController } from './partners.controller';
import { EarthbeamApiModule } from '../earthbeam/api/earthbeam-api.module';

@Module({
imports: [],
imports: [EarthbeamApiModule],
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is where the EDU service lives -- probably a better place for it, but not something I want to tackle right now

providers: [],
controllers: [PartnersController],
})
Expand Down
2 changes: 2 additions & 0 deletions app/fe/src/app/Pages/Admin/AdminPage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Box, VStack } from '@chakra-ui/react';
import { useMe } from '../../api/queries/me.queries';
import { SchoolYearConfigSection } from './SchoolYearConfigSection';
import { PartnerConfig } from './PartnerConfig';

export const AdminPage = () => {
const { data: me } = useMe();
Expand All @@ -12,6 +13,7 @@ export const AdminPage = () => {
admin settings ({partnerId})
</Box>
<SchoolYearConfigSection />
<PartnerConfig />
</VStack>
);
};
92 changes: 24 additions & 68 deletions app/fe/src/app/Pages/Admin/ConfirmChangesModal.tsx
Original file line number Diff line number Diff line change
@@ -1,75 +1,31 @@
import {
Box,
Button,
HStack,
List,
ListItem,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from '@chakra-ui/react';
import { List, ListItem } from '@chakra-ui/react';
import { ConfirmModal } from './ConfirmModal';

interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => void;
title?: string;
description?: string;
confirmLabel?: string;
changes?: string[];
changes: string[];
}

export const ConfirmChangesModal = ({
isOpen,
onClose,
onConfirm,
title = 'confirm changes',
description = 'The following changes will be saved:',
confirmLabel = 'confirm',
changes = [],
}: Props) => {
return (
<Modal isOpen={isOpen} onClose={onClose} isCentered>
<ModalOverlay />
<ModalContent>
<ModalHeader>{title}</ModalHeader>
<ModalCloseButton />
<ModalBody>
<Box textStyle="body" mb={changes.length > 0 ? '200' : '0'}>
{description}
</Box>
{changes.length > 0 && (
<List spacing="100">
{changes.map((change, i) => (
<ListItem key={i} textStyle="body">
{change}
</ListItem>
))}
</List>
)}
</ModalBody>
<ModalFooter>
<HStack gap="200">
<Button variant="ghost" _hover={{ bg: 'transparent' }} onClick={onClose}>
cancel
</Button>
<Button
layerStyle="buttonPrimary"
textStyle="button"
bg="green.600"
color="green.50"
_hover={{ bg: 'green.400' }}
onClick={onConfirm}
>
{confirmLabel}
</Button>
</HStack>
</ModalFooter>
</ModalContent>
</Modal>
);
};
/** Confirms a set of pending edits before saving them. */
export const ConfirmChangesModal = ({ isOpen, onClose, onConfirm, changes }: Props) => (
<ConfirmModal
isOpen={isOpen}
onClose={onClose}
onConfirm={onConfirm}
title="confirm changes"
description="The following changes will be saved:"
confirmLabel="confirm"
>
{changes.length > 0 && (
<List spacing="100">
{changes.map((change, i) => (
<ListItem key={i} textStyle="body">
{change}
</ListItem>
))}
</List>
)}
</ConfirmModal>
);
Loading