From e8c9ab202e35c99bc180c922e9f83db90e005eaa Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 5 Oct 2026 15:59:54 +0200 Subject: [PATCH 1/3] Make Communities.kfOrgId nullable again and retire the NOT NULL migration --- server/community/model.ts | 6 +++- .../2026_06_15_makeKfOrgIdNotNull.js | 27 +++++--------- .../2026_10_05_makeKfOrgIdNullable.js | 36 +++++++++++++++++++ 3 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 tools/migrations/2026_10_05_makeKfOrgIdNullable.js diff --git a/server/community/model.ts b/server/community/model.ts index c5bd06a7f..047214402 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -251,7 +251,11 @@ export class Community extends Model< @Column(DataType.UUID) declare templateId: string | null; - /** KF Auth organization that owns this community (for billing/ownership) */ + /** + * KF account this community is billed to. Billing attribution set by KF staff, + * not ownership: NULL until someone assigns it (users who sign up through + * kf-console have no personal org to default to). + */ @Index @Column(DataType.TEXT) declare kfOrgId: string | null; diff --git a/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js b/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js index f9ec0a880..6898e5f4f 100644 --- a/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js +++ b/tools/migrations/2026_06_15_makeKfOrgIdNotNull.js @@ -1,28 +1,17 @@ /** * Phase D cleanup: Make kfOrgId NOT NULL on Communities. * - * Run this ONLY after confirming all communities have been assigned - * a kfOrgId value (from the seed script + new community creation). + * SUPERSEDED by 2026_10_05_makeKfOrgIdNullable. kfOrgId is staff-set billing + * attribution and may be NULL (users who sign up through kf-console have no + * personal org), so `up` refuses to run. `down` is kept so the constraint can + * still be removed where this was applied. */ -export const up = async ({ Sequelize, sequelize }) => { - // First verify there are no NULL values - const [results] = await sequelize.query( - `SELECT count(*) as count FROM "Communities" WHERE "kfOrgId" IS NULL`, +export const up = async () => { + throw new Error( + '2026_06_15_makeKfOrgIdNotNull is superseded by 2026_10_05_makeKfOrgIdNullable; ' + + 'kfOrgId must stay nullable.', ); - const nullCount = parseInt(results[0].count, 10); - - if (nullCount > 0) { - throw new Error( - `Cannot make kfOrgId NOT NULL: ${nullCount} communities still have NULL kfOrgId. ` + - `Assign ownership first, then re-run this migration.`, - ); - } - - await sequelize.queryInterface.changeColumn('Communities', 'kfOrgId', { - type: Sequelize.TEXT, - allowNull: false, - }); }; export const down = async ({ Sequelize, sequelize }) => { diff --git a/tools/migrations/2026_10_05_makeKfOrgIdNullable.js b/tools/migrations/2026_10_05_makeKfOrgIdNullable.js new file mode 100644 index 000000000..04db1853e --- /dev/null +++ b/tools/migrations/2026_10_05_makeKfOrgIdNullable.js @@ -0,0 +1,36 @@ +/** + * Make Communities.kfOrgId nullable again, undoing 2026_06_15_makeKfOrgIdNotNull. + * + * kfOrgId is billing attribution set by KF staff, not ownership a user has to + * pick at creation. Users who sign up through kf-console get no personal org, + * so their orgs claim is empty and create-community sends no kfOrgId; with the + * NOT NULL constraint in place that insert fails. + * + * Safe to run whether or not the NOT NULL migration was ever applied. + */ + +export const up = async ({ Sequelize, sequelize }) => { + await sequelize.queryInterface.changeColumn('Communities', 'kfOrgId', { + type: Sequelize.TEXT, + allowNull: true, + }); +}; + +export const down = async ({ Sequelize, sequelize }) => { + const [results] = await sequelize.query( + `SELECT count(*) as count FROM "Communities" WHERE "kfOrgId" IS NULL`, + ); + const nullCount = parseInt(results[0].count, 10); + + if (nullCount > 0) { + throw new Error( + `Cannot make kfOrgId NOT NULL: ${nullCount} communities have NULL kfOrgId. ` + + `Assign them an org first, then re-run this migration.`, + ); + } + + await sequelize.queryInterface.changeColumn('Communities', 'kfOrgId', { + type: Sequelize.TEXT, + allowNull: false, + }); +}; From ae4aa7a791c39a134c360be9194a84a05358ec12 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 5 Oct 2026 16:00:00 +0200 Subject: [PATCH 2/3] Only keep a picked kfOrgId on community create if the user belongs to that org --- server/community/__tests__/api.test.ts | 44 +++++++++++++++++++++++++- server/community/api.ts | 9 ++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/server/community/__tests__/api.test.ts b/server/community/__tests__/api.test.ts index 792bca0ab..bdc4fd942 100644 --- a/server/community/__tests__/api.test.ts +++ b/server/community/__tests__/api.test.ts @@ -25,10 +25,11 @@ const models = modelize` } `; -const { subscribeUser, postToSlackAboutNewCommunity } = vi.hoisted(() => { +const { subscribeUser, postToSlackAboutNewCommunity, fetchUserOrgs } = vi.hoisted(() => { return { subscribeUser: vi.fn(), postToSlackAboutNewCommunity: vi.fn(), + fetchUserOrgs: vi.fn(async () => [] as { id: string }[]), }; }); @@ -39,6 +40,10 @@ setup(beforeAll, async () => { vi.mock('server/utils/slack', () => ({ postToSlackAboutNewCommunity, })); + vi.mock('server/kf/oidc.server', async (importOriginal) => ({ + ...(await importOriginal()), + fetchUserOrgs, + })); await models.resolve(); }); @@ -116,6 +121,43 @@ describe('/api/communities', () => { expect(newCommunity?.title).toEqual('Journal of Regular Users'); }); + it('creates a community with no kfOrgId when the user has no KF orgs', async () => { + const { willNotCreateCommunity } = models; + const agent = await login(willNotCreateCommunity); + const subdomain = 'no-orgs-' + uuid.v4(); + await agent + .post('/api/communities') + .send({ subdomain, title: 'Journal of No Orgs' }) + .expect(201); + const newCommunity = await Community.findOne({ where: { subdomain } }); + expect(newCommunity?.kfOrgId).toBeNull(); + }); + + it('keeps a picked kfOrgId only if the user belongs to that org', async () => { + const { willNotCreateCommunity } = models; + const agent = await login(willNotCreateCommunity); + fetchUserOrgs.mockResolvedValue([{ id: 'org-mine' }]); + + const mine = 'org-mine-' + uuid.v4(); + await agent + .post('/api/communities') + .send({ subdomain: mine, title: 'Mine', kfOrgId: 'org-mine' }) + .expect(201); + expect((await Community.findOne({ where: { subdomain: mine } }))?.kfOrgId).toEqual( + 'org-mine', + ); + + const theirs = 'org-theirs-' + uuid.v4(); + await agent + .post('/api/communities') + .send({ subdomain: theirs, title: 'Theirs', kfOrgId: 'org-theirs' }) + .expect(201); + expect((await Community.findOne({ where: { subdomain: theirs } }))?.kfOrgId).toBeNull(); + + fetchUserOrgs.mockReset(); + fetchUserOrgs.mockResolvedValue([]); + }); + it('does not create a community if you are logged out', async () => { await (await login()) .post('/api/communities') diff --git a/server/community/api.ts b/server/community/api.ts index 1cb61cbd3..648484fa0 100644 --- a/server/community/api.ts +++ b/server/community/api.ts @@ -2,6 +2,7 @@ import { initServer } from '@ts-rest/express'; import { Op } from 'sequelize'; import { setSubdomain } from 'server/dev/api'; +import { fetchUserOrgs } from 'server/kf/oidc.server'; import { Community, User, WorkerTask } from 'server/models'; import { updateDiscussionCreationAccess } from 'server/publicPermissions/queries'; import { verifyCaptchaPayload } from 'server/utils/captcha'; @@ -155,6 +156,14 @@ export const communityServer = s.router(contract.community, { const body = { ...req.body }; delete body.altcha; delete body._honeypot; + // kfOrgId is billing attribution, not required ownership. Keep a picked org + // only if the user belongs to it; otherwise leave it NULL for staff to set. + if (body.kfOrgId) { + const userOrgs = await fetchUserOrgs(req.user.id).catch(() => []); + if (!userOrgs.some((org) => org.id === body.kfOrgId)) { + delete body.kfOrgId; + } + } try { // Resolve hub context before creating the community const hubSlug = (req as any).body.hubSlug as string | undefined; From aa7a7ef95bf443728ba1939e866d5bfe56421739 Mon Sep 17 00:00:00 2001 From: "Thomas F. K. Jorna" Date: Mon, 5 Oct 2026 17:36:43 +0200 Subject: [PATCH 3/3] Rename Communities.kfOrgId to kfAccountId, keeping the old kf_org_id/kfOrgId names accepted on the KF endpoints --- .../CommunityCreate/CommunityCreate.tsx | 32 +++---- .../CommunitySettings/TransferOwnership.tsx | 77 ++++++++-------- server/community/__tests__/api.test.ts | 26 +++--- server/community/api.ts | 12 +-- server/community/model.ts | 9 +- server/community/queries.ts | 2 +- server/kf/__tests__/api.test.ts | 90 +++++++++++++++++++ server/kf/api.ts | 62 ++++++++----- server/routes/communityCreate.tsx | 4 +- .../2026_10_05_makeKfOrgIdNullable.js | 4 + .../2026_10_05_renameKfOrgIdToKfAccountId.js | 46 ++++++++++ utils/api/schemas/community.ts | 4 +- 12 files changed, 263 insertions(+), 105 deletions(-) create mode 100644 server/kf/__tests__/api.test.ts create mode 100644 tools/migrations/2026_10_05_renameKfOrgIdToKfAccountId.js diff --git a/client/containers/CommunityCreate/CommunityCreate.tsx b/client/containers/CommunityCreate/CommunityCreate.tsx index ab907a3e1..c50a332d0 100644 --- a/client/containers/CommunityCreate/CommunityCreate.tsx +++ b/client/containers/CommunityCreate/CommunityCreate.tsx @@ -82,7 +82,7 @@ const CommunityCreatedView = ({ subdomain, hubName }: { subdomain: string; hubNa ); }; -type KFOrg = { +type KFAccount = { id: string; name: string; slug: string; @@ -94,7 +94,7 @@ type Props = { hubData?: Hub | null; templates?: CommunityTemplate[]; hubCommunities?: { id: string; title: string; subdomain: string; avatar?: string | null }[]; - kfOrgs?: KFOrg[]; + kfAccounts?: KFAccount[]; }; const HubBrandedHeader = ({ hub }: { hub: Hub }) => { @@ -118,7 +118,7 @@ const HubBrandedHeader = ({ hub }: { hub: Hub }) => { }; const CommunityCreate = (props: Props) => { - const { hubData, templates = [], hubCommunities = [], kfOrgs = [] } = props; + const { hubData, templates = [], hubCommunities = [], kfAccounts = [] } = props; const { loginData, locationData } = usePageContext(); const altchaRef = useRef(null); const hubSlug = hubData?.slug || locationData?.query?.hub || null; @@ -135,10 +135,10 @@ const CommunityCreate = (props: Props) => { const [selectedTemplateId, setSelectedTemplateId] = useState(null); const [cloneCommunityId, setCloneCommunityId] = useState(null); - // KF org picker: default to personal org, or first available - const personalOrg = kfOrgs.find((o) => o.type === 'personal'); - const [selectedKfOrgId, setSelectedKfOrgId] = useState( - personalOrg?.id ?? kfOrgs[0]?.id ?? null, + // KF account picker: default to the personal account, or first available + const personalAccount = kfAccounts.find((a) => a.type === 'personal'); + const [selectedKfAccountId, setSelectedKfAccountId] = useState( + personalAccount?.id ?? kfAccounts[0]?.id ?? null, ); const hasHub = !!hubData; @@ -178,7 +178,7 @@ const CommunityCreate = (props: Props) => { ...(selectedTemplateId === CLONE_MARKER && cloneCommunityId ? { cloneCommunityId } : {}), - ...(selectedKfOrgId ? { kfOrgId: selectedKfOrgId } : {}), + ...(selectedKfAccountId ? { kfAccountId: selectedKfAccountId } : {}), }); setCreateIsLoading(false); setIsCreated(true); @@ -324,19 +324,19 @@ const CommunityCreate = (props: Props) => { onChange={onDescriptionChange} helperText={`${description.length}/280 characters`} /> - {kfOrgs.length > 1 && ( - + {kfAccounts.length > 1 && ( +
{ - setSelectedOrgId(e.target.value || null); + setSelectedAccountId(e.target.value || null); setSuccess(null); }} disabled={isTransferring} > - {orgs.map((org) => ( - ))} @@ -143,7 +146,7 @@ const TransferOwnership = (props: Props) => { intent="warning" text="Transfer" loading={isTransferring} - disabled={isCurrentOrg || !selectedOrgId} + disabled={isCurrentAccount || !selectedAccountId} onClick={handleTransfer} />
diff --git a/server/community/__tests__/api.test.ts b/server/community/__tests__/api.test.ts index bdc4fd942..8e70ef3e5 100644 --- a/server/community/__tests__/api.test.ts +++ b/server/community/__tests__/api.test.ts @@ -121,38 +121,38 @@ describe('/api/communities', () => { expect(newCommunity?.title).toEqual('Journal of Regular Users'); }); - it('creates a community with no kfOrgId when the user has no KF orgs', async () => { + it('creates a community with no kfAccountId when the user has no KF accounts', async () => { const { willNotCreateCommunity } = models; const agent = await login(willNotCreateCommunity); - const subdomain = 'no-orgs-' + uuid.v4(); + const subdomain = 'no-accounts-' + uuid.v4(); await agent .post('/api/communities') - .send({ subdomain, title: 'Journal of No Orgs' }) + .send({ subdomain, title: 'Journal of No Accounts' }) .expect(201); const newCommunity = await Community.findOne({ where: { subdomain } }); - expect(newCommunity?.kfOrgId).toBeNull(); + expect(newCommunity?.kfAccountId).toBeNull(); }); - it('keeps a picked kfOrgId only if the user belongs to that org', async () => { + it('keeps a picked kfAccountId only if the user belongs to that account', async () => { const { willNotCreateCommunity } = models; const agent = await login(willNotCreateCommunity); - fetchUserOrgs.mockResolvedValue([{ id: 'org-mine' }]); + fetchUserOrgs.mockResolvedValue([{ id: 'account-mine' }]); - const mine = 'org-mine-' + uuid.v4(); + const mine = 'account-mine-' + uuid.v4(); await agent .post('/api/communities') - .send({ subdomain: mine, title: 'Mine', kfOrgId: 'org-mine' }) + .send({ subdomain: mine, title: 'Mine', kfAccountId: 'account-mine' }) .expect(201); - expect((await Community.findOne({ where: { subdomain: mine } }))?.kfOrgId).toEqual( - 'org-mine', + expect((await Community.findOne({ where: { subdomain: mine } }))?.kfAccountId).toEqual( + 'account-mine', ); - const theirs = 'org-theirs-' + uuid.v4(); + const theirs = 'account-theirs-' + uuid.v4(); await agent .post('/api/communities') - .send({ subdomain: theirs, title: 'Theirs', kfOrgId: 'org-theirs' }) + .send({ subdomain: theirs, title: 'Theirs', kfAccountId: 'account-theirs' }) .expect(201); - expect((await Community.findOne({ where: { subdomain: theirs } }))?.kfOrgId).toBeNull(); + expect((await Community.findOne({ where: { subdomain: theirs } }))?.kfAccountId).toBeNull(); fetchUserOrgs.mockReset(); fetchUserOrgs.mockResolvedValue([]); diff --git a/server/community/api.ts b/server/community/api.ts index 648484fa0..eedc887e5 100644 --- a/server/community/api.ts +++ b/server/community/api.ts @@ -156,12 +156,12 @@ export const communityServer = s.router(contract.community, { const body = { ...req.body }; delete body.altcha; delete body._honeypot; - // kfOrgId is billing attribution, not required ownership. Keep a picked org - // only if the user belongs to it; otherwise leave it NULL for staff to set. - if (body.kfOrgId) { - const userOrgs = await fetchUserOrgs(req.user.id).catch(() => []); - if (!userOrgs.some((org) => org.id === body.kfOrgId)) { - delete body.kfOrgId; + // kfAccountId is billing attribution, not required ownership. Keep a picked + // account only if the user belongs to it; otherwise leave it NULL for staff to set. + if (body.kfAccountId) { + const userAccounts = await fetchUserOrgs(req.user.id).catch(() => []); + if (!userAccounts.some((account) => account.id === body.kfAccountId)) { + delete body.kfAccountId; } } try { diff --git a/server/community/model.ts b/server/community/model.ts index 047214402..0c6e7c61e 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -18,7 +18,6 @@ import { DefaultScope, ForeignKey, HasMany, - Index, Is, IsLowercase, Length, @@ -254,11 +253,13 @@ export class Community extends Model< /** * KF account this community is billed to. Billing attribution set by KF staff, * not ownership: NULL until someone assigns it (users who sign up through - * kf-console have no personal org to default to). + * kf-console have no personal account to default to). Was kfOrgId until + * 2026_10_05_renameKfOrgIdToKfAccountId. Its index (communities_kf_account_id_idx) + * is owned by the migrations, not declared here: sync() would otherwise try to + * create it on kfAccountId before the rename migration has run. */ - @Index @Column(DataType.TEXT) - declare kfOrgId: string | null; + declare kfAccountId: string | null; /** * CMS mode: the community is only visible to members; diff --git a/server/community/queries.ts b/server/community/queries.ts index edf4b0a4f..0ec78c0c0 100644 --- a/server/community/queries.ts +++ b/server/community/queries.ts @@ -105,7 +105,7 @@ export const createCommunity = async ( accentColorDark: inputValues.accentColorDark ?? '#000000', navigation: [{ type: 'page', id: homePageId }], hideCreatePubButton: true, - ...(inputValues.kfOrgId ? { kfOrgId: inputValues.kfOrgId } : {}), + ...(inputValues.kfAccountId ? { kfAccountId: inputValues.kfAccountId } : {}), }, { actorId: userData.id }, ); diff --git a/server/kf/__tests__/api.test.ts b/server/kf/__tests__/api.test.ts new file mode 100644 index 000000000..c196b1ce7 --- /dev/null +++ b/server/kf/__tests__/api.test.ts @@ -0,0 +1,90 @@ +import { vi } from 'vitest'; + +import { Community } from 'server/models'; +import { login, modelize, setup, teardown } from 'stubstub'; + +const { fetchUserOrgs } = vi.hoisted(() => { + // server/kf/api reads the key at import time; it is unset again in setup so that + // test logins take the legacy local path instead of calling kf-auth. + process.env.AUTH_INTERNAL_API_KEY = 'test-internal-key'; + return { fetchUserOrgs: vi.fn(async () => [] as { id: string }[]) }; +}); + +const models = modelize` + Community billedCommunity { + kfAccountId: "account-summary" + } + Community transferredCommunity { + Member { + permissions: "admin" + User admin {} + } + } +`; + +setup(beforeAll, async () => { + vi.mock('server/kf/oidc.server', async (importOriginal) => ({ + ...(await importOriginal()), + fetchUserOrgs, + })); + delete process.env.AUTH_INTERNAL_API_KEY; + await models.resolve(); +}); + +const bearer = 'Bearer test-internal-key'; + +describe('/api/kf/summary', () => { + it('lists communities billed to the kf_account_id', async () => { + const agent = await login(); + const { body } = await agent + .get('/api/kf/summary?kf_account_id=account-summary') + .set('Authorization', bearer) + .expect(200); + expect(body.accounts.map((a) => a.id)).toEqual([models.billedCommunity.id]); + }); + + it('still accepts the old kf_org_id parameter', async () => { + const agent = await login(); + const { body } = await agent + .get('/api/kf/summary?kf_org_id=account-summary') + .set('Authorization', bearer) + .expect(200); + expect(body.accounts.map((a) => a.id)).toEqual([models.billedCommunity.id]); + }); + + it('requires an account id', async () => { + const agent = await login(); + await agent.get('/api/kf/summary').set('Authorization', bearer).expect(400); + }); +}); + +describe('/api/kf/transfer-community', () => { + it('moves a community to an account the user belongs to, under either field name', async () => { + const { admin, transferredCommunity } = models; + const agent = await login(admin); + fetchUserOrgs.mockResolvedValue([{ id: 'account-a' }, { id: 'account-b' }]); + + await agent + .post('/api/kf/transfer-community') + .send({ communityId: transferredCommunity.id, kfAccountId: 'account-a' }) + .expect(200); + expect((await Community.findByPk(transferredCommunity.id))?.kfAccountId).toEqual( + 'account-a', + ); + + await agent + .post('/api/kf/transfer-community') + .send({ communityId: transferredCommunity.id, kfOrgId: 'account-b' }) + .expect(200); + expect((await Community.findByPk(transferredCommunity.id))?.kfAccountId).toEqual( + 'account-b', + ); + + await agent + .post('/api/kf/transfer-community') + .send({ communityId: transferredCommunity.id, kfAccountId: 'account-other' }) + .expect(403); + }); +}); + +teardown(afterAll); diff --git a/server/kf/api.ts b/server/kf/api.ts index 539db97ae..35cb2e00d 100644 --- a/server/kf/api.ts +++ b/server/kf/api.ts @@ -10,7 +10,7 @@ docker service logs auth_auth --tail 50 2>&1 | grep -i "error\|invalid\|authoriz * Internal service-to-service endpoints (AUTH_INTERNAL_API_KEY): * POST /api/kf/webhooks — receive webhook events from KF Auth (profile, bans, sessions) * GET /api/kf/branding — return community branding for login page - * GET /api/kf/summary — return community list for a KF org + * GET /api/kf/summary — return community list for a KF account (?kf_account_id=) * GET /api/kf/billing/usage — return usage stats for billing (placeholder) * * Session-authenticated endpoints: @@ -398,15 +398,25 @@ router.get('/api/kf/branding', requireInternalKey, async (req: any, res: any) => // ─── Summary API (for KF Account roster / Hub) ────────────────────── +/** + * The KF account id from an internal-API query string. kf_org_id is the old name, + * still accepted because kf-console (src/api/org-assets.ts) and Hubs call + * /api/kf/summary?kf_org_id=; drop the fallback once they send kf_account_id. + */ +const getKfAccountIdParam = (query: Record): string | null => { + const value = query.kf_account_id ?? query.kf_org_id; + return typeof value === 'string' && value ? value : null; +}; + router.get('/api/kf/summary', requireInternalKey, async (req: any, res: any) => { try { - const { kf_org_id } = req.query; - if (!kf_org_id) { - return res.status(400).json({ error: 'kf_org_id is required' }); + const kfAccountId = getKfAccountIdParam(req.query); + if (!kfAccountId) { + return res.status(400).json({ error: 'kf_account_id is required' }); } const communities = await Community.findAll({ - where: { kfOrgId: kf_org_id }, + where: { kfAccountId }, attributes: ['id', 'title', 'subdomain', 'domain', 'avatar'], }); @@ -446,18 +456,20 @@ router.get('/api/kf/summary', requireInternalKey, async (req: any, res: any) => router.get('/api/kf/billing/usage', requireInternalKey, async (req: any, res: any) => { try { - const { kf_org_id } = req.query; - if (!kf_org_id) { - return res.status(400).json({ error: 'kf_org_id is required' }); + const kfAccountId = getKfAccountIdParam(req.query); + if (!kfAccountId) { + return res.status(400).json({ error: 'kf_account_id is required' }); } const communityCount = await Community.count({ - where: { kfOrgId: kf_org_id }, + where: { kfAccountId }, }); - // Placeholder — just return community count for now + // Placeholder — just return community count for now. kf_org_id is echoed + // under its old name too until callers read kf_account_id. return res.json({ - kf_org_id, + kf_account_id: kfAccountId, + kf_org_id: kfAccountId, line_items: [{ key: 'communities', quantity: communityCount }], }); } catch (err) { @@ -488,9 +500,11 @@ router.post('/api/kf/transfer-community', async (req: any, res: any) => { return res.status(401).json({ error: 'Not authenticated' }); } - const { communityId, kfOrgId } = req.body; - if (!communityId || !kfOrgId) { - return res.status(400).json({ error: 'communityId and kfOrgId are required' }); + const { communityId } = req.body; + // kfOrgId is the old body field name, accepted until every caller sends kfAccountId. + const kfAccountId = req.body.kfAccountId ?? req.body.kfOrgId; + if (!communityId || !kfAccountId) { + return res.status(400).json({ error: 'communityId and kfAccountId are required' }); } try { @@ -501,23 +515,23 @@ router.post('/api/kf/transfer-community', async (req: any, res: any) => { } try { - // Verify the user belongs to the target org - const userOrgs = await fetchUserOrgs(req.user.id); - const targetOrg = userOrgs.find((o) => o.id === kfOrgId); - if (!targetOrg) { - return res - .status(403) - .json({ error: 'You are not a member of the target organization' }); + // Verify the user belongs to the target account + const userAccounts = await fetchUserOrgs(req.user.id); + const targetAccount = userAccounts.find((a) => a.id === kfAccountId); + if (!targetAccount) { + return res.status(403).json({ error: 'You are not a member of the target account' }); } - // Update the community's kfOrgId - const [updatedCount] = await Community.update({ kfOrgId }, { where: { id: communityId } }); + const [updatedCount] = await Community.update( + { kfAccountId }, + { where: { id: communityId } }, + ); if (updatedCount === 0) { return res.status(404).json({ error: 'Community not found' }); } - return res.json({ success: true, kfOrgId }); + return res.json({ success: true, kfAccountId }); } catch (err) { console.error('Transfer community error:', err); return res.status(500).json({ error: 'Internal error' }); diff --git a/server/routes/communityCreate.tsx b/server/routes/communityCreate.tsx index cf2d9ba8f..20ff44277 100644 --- a/server/routes/communityCreate.tsx +++ b/server/routes/communityCreate.tsx @@ -30,7 +30,7 @@ router.get('/community/create', (req, res, next) => { hubSlug ? getHubBySlug(hubSlug) : Promise.resolve(null), req.user?.id ? fetchUserOrgs(req.user.id) : Promise.resolve([]), ]) - .then(async ([initialData, hubData, kfOrgs]) => { + .then(async ([initialData, hubData, kfAccounts]) => { const templates = hubData ? await getActiveTemplatesForHub(hubData.id) : []; // Fetch hub communities for the clone-from-community picker @@ -83,7 +83,7 @@ router.get('/community/create', (req, res, next) => { { diff --git a/tools/migrations/2026_10_05_renameKfOrgIdToKfAccountId.js b/tools/migrations/2026_10_05_renameKfOrgIdToKfAccountId.js new file mode 100644 index 000000000..ed8bd37d2 --- /dev/null +++ b/tools/migrations/2026_10_05_renameKfOrgIdToKfAccountId.js @@ -0,0 +1,46 @@ +/** + * Rename Communities.kfOrgId to kfAccountId, and its indexes to match. In the KF + * console these are accounts (billing entities); "org" is the old name. + * + * Run AFTER 2026_10_05_makeKfOrgIdNullable, which still operates on kfOrgId: + * pnpm tools migrate --name 2026_10_05_makeKfOrgIdNullable + * pnpm tools migrate --name 2026_10_05_renameKfOrgIdToKfAccountId + * To roll back, run them down in reverse order. + * + * Two indexes may exist on the column: communities_kf_org_id_idx from + * 2026_05_15_addKfOrgIdToCommunities, and communities_kf_org_id, which + * sequelize.sync() added from the model's old @Index. The model no longer + * declares the index (sync would try to create it on kfAccountId before this + * migration runs), so both are renamed here, whichever exist. + */ + +const indexRenames = [ + ['communities_kf_org_id_idx', 'communities_kf_account_id_idx'], + ['communities_kf_org_id', 'communities_kf_account_id'], +]; + +export const up = async ({ sequelize }) => { + await sequelize.transaction(async (transaction) => { + await sequelize.queryInterface.renameColumn('Communities', 'kfOrgId', 'kfAccountId', { + transaction, + }); + for (const [from, to] of indexRenames) { + await sequelize.query(`ALTER INDEX IF EXISTS "${from}" RENAME TO "${to}"`, { + transaction, + }); + } + }); +}; + +export const down = async ({ sequelize }) => { + await sequelize.transaction(async (transaction) => { + await sequelize.queryInterface.renameColumn('Communities', 'kfAccountId', 'kfOrgId', { + transaction, + }); + for (const [from, to] of indexRenames) { + await sequelize.query(`ALTER INDEX IF EXISTS "${to}" RENAME TO "${from}"`, { + transaction, + }); + } + }); +}; diff --git a/utils/api/schemas/community.ts b/utils/api/schemas/community.ts index e94b57576..1c5946097 100644 --- a/utils/api/schemas/community.ts +++ b/utils/api/schemas/community.ts @@ -100,7 +100,7 @@ export const communitySchema = baseSchema.extend({ spamTagId: z.string().uuid().nullable(), scopeSummaryId: z.string().uuid().nullable(), templateId: z.string().uuid().nullable(), - kfOrgId: z.string().nullable(), + kfAccountId: z.string().nullable(), cmsMode: z.boolean().default(false), canonicalBaseUrl: z.string().url().nullable(), canonicalPubUrlTemplate: z @@ -142,7 +142,7 @@ export const communityCreateSchema = communitySchema altcha: z.string().optional(), _honeypot: z.string().optional(), templateId: z.string().uuid().nullish(), - kfOrgId: z.string().nullish(), + kfAccountId: z.string().nullish(), }); export const communityUpdateSchema = communitySchema