Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@ import {computed, ref, watch} from "vue"
import BulkDialogScaffold from "./BulkDialogScaffold.vue"
import {useBulkPreview} from "@/composables/useBulkPreview"
import {useSubmitFeedback} from "@/composables/formUtils"
import type {BulkActionResult, BulkMembershipPreview} from "@/domains/user"
import {
endMemberships,
previewBulkEnd,
previewBulkStart,
startMemberships,
} from "@/services/api/blueshell/sdk.gen"
import type {BulkActionResult, BulkMembershipPreview} from "@/services/api"
endTheMemberships,
readMembershipEnd,
readMembershipStart,
startTheMemberships,
} from "@/domains/user"
import {parseBulkRejection, type BulkRejection} from "@/utils/bulkRejection"
import {bulkRowsFromPreview} from "@/utils/bulkPreviewRows"
import type {BulkTarget} from "@/utils/bulkTarget"
Expand Down Expand Up @@ -60,8 +60,8 @@ interface DialogConfig {
dateSentence: string
/** Past tense for the result line, e.g. "3 ended, 1 skipped". */
appliedVerb: string
previewApi: typeof previewBulkEnd | typeof previewBulkStart
submitApi: typeof endMemberships | typeof startMemberships
previewApi: typeof readMembershipEnd | typeof readMembershipStart
submitApi: typeof endTheMemberships | typeof startTheMemberships
help: {title: string; body: string}
}

Expand All @@ -72,8 +72,8 @@ const configMap: Record<MembershipAction, DialogConfig> = {
icon: "mdi-account-remove",
dateSentence: "Memberships end on",
appliedVerb: "ended",
previewApi: previewBulkEnd,
submitApi: endMemberships,
previewApi: readMembershipEnd,
submitApi: endTheMemberships,
help: {
title: "End membership",
body:
Expand All @@ -90,8 +90,8 @@ const configMap: Record<MembershipAction, DialogConfig> = {
icon: "mdi-account-plus",
dateSentence: "Memberships start on",
appliedVerb: "started",
previewApi: previewBulkStart,
submitApi: startMemberships,
previewApi: readMembershipStart,
submitApi: startTheMemberships,
help: {
title: "Start membership",
body:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {computed, ref, watch} from "vue"
import BulkDialogScaffold from "./BulkDialogScaffold.vue"
import {useBulkPreview} from "@/composables/useBulkPreview"
import {useSubmitFeedback} from "@/composables/formUtils"
import {markPaid, markUnpaid} from "@/services/api/blueshell/sdk.gen"
import {recordPaid, recordUnpaid, type BulkContributionCall} from "@/domains/contribution"
import {parseBulkRejection, type BulkRejection} from "@/utils/bulkRejection"
import {computeMarkPaidRows, computeMarkUnpaidRows} from "@/utils/bulkCompute"
import type {BulkTarget} from "@/utils/bulkTarget"
Expand Down Expand Up @@ -46,7 +46,7 @@ interface DialogConfig {
confirmLabel: string
icon: string
computeRows: (targets: BulkTarget[]) => ReturnType<typeof computeMarkPaidRows>
submitApi: typeof markPaid | typeof markUnpaid
submitApi: BulkContributionCall
help: {title: string; body: string}
}

Expand All @@ -56,7 +56,7 @@ const configMap: Record<"paid" | "unpaid", DialogConfig> = {
confirmLabel: "Mark paid",
icon: "mdi-cash-check",
computeRows: (targets: BulkTarget[]) => computeMarkPaidRows(targets),
submitApi: markPaid,
submitApi: recordPaid,
help: {
title: "Mark as paid",
body:
Expand All @@ -71,7 +71,7 @@ const configMap: Record<"paid" | "unpaid", DialogConfig> = {
confirmLabel: "Mark unpaid",
icon: "mdi-cash-remove",
computeRows: (targets: BulkTarget[]) => computeMarkUnpaidRows(targets),
submitApi: markUnpaid,
submitApi: recordUnpaid,
help: {
title: "Mark as unpaid",
body:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import {computed} from "vue"
import {ContributionEmailKind, type ContributionPeriodResponse} from "@/services/api"
import {ContributionEmailKind, type ContributionPeriodResponse} from "@/domains/contribution"
import {useNarrowLayout} from "@/composables/useNarrowLayout"
import {formatBulkDate} from "@/utils/bulkDisposition"
import {BulkFeeType, type BulkRow} from "@/utils/bulkRow"
Expand All @@ -13,7 +13,7 @@ import {
switchedDescription,
kindFor,
switchedNote,
} from "@/utils/contributionEmail"
} from "@/domains/contribution"
import {effectiveAmount, feeTypeItems} from "@/utils/feePreview"

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {useNarrowLayout} from "@/composables/useNarrowLayout"
import {useTableSort} from "@/composables/useTableSort"
import {formatBulkDate, reasonLabel, rowColorClass} from "@/utils/bulkDisposition"
import type {BulkRow} from "@/utils/bulkRow"
import {isSelectable, lastAskedOn} from "@/utils/contributionEmail"
import {isSelectable, lastAskedOn} from "@/domains/contribution"
import {memberTypeLabel} from "@/utils/memberType"

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<script lang="ts" setup>
import {ContributionEmailKind, type ContributionPeriodResponse} from "@/services/api"
import {ContributionEmailKind, type ContributionPeriodResponse} from "@/domains/contribution"
import {useNarrowLayout} from "@/composables/useNarrowLayout"
import type {BulkFeeType, BulkRow} from "@/utils/bulkRow"
import {contributionEmailLabels, kindFor} from "@/utils/contributionEmail"
import {contributionEmailLabels, kindFor} from "@/domains/contribution"
import {effectiveAmount, feeTypeLabels} from "@/utils/feePreview"

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import {useEmailPreview} from "@/composables/useEmailPreview"
import {useSubmitFeedback} from "@/composables/formUtils"
import {
ContributionEmailKind,
previewBulkContributionEmail,
readContributionEmail,
sendPaymentEmails,
} from "@/services/api"
import type {ContributionPeriodResponse} from "@/services/api"
type ContributionPeriodResponse,
readOneEmail,
readSelection,
sendTheEmails,
} from "@/domains/contribution"
import {parseBulkRejection, type BulkRejection} from "@/utils/bulkRejection"
import type {BulkFeeType, BulkRow} from "@/utils/bulkRow"
import {
Expand All @@ -33,7 +33,7 @@ import {
toBulkRows,
willSend,
type PaymentEmailChoices,
} from "@/utils/contributionEmail"
} from "@/domains/contribution"

/**
* Sending a period's payment emails, as three questions asked one at a time: who the batch
Expand Down Expand Up @@ -192,9 +192,7 @@ async function loadRows(carry?: {made: PaymentEmailChoices; contradicted: number
if (periodId == null || props.userIds.length === 0) return
loading.value = true
loadError.value = null
const {data} = await previewBulkContributionEmail({
body: {contributionPeriodId: periodId, userIds: props.userIds},
})
const {data} = await readSelection(periodId, props.userIds)
loading.value = false
if (!data) {
loadError.value = "The selection could not be read."
Expand Down Expand Up @@ -266,14 +264,12 @@ async function onPreview(userId: number) {
const row = rows.value.find((r) => r.userId === userId)
if (periodId == null || !row) return
await showEmailPreview(async () => {
const {data} = await readContributionEmail({
query: {
kind: kindFor(row, kindSelections.value),
contributionPeriodId: periodId,
userId,
date: dateFor(row),
feeType: feeTypeSelections.value[userId],
},
const {data} = await readOneEmail({
kind: kindFor(row, kindSelections.value),
contributionPeriodId: periodId,
userId,
date: dateFor(row),
feeType: feeTypeSelections.value[userId],
})
return data ?? null
})
Expand Down Expand Up @@ -327,16 +323,14 @@ async function onFinalSend() {
submitting.value = true
let ok = false
try {
const response = await sendPaymentEmails({
body: {
contributionPeriodId: periodId,
userIds: recipients.value.map((row) => row.userId),
forciblyIncludedUserIds: forcedUserIds(rows.value, sendTo.value),
kindOverrides: changedKinds(recipients.value, kindSelections.value),
paymentDueDate: sendsReminders.value ? paymentDueDate.value : undefined,
debitDate: sendsNotifications.value ? debitDate.value : undefined,
feeTypeOverrides: changedFeeTypes(recipients.value, feeTypeSelections.value),
},
const response = await sendTheEmails({
contributionPeriodId: periodId,
userIds: recipients.value.map((row) => row.userId),
forciblyIncludedUserIds: forcedUserIds(rows.value, sendTo.value),
kindOverrides: changedKinds(recipients.value, kindSelections.value),
paymentDueDate: sendsReminders.value ? paymentDueDate.value : undefined,
debitDate: sendsNotifications.value ? debitDate.value : undefined,
feeTypeOverrides: changedFeeTypes(recipients.value, feeTypeSelections.value),
})
// The generated client returns a refusal rather than throwing.
const refused = parseBulkRejection(response)
Expand Down
7 changes: 4 additions & 3 deletions services/frontend/src/components/form/fields/UserSelect.vue
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts" setup>
import {onBeforeUnmount, ref, watch} from "vue"
import {findUsers, type UserDetailResponse} from "@/services/api"
import {searchMemberAccounts} from "@/domains/user"
import type {UserDetailResponse} from "@/services/api"
import {VAutocomplete} from "vuetify/components"

type Rule = (v: UserDetailResponse | undefined) => true | string
Expand Down Expand Up @@ -64,10 +65,10 @@ async function ask(term: string): Promise<void> {
const mine = ++latest
loading.value = true
try {
const resp = await findUsers({query: {search: term, page: 0, size: PAGE}})
const found = await searchMemberAccounts(term, PAGE)
// An older answer must not overwrite a newer one: the reader has typed since.
if (mine !== latest) return
options.value = mergeSelected(resp.data?.content ?? [])
options.value = mergeSelected(found)
} finally {
if (mine === latest) loading.value = false
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/**
* Recording what a member has paid, in bulk. Idempotent on both sides: a member already in
* the state asked for is skipped rather than written twice.
*/
import {markPaid, markUnpaid} from "@/services/api"

export type BulkContributionCall = typeof markPaid

export const recordPaid: BulkContributionCall = markPaid
export const recordUnpaid: BulkContributionCall = markUnpaid
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* Contribution domain adapter — the only file in this domain that imports from
* `@/services/api` (frontend ADR-002). Everything else, dialogs included, comes through here.
*
* The return types are inferred rather than named: the generated client answers a refusal
* instead of throwing, and an alias written by hand here loses the error half of that union,
* which is precisely what the callers read.
*/
import {previewBulkContributionEmail, readContributionEmail, sendPaymentEmails} from "@/services/api"

/** The query the endpoint documents, not a restatement of it that would accept more. */
export type ReadEmailQuery = NonNullable<Parameters<typeof readContributionEmail>[0]>["query"]
export type SendPaymentEmailsBody = NonNullable<Parameters<typeof sendPaymentEmails>[0]>["body"]

/** What the send would do to a selection, before anybody is written to. */
export function readSelection(contributionPeriodId: number, userIds: number[]) {
return previewBulkContributionEmail({body: {contributionPeriodId, userIds}})
}

/** One member's email as they would receive it. */
export function readOneEmail(query: ReadEmailQuery) {
return readContributionEmail({query})
}

export function sendTheEmails(body: SendPaymentEmailsBody) {
return sendPaymentEmails({body})
}
44 changes: 44 additions & 0 deletions services/frontend/src/domains/contribution/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* The contribution domain's public API: its own files import each other directly, and anything
* outside it comes through here (frontend ADR-001). Re-exported by name rather than with
* `export *`, because the list of names is the promise being made.
*/
export {ContributionEmailKind, type ContributionPeriodResponse} from "@/services/api"
export {
readOneEmail,
readSelection,
sendTheEmails,
type ReadEmailQuery,
type SendPaymentEmailsBody,
} from "./adapters/paymentEmails"
export {recordPaid, recordUnpaid, type BulkContributionCall} from "./adapters/contributions"
export {
changedFeeTypes,
changedKinds,
contributionEmailItems,
contributionEmailLabels,
countByKind,
forcedUserIds,
isReCharged,
isSelectable,
isSwitched,
kindFor,
lastAskedOn,
lastSentOfKind,
PERIOD_OVERHANG_MONTHS,
paymentDateProblem,
periodDateWindow,
reChargedDescription,
reapplyChoices,
seedChoices,
seedSendTo,
summarise,
switchedDescription,
switchedNote,
toBulkRow,
toBulkRows,
willSend,
type FlaggedMember,
type PaymentEmailChoices,
type PaymentEmailSummary,
} from "./paymentEmail"
14 changes: 14 additions & 0 deletions services/frontend/src/domains/user/adapters/memberships.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
/**
* Starting and ending memberships in bulk. A membership belongs to the user domain rather than
* to contribution: what one costs is a contribution's business, whether somebody holds one is
* this domain's.
*
* Re-exported under the names the dialogs read them by, so a component names an intention
* rather than an endpoint.
*/
import {endMemberships, previewBulkEnd, previewBulkStart, startMemberships} from "@/services/api"

export const readMembershipStart = previewBulkStart
export const readMembershipEnd = previewBulkEnd
export const startTheMemberships = startMemberships
export const endTheMemberships = endMemberships
11 changes: 10 additions & 1 deletion services/frontend/src/domains/user/adapters/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* User domain adapter — the only file in this domain that imports from @/services/api
* (per frontend ADR-002). Everything else imports from here.
*/
import {findUsers} from "@/services/api"
import {findUsers, type UserDetailResponse} from "@/services/api"

/**
* An account here, as the thing attaching one needs to name it: who it belongs to, and how to
Expand Down Expand Up @@ -39,3 +39,12 @@ export async function loadMemberAccounts(): Promise<MemberAccount[] | null> {
}))
.sort((a, b) => a.name.localeCompare(b.name))
}

/**
* The accounts whose name, username or Discord handle carries what somebody typed, one page of
* them. A picker cannot hold the whole table, so it asks as the reader types (#1139).
*/
export async function searchMemberAccounts(term: string, size: number): Promise<UserDetailResponse[]> {
const res = await findUsers({query: {search: term, page: 0, size}})
return res.data?.content ?? []
}
9 changes: 8 additions & 1 deletion services/frontend/src/domains/user/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,11 @@
* This is the first one in the codebase, so it is also the pattern: re-export by name rather
* than with `export *`, because the list of names is the promise being made.
*/
export {loadMemberAccounts, type MemberAccount} from "./adapters/users"
export {loadMemberAccounts, searchMemberAccounts, type MemberAccount} from "./adapters/users"
export {
endTheMemberships,
readMembershipEnd,
readMembershipStart,
startTheMemberships,
} from "./adapters/memberships"
export type {BulkActionResult, BulkMembershipPreview} from "@/services/api"
Loading
Loading