diff --git a/.changeset/batch-update-businesses.md b/.changeset/batch-update-businesses.md new file mode 100644 index 000000000..237ddc1b3 --- /dev/null +++ b/.changeset/batch-update-businesses.md @@ -0,0 +1,31 @@ +--- +'@accounter/server': minor +'@accounter/client': minor +--- + +Batch-update selected businesses from the businesses table. + +Client: the businesses table's selection-column header gains a bulk-actions menu, mirroring the +charges table's. "Update fields" opens a dialog for locality (country/city/zip), sort code, default +tax category, IRS code, suggestion description and the boolean flags (is active, receipt enough, +docs optional, VAT optional, exempt dealer) — only the fields filled in are applied, flags are +tri-state so "No change" leaves each business's own value alone. Sort code and tax category are now +pickers rather than raw number/UUID inputs. "Change tags" opens an Add/Remove dialog for suggestion +tags. The previous footer "Batch update" button moves into this menu. + +Server: new `batchUpdateBusinessesTags(businessIds: [UUID!]!, addTagIds: [UUID!], removeTagIds: +[UUID!])` mutation, the businesses counterpart of `batchUpdateChargesTags`. It adds and/or removes +the given suggestion tags on every listed business while leaving each business's other tags +untouched; an id passed in both lists ends up added. + +`batchUpdateBusinesses` no longer updates one business at a time. Every touched table now gets a +single statement for the whole selection (`businesses`, `financial_entities`, and an upsert into +`business_tax_category_match`) plus one read-back, so the query count is fixed regardless of how +many businesses are selected. Business suggestion tags live inside the `suggestion_data` JSON blob +rather than a join table, so `batchUpdateBusinessesTags` performs its set arithmetic in SQL and +costs a single statement as well. + +**Breaking:** `BatchUpdateBusinessInput.suggestions: SuggestionsInput` is replaced by +`suggestionDescription: String`. The removed input accepted `phrases`, `emails` and `emailListener`, +which are per-business by nature and meaningless when applied wholesale, and its `tags` replaced a +business's entire tag set — use `batchUpdateBusinessesTags` for additive/subtractive tag changes. diff --git a/packages/client/src/components/businesses/batch-tags-dialog.tsx b/packages/client/src/components/businesses/batch-tags-dialog.tsx new file mode 100644 index 000000000..609a3869a --- /dev/null +++ b/packages/client/src/components/businesses/batch-tags-dialog.tsx @@ -0,0 +1,128 @@ +import { useState, type ReactElement } from 'react'; +import { useBatchUpdateBusinessesTags } from '../../hooks/use-batch-update-businesses-tags.js'; +import { useGetTags } from '../../hooks/use-get-tags.js'; +import { MultiSelect } from '../common/index.js'; +import { Button } from '../ui/button.js'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '../ui/dialog.js'; +import { Label } from '../ui/label.js'; +import { Tabs, TabsList, TabsTrigger } from '../ui/tabs.js'; + +type Mode = 'add' | 'remove'; + +interface Props { + /** Business ids the tag change is applied to (the table's selected rows). */ + businessIds: string[]; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called after a successful apply, so the caller can refresh the table. */ + onDone: () => void; +} + +/** + * Batch add or remove suggestion tags across the selected businesses. "Add" mode adds the chosen + * tags to every selected business (keeping their other tags); "Remove" mode removes them. Mirrors + * {@link ChargesBatchTagsDialog}, backed by the `batchUpdateBusinessesTags` mutation. + */ +export function BatchUpdateBusinessesTagsDialog({ + businessIds, + open, + onOpenChange, + onDone, +}: Props): ReactElement { + const { selectableTags, fetching: fetchingTags } = useGetTags(); + const { fetching, batchUpdateBusinessesTags } = useBatchUpdateBusinessesTags(); + const [mode, setMode] = useState('add'); + const [selectedTags, setSelectedTags] = useState([]); + // Bumped to force-remount the (internally stateful) MultiSelect, clearing its selection. + const [resetNonce, setResetNonce] = useState(0); + + const count = businessIds.length; + + const reset = (): void => { + setSelectedTags([]); + setResetNonce(nonce => nonce + 1); + }; + + const onModeChange = (value: string): void => { + setMode(value as Mode); + reset(); + }; + + // Single close path so both the Dialog's own dismissals (overlay/esc/X) and the Cancel button + // clear the selection before closing. + const handleOpenChange = (nextOpen: boolean): void => { + if (!nextOpen) { + reset(); + } + onOpenChange(nextOpen); + }; + + const onApply = async (): Promise => { + if (selectedTags.length === 0 || count === 0) { + return; + } + const res = await batchUpdateBusinessesTags({ + businessIds, + ...(mode === 'add' ? { addTagIds: selectedTags } : { removeTagIds: selectedTags }), + }); + if (res) { + reset(); + onOpenChange(false); + onDone(); + } + }; + + return ( + + event.stopPropagation()}> + + + Change tags for {count} business{count === 1 ? '' : 'es'} + + + Add the selected tags to, or remove them from, every selected business. Other tags are + left unchanged. + + +
+ + + Add tags + Remove tags + + +
+ + +
+
+ + + + +
+
+ ); +} diff --git a/packages/client/src/components/businesses/batch-update-dialog.tsx b/packages/client/src/components/businesses/batch-update-dialog.tsx index 047f84dcc..dc854db74 100644 --- a/packages/client/src/components/businesses/batch-update-dialog.tsx +++ b/packages/client/src/components/businesses/batch-update-dialog.tsx @@ -1,10 +1,11 @@ -import { useState, type ReactElement } from 'react'; +import { useContext, useState, type ReactElement } from 'react'; import type { BatchUpdateBusinessInput } from '../../gql/graphql.js'; import { useBatchUpdateBusinesses } from '../../hooks/use-batch-update-businesses.js'; import { useAllCountries } from '../../hooks/use-get-countries.js'; -import { useGetTags } from '../../hooks/use-get-tags.js'; +import { useGetTaxCategories } from '../../hooks/use-get-tax-categories.js'; import { cn } from '../../lib/utils.js'; -import { ComboBox, MultiSelect } from '../common/index.js'; +import { UserContext } from '../../providers/user-provider.js'; +import { ComboBox, SortCodeSelect } from '../common/index.js'; import { Button } from '../ui/button.js'; import { Dialog, @@ -13,7 +14,6 @@ import { DialogFooter, DialogHeader, DialogTitle, - DialogTrigger, } from '../ui/dialog.js'; import { Input } from '../ui/input.js'; import { Label } from '../ui/label.js'; @@ -21,6 +21,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '. interface BatchUpdateBusinessesDialogProps { businessIds: string[]; + open: boolean; + onOpenChange: (open: boolean) => void; + /** Called after a successful apply, so the caller can refresh the table. */ onDone: () => void; } @@ -38,8 +41,7 @@ type FormState = { sortCode: string; irsCode: string; taxCategory: string; - description: string; - tags: string[]; + suggestionDescription: string; } & Record; const EMPTY_FORM: FormState = { @@ -49,8 +51,7 @@ const EMPTY_FORM: FormState = { sortCode: '', irsCode: '', taxCategory: '', - description: '', - tags: [], + suggestionDescription: '', isActive: 'unset', isReceiptEnough: 'unset', isDocumentsOptional: 'unset', @@ -58,21 +59,18 @@ const EMPTY_FORM: FormState = { exemptDealer: 'unset', }; -// `country` is rendered separately as a searchable ComboBox; the rest are simple inputs. +// Free-text fields. Country, sort code and tax category are rendered separately as pickers. // `fullWidth` fields span both columns on wider screens. const FIELDS: { key: keyof FormState; label: string; - placeholder?: string; numeric?: boolean; fullWidth?: boolean; }[] = [ { key: 'city', label: 'City' }, { key: 'zipCode', label: 'Zip code' }, - { key: 'sortCode', label: 'Sort code', numeric: true }, { key: 'irsCode', label: 'IRS code', numeric: true }, - { key: 'taxCategory', label: 'Tax category (UUID)', fullWidth: true }, - { key: 'description', label: 'Suggestion description', fullWidth: true }, + { key: 'suggestionDescription', label: 'Suggestion description', fullWidth: true }, ]; // boolean flags rendered as tri-state selects. @@ -108,16 +106,8 @@ function buildFields(form: FormState): BatchUpdateBusinessInput { if (form.taxCategory.trim()) { fields.taxCategory = form.taxCategory.trim(); } - - const suggestions: NonNullable = {}; - if (form.description.trim()) { - suggestions.description = form.description.trim(); - } - if (form.tags.length > 0) { - suggestions.tags = form.tags.map(id => ({ id })); - } - if (Object.keys(suggestions).length > 0) { - fields.suggestions = suggestions; + if (form.suggestionDescription.trim()) { + fields.suggestionDescription = form.suggestionDescription.trim(); } for (const { key } of FLAG_FIELDS) { @@ -129,46 +119,63 @@ function buildFields(form: FormState): BatchUpdateBusinessInput { return fields; } +/** + * Batch-edit the shared fields of the selected businesses: locality (country/city/zip), sort code, + * default tax category, IRS code, the suggestion description, and the boolean flags. Only the + * fields the user actually fills in are sent, so everything else keeps each business's own value. + * Tags are handled separately by {@link BatchUpdateBusinessesTagsDialog}, which needs add/remove + * semantics rather than a single shared value. + */ export function BatchUpdateBusinessesDialog({ businessIds, + open, + onOpenChange, onDone, }: BatchUpdateBusinessesDialogProps): ReactElement { - const [open, setOpen] = useState(false); const [form, setForm] = useState(EMPTY_FORM); + const { userContext } = useContext(UserContext); const { fetching, batchUpdateBusinesses } = useBatchUpdateBusinesses(); const { countries, fetching: fetchingCountries } = useAllCountries(); - const { selectableTags, fetching: fetchingTags } = useGetTags(); + const { selectableTaxCategories, fetching: fetchingTaxCategories } = useGetTaxCategories(); const fields = buildFields(form); const isFormEmpty = Object.keys(fields).length === 0; - // sortCode/irsCode map to GraphQL Int, so only whole non-negative integers are valid — reject - // decimals and scientific notation that Number() would otherwise coerce. + // irsCode maps to GraphQL Int, so only whole non-negative integers are valid — reject decimals + // and scientific notation that Number() would otherwise coerce. sortCode comes from a picker. const hasInvalidNumericFields = - (form.sortCode.trim() !== '' && !INTEGER_PATTERN.test(form.sortCode.trim())) || - (form.irsCode.trim() !== '' && !INTEGER_PATTERN.test(form.irsCode.trim())); + form.irsCode.trim() !== '' && !INTEGER_PATTERN.test(form.irsCode.trim()); + + // Single close path so both the Dialog's own dismissals (overlay/esc/X) and the Cancel button + // clear the form before closing. + const handleOpenChange = (nextOpen: boolean): void => { + if (!nextOpen) { + setForm(EMPTY_FORM); + } + onOpenChange(nextOpen); + }; const onSubmit = async (): Promise => { - if (Object.keys(fields).length === 0) { + if (isFormEmpty || businessIds.length === 0) { return; } const updated = await batchUpdateBusinesses({ businessIds, fields }); if (updated) { setForm(EMPTY_FORM); - setOpen(false); + onOpenChange(false); onDone(); } }; return ( - - - - - + + event.stopPropagation()} + > - Batch update {businessIds.length} businesses + + Batch update {businessIds.length} business{businessIds.length === 1 ? '' : 'es'} + Only the fields you fill in are applied to every selected business. @@ -184,6 +191,27 @@ export function BatchUpdateBusinessesDialog({ placeholder="Select country" /> +
+ + + setForm(prev => ({ ...prev, sortCode: value == null ? '' : value.toString() })) + } + placeholder="Select sort code" + /> +
+
+ + setForm(prev => ({ ...prev, taxCategory: value ?? '' }))} + disabled={fetchingTaxCategories} + placeholder="Select tax category" + /> +
{FIELDS.map(field => (
@@ -191,22 +219,10 @@ export function BatchUpdateBusinessesDialog({ id={`batch-${field.key}`} type={field.numeric ? 'number' : 'text'} value={form[field.key] as string} - placeholder={field.placeholder} onChange={event => setForm(prev => ({ ...prev, [field.key]: event.target.value }))} />
))} -
- - setForm(prev => ({ ...prev, tags: value }))} - defaultValue={form.tags} - placeholder="Select tags" - variant="default" - disabled={fetchingTags} - /> -
{FLAG_FIELDS.map(flag => (
@@ -229,7 +245,7 @@ export function BatchUpdateBusinessesDialog({ ))}
- + + + setUpdateOpen(true)}> + + Update fields + + setTagsOpen(true)}> + + Change tags + + + + + + + ); +} diff --git a/packages/client/src/components/businesses/columns.tsx b/packages/client/src/components/businesses/columns.tsx index 48798617a..d5767b340 100644 --- a/packages/client/src/components/businesses/columns.tsx +++ b/packages/client/src/components/businesses/columns.tsx @@ -14,6 +14,7 @@ import { Badge } from '../ui/badge.js'; import { Checkbox } from '../ui/checkbox.js'; import { BusinessRowActions } from './business-row-actions.js'; import { formatLocality, type BusinessTableMeta, type BusinessTableRow } from './business-rows.js'; +import { BusinessesBatchActionsMenu } from './businesses-batch-actions-menu.js'; function formatDate(value: Date | null): string { // A failed `new Date(...)` parse yields an Invalid Date (truthy), which makes date-fns `format` @@ -42,13 +43,17 @@ export const columns: ColumnDef[] = [ { id: 'select', header: ({ table }) => ( - table.toggleAllPageRowsSelected(!!value)} - aria-label="Select all" - /> +
+ table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + /> + +
), cell: ({ row }) => ( { usageLoading={usageEnabled && usageFetching} /> setRowSelection({})} /> - { - refetch(); - setRowSelection({}); - }} - /> , ); }, [setFiltersContext, selectedIds, refetch, filters, setFilters, usageEnabled, usageFetching]); diff --git a/packages/client/src/hooks/use-batch-update-businesses-tags.ts b/packages/client/src/hooks/use-batch-update-businesses-tags.ts new file mode 100644 index 000000000..234bee874 --- /dev/null +++ b/packages/client/src/hooks/use-batch-update-businesses-tags.ts @@ -0,0 +1,69 @@ +import { useCallback } from 'react'; +import { toast } from 'sonner'; +import { useMutation } from 'urql'; +import { + BatchUpdateBusinessesTagsDocument, + type BatchUpdateBusinessesTagsMutationVariables, +} from '../gql/graphql.js'; +import { handleCommonErrors } from '../helpers/error-handling.js'; + +// eslint-disable-next-line @typescript-eslint/no-unused-expressions -- used by codegen +/* GraphQL */ ` + mutation BatchUpdateBusinessesTags( + $businessIds: [UUID!]! + $addTagIds: [UUID!] + $removeTagIds: [UUID!] + ) { + batchUpdateBusinessesTags( + businessIds: $businessIds + addTagIds: $addTagIds + removeTagIds: $removeTagIds + ) { + id + } + } +`; + +type UseBatchUpdateBusinessesTags = { + fetching: boolean; + batchUpdateBusinessesTags: ( + variables: BatchUpdateBusinessesTagsMutationVariables, + ) => Promise; +}; + +// Short, stable toast id — a batch action, so don't build it from every selected UUID. +const NOTIFICATION_ID = 'batchUpdateBusinessesTags'; + +export const useBatchUpdateBusinessesTags = (): UseBatchUpdateBusinessesTags => { + const [{ fetching }, mutate] = useMutation(BatchUpdateBusinessesTagsDocument); + const batchUpdateBusinessesTags = useCallback( + async (variables: BatchUpdateBusinessesTagsMutationVariables) => { + const count = variables.businessIds.length; + const message = `Error updating tags for ${count} business${count === 1 ? '' : 'es'}`; + toast.loading('Updating tags', { id: NOTIFICATION_ID }); + try { + const res = await mutate(variables); + const data = handleCommonErrors(res, message, NOTIFICATION_ID); + if (data) { + toast.success('Success', { + id: NOTIFICATION_ID, + description: `Tags updated for ${count} business${count === 1 ? '' : 'es'}`, + }); + return true; + } + } catch (e) { + console.error(`${message}: ${e}`); + toast.error('Error', { + id: NOTIFICATION_ID, + description: message, + duration: 100_000, + closeButton: true, + }); + } + return false; + }, + [mutate], + ); + + return { fetching, batchUpdateBusinessesTags }; +}; diff --git a/packages/server/src/modules/financial-entities/helpers/batch-update-businesses.helper.ts b/packages/server/src/modules/financial-entities/helpers/batch-update-businesses.helper.ts new file mode 100644 index 000000000..9f5168aae --- /dev/null +++ b/packages/server/src/modules/financial-entities/helpers/batch-update-businesses.helper.ts @@ -0,0 +1,181 @@ +import { GraphQLError } from 'graphql'; +import { Injector } from 'graphql-modules'; +import type { BatchUpdateBusinessInput } from '../../../__generated__/types.js'; +import { updateGreenInvoiceClient } from '../../green-invoice/helpers/green-invoice-clients.helper.js'; +import { BusinessesProvider } from '../providers/businesses.provider.js'; +import { ClientsProvider } from '../providers/clients.provider.js'; +import { FinancialEntitiesProvider } from '../providers/financial-entities.provider.js'; +import { TaxCategoriesProvider } from '../providers/tax-categories.provider.js'; +import type { IGetBusinessesByIdsResult } from '../types.js'; + +/** + * Reload the given businesses after a batch write and return them in the requested order. + * + * The batch statements each `RETURNING *` only their own table, while a `Business` is the join of + * `businesses` and `financial_entities` — so read them back through the (batching) id loader, which + * the providers just invalidated. One query for the whole selection. + */ +async function reloadBusinesses( + injector: Injector, + businessIds: readonly string[], +): Promise { + const businesses = await injector + .get(BusinessesProvider) + .getBusinessByIdLoader.loadMany(businessIds); + return businesses.map((business, index) => { + if (!business || business instanceof Error) { + throw new GraphQLError(`Business ID="${businessIds[index]}" not found`); + } + return business; + }); +} + +/** + * Push the batch's field changes to Green Invoice for whichever of the businesses are linked + * clients. `updateGreenInvoiceClient` bails out for businesses with no Green Invoice id, so this is + * a no-op for the common case. + * + * The calls stay sequential: they hit an external API that is not covered by the DB mutex, and a + * failure mid-batch should stop rather than fan out more partial writes. Both loaders they read from + * are primed first, so the DB side still costs two queries total rather than two per business. + */ +async function syncGreenInvoiceClients( + injector: Injector, + businessIds: readonly string[], + fields: BatchUpdateBusinessInput, +): Promise { + await Promise.all([ + injector.get(BusinessesProvider).getBusinessByIdLoader.loadMany(businessIds), + injector.get(ClientsProvider).getClientByIdLoader.loadMany(businessIds), + ]); + for (const businessId of businessIds) { + await updateGreenInvoiceClient(businessId, injector, fields); + } +} + +/** + * Apply one set of field values to many businesses. + * + * Every touched table gets a single statement for the whole selection, so the cost is a small fixed + * number of queries regardless of how many businesses were selected: + * - `businesses` (locality, flags, suggestion description) — one `batchUpdateBusinesses` + * - `financial_entities` (sort code, IRS code, active) — one `batchUpdateFinancialEntities` + * - `business_tax_category_match` (tax category) — one upsert + * plus one read to return the refreshed rows. + * + * Only the fields present in `fields` are written; the statements `COALESCE` every column against + * itself, so an absent field leaves each business's own value alone. + */ +export async function applyBatchBusinessUpdate( + injector: Injector, + businessIds: readonly string[], + ownerId: string, + fields: BatchUpdateBusinessInput, +): Promise { + const ids = [...new Set(businessIds)]; + if (ids.length === 0) { + return []; + } + + const businessFields = { + city: fields.city, + zipCode: fields.zipCode, + country: fields.country, + isDocumentsOptional: fields.isDocumentsOptional, + isReceiptEnough: fields.isReceiptEnough, + exemptDealer: fields.exemptDealer, + optionalVat: fields.optionalVAT, + pcn874RecordTypeOverride: fields.pcn874RecordType, + suggestionDescription: fields.suggestionDescription, + }; + const financialEntityFields = { + sortCode: fields.sortCode, + irsCode: fields.irsCode, + isActive: fields.isActive, + }; + + const writes: Promise[] = []; + + if (Object.values(businessFields).some(value => value != null)) { + writes.push( + injector + .get(BusinessesProvider) + .batchUpdateBusinesses({ ...businessFields, businessIds: ids }) + .catch((e: Error) => { + console.error(`Error batch updating businesses: ${e}`); + throw new GraphQLError(`Error updating ${ids.length} businesses`); + }), + ); + } + + if (Object.values(financialEntityFields).some(value => value != null)) { + writes.push( + injector + .get(FinancialEntitiesProvider) + .batchUpdateFinancialEntities({ ...financialEntityFields, financialEntityIds: ids }) + .catch((e: Error) => { + console.error(`Error batch updating financial entities: ${e}`); + throw new GraphQLError(`Error updating ${ids.length} businesses`); + }), + ); + } + + if (fields.taxCategory) { + writes.push( + injector + .get(TaxCategoriesProvider) + .batchUpsertBusinessesTaxCategory({ + businessIds: ids, + ownerId, + taxCategoryId: fields.taxCategory, + }) + .catch((e: Error) => { + console.error(`Error batch updating businesses tax category: ${e}`); + throw new GraphQLError(`Error updating tax category for ${ids.length} businesses`); + }), + ); + } + + // Distinct tables, so the statements don't contend with each other. + await Promise.all(writes); + + const updatedBusinesses = await reloadBusinesses(injector, ids); + await syncGreenInvoiceClients(injector, ids, fields); + + return updatedBusinesses; +} + +/** + * Add and/or remove suggestion tags across many businesses, leaving each business's other tags + * untouched. Mirrors the charges-side `applyChargeTagChanges`, but business suggestion tags live + * inside the `suggestion_data` JSON blob rather than a join table, so the whole selection is handled + * by one statement (the set arithmetic runs in SQL). An id passed in both lists ends up added. + */ +export async function applyBatchBusinessTagChanges( + injector: Injector, + businessIds: readonly string[], + addTagIds: readonly string[], + removeTagIds: readonly string[], +): Promise { + const ids = [...new Set(businessIds)]; + if (ids.length === 0) { + return []; + } + if (addTagIds.length === 0 && removeTagIds.length === 0) { + return reloadBusinesses(injector, ids); + } + + await injector + .get(BusinessesProvider) + .batchUpdateBusinessesSuggestionTags({ + businessIds: ids, + addTagIds: [...addTagIds], + removeTagIds: [...removeTagIds], + }) + .catch((e: Error) => { + console.error(`Error batch updating businesses tags: ${e}`); + throw new GraphQLError(`Error updating tags for ${ids.length} businesses`); + }); + + return reloadBusinesses(injector, ids); +} diff --git a/packages/server/src/modules/financial-entities/providers/businesses.provider.ts b/packages/server/src/modules/financial-entities/providers/businesses.provider.ts index f0d5cd780..dd54a5e9e 100644 --- a/packages/server/src/modules/financial-entities/providers/businesses.provider.ts +++ b/packages/server/src/modules/financial-entities/providers/businesses.provider.ts @@ -7,6 +7,10 @@ import { reassureOwnerIdExists } from '../../../shared/helpers/index.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import type { + IBatchUpdateBusinessesParams, + IBatchUpdateBusinessesQuery, + IBatchUpdateBusinessesSuggestionTagsParams, + IBatchUpdateBusinessesSuggestionTagsQuery, IGetAllBusinessesQuery, IGetAllBusinessesResult, IGetBusinessByEmailQuery, @@ -134,6 +138,104 @@ const updateBusiness = sql` RETURNING *; `; +// Batch sibling of `updateBusiness`: applies one set of values to many businesses in a single +// statement, so a bulk edit costs one round-trip instead of one per business. Only the fields the +// businesses table owns; sort code / IRS code / active flag live on financial_entities (see +// `batchUpdateFinancialEntities`) and the tax-category match has its own table. +// `suggestionDescription` is folded into the JSON `suggestion_data` blob rather than a column, so +// it needs the CASE guard — a plain COALESCE would rebuild the object even when unset. +const batchUpdateBusinesses = sql` + UPDATE accounter_schema.businesses + SET + city = COALESCE( + $city, + city + ), + zip_code = COALESCE( + $zipCode, + zip_code + ), + country = COALESCE( + $country, + country + ), + no_invoices_required = COALESCE( + $isDocumentsOptional, + no_invoices_required + ), + can_settle_with_receipt = COALESCE( + $isReceiptEnough, + can_settle_with_receipt + ), + exempt_dealer = COALESCE( + $exemptDealer, + exempt_dealer + ), + optional_vat = COALESCE( + $optionalVat, + optional_vat + ), + pcn874_record_type_override = COALESCE( + $pcn874RecordTypeOverride, + pcn874_record_type_override + ), + suggestion_data = CASE + WHEN $suggestionDescription::text IS NULL THEN suggestion_data + ELSE jsonb_set( + CASE WHEN jsonb_typeof(suggestion_data) = 'object' + THEN suggestion_data + ELSE '{}'::jsonb + END, + '{description}', + to_jsonb($suggestionDescription::text) + ) + END + WHERE + id IN $$businessIds + RETURNING *; +`; + +// Additive/subtractive suggestion-tag update across many businesses in one statement. Business +// suggestion tags live as a string array inside the `suggestion_data` JSON blob (no join table like +// charge_tags), so the set arithmetic happens in SQL: the current tags minus `removeTagIds`, unioned +// with `addTagIds`. EXCEPT binds before UNION, so an id in both lists ends up added ("add wins"), +// matching `applyChargeTagChanges`. Both set operators de-duplicate, so the result is a clean set. +// The jsonb_typeof guards (same as in `getBusinessByEmail`) keep a malformed/legacy record — a +// non-object `suggestion_data`, or a `tags` that is not a JSON array — from throwing and taking the +// whole batch down with it; such a record is treated as having no tags and is rewritten cleanly. +const batchUpdateBusinessesSuggestionTags = sql` + UPDATE accounter_schema.businesses + SET + suggestion_data = jsonb_set( + CASE WHEN jsonb_typeof(suggestion_data) = 'object' + THEN suggestion_data + ELSE '{}'::jsonb + END, + '{tags}', + COALESCE( + ( + SELECT jsonb_agg(tag_id) + FROM ( + SELECT jsonb_array_elements_text( + CASE WHEN jsonb_typeof(suggestion_data -> 'tags') = 'array' + THEN suggestion_data -> 'tags' + ELSE '[]'::jsonb + END + ) AS tag_id + EXCEPT + SELECT unnest($removeTagIds::uuid[])::text + UNION + SELECT unnest($addTagIds::uuid[])::text + ) AS updated_tags + ), + '[]'::jsonb + ) + ) + WHERE + id IN $$businessIds + RETURNING *; +`; + const insertBusinesses = sql` INSERT INTO accounter_schema.businesses (id, hebrew_name, address, city, zip_code, email, website, phone_number, vat_number, exempt_dealer, suggestion_data, optional_vat, country, pcn874_record_type_override, can_settle_with_receipt, no_invoices_required, owner_id) VALUES $$businesses(id, hebrewName, address, city, zipCode, email, website, phoneNumber, governmentId, exemptDealer, suggestions, optionalVat, country, pcn874RecordTypeOverride, isReceiptEnough, isDocumentsOptional, ownerId) @@ -294,6 +396,22 @@ export class BusinessesProvider { return updateBusiness.run(params, this.db); } + public async batchUpdateBusinesses( + params: Omit & { businessIds: string[] }, + ) { + await Promise.all(params.businessIds.map(id => this.invalidateBusinessById(id))); + return batchUpdateBusinesses.run(params, this.db); + } + + public async batchUpdateBusinessesSuggestionTags( + params: Omit & { + businessIds: string[]; + }, + ) { + await Promise.all(params.businessIds.map(id => this.invalidateBusinessById(id))); + return batchUpdateBusinessesSuggestionTags.run(params, this.db); + } + private async batchInsertBusinesses( newBusinesses: readonly IInsertBusinessesParams['businesses'][number][], ) { diff --git a/packages/server/src/modules/financial-entities/providers/financial-entities.provider.ts b/packages/server/src/modules/financial-entities/providers/financial-entities.provider.ts index 48adbb4aa..edd182fee 100644 --- a/packages/server/src/modules/financial-entities/providers/financial-entities.provider.ts +++ b/packages/server/src/modules/financial-entities/providers/financial-entities.provider.ts @@ -5,6 +5,8 @@ import type { PoolClient } from 'pg'; import { sql } from '@pgtyped/runtime'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import type { + IBatchUpdateFinancialEntitiesParams, + IBatchUpdateFinancialEntitiesQuery, IDeleteFinancialEntityQuery, IGetAllFinancialEntitiesQuery, IGetAllFinancialEntitiesResult, @@ -56,6 +58,29 @@ const updateFinancialEntity = sql` RETURNING *; `; +// Batch sibling of `updateFinancialEntity`, for the fields a bulk edit may touch. One statement for +// the whole selection instead of one per entity. `name` and `type` are deliberately excluded — they +// are per-entity by nature and must never be applied wholesale. +const batchUpdateFinancialEntities = sql` + UPDATE accounter_schema.financial_entities + SET + sort_code = COALESCE( + $sortCode, + sort_code + ), + irs_code = COALESCE( + $irsCode, + irs_code + ), + is_active = COALESCE( + $isActive, + is_active + ) + WHERE + id IN $$financialEntityIds + RETURNING *; +`; + const insertFinancialEntities = sql` INSERT INTO accounter_schema.financial_entities (type, owner_id, name, sort_code, irs_code, is_active) VALUES $$financialEntities(type, ownerId, name, sortCode, irsCode, isActive) @@ -172,6 +197,17 @@ export class FinancialEntitiesProvider { return updateFinancialEntity.run(params, this.db); } + public batchUpdateFinancialEntities( + params: Omit & { + financialEntityIds: string[]; + }, + ) { + for (const financialEntityId of params.financialEntityIds) { + this.invalidateFinancialEntityById(financialEntityId); + } + return batchUpdateFinancialEntities.run(params, this.db); + } + public insertFinancialEntity( params: IInsertFinancialEntitiesParams['financialEntities'][number], client?: PoolClient, diff --git a/packages/server/src/modules/financial-entities/providers/tax-categories.provider.ts b/packages/server/src/modules/financial-entities/providers/tax-categories.provider.ts index e7e540922..0ca768a84 100644 --- a/packages/server/src/modules/financial-entities/providers/tax-categories.provider.ts +++ b/packages/server/src/modules/financial-entities/providers/tax-categories.provider.ts @@ -6,6 +6,8 @@ import { reassureOwnerIdExists } from '../../../shared/helpers/index.js'; import { AdminContextProvider } from '../../admin-context/providers/admin-context.provider.js'; import { TenantAwareDBClient } from '../../app-providers/tenant-db-client.js'; import type { + IBatchUpsertBusinessesTaxCategoryParams, + IBatchUpsertBusinessesTaxCategoryQuery, IDeleteBusinessTaxCategoryParams, IDeleteBusinessTaxCategoryQuery, IDeleteTaxCategoryQuery, @@ -125,6 +127,17 @@ const insertBusinessTaxCategory = sql` VALUES ($businessId, $ownerId, $taxCategoryId) RETURNING *;`; +// Point many businesses at one tax category in a single statement. The (business_id, owner_id) +// primary key makes this an upsert, so callers don't need a preceding "does a match already exist?" +// read per business the way the single-business path does. +const batchUpsertBusinessesTaxCategory = sql` + INSERT INTO accounter_schema.business_tax_category_match (business_id, owner_id, tax_category_id) + SELECT business_id, $ownerId, $taxCategoryId + FROM unnest($businessIds::uuid[]) AS business_id + ON CONFLICT (business_id, owner_id) DO UPDATE + SET tax_category_id = EXCLUDED.tax_category_id + RETURNING *;`; + const deleteBusinessTaxCategory = sql` DELETE FROM accounter_schema.business_tax_category_match WHERE @@ -310,6 +323,15 @@ export class TaxCategoriesProvider { return insertBusinessTaxCategory.run(params, this.db); } + public batchUpsertBusinessesTaxCategory( + params: Omit & { + businessIds: string[]; + }, + ) { + if (params.taxCategoryId) this.invalidateTaxCategoryById(params.taxCategoryId); + return batchUpsertBusinessesTaxCategory.run(params, this.db); + } + public deleteBusinessTaxCategory(params: IDeleteBusinessTaxCategoryParams) { return deleteBusinessTaxCategory.run(params, this.db); } diff --git a/packages/server/src/modules/financial-entities/resolvers/businesses.resolver.ts b/packages/server/src/modules/financial-entities/resolvers/businesses.resolver.ts index 06206c343..f6bb29d49 100644 --- a/packages/server/src/modules/financial-entities/resolvers/businesses.resolver.ts +++ b/packages/server/src/modules/financial-entities/resolvers/businesses.resolver.ts @@ -6,6 +6,10 @@ import { AdminContextProvider } from '../../admin-context/providers/admin-contex import { SortCodesProvider } from '../../sort-codes/providers/sort-codes.provider.js'; import { TagsProvider } from '../../tags/providers/tags.provider.js'; import { TransactionsProvider } from '../../transactions/providers/transactions.provider.js'; +import { + applyBatchBusinessTagChanges, + applyBatchBusinessUpdate, +} from '../helpers/batch-update-businesses.helper.js'; import { SuggestionData, suggestionDataSchema, @@ -283,14 +287,19 @@ export const businessesResolvers: FinancialEntitiesModule.Resolvers & }, batchUpdateBusinesses: async (_, { businessIds, fields }, { injector }) => { const { ownerId } = await injector.get(AdminContextProvider).getVerifiedAdminContext(); - // sequential: the external green-invoice sync inside updateSingleBusiness is not behind - // the DB mutex, so a concurrent Promise.all would fire those calls in parallel, and a - // failure mid-batch should stop rather than leave more partial updates behind - const updatedBusinesses: IGetBusinessesByIdsResult[] = []; - for (const businessId of businessIds) { - updatedBusinesses.push(await updateSingleBusiness(injector, businessId, ownerId, fields)); - } - return updatedBusinesses; + return applyBatchBusinessUpdate(injector, businessIds, ownerId, fields); + }, + batchUpdateBusinessesTags: async ( + _, + { businessIds, addTagIds, removeTagIds }, + { injector }, + ) => { + return applyBatchBusinessTagChanges( + injector, + businessIds, + addTagIds ?? [], + removeTagIds ?? [], + ); }, batchGenerateBusinessesOutOfTransactions: async (_, __, { injector }) => { const { ownerId, locality } = await injector diff --git a/packages/server/src/modules/financial-entities/typeDefs/businesses.graphql.ts b/packages/server/src/modules/financial-entities/typeDefs/businesses.graphql.ts index 1e46002ec..e7f30fba8 100644 --- a/packages/server/src/modules/financial-entities/typeDefs/businesses.graphql.ts +++ b/packages/server/src/modules/financial-entities/typeDefs/businesses.graphql.ts @@ -112,6 +112,12 @@ export default gql` batchUpdateBusinesses(businessIds: [UUID!]!, fields: BatchUpdateBusinessInput!): [Business!]! @requiresAuth @requiresAnyRole(roles: ["business_owner", "accountant"]) + " add and/or remove suggestion tags across many businesses at once; returns the updated businesses " + batchUpdateBusinessesTags( + businessIds: [UUID!]! + addTagIds: [UUID!] + removeTagIds: [UUID!] + ): [Business!]! @requiresAuth @requiresAnyRole(roles: ["business_owner", "accountant"]) batchGenerateBusinessesOutOfTransactions: [Business!]! @requiresAuth @requiresAnyRole(roles: ["business_owner", "accountant"]) @@ -180,7 +186,8 @@ export default gql` taxCategory: UUID irsCode: Int pcn874RecordType: Pcn874RecordType - suggestions: SuggestionsInput + " suggestion description, applied to every selected business; the rest of the suggestion data (tags, phrases, emails) is per-business and is not batch-editable here — use batchUpdateBusinessesTags for tags " + suggestionDescription: String exemptDealer: Boolean optionalVAT: Boolean isReceiptEnough: Boolean diff --git a/packages/server/src/modules/financial-entities/types.ts b/packages/server/src/modules/financial-entities/types.ts index 8acc15da9..a2e92fb8e 100644 --- a/packages/server/src/modules/financial-entities/types.ts +++ b/packages/server/src/modules/financial-entities/types.ts @@ -1,5 +1,7 @@ export type * from './__generated__/types.js'; -export type { Json, pcn874_record_type } from './__generated__/businesses.types.js'; +// `stringArray` is emitted by pgtyped into every generated file that has a text[] param, so it is +// ambiguous across the star re-exports below — pin it to one source. +export type { Json, pcn874_record_type, stringArray } from './__generated__/businesses.types.js'; export type * from './__generated__/businesses.types.js'; export type * from './__generated__/businesses-usage.types.js'; export type * from './__generated__/entity-ensure.types.js';