Skip to content

feat: Add custom reason for Out of Office#27950

Open
Shrey-Sutariya wants to merge 6 commits intocalcom:mainfrom
Shrey-Sutariya:feature/ooo-public-status
Open

feat: Add custom reason for Out of Office#27950
Shrey-Sutariya wants to merge 6 commits intocalcom:mainfrom
Shrey-Sutariya:feature/ooo-public-status

Conversation

@Shrey-Sutariya
Copy link
Contributor

@Shrey-Sutariya Shrey-Sutariya commented Feb 14, 2026

What does this PR do?

  1. OOO Visibility in Monthly Booker View
  • Added an Out-of-Office (OOO) indicator in the monthly booking calendar.
  • Bookers can now clearly see when the organiser is unavailable without needing to click into a date.
  • Improves transparency and reduces confusion when no slots are available.
image
  1. Custom OOO Reasons Support
  • Added a new Custom Reason button and form.
  • Users can create their own OOO reasons (e.g., Client Visit, On-site Day).
  • Custom reasons automatically appear in the Reason dropdown, making them reusable.
  • Users can delete custom reasons when they are no longer needed.
  • Added restriction: a custom reason cannot be deleted while it is being used in an existing OOO entry.
image image image
  1. “Other” Reason with Required Specification
  • Added a new “Other” reason type.
  • When selected, users must provide a Specified Reason.
  • Useful for rare or temporary cases (e.g., Flight missed) that should not appear permanently in the dropdown.
image

Visual Demo (For contributors especially)

Screen.Recording.2026-02-14.at.1.mp4

Mandatory Tasks (DO NOT REMOVE)

  • I have self-reviewed the code (A decent size PR without self-review might be rejected).
  • I have updated the developer docs in /docs if this PR makes changes that would require a documentation change. If N/A, write N/A here and check the checkbox.
  • I confirm automated tests are in place that prove my fix is effective or that my feature works.

How should this be tested?

  • Are there environment variables that should be set?
  • What are the minimal test data to have?
  • What is expected (happy path) to have (input and output)?
  • Any other important info that could help to test that PR

Checklist

  • I haven't read the contributing guide
  • My code doesn't follow the style guidelines of this project
  • I haven't commented my code, particularly in hard-to-understand areas
  • I haven't checked if my changes generate no new warnings
  • My PR is too large (>500 lines or >10 files) and should be split into smaller PRs

Open with Devin

@Shrey-Sutariya Shrey-Sutariya requested a review from a team as a code owner February 14, 2026 11:59
@CLAassistant
Copy link

CLAassistant commented Feb 14, 2026

CLA assistant check
All committers have signed the CLA.

@github-actions github-actions bot added Low priority Created by Linear-GitHub Sync 🧹 Improvements Improvements to existing features. Mostly UX/UI ❗️ migrations contains migration files and removed 🧹 Improvements Improvements to existing features. Mostly UX/UI Low priority Created by Linear-GitHub Sync labels Feb 14, 2026
@github-actions
Copy link
Contributor

github-actions bot commented Feb 14, 2026

Hey there and thank you for opening this pull request! 👋🏼

We require pull request titles to follow the Conventional Commits specification and it looks like your proposed title needs to be adjusted.

Details:

No release type found in pull request title "Feature/ooo public status :  Add custom reason for Out of Office". Add a prefix to indicate what kind of release this pull request corresponds to. For reference, see https://www.conventionalcommits.org/

Available types:
 - feat: A new feature
 - fix: A bug fix
 - docs: Documentation only changes
 - style: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc)
 - refactor: A code change that neither fixes a bug nor adds a feature
 - perf: A code change that improves performance
 - test: Adding missing tests or correcting existing tests
 - build: Changes that affect the build system or external dependencies (example scopes: gulp, broccoli, npm)
 - ci: Changes to our CI configuration files and scripts (example scopes: Travis, Circle, BrowserStack, SauceLabs)
 - chore: Other changes that don't modify src or test files
 - revert: Reverts a previous commit

@graphite-app graphite-app bot added the community Created by Linear-GitHub Sync label Feb 14, 2026
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3 issues found across 33 files

Prompt for AI agents (all issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/trpc/server/routers/viewer/ooo/outOfOfficeCustomReasonDelete.handler.ts">

<violation number="1" location="packages/trpc/server/routers/viewer/ooo/outOfOfficeCustomReasonDelete.handler.ts:19">
P2: Non-atomic check-then-delete allows a race where a reason becomes referenced after the check but before deletion, bypassing the “cannot delete if in use” rule. Because the FK uses onDelete: SetNull, the DB won’t prevent deletion.</violation>
</file>

<file name="packages/trpc/server/routers/viewer/ooo/outOfOfficeCustomReasonCreate.handler.ts">

<violation number="1" location="packages/trpc/server/routers/viewer/ooo/outOfOfficeCustomReasonCreate.handler.ts:17">
P2: Whitespace-only emoji/reason can pass validation and be persisted as empty strings because trimming happens after min-length validation.</violation>
</file>

<file name="apps/web/modules/bookings/components/OutOfOfficeInSlots.tsx">

<violation number="1" location="apps/web/modules/bookings/components/OutOfOfficeInSlots.tsx:42">
P2: OutOfOfficeInSlots now unconditionally calls an authed viewer.ooo.outOfOfficeReasonList query. The router defines this procedure as authedProcedure, so anonymous booking/embed users will receive UNAUTHORIZED errors, which can break or degrade public booking UI. Consider using a public endpoint or gating the query on auth.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

throw new TRPCError({ code: "NOT_FOUND", message: "custom_reason_not_found" });
}

const entryUsingReason = await prisma.outOfOfficeEntry.findFirst({
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Non-atomic check-then-delete allows a race where a reason becomes referenced after the check but before deletion, bypassing the “cannot delete if in use” rule. Because the FK uses onDelete: SetNull, the DB won’t prevent deletion.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/trpc/server/routers/viewer/ooo/outOfOfficeCustomReasonDelete.handler.ts, line 19:

<comment>Non-atomic check-then-delete allows a race where a reason becomes referenced after the check but before deletion, bypassing the “cannot delete if in use” rule. Because the FK uses onDelete: SetNull, the DB won’t prevent deletion.</comment>

<file context>
@@ -0,0 +1,31 @@
+    throw new TRPCError({ code: "NOT_FOUND", message: "custom_reason_not_found" });
+  }
+
+  const entryUsingReason = await prisma.outOfOfficeEntry.findFirst({
+    where: { reasonId: input.id },
+  });
</file context>
Fix with Cubic

data: {
userId: ctx.user.id,
emoji: input.emoji.trim(),
reason: input.reason.trim(),
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Whitespace-only emoji/reason can pass validation and be persisted as empty strings because trimming happens after min-length validation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/trpc/server/routers/viewer/ooo/outOfOfficeCustomReasonCreate.handler.ts, line 17:

<comment>Whitespace-only emoji/reason can pass validation and be persisted as empty strings because trimming happens after min-length validation.</comment>

<file context>
@@ -0,0 +1,23 @@
+    data: {
+      userId: ctx.user.id,
+      emoji: input.emoji.trim(),
+      reason: input.reason.trim(),
+      enabled: true,
+    },
</file context>
Fix with Cubic

const router = useRouter();

// Check if this is a holiday (no fromUser but has reason)
const { data: outOfOfficeReasonList } = trpc.viewer.ooo.outOfOfficeReasonList.useQuery(undefined, {
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Feb 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: OutOfOfficeInSlots now unconditionally calls an authed viewer.ooo.outOfOfficeReasonList query. The router defines this procedure as authedProcedure, so anonymous booking/embed users will receive UNAUTHORIZED errors, which can break or degrade public booking UI. Consider using a public endpoint or gating the query on auth.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/modules/bookings/components/OutOfOfficeInSlots.tsx, line 42:

<comment>OutOfOfficeInSlots now unconditionally calls an authed viewer.ooo.outOfOfficeReasonList query. The router defines this procedure as authedProcedure, so anonymous booking/embed users will receive UNAUTHORIZED errors, which can break or degrade public booking UI. Consider using a public endpoint or gating the query on auth.</comment>

<file context>
@@ -26,17 +28,30 @@ export const OutOfOfficeInSlots = (props: IOutOfOfficeInSlotsProps) => {
   const router = useRouter();
 
-  // Check if this is a holiday (no fromUser but has reason)
+  const { data: outOfOfficeReasonList } = trpc.viewer.ooo.outOfOfficeReasonList.useQuery(undefined, {
+    retry: false,
+  });
</file context>
Fix with Cubic

Copy link
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 8 additional findings in Devin Review.

Open in Devin Review

Comment on lines +3 to 6
export const outOfOfficeReasonList = async (_opts?: unknown) => {
const outOfOfficeReasons = await prisma.outOfOfficeReason.findMany({
where: {
enabled: true,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 outOfOfficeReasonList leaks all users' custom reasons to every authenticated user

The outOfOfficeReasonList handler returns ALL enabled OutOfOfficeReason rows without filtering by the current user's ID. Since custom reasons now have a non-null userId, every authenticated user sees every other user's custom reasons in their OOO dropdown and reason display.

Root Cause and Impact

The handler at packages/trpc/server/routers/viewer/ooo/outOfOfficeReasons.handler.ts:3-11 queries:

prisma.outOfOfficeReason.findMany({ where: { enabled: true } })

This has no userId filter, so it returns system reasons (userId=null) and custom reasons from all users. The correct behavior would be to filter: OR: [{ userId: null }, { userId: ctx.user.id }].

Impact:

  • Privacy leak: User A's custom OOO reasons (e.g., "Therapy appointment", "Job interview") are visible to User B.
  • UX pollution: Every user's dropdown is cluttered with other users' custom reasons.
  • The same unfiltered list is used in OutOfOfficeInSlots.tsx:42 and OutOfOfficeMonthViewDetails.tsx:106 for label resolution, so custom reasons from other users will show labels, but this is a minor side-effect compared to the dropdown leak.

(Refers to lines 3-11)

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +42 to +44
const { data: outOfOfficeReasonList } = trpc.viewer.ooo.outOfOfficeReasonList.useQuery(undefined, {
retry: false,
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Authenticated tRPC procedure called from public booker page breaks for unauthenticated visitors

Both OutOfOfficeInSlots and OutOfOfficeMonthViewDetails call trpc.viewer.ooo.outOfOfficeReasonList.useQuery(), which is an authedProcedure (see _router.tsx:24). These components are rendered on the public booking page via the Booker component, which is accessible to unauthenticated visitors.

Detailed Explanation

In OutOfOfficeInSlots.tsx:42:

const { data: outOfOfficeReasonList } = trpc.viewer.ooo.outOfOfficeReasonList.useQuery(undefined, {
  retry: false,
});

And in OutOfOfficeMonthViewDetails.tsx:106:

const { data: outOfOfficeReasonList } = trpc.viewer.ooo.outOfOfficeReasonList.useQuery(undefined, {
  retry: false,
});

When an unauthenticated booker visits someone's booking page and that person is OOO:

  1. The component renders and fires an authenticated API request
  2. The request returns a 401/UNAUTHORIZED error
  3. outOfOfficeReasonList is undefined, so getReasonLabel falls back to returning raw reason keys (e.g., "ooo_reasons_vacation" instead of the translated "Vacation")
  4. Console/network errors appear for every public visitor

Impact: Degraded reason labels on the public booker (raw translation keys displayed) and unnecessary failed API calls for every unauthenticated visitor. For OutOfOfficeMonthViewDetails, this is especially visible since it shows the OOO reason prominently in the month view sidebar.

Prompt for agents
The trpc.viewer.ooo.outOfOfficeReasonList.useQuery() calls in OutOfOfficeInSlots.tsx (line 42) and OutOfOfficeMonthViewDetails.tsx (line 106) use an authedProcedure but are rendered on the public booker page. Fix this by either: (1) creating a new public (unauthenticated) tRPC procedure that returns only system reason labels needed for display, or (2) removing the tRPC calls entirely and instead passing reason labels through the slot data from the server side (the slot already carries the reason string key, and the server could resolve it to a display label before sending). The second approach is more robust and avoids an extra client-side API call.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +19 to +21
const entryUsingReason = await prisma.outOfOfficeEntry.findFirst({
where: { reasonId: input.id },
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 outOfOfficeCustomReasonDelete checks in-use across ALL users, not scoped to owner

The delete handler checks if a custom reason is in use by querying outOfOfficeEntry.findFirst({ where: { reasonId: input.id } }) without scoping to the current user. This means if User A created a custom reason and User B somehow used it (or a reason ID collision scenario), User A cannot delete their own reason.

Root Cause

In outOfOfficeCustomReasonDelete.handler.ts:19-21:

const entryUsingReason = await prisma.outOfOfficeEntry.findFirst({
  where: { reasonId: input.id },
});

This queries ALL OOO entries, not just those belonging to the current user. Combined with BUG-0001 (where all users see all custom reasons), if User B selects User A's custom reason for their OOO entry, User A can never delete their own custom reason.

The ownership check at line 12-13 correctly validates that only the reason creator can delete it, but the in-use check should also be scoped to entries owned by the same user, or at minimum clearly documented as intentional cross-user protection.

Impact: Users may be unable to delete their own custom reasons due to other users' OOO entries referencing them (which is possible due to BUG-0001).

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

{notes && showNotePublicly && (
<p className="text-subtle mt-2 max-h-[120px] overflow-y-auto break-words px-2 text-center text-sm italic">
{notes}
{t("note")}:{notes}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Missing space after colon in notes display creates squished text

In OutOfOfficeInSlots.tsx line 96, the notes display uses {t("note")}:{notes} without a space after the colon, producing output like "Note:Some note text" instead of "Note: Some note text".

Comparison with other reason displays

Other similar displays in the same file correctly include a space:

  • Line 84: {t("reason")}: {getReasonLabel(specifiedReason)} (has space)
  • Line 88: {t("reason")}: {getReasonLabel(reason)} (has space)

But line 96 is missing the space:

{t("note")}:{notes}
Suggested change
{t("note")}:{notes}
{t("note")}: {notes}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@romitg2 romitg2 changed the title Feature/ooo public status : Add custom reason for Out of Office feat: Add custom reason for Out of Office Feb 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

community Created by Linear-GitHub Sync ❗️ migrations contains migration files size/XL

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Custom Out of Office Status

3 participants