Skip to content
Draft
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { WebSocketMessageValue } from '$features/websockets/models';
import type { BillingPlan, ChangePlanRequest, ChangePlanResult } from '$lib/generated/api';
import type { BillingPlan, ChangePlanRequest, ChangePlanResult, StringValueFromBody } from '$lib/generated/api';
import type { QueryClient } from '@tanstack/svelte-query';

import { accessToken } from '$features/auth/index.svelte';
Expand All @@ -26,6 +26,7 @@ export async function invalidateOrganizationQueries(queryClient: QueryClient, me
export const queryKeys = {
adminSearch: (params: GetAdminSearchOrganizationsParams) => [...queryKeys.list(params.mode), 'admin', { ...params }] as const,
changePlan: (id: string | undefined) => [...queryKeys.type, id, 'change-plan'] as const,
data: (id: string | undefined) => [...queryKeys.type, id, 'data'] as const,
deleteOrganization: (ids: string[] | undefined) => [...queryKeys.ids(ids), 'delete'] as const,
icon: (id: string | undefined) => [...queryKeys.id(id, undefined), 'icon'] as const,
id: (id: string | undefined, mode: 'stats' | undefined) => (mode ? ([...queryKeys.type, id, { mode }] as const) : ([...queryKeys.type, id] as const)),
Expand Down Expand Up @@ -54,6 +55,11 @@ export interface ChangePlanMutationRequest {
};
}

export interface DeleteOrganizationDataParams {
key: string;
organizationId: string;
}

export interface DeleteOrganizationRequest {
route: {
ids: string[];
Expand Down Expand Up @@ -142,6 +148,12 @@ export interface PatchOrganizationRequest {
};
}

export interface PostOrganizationDataParams {
key: string;
organizationId: string;
value: string;
}

export interface PostSetBonusOrganizationParams {
bonusEvents: number;
expires?: Date;
Expand Down Expand Up @@ -225,6 +237,35 @@ export function deleteOrganization(request: DeleteOrganizationRequest) {
}));
}

export function deleteOrganizationData() {
const queryClient = useQueryClient();

return createMutation<boolean, ProblemDetails, DeleteOrganizationDataParams>(() => ({
enabled: () => !!accessToken.current,
mutationFn: async ({ key, organizationId }: DeleteOrganizationDataParams) => {
const client = useFetchClient();
const response = await client.delete(`organizations/${organizationId}/data/${encodeURIComponent(key)}`);
return response.ok;
},
mutationKey: queryKeys.data(undefined),
onError: (_, { organizationId }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.id(organizationId, undefined) });
},
onSuccess: (_, { key, organizationId }) => {
updateOrganizationQueryData(queryClient, organizationId, (organization) => {
if (!organization.data) {
return organization;
}

const data = { ...organization.data };
delete data[key];

return { ...organization, data };
});
}
}));
}

export function deleteOrganizationIcon(request: OrganizationIconRequest) {
const queryClient = useQueryClient();

Expand Down Expand Up @@ -462,6 +503,32 @@ export function postOrganization() {
}));
}

export function postOrganizationData() {
const queryClient = useQueryClient();

return createMutation<boolean, ProblemDetails, PostOrganizationDataParams>(() => ({
enabled: () => !!accessToken.current,
mutationFn: async ({ key, organizationId, value }: PostOrganizationDataParams) => {
const client = useFetchClient();
const response = await client.post(`organizations/${organizationId}/data/${encodeURIComponent(key)}`, <StringValueFromBody>{ value });
return response.ok;
},
mutationKey: queryKeys.data(undefined),
onError: (_, { organizationId }) => {
queryClient.invalidateQueries({ queryKey: queryKeys.id(organizationId, undefined) });
},
onSuccess: (_, { key, organizationId, value }) => {
updateOrganizationQueryData(queryClient, organizationId, (organization) => ({
...organization,
data: {
...(organization.data ?? {}),
[key]: value
}
}));
}
}));
}

export function postSetBonusOrganization() {
const queryClient = useQueryClient();

Expand Down Expand Up @@ -579,17 +646,23 @@ export function uploadOrganizationIcon(request: OrganizationIconRequest) {
}

function updateOrganizationCache(queryClient: QueryClient, id: string | undefined, organization: ViewOrganization) {
queryClient.setQueryData(queryKeys.id(id, 'stats'), organization);
queryClient.setQueryData(queryKeys.id(id, undefined), organization);
updateOrganizationQueryData(queryClient, id, () => organization);
}

function updateOrganizationQueryData(queryClient: QueryClient, id: string | undefined, updater: (organization: ViewOrganization) => ViewOrganization) {
for (const mode of [undefined, 'stats'] as const) {
queryClient.setQueryData<undefined | ViewOrganization>(queryKeys.id(id, mode), (organization) => (organization ? updater(organization) : organization));
}

queryClient.setQueriesData<FetchClientResponse<ViewOrganization[]> | undefined>({ queryKey: queryKeys.type }, (response) => {
if (!Array.isArray(response?.data) || !response.data.some((existingOrganization) => existingOrganization.id === organization.id)) {
if (!Array.isArray(response?.data) || !response.data.some((organization) => organization.id === id)) {
return response;
}

return {
...response,
data: response.data.map((existingOrganization) => {
return existingOrganization.id === organization.id ? organization : existingOrganization;
data: response.data.map((organization) => {
return organization.id === id ? updater(organization) : organization;
})
};
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';

import {
getOrganizationBillingInformation,
getOrganizationBillingInformationChanges,
normalizeOrganizationBillingInformationValue,
organizationBillingInformationDataKeys
} from './billing-information';

describe('getOrganizationBillingInformation', () => {
it('returns billing information from known organization data keys', () => {
// Arrange
const organization = {
data: {
[organizationBillingInformationDataKeys.address]: '123 Main Street',
[organizationBillingInformationDataKeys.name]: 'Acme, Inc.',
[organizationBillingInformationDataKeys.vatId]: 'DE123456789',
[organizationBillingInformationDataKeys.vatNumber]: '123456789'
}
};

// Act
const billingInformation = getOrganizationBillingInformation(organization);

// Assert
expect(billingInformation).toEqual({
address: '123 Main Street',
name: 'Acme, Inc.',
vatId: 'DE123456789',
vatNumber: '123456789'
});
});

it('defaults missing or non-string billing information values to empty strings', () => {
// Arrange
const organization = {
data: {
[organizationBillingInformationDataKeys.address]: ['invalid'],
[organizationBillingInformationDataKeys.name]: null,
[organizationBillingInformationDataKeys.vatId]: undefined,
[organizationBillingInformationDataKeys.vatNumber]: 42
}
};

// Act
const billingInformation = getOrganizationBillingInformation(organization);

// Assert
expect(billingInformation).toEqual({
address: '',
name: '',
vatId: '',
vatNumber: ''
});
});
});

describe('normalizeOrganizationBillingInformationValue', () => {
it('trims non-empty values and removes blank values', () => {
// Arrange
const value = ' DE123456789 ';
const blankValue = ' ';

// Act
const normalizedValue = normalizeOrganizationBillingInformationValue(value);
const normalizedBlankValue = normalizeOrganizationBillingInformationValue(blankValue);

// Assert
expect(normalizedValue).toBe('DE123456789');
expect(normalizedBlankValue).toBeNull();
});
});

describe('getOrganizationBillingInformationChanges', () => {
it('returns only normalized values that changed', () => {
const current = {
address: '123 Main Street',
name: 'Acme, Inc.',
vatId: 'DE123456789',
vatNumber: ''
};

const changes = getOrganizationBillingInformationChanges(current, {
...current,
name: ' Acme, Inc. ',
vatId: ' ',
vatNumber: ' 123456789 '
});

expect(changes).toEqual([
{ key: organizationBillingInformationDataKeys.vatId, value: null },
{ key: organizationBillingInformationDataKeys.vatNumber, value: '123456789' }
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import type { ViewOrganization } from './models';

export const organizationBillingInformationDataKeys = {
address: 'billing_address',
name: 'billing_name',
vatId: 'billing_vat_id',
vatNumber: 'billing_vat_number'
} as const;

export interface OrganizationBillingInformation {
address: string;
name: string;
vatId: string;
vatNumber: string;
}

export interface OrganizationBillingInformationChange {
key: (typeof organizationBillingInformationDataKeys)[keyof typeof organizationBillingInformationDataKeys];
value: null | string;
}

export function getOrganizationBillingInformation(organization?: null | Pick<ViewOrganization, 'data'>): OrganizationBillingInformation {
const data = organization?.data;

return {
address: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.address]),
name: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.name]),
vatId: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.vatId]),
vatNumber: getOrganizationBillingInformationValue(data?.[organizationBillingInformationDataKeys.vatNumber])
};
}

export function getOrganizationBillingInformationChanges(
current: OrganizationBillingInformation,
next: OrganizationBillingInformation
): OrganizationBillingInformationChange[] {
return (Object.keys(organizationBillingInformationDataKeys) as (keyof OrganizationBillingInformation)[]).flatMap((field) => {
const currentValue = normalizeOrganizationBillingInformationValue(current[field]);
const nextValue = normalizeOrganizationBillingInformationValue(next[field]);

return currentValue === nextValue ? [] : [{ key: organizationBillingInformationDataKeys[field], value: nextValue }];
});
}

export function normalizeOrganizationBillingInformationValue(value: string): null | string {
const trimmedValue = value.trim();
return trimmedValue || null;
}

function getOrganizationBillingInformationValue(value: unknown): string {
return typeof value === 'string' ? value : '';
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ import { SuspensionCode } from './models';

export { type NewOrganizationFormData, NewOrganizationSchema } from '$generated/schemas';

export const OrganizationBillingInformationSchema = object({
address: string(),
name: string(),
vatId: string(),
vatNumber: string()
});
export type OrganizationBillingInformationFormData = Infer<typeof OrganizationBillingInformationSchema>;

export const SetBonusOrganizationSchema = object({
bonusEvents: number().int('Bonus events must be a whole number'),
expires: date().optional()
Expand Down
Loading
Loading