feat: add utm tracking on booking (#19965)
* add tracking to booking + pass to handleNewBooking * save tracking to booking * return undefined if no utm params * add test for handleNewBooking * fix type error in test * refactor code * code clean up * code clean up * add missing import * fix type import * add utm params to insights (#20063) * add utm params to insights * remove duplicates * remove duplicate columns --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> --------- Co-authored-by: CarinaWolli <wollencarina@gmail.com> Co-authored-by: Omar López <zomars@me.com>
This commit is contained in:
co-authored by
CarinaWolli
Omar López
parent
13044cb5b0
commit
0968fc56eb
@@ -10,6 +10,7 @@ import { expect, vi } from "vitest";
|
||||
import "vitest-fetch-mock";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import type { Tracking } from "@calcom/features/bookings/lib/handleNewBooking/types";
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
@@ -428,6 +429,18 @@ export async function expectBookingToBeInDatabase(
|
||||
);
|
||||
}
|
||||
|
||||
export async function expectBookingTrackingToBeInDatabase(tracking: Tracking, uid?: string) {
|
||||
const actualBooking = await prismaMock.booking.findUnique({
|
||||
where: {
|
||||
uid,
|
||||
},
|
||||
select: {
|
||||
tracking: true,
|
||||
},
|
||||
});
|
||||
expect(actualBooking?.tracking).toEqual(expect.objectContaining(tracking));
|
||||
}
|
||||
|
||||
export function expectSMSToBeTriggered({ sms, toNumber }: { sms: Fixtures["sms"]; toNumber: string }) {
|
||||
expect(sms.get()).toEqual(
|
||||
expect.arrayContaining([
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
|
||||
|
||||
import type { Tracking } from "@calcom/features/bookings/lib/handleNewBooking/types";
|
||||
import type { SchedulingType } from "@calcom/prisma/client";
|
||||
import type { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
@@ -42,6 +43,7 @@ export function getMockRequestDataForBooking({
|
||||
eventTypeId: number;
|
||||
user?: string;
|
||||
creationSource?: CreationSource;
|
||||
tracking?: Tracking;
|
||||
} & CommonPropsMockRequestData;
|
||||
}) {
|
||||
return {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { parseRecurringDates } from "@calcom/lib/parse-dates";
|
||||
import type { RoutingFormSearchParams } from "@calcom/platform-types";
|
||||
|
||||
import type { BookerEvent, BookingCreateBody, RecurringBookingCreateBody } from "../../types";
|
||||
import type { Tracking } from "../handleNewBooking/types";
|
||||
|
||||
export type BookingOptions = {
|
||||
values: Record<string, unknown>;
|
||||
@@ -99,7 +100,8 @@ export const mapBookingToMutationInput = ({
|
||||
// Other than that it forwards the mapping to mapBookingToMutationInput.
|
||||
export const mapRecurringBookingToMutationInput = (
|
||||
booking: BookingOptions,
|
||||
recurringCount: number
|
||||
recurringCount: number,
|
||||
tracking?: Tracking
|
||||
): RecurringBookingCreateBody[] => {
|
||||
const recurringEventId = uuidv4();
|
||||
const [, recurringDates] = parseRecurringDates(
|
||||
@@ -124,5 +126,6 @@ export const mapRecurringBookingToMutationInput = (
|
||||
recurringEventId,
|
||||
schedulingType: booking.event.schedulingType || undefined,
|
||||
recurringCount: recurringDates.length,
|
||||
tracking,
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -1204,6 +1204,7 @@ async function handler(
|
||||
evt,
|
||||
originalRescheduledBooking,
|
||||
creationSource: req.body.creationSource,
|
||||
tracking: reqBody.tracking,
|
||||
});
|
||||
|
||||
if (booking?.userId) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import type { AwaitedBookingData, EventTypeId } from "./getBookingData";
|
||||
import type { NewBookingEventType } from "./getEventTypesFromDB";
|
||||
import type { LoadedUsers } from "./loadUsers";
|
||||
import type { OriginalRescheduledBooking } from "./originalRescheduledBookingUtils";
|
||||
import type { PaymentAppData } from "./types";
|
||||
import type { PaymentAppData, Tracking } from "./types";
|
||||
|
||||
type ReqBodyWithEnd = TgetBookingDataSchema & { end: string };
|
||||
|
||||
@@ -50,6 +50,7 @@ type CreateBookingParams = {
|
||||
evt: CalendarEvent;
|
||||
originalRescheduledBooking: OriginalRescheduledBooking;
|
||||
creationSource?: CreationSource;
|
||||
tracking?: Tracking;
|
||||
};
|
||||
|
||||
function updateEventDetails(
|
||||
@@ -85,6 +86,7 @@ export async function createBooking({
|
||||
reroutingFormResponses,
|
||||
rescheduledBy,
|
||||
creationSource,
|
||||
tracking,
|
||||
}: CreateBookingParams & { rescheduledBy: string | undefined }) {
|
||||
updateEventDetails(evt, originalRescheduledBooking, input.changedOrganizer);
|
||||
const associatedBookingForFormResponse = routingFormResponseId
|
||||
@@ -102,6 +104,7 @@ export async function createBooking({
|
||||
evt,
|
||||
originalRescheduledBooking,
|
||||
creationSource,
|
||||
tracking,
|
||||
});
|
||||
|
||||
return await saveBooking(
|
||||
@@ -213,6 +216,7 @@ function buildNewBookingData(params: CreateBookingParams) {
|
||||
reroutingFormResponses,
|
||||
rescheduledBy,
|
||||
creationSource,
|
||||
tracking,
|
||||
} = params;
|
||||
|
||||
const attendeesData = getAttendeesData(evt);
|
||||
@@ -261,6 +265,7 @@ function buildNewBookingData(params: CreateBookingParams) {
|
||||
? { connect: { id: routingFormResponseId } }
|
||||
: undefined,
|
||||
creationSource,
|
||||
tracking: tracking ? { create: tracking } : undefined,
|
||||
};
|
||||
|
||||
if (reqBody.recurringEventId) {
|
||||
|
||||
@@ -43,6 +43,7 @@ import {
|
||||
expectBrokenIntegrationEmails,
|
||||
expectSuccessfulCalendarEventCreationInCalendar,
|
||||
expectICalUIDAsString,
|
||||
expectBookingTrackingToBeInDatabase,
|
||||
} from "@calcom/web/test/utils/bookingScenario/expects";
|
||||
import { getMockRequestDataForBooking } from "@calcom/web/test/utils/bookingScenario/getMockRequestDataForBooking";
|
||||
import { setupAndTeardown } from "@calcom/web/test/utils/bookingScenario/setupAndTeardown";
|
||||
@@ -1550,6 +1551,92 @@ describe("handleNewBooking", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("UTM tracking tests", () => {
|
||||
test(
|
||||
"should create a booking with UTM tracking",
|
||||
async ({ emails }) => {
|
||||
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
|
||||
const booker = getBooker({
|
||||
email: "booker@example.com",
|
||||
name: "Booker",
|
||||
});
|
||||
|
||||
const organizer = getOrganizer({
|
||||
name: "Organizer",
|
||||
email: "organizer@example.com",
|
||||
id: 101,
|
||||
schedules: [TestData.schedules.IstWorkHours],
|
||||
credentials: [getZoomAppCredential()],
|
||||
selectedCalendars: [TestData.selectedCalendars.google],
|
||||
});
|
||||
|
||||
const tracking = {
|
||||
utm_source: "source test",
|
||||
utm_medium: "medium test",
|
||||
utm_campaign: "campaign test",
|
||||
utm_term: "term test",
|
||||
utm_content: "content test",
|
||||
};
|
||||
|
||||
const { req } = createMockNextJsRequest({
|
||||
method: "POST",
|
||||
body: getMockRequestDataForBooking({
|
||||
data: {
|
||||
eventTypeId: 1,
|
||||
responses: {
|
||||
email: booker.email,
|
||||
name: booker.name,
|
||||
},
|
||||
tracking,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const scenarioData = getScenarioData({
|
||||
webhooks: [
|
||||
{
|
||||
userId: organizer.id,
|
||||
eventTriggers: ["BOOKING_CREATED"],
|
||||
subscriberUrl: "http://my-webhook.example.com",
|
||||
active: true,
|
||||
eventTypeId: 1,
|
||||
appId: null,
|
||||
},
|
||||
],
|
||||
eventTypes: [
|
||||
{
|
||||
id: 1,
|
||||
slotInterval: 30,
|
||||
length: 30,
|
||||
users: [
|
||||
{
|
||||
id: 101,
|
||||
},
|
||||
],
|
||||
locations: [
|
||||
{
|
||||
type: BookingLocations.ZoomVideo,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
organizer,
|
||||
apps: [TestData.apps["zoomvideo"]],
|
||||
});
|
||||
|
||||
mockSuccessfulVideoMeetingCreation({
|
||||
metadataLookupKey: "zoomvideo",
|
||||
});
|
||||
await createBookingScenario(scenarioData);
|
||||
const createdBooking = await handleNewBooking(req);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
expectBookingTrackingToBeInDatabase(tracking, createdBooking.uid!);
|
||||
},
|
||||
timeout
|
||||
);
|
||||
});
|
||||
|
||||
describe("Creation source tests", () => {
|
||||
test(
|
||||
`should create a booking with creation source as WEBAPP`,
|
||||
|
||||
@@ -43,3 +43,11 @@ export type IsFixedAwareUser = User & {
|
||||
};
|
||||
|
||||
export type { PaymentAppData };
|
||||
|
||||
export type Tracking = {
|
||||
utm_source?: string;
|
||||
utm_medium?: string;
|
||||
utm_campaign?: string;
|
||||
utm_term?: string;
|
||||
utm_content?: string;
|
||||
};
|
||||
|
||||
@@ -84,6 +84,11 @@ export function RoutingFormResponsesTable() {
|
||||
columnVisibility: {
|
||||
formId: false,
|
||||
bookingUserId: false,
|
||||
utm_source: false,
|
||||
utm_medium: false,
|
||||
utm_campaign: false,
|
||||
utm_term: false,
|
||||
utm_content: false,
|
||||
},
|
||||
},
|
||||
getFacetedUniqueValues: getInsightsFacetedUniqueValues,
|
||||
|
||||
@@ -199,6 +199,76 @@ export const useInsightsColumns = ({
|
||||
return <div className="max-w-[250px]">{assignmentReason}</div>;
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("utm_source", {
|
||||
id: "utm_source",
|
||||
header: t("utm_source"),
|
||||
size: 100,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return (
|
||||
<div className="truncate">
|
||||
<span>{info.getValue()}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("utm_medium", {
|
||||
id: "utm_medium",
|
||||
header: "utm_medium",
|
||||
size: 100,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return (
|
||||
<div className="truncate">
|
||||
<span>{info.getValue()}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("utm_term", {
|
||||
id: "utm_term",
|
||||
header: "utm_term",
|
||||
size: 100,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return (
|
||||
<div className="truncate">
|
||||
<span>{info.getValue()}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("utm_content", {
|
||||
id: "utm_content",
|
||||
header: "utm_content",
|
||||
size: 100,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return (
|
||||
<div className="truncate">
|
||||
<span>{info.getValue()}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("utm_campaign", {
|
||||
id: "utm_campaign",
|
||||
header: "utm_campaign",
|
||||
size: 100,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
cell: (info) => {
|
||||
return (
|
||||
<div className="truncate">
|
||||
<span>{info.getValue()}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor("createdAt", {
|
||||
id: "createdAt",
|
||||
header: t("routing_form_insights_submitted_at"),
|
||||
|
||||
@@ -321,6 +321,11 @@ class RoutingEventsInsights {
|
||||
bookingStartTime: true,
|
||||
bookingEndTime: true,
|
||||
createdAt: true,
|
||||
utm_source: true,
|
||||
utm_medium: true,
|
||||
utm_campaign: true,
|
||||
utm_term: true,
|
||||
utm_content: true,
|
||||
},
|
||||
where: whereClause,
|
||||
orderBy: sorting && sorting.length > 0 ? makeOrderBy(sorting) : { createdAt: "desc" },
|
||||
@@ -426,6 +431,11 @@ class RoutingEventsInsights {
|
||||
"Routed To Name": item.bookingUserName || "",
|
||||
"Routed To Email": item.bookingUserEmail || "",
|
||||
...mapKeys(fields, (_, key) => startCase(key)),
|
||||
utm_source: item.utm_source || "",
|
||||
utm_medium: item.utm_medium || "",
|
||||
utm_campaign: item.utm_campaign || "",
|
||||
utm_term: item.utm_term || "",
|
||||
utm_content: item.utm_content || "",
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useSearchParams } from "next/navigation";
|
||||
|
||||
import { useIsPlatform } from "@calcom/atoms/hooks/useIsPlatform";
|
||||
import { useBookerTime } from "@calcom/features/bookings/Booker/components/hooks/useBookerTime";
|
||||
import type { UseBookingFormReturnType } from "@calcom/features/bookings/Booker/components/hooks/useBookingForm";
|
||||
@@ -10,6 +12,7 @@ import type { RoutingFormSearchParams } from "@calcom/platform-types";
|
||||
import type { BookingCreateBody } from "@calcom/prisma/zod/custom/booking";
|
||||
import { showToast } from "@calcom/ui";
|
||||
|
||||
import { getUtmTrackingParameters } from "../../lib/getUtmTrackingParameters";
|
||||
import type { UseCreateBookingInput } from "./useCreateBooking";
|
||||
|
||||
type Callbacks = { onSuccess?: () => void; onError?: (err: any) => void };
|
||||
@@ -62,6 +65,7 @@ export const useHandleBookEvent = ({
|
||||
const errorMessage = err?.message ? t(err.message) : t("can_you_try_again");
|
||||
showToast(errorMessage, "error");
|
||||
};
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const handleBookEvent = (inputTimeSlot?: string) => {
|
||||
const values = bookingForm.getValues();
|
||||
@@ -109,12 +113,17 @@ export const useHandleBookEvent = ({
|
||||
routingFormSearchParams,
|
||||
};
|
||||
|
||||
const tracking = getUtmTrackingParameters(searchParams);
|
||||
|
||||
if (isInstantMeeting) {
|
||||
handleInstantBooking(mapBookingToMutationInput(bookingInput), callbacks);
|
||||
} else if (event.data?.recurringEvent?.freq && recurringEventCount && !rescheduleUid) {
|
||||
handleRecBooking(mapRecurringBookingToMutationInput(bookingInput, recurringEventCount), callbacks);
|
||||
handleRecBooking(
|
||||
mapRecurringBookingToMutationInput(bookingInput, recurringEventCount, tracking),
|
||||
callbacks
|
||||
);
|
||||
} else {
|
||||
handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl }, callbacks);
|
||||
handleBooking({ ...mapBookingToMutationInput(bookingInput), locationUrl, tracking }, callbacks);
|
||||
}
|
||||
// Clears form values stored in store, so old values won't stick around.
|
||||
setFormValues({});
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
|
||||
export function getUtmTrackingParameters(searchParams: ReadonlyURLSearchParams | null) {
|
||||
if (!searchParams) return undefined;
|
||||
|
||||
const utmParams = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
|
||||
|
||||
const tracking = Object.fromEntries(
|
||||
utmParams.map((param) => [param, searchParams.get(param) ?? undefined])
|
||||
);
|
||||
|
||||
return Object.values(tracking).every((value) => value === undefined) ? undefined : tracking;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "Tracking" (
|
||||
"id" SERIAL NOT NULL,
|
||||
"bookingId" INTEGER NOT NULL,
|
||||
"utm_source" TEXT,
|
||||
"utm_medium" TEXT,
|
||||
"utm_campaign" TEXT,
|
||||
"utm_term" TEXT,
|
||||
"utm_content" TEXT,
|
||||
|
||||
CONSTRAINT "Tracking_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Tracking_bookingId_key" ON "Tracking"("bookingId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Tracking" ADD CONSTRAINT "Tracking_bookingId_fkey" FOREIGN KEY ("bookingId") REFERENCES "Booking"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,80 @@
|
||||
CREATE OR REPLACE VIEW "RoutingFormResponse" AS
|
||||
SELECT
|
||||
r.id,
|
||||
r.response,
|
||||
(
|
||||
SELECT jsonb_object_agg(
|
||||
key,
|
||||
CASE
|
||||
WHEN jsonb_typeof(value->'value') = 'string'
|
||||
THEN jsonb_build_object(
|
||||
'label', value->'label',
|
||||
'value', lower((value->>'value')::text)
|
||||
)
|
||||
ELSE value
|
||||
END
|
||||
)
|
||||
FROM jsonb_each(r.response::jsonb)
|
||||
) as "responseLowercase",
|
||||
f.id as "formId",
|
||||
f.name as "formName",
|
||||
f."teamId" as "formTeamId",
|
||||
f."userId" as "formUserId",
|
||||
b.uid as "bookingUid",
|
||||
b.status as "bookingStatus",
|
||||
|
||||
CASE b.status
|
||||
WHEN 'accepted' THEN 1
|
||||
WHEN 'pending' THEN 2
|
||||
WHEN 'awaiting_host' THEN 3
|
||||
WHEN 'cancelled' THEN 4
|
||||
WHEN 'rejected' THEN 5
|
||||
END as "bookingStatusOrder",
|
||||
b."createdAt" as "bookingCreatedAt",
|
||||
b."startTime" as "bookingStartTime",
|
||||
b."endTime" as "bookingEndTime",
|
||||
(SELECT
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'name', a.name, 'timeZone', a."timeZone", 'email', a.email
|
||||
)
|
||||
)
|
||||
FROM "Attendee" a
|
||||
WHERE "a"."bookingId" = b.id
|
||||
) as "bookingAttendees",
|
||||
u.id as "bookingUserId",
|
||||
u.name as "bookingUserName",
|
||||
u.email as "bookingUserEmail",
|
||||
u."avatarUrl" as "bookingUserAvatarUrl",
|
||||
COALESCE(
|
||||
(
|
||||
SELECT
|
||||
ar."reasonString"
|
||||
FROM "AssignmentReason" ar
|
||||
WHERE ar."bookingId" = b.id
|
||||
LIMIT 1
|
||||
),
|
||||
''
|
||||
) as "bookingAssignmentReason",
|
||||
COALESCE(
|
||||
(
|
||||
SELECT
|
||||
LOWER(ar."reasonString")
|
||||
FROM "AssignmentReason" ar
|
||||
WHERE ar."bookingId" = b.id
|
||||
LIMIT 1
|
||||
),
|
||||
''
|
||||
) as "bookingAssignmentReasonLowercase",
|
||||
r."createdAt" as "createdAt",
|
||||
-- UTM parameters from Tracking table
|
||||
t.utm_source as "utm_source",
|
||||
t.utm_medium as "utm_medium",
|
||||
t.utm_campaign as "utm_campaign",
|
||||
t.utm_term as "utm_term",
|
||||
t.utm_content as "utm_content"
|
||||
FROM "App_RoutingForms_FormResponse" r
|
||||
LEFT JOIN "App_RoutingForms_Form" f ON r."formId" = f.id
|
||||
LEFT JOIN "Booking" b ON r."routedToBookingUid" = b.uid
|
||||
LEFT JOIN "users" u ON b."userId" = u.id
|
||||
LEFT JOIN "Tracking" t ON t."bookingId" = b.id;
|
||||
@@ -699,6 +699,7 @@ model Booking {
|
||||
assignmentReason AssignmentReason[]
|
||||
internalNote BookingInternalNote[]
|
||||
creationSource CreationSource?
|
||||
tracking Tracking?
|
||||
|
||||
@@index([eventTypeId])
|
||||
@@index([userId])
|
||||
@@ -709,6 +710,19 @@ model Booking {
|
||||
@@index([startTime, endTime, status])
|
||||
}
|
||||
|
||||
model Tracking {
|
||||
id Int @id @default(autoincrement())
|
||||
bookingId Int
|
||||
booking Booking @relation(fields: [bookingId], references: [id], onDelete: Cascade)
|
||||
utm_source String?
|
||||
utm_medium String?
|
||||
utm_campaign String?
|
||||
utm_term String?
|
||||
utm_content String?
|
||||
|
||||
@@unique([bookingId])
|
||||
}
|
||||
|
||||
model Schedule {
|
||||
id Int @id @default(autoincrement())
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
@@ -1080,6 +1094,11 @@ view RoutingFormResponse {
|
||||
bookingStartTime DateTime?
|
||||
bookingEndTime DateTime?
|
||||
createdAt DateTime
|
||||
utm_source String?
|
||||
utm_medium String?
|
||||
utm_campaign String?
|
||||
utm_term String?
|
||||
utm_content String?
|
||||
}
|
||||
|
||||
model Feedback {
|
||||
|
||||
@@ -39,6 +39,13 @@ export const bookingCreateBodySchema = z.object({
|
||||
_isDryRun: z.boolean().optional(),
|
||||
/** Whether to override the cache */
|
||||
_shouldServeCache: z.boolean().optional(),
|
||||
tracking: z.object({
|
||||
utm_source: z.string().optional(),
|
||||
utm_medium: z.string().optional(),
|
||||
utm_campaign: z.string().optional(),
|
||||
utm_term: z.string().optional(),
|
||||
utm_content: z.string().optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export type BookingCreateBody = z.input<typeof bookingCreateBodySchema>;
|
||||
|
||||
Reference in New Issue
Block a user