From 85a2204c411009489b8a77c023e11e49336fdfb4 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Wed, 25 Feb 2026 14:22:29 -0500 Subject: [PATCH 1/2] feat: load user roles and groups into MPUserProfile After login, UserService now fetches the user's roles (from dp_User_Roles) and user groups (from dp_User_User_Groups) in parallel and includes them as string arrays on MPUserProfile. This enables downstream components to make authorization decisions based on roles and group membership. - Add roles and userGroups fields to MPUserProfile interface - Query dp_User_Roles and dp_User_User_Groups via _ID_TABLE JOINs - Return undefined explicitly when user not found (with early return guard) - Update shared action and user context to handle undefined return type - Add tests for roles/groups mapping and empty result handling Co-Authored-By: Claude Opus 4.6 --- src/components/shared-actions/user.test.ts | 2 + src/components/shared-actions/user.ts | 2 +- src/contexts/user-context.tsx | 2 +- .../types/user-profile.types.ts | 2 + src/services/userService.test.ts | 54 +++++++++++++++++-- src/services/userService.ts | 29 ++++++++-- 6 files changed, 80 insertions(+), 11 deletions(-) diff --git a/src/components/shared-actions/user.test.ts b/src/components/shared-actions/user.test.ts index fa67ac6..30166b8 100644 --- a/src/components/shared-actions/user.test.ts +++ b/src/components/shared-actions/user.test.ts @@ -30,6 +30,8 @@ describe('getCurrentUserProfile', () => { Email_Address: 'john@example.com', Mobile_Phone: null, Image_GUID: null, + roles: ['Admin'], + userGroups: ['Staff'], }; mockGetUserProfile.mockResolvedValueOnce(mockProfile); diff --git a/src/components/shared-actions/user.ts b/src/components/shared-actions/user.ts index 27c7d63..1e65fed 100644 --- a/src/components/shared-actions/user.ts +++ b/src/components/shared-actions/user.ts @@ -8,7 +8,7 @@ import { UserService } from '@/services/userService'; * @param id - The user's contact ID * @returns The user's profile data */ -export async function getCurrentUserProfile(id: string): Promise { +export async function getCurrentUserProfile(id: string): Promise { const userService = await UserService.getInstance(); const userProfile = await userService.getUserProfile(id); return userProfile; diff --git a/src/contexts/user-context.tsx b/src/contexts/user-context.tsx index 03b6d89..f2523ab 100644 --- a/src/contexts/user-context.tsx +++ b/src/contexts/user-context.tsx @@ -39,7 +39,7 @@ export function UserProvider({ children }: UserProviderProps) { setIsLoading(true); setError(null); const profile = await getCurrentUserProfile(userGuid); - setUserProfile(profile); + setUserProfile(profile ?? null); } catch (err) { setError(err instanceof Error ? err : new Error("Failed to load user profile")); setUserProfile(null); diff --git a/src/lib/providers/ministry-platform/types/user-profile.types.ts b/src/lib/providers/ministry-platform/types/user-profile.types.ts index ff70410..d1f834a 100644 --- a/src/lib/providers/ministry-platform/types/user-profile.types.ts +++ b/src/lib/providers/ministry-platform/types/user-profile.types.ts @@ -8,4 +8,6 @@ export interface MPUserProfile { Email_Address: string | null; Mobile_Phone: string | null; Image_GUID: string | null; + roles: string[]; + userGroups: string[]; } diff --git a/src/services/userService.test.ts b/src/services/userService.test.ts index ab01bcc..9c82745 100644 --- a/src/services/userService.test.ts +++ b/src/services/userService.test.ts @@ -28,7 +28,7 @@ describe('UserService', () => { }); describe('getUserProfile', () => { - it('should fetch user profile with correct parameters', async () => { + it('should fetch user profile with roles and groups', async () => { const mockProfile = { User_ID: 1, User_GUID: 'test-guid-123', @@ -40,18 +40,36 @@ describe('UserService', () => { Mobile_Phone: '555-1234', Image_GUID: 'img-guid-456', }; - mockGetTableRecords.mockResolvedValueOnce([mockProfile]); + mockGetTableRecords + .mockResolvedValueOnce([mockProfile]) + .mockResolvedValueOnce([{ Role_Name: 'Admin' }, { Role_Name: 'Editor' }]) + .mockResolvedValueOnce([{ User_Group_Name: 'Staff' }]); const service = await UserService.getInstance(); const result = await service.getUserProfile('test-guid-123'); + expect(mockGetTableRecords).toHaveBeenCalledTimes(3); expect(mockGetTableRecords).toHaveBeenCalledWith({ table: 'dp_Users', filter: "User_GUID = 'test-guid-123'", - select: expect.stringContaining('User_GUID'), + select: expect.stringContaining('User_ID'), top: 1, }); - expect(result).toEqual(mockProfile); + expect(mockGetTableRecords).toHaveBeenCalledWith({ + table: 'dp_User_Roles', + filter: 'User_ID = 1', + select: 'Role_ID_TABLE.Role_Name', + }); + expect(mockGetTableRecords).toHaveBeenCalledWith({ + table: 'dp_User_User_Groups', + filter: 'User_ID = 1', + select: 'User_Group_ID_TABLE.User_Group_Name', + }); + expect(result).toEqual({ + ...mockProfile, + roles: ['Admin', 'Editor'], + userGroups: ['Staff'], + }); }); it('should return undefined when user not found', async () => { @@ -61,6 +79,34 @@ describe('UserService', () => { const result = await service.getUserProfile('nonexistent-guid'); expect(result).toBeUndefined(); + expect(mockGetTableRecords).toHaveBeenCalledTimes(1); + }); + + it('should return empty arrays when user has no roles or groups', async () => { + const mockProfile = { + User_ID: 2, + User_GUID: 'test-guid-456', + Contact_ID: 200, + First_Name: 'Jane', + Nickname: 'Jane', + Last_Name: 'Smith', + Email_Address: 'jane@example.com', + Mobile_Phone: null, + Image_GUID: null, + }; + mockGetTableRecords + .mockResolvedValueOnce([mockProfile]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const service = await UserService.getInstance(); + const result = await service.getUserProfile('test-guid-456'); + + expect(result).toEqual({ + ...mockProfile, + roles: [], + userGroups: [], + }); }); it('should propagate errors from MPHelper', async () => { diff --git a/src/services/userService.ts b/src/services/userService.ts index 918f083..7c61edd 100644 --- a/src/services/userService.ts +++ b/src/services/userService.ts @@ -57,15 +57,34 @@ export class UserService { * @returns Promise - The user profile data from Ministry Platform * @throws Will throw an error if the Ministry Platform query fails */ - public async getUserProfile(id: string): Promise { + public async getUserProfile(id: string): Promise { const records = await this.mp!.getTableRecords({ table: "dp_Users", filter: `User_GUID = '${id}'`, - select: "User_GUID, Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address,Contact_ID_TABLE.Mobile_Phone,Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID", + select: "User_ID, User_GUID, Contact_ID_TABLE.First_Name,Contact_ID_TABLE.Nickname,Contact_ID_TABLE.Last_Name,Contact_ID_TABLE.Email_Address,Contact_ID_TABLE.Mobile_Phone,Contact_ID_TABLE.dp_fileUniqueId AS Image_GUID", top: 1 }); - - // Return the first (and should be only) matching record - return records[0]; + + const profile = records[0]; + if (!profile) return undefined; + + const [roleRecords, groupRecords] = await Promise.all([ + this.mp!.getTableRecords<{ Role_Name: string }>({ + table: "dp_User_Roles", + filter: `User_ID = ${profile.User_ID}`, + select: "Role_ID_TABLE.Role_Name", + }), + this.mp!.getTableRecords<{ User_Group_Name: string }>({ + table: "dp_User_User_Groups", + filter: `User_ID = ${profile.User_ID}`, + select: "User_Group_ID_TABLE.User_Group_Name", + }), + ]); + + return { + ...profile, + roles: roleRecords.map((r) => r.Role_Name), + userGroups: groupRecords.map((g) => g.User_Group_Name), + }; } } \ No newline at end of file From 6123912fd53c16991b72d6ae55f2ecf2ed374236 Mon Sep 17 00:00:00 2001 From: Chris Kehayias Date: Thu, 26 Feb 2026 12:47:56 -0500 Subject: [PATCH 2/2] ci: add Discord release notification workflow Sends a Discord embed on new GitHub releases using jq to safely build the JSON payload, avoiding breakage from special characters in release notes. Co-Authored-By: Claude Sonnet 4.6 --- .../discord-release-notification.yml | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 .github/workflows/discord-release-notification.yml diff --git a/.github/workflows/discord-release-notification.yml b/.github/workflows/discord-release-notification.yml new file mode 100644 index 0000000..05025b3 --- /dev/null +++ b/.github/workflows/discord-release-notification.yml @@ -0,0 +1,33 @@ +name: Discord Release Notification + +on: + release: + types: [published] + +jobs: + notify: + runs-on: ubuntu-latest + steps: + - name: Send Discord Notification + env: + DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK_URL }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_NAME: ${{ github.event.release.name }} + RELEASE_BODY: ${{ github.event.release.body }} + RELEASE_URL: ${{ github.event.release.html_url }} + RELEASE_AUTHOR: ${{ github.event.release.author.login }} + RELEASE_TIMESTAMP: ${{ github.event.release.published_at }} + REPO: ${{ github.repository }} + run: | + PAYLOAD=$(jq -n \ + --arg title "🚀 New Release: $RELEASE_TAG" \ + --arg desc "$RELEASE_NAME\n\n$RELEASE_BODY" \ + --arg url "$RELEASE_URL" \ + --arg author "$RELEASE_AUTHOR" \ + --arg repo "$REPO" \ + --arg ts "$RELEASE_TIMESTAMP" \ + '{embeds: [{title: $title, description: $desc, url: $url, color: 5763719, + fields: [{name: "Repository", value: $repo, inline: true}, + {name: "Author", value: $author, inline: true}], + timestamp: $ts}]}') + curl -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK"