From d86249c18eca5150ddffb4ba59f02ff1a1050962 Mon Sep 17 00:00:00 2001 From: "Daniel J. Summers" Date: Thu, 30 Jan 2025 23:01:49 -0500 Subject: [PATCH] Being migrating application to doc lib (#55) --- src/PrayerTracker.Data/Access.fs | 445 ++++++------------ src/PrayerTracker.Data/Entities.fs | 15 + .../PrayerTracker.Data.fsproj | 3 +- src/PrayerTracker/Church.fs | 2 +- src/PrayerTracker/Extensions.fs | 26 +- src/PrayerTracker/PrayerRequest.fs | 2 +- src/PrayerTracker/SmallGroup.fs | 9 +- src/PrayerTracker/User.fs | 4 +- 8 files changed, 177 insertions(+), 329 deletions(-) diff --git a/src/PrayerTracker.Data/Access.fs b/src/PrayerTracker.Data/Access.fs index fff7867..47d908d 100644 --- a/src/PrayerTracker.Data/Access.fs +++ b/src/PrayerTracker.Data/Access.fs @@ -1,8 +1,7 @@ namespace PrayerTracker.Data +open System open NodaTime -open Npgsql -open Npgsql.FSharp open PrayerTracker.Entities /// Table names @@ -30,51 +29,75 @@ module Table = let User = "pt_user" +/// JSON serialization customizations +[] +module Json = + + open System.Text.Json.Serialization + + /// Convert a wrapped DU to/from its string representation + type WrappedJsonConverter<'T>(wrap : string -> 'T, unwrap : 'T -> string) = + inherit JsonConverter<'T>() + override _.Read(reader, _, _) = + wrap (reader.GetString()) + override _.Write(writer, value, _) = + writer.WriteStringValue(unwrap value) + + open System.Text.Json + open NodaTime.Serialization.SystemTextJson + + /// JSON serializer options to support the target domain + let options = + let opts = JsonSerializerOptions() + [ WrappedJsonConverter(AsOfDateDisplay.Parse, string) :> JsonConverter + WrappedJsonConverter(EmailFormat.Parse, string) + WrappedJsonConverter(Expiration.Parse, string) + WrappedJsonConverter(PrayerRequestType.Parse, string) + WrappedJsonConverter(RequestSort.Parse, string) + WrappedJsonConverter(TimeZoneId, string) + WrappedJsonConverter(Guid.Parse >> ChurchId, string) + WrappedJsonConverter(Guid.Parse >> MemberId, string) + WrappedJsonConverter(Guid.Parse >> PrayerRequestId, string) + WrappedJsonConverter(Guid.Parse >> SmallGroupId, string) + WrappedJsonConverter(Guid.Parse >> UserId, string) + JsonFSharpConverter() ] + |> List.iter opts.Converters.Add + let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb + opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase + opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull + opts + + +open BitBadger.Documents.Sqlite + +/// Establish the required data environment +[] +module Environment = + + /// Ensure tables and indexes are defined + let setUp () = backgroundTask { + let! tables = Custom.list "SELECT table_name FROM sqlite_master" [] _.GetString(0) + if not (List.contains Table.Church tables) then + do! Definition.ensureTable Table.Church + if not (List.contains Table.Group tables) then + do! Definition.ensureTable Table.Group + do! Definition.ensureFieldIndex Table.Group "church" [ "churchId" ] + if not (List.contains Table.Member tables) then + do! Definition.ensureTable Table.Member + do! Definition.ensureFieldIndex Table.Member "group" [ "smallGroupId" ] + if not (List.contains Table.Request tables) then + do! Definition.ensureTable Table.Request + do! Definition.ensureFieldIndex Table.Request "group" [ "smallGroupId" ] + if not (List.contains Table.User tables) then + do! Definition.ensureTable Table.User + do! Definition.ensureFieldIndex Table.User "email" [ "email" ] + } + + /// Helper functions for the PostgreSQL data implementation [] module private Helpers = - /// Map a row to a Church instance - let mapToChurch (row : RowReader) = - { Id = ChurchId (row.uuid "id") - Name = row.string "church_name" - City = row.string "city" - State = row.string "state" - HasVpsInterface = row.bool "has_vps_interface" - InterfaceAddress = row.stringOrNone "interface_address" - } - - /// Map a row to a ListPreferences instance - let mapToListPreferences (row : RowReader) = - { SmallGroupId = SmallGroupId (row.uuid "small_group_id") - DaysToKeepNew = row.int "days_to_keep_new" - DaysToExpire = row.int "days_to_expire" - LongTermUpdateWeeks = row.int "long_term_update_weeks" - EmailFromName = row.string "email_from_name" - EmailFromAddress = row.string "email_from_address" - Fonts = row.string "fonts" - HeadingColor = row.string "heading_color" - LineColor = row.string "line_color" - HeadingFontSize = row.int "heading_font_size" - TextFontSize = row.int "text_font_size" - GroupPassword = row.string "group_password" - IsPublic = row.bool "is_public" - PageSize = row.int "page_size" - TimeZoneId = TimeZoneId (row.string "time_zone_id") - RequestSort = RequestSort.Parse (row.string "request_sort") - DefaultEmailType = EmailFormat.Parse (row.string "default_email_type") - AsOfDateDisplay = AsOfDateDisplay.Parse (row.string "as_of_date_display") - } - - /// Map a row to a Member instance - let mapToMember (row : RowReader) = - { Id = MemberId (row.uuid "id") - SmallGroupId = SmallGroupId (row.uuid "small_group_id") - Name = row.string "member_name" - Email = row.string "email" - Format = row.stringOrNone "email_format" |> Option.map EmailFormat.Parse - } - /// Map a row to a Prayer Request instance let mapToPrayerRequest (row : RowReader) = { Id = PrayerRequestId (row.uuid "id") @@ -89,14 +112,6 @@ module private Helpers = Expiration = Expiration.Parse (row.string "expiration") } - /// Map a row to a Small Group instance - let mapToSmallGroup (row : RowReader) = - { Id = SmallGroupId (row.uuid "id") - ChurchId = ChurchId (row.uuid "church_id") - Name = row.string "group_name" - Preferences = ListPreferences.Empty - } - /// Map a row to a Small Group information set let mapToSmallGroupInfo (row : RowReader) = { Id = Giraffe.ShortGuid.fromGuid (row.uuid "id") @@ -110,12 +125,6 @@ module private Helpers = let mapToSmallGroupItem (row : RowReader) = Giraffe.ShortGuid.fromGuid (row.uuid "id"), $"""{row.string "church_name"} | {row.string "group_name"}""" - /// Map a row to a Small Group instance with populated list preferences - let mapToSmallGroupWithPreferences (row : RowReader) = - { mapToSmallGroup row with - Preferences = mapToListPreferences row - } - /// Map a row to a User instance let mapToUser (row : RowReader) = { Id = UserId (row.uuid "id") @@ -129,21 +138,23 @@ module private Helpers = } -open BitBadger.Documents.Postgres +open BitBadger.Documents +open Npgsql +open Npgsql.FSharp /// Functions to manipulate churches module Churches = /// Get a list of all churches let all () = - Custom.list "SELECT * FROM pt.church ORDER BY church_name" [] mapToChurch + Find.all Table.Church /// Delete a church by its ID - let deleteById (churchId : ChurchId) = backgroundTask { + let deleteById (churchId: ChurchId) = backgroundTask { let idParam = [ [ "@churchId", Sql.uuid churchId.Value ] ] let where = "WHERE small_group_id IN (SELECT id FROM pt.small_group WHERE church_id = @churchId)" let! _ = - Configuration.dataSource () + BitBadger.Documents.Postgres.Configuration.dataSource () |> Sql.fromDataSource |> Sql.executeTransactionAsync [ $"DELETE FROM pt.prayer_request {where}", idParam @@ -155,67 +166,37 @@ module Churches = } /// Save a church's information - let save (church : Church) = - Custom.nonQuery - "INSERT INTO pt.church ( - id, church_name, city, state, has_vps_interface, interface_address - ) VALUES ( - @id, @name, @city, @state, @hasVpsInterface, @interfaceAddress - ) ON CONFLICT (id) DO UPDATE - SET church_name = EXCLUDED.church_name, - city = EXCLUDED.city, - state = EXCLUDED.state, - has_vps_interface = EXCLUDED.has_vps_interface, - interface_address = EXCLUDED.interface_address" - [ "@id", Sql.uuid church.Id.Value - "@name", Sql.string church.Name - "@city", Sql.string church.City - "@state", Sql.string church.State - "@hasVpsInterface", Sql.bool church.HasVpsInterface - "@interfaceAddress", Sql.stringOrNone church.InterfaceAddress ] + let save church = + save Table.Church church /// Find a church by its ID - let tryById (churchId : ChurchId) = - Custom.single "SELECT * FROM pt.church WHERE id = @id" [ "@id", Sql.uuid churchId.Value ] mapToChurch + let tryById churchId = + Find.byId Table.Church churchId /// Functions to manipulate small group members module Members = /// Count members for the given small group - let countByGroup (groupId : SmallGroupId) = - Custom.scalar "SELECT COUNT(id) AS mbr_count FROM pt.member WHERE small_group_id = @groupId" - [ "@groupId", Sql.uuid groupId.Value ] (fun row -> row.int "mbr_count") + let countByGroup (groupId: SmallGroupId) = + Count.byFields Table.Member All [ Field.Equal "smallGroupId" groupId ] /// Delete a small group member by its ID - let deleteById (memberId : MemberId) = - Custom.nonQuery "DELETE FROM pt.member WHERE id = @id" [ "@id", Sql.uuid memberId.Value ] + let deleteById (memberId: MemberId) = + Delete.byId Table.Member memberId /// Retrieve all members for a given small group let forGroup (groupId : SmallGroupId) = - Custom.list "SELECT * FROM pt.member WHERE small_group_id = @groupId ORDER BY member_name" - [ "@groupId", Sql.uuid groupId.Value ] mapToMember + Find.byFieldsOrdered + Table.Member All [ Field.Equal "smallGroupId" groupId ] [ Field.Named "memberName" ] /// Save a small group member - let save (mbr : Member) = - Custom.nonQuery - "INSERT INTO pt.member ( - id, small_group_id, member_name, email, email_format - ) VALUES ( - @id, @groupId, @name, @email, @format - ) ON CONFLICT (id) DO UPDATE - SET member_name = EXCLUDED.member_name, - email = EXCLUDED.email, - email_format = EXCLUDED.email_format" - [ "@id", Sql.uuid mbr.Id.Value - "@groupId", Sql.uuid mbr.SmallGroupId.Value - "@name", Sql.string mbr.Name - "@email", Sql.string mbr.Email - "@format", Sql.stringOrNone (mbr.Format |> Option.map string) ] + let save mbr = + save Table.Member mbr /// Retrieve a small group member by its ID - let tryById (memberId : MemberId) = - Custom.single "SELECT * FROM pt.member WHERE id = @id" [ "@id", Sql.uuid memberId.Value ] mapToMember + let tryById memberId = + Find.byId Table.Member memberId /// Options to retrieve a list of requests @@ -252,20 +233,19 @@ module PrayerRequests = /// Count the number of prayer requests for a church let countByChurch (churchId : ChurchId) = - Custom.scalar + BitBadger.Documents.Postgres.Custom.scalar "SELECT COUNT(id) AS req_count FROM pt.prayer_request WHERE small_group_id IN (SELECT id FROM pt.small_group WHERE church_id = @churchId)" [ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "req_count") /// Count the number of prayer requests for a small group - let countByGroup (groupId : SmallGroupId) = - Custom.scalar "SELECT COUNT(id) AS req_count FROM pt.prayer_request WHERE small_group_id = @groupId" - [ "@groupId", Sql.uuid groupId.Value ] (fun row -> row.int "req_count") + let countByGroup (groupId: SmallGroupId) = + Count.byFields Table.Request All [ Field.Equal "smallGroupId" groupId ] /// Delete a prayer request by its ID - let deleteById (reqId : PrayerRequestId) = - Custom.nonQuery "DELETE FROM pt.prayer_request WHERE id = @id" [ "@id", Sql.uuid reqId.Value ] + let deleteById (reqId: PrayerRequestId) = + Delete.byId Table.Request reqId /// Get all (or active) requests for a small group as of now or the specified date let forGroup (opts : PrayerRequestOptions) = @@ -288,7 +268,7 @@ module PrayerRequests = "@expecting", Sql.string (string Expecting) "@forced", Sql.string (string Forced) ] else "", [] - Custom.list + BitBadger.Documents.Postgres.Custom.list $"SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId {where} @@ -297,35 +277,12 @@ module PrayerRequests = (("@groupId", Sql.uuid opts.SmallGroup.Id.Value) :: parameters) mapToPrayerRequest /// Save a prayer request - let save (req : PrayerRequest) = - Custom.nonQuery - "INSERT into pt.prayer_request ( - id, request_type, user_id, small_group_id, entered_date, updated_date, requestor, request_text, - notify_chaplain, expiration - ) VALUES ( - @id, @type, @userId, @groupId, @entered, @updated, @requestor, @text, - @notifyChaplain, @expiration - ) ON CONFLICT (id) DO UPDATE - SET request_type = EXCLUDED.request_type, - updated_date = EXCLUDED.updated_date, - requestor = EXCLUDED.requestor, - request_text = EXCLUDED.request_text, - notify_chaplain = EXCLUDED.notify_chaplain, - expiration = EXCLUDED.expiration" - [ "@id", Sql.uuid req.Id.Value - "@type", Sql.string (string req.RequestType) - "@userId", Sql.uuid req.UserId.Value - "@groupId", Sql.uuid req.SmallGroupId.Value - "@entered", Sql.parameter (NpgsqlParameter("@entered", req.EnteredDate)) - "@updated", Sql.parameter (NpgsqlParameter("@updated", req.UpdatedDate)) - "@requestor", Sql.stringOrNone req.Requestor - "@text", Sql.string req.Text - "@notifyChaplain", Sql.bool req.NotifyChaplain - "@expiration", Sql.string (string req.Expiration) ] + let save req = + save Table.Request req /// Search prayer requests for the given term let searchForGroup group searchTerm pageNbr = - Custom.list + BitBadger.Documents.Postgres.Custom.list $"SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId AND request_text ILIKE @search UNION SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId AND COALESCE(requestor, '') ILIKE @search @@ -334,35 +291,29 @@ module PrayerRequests = [ "@groupId", Sql.uuid group.Id.Value; "@search", Sql.string $"%%%s{searchTerm}%%" ] mapToPrayerRequest /// Retrieve a prayer request by its ID - let tryById (reqId : PrayerRequestId) = - Custom.single "SELECT * FROM pt.prayer_request WHERE id = @id" [ "@id", Sql.uuid reqId.Value ] - mapToPrayerRequest + let tryById reqId = + Find.byId Table.Request reqId /// Update the expiration for the given prayer request - let updateExpiration (req : PrayerRequest) withTime = - let sql, parameters = - if withTime then - ", updated_date = @updated", - [ "@updated", Sql.parameter (NpgsqlParameter ("@updated", req.UpdatedDate)) ] - else "", [] - Custom.nonQuery $"UPDATE pt.prayer_request SET expiration = @expiration{sql} WHERE id = @id" - ([ "@expiration", Sql.string (string req.Expiration); "@id", Sql.uuid req.Id.Value ] - |> List.append parameters) + let updateExpiration (req: PrayerRequest) withTime = + if withTime then + Patch.byId Table.Request req.Id {| UpdatedDate = req.UpdatedDate; Expiration = req.Expiration |} + else + Patch.byId Table.Request req.Id {| Expiration = req.Expiration |} /// Functions to retrieve small group information module SmallGroups = /// Count the number of small groups for a church - let countByChurch (churchId : ChurchId) = - Custom.scalar "SELECT COUNT(id) AS group_count FROM pt.small_group WHERE church_id = @churchId" - [ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "group_count") + let countByChurch (churchId: ChurchId) = + Count.byFields Table.Group All [ Field.Equal "churchId" churchId ] /// Delete a small group by its ID let deleteById (groupId : SmallGroupId) = backgroundTask { let idParam = [ [ "@groupId", Sql.uuid groupId.Value ] ] let! _ = - Configuration.dataSource () + BitBadger.Documents.Postgres.Configuration.dataSource () |> Sql.fromDataSource |> Sql.executeTransactionAsync [ "DELETE FROM pt.prayer_request WHERE small_group_id = @groupId", idParam @@ -374,7 +325,7 @@ module SmallGroups = /// Get information for all small groups let infoForAll () = - Custom.list + BitBadger.Documents.Postgres.Custom.list "SELECT sg.id, sg.group_name, c.church_name, lp.time_zone_id, lp.is_public FROM pt.small_group sg INNER JOIN pt.church c ON c.id = sg.church_id @@ -384,7 +335,7 @@ module SmallGroups = /// Get a list of small group IDs along with a description that includes the church name let listAll () = - Custom.list + BitBadger.Documents.Postgres.Custom.list "SELECT g.group_name, g.id, c.church_name FROM pt.small_group g INNER JOIN pt.church c ON c.id = g.church_id @@ -393,7 +344,7 @@ module SmallGroups = /// Get a list of small group IDs and descriptions for groups with a group password let listProtected () = - Custom.list + BitBadger.Documents.Postgres.Custom.list "SELECT g.group_name, g.id, c.church_name, lp.is_public FROM pt.small_group g INNER JOIN pt.church c ON c.id = g.church_id @@ -404,7 +355,7 @@ module SmallGroups = /// Get a list of small group IDs and descriptions for groups that are public or have a group password let listPublicAndProtected () = - Custom.list + BitBadger.Documents.Postgres.Custom.list "SELECT g.group_name, g.id, c.church_name, lp.time_zone_id, lp.is_public FROM pt.small_group g INNER JOIN pt.church c ON c.id = g.church_id @@ -415,91 +366,21 @@ module SmallGroups = [] mapToSmallGroupInfo /// Log on for a small group (includes list preferences) - let logOn (groupId : SmallGroupId) password = - Custom.single - "SELECT sg.*, lp.* - FROM pt.small_group sg - INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id - WHERE sg.id = @id - AND lp.group_password = @password" - [ "@id", Sql.uuid groupId.Value; "@password", Sql.string password ] mapToSmallGroupWithPreferences + let logOn (groupId: SmallGroupId) (password: string) = + Find.firstByFields + Table.Group All [ Field.Equal "id" groupId; Field.Equal "preferences.groupPassword" password ] /// Save a small group - let save (group : SmallGroup) isNew = backgroundTask { - let! _ = - Configuration.dataSource () - |> Sql.fromDataSource - |> Sql.executeTransactionAsync [ - "INSERT INTO pt.small_group ( - id, church_id, group_name - ) VALUES ( - @id, @churchId, @name - ) ON CONFLICT (id) DO UPDATE - SET church_id = EXCLUDED.church_id, - group_name = EXCLUDED.group_name", - [ [ "@id", Sql.uuid group.Id.Value - "@churchId", Sql.uuid group.ChurchId.Value - "@name", Sql.string group.Name ] ] - if isNew then - "INSERT INTO pt.list_preference (small_group_id) VALUES (@id)", - [ [ "@id", Sql.uuid group.Id.Value ] ] - ] - () - } + let save group = + save Table.Group group /// Save a small group's list preferences - let savePreferences (pref : ListPreferences) = - Custom.nonQuery - "UPDATE pt.list_preference - SET days_to_keep_new = @daysToKeepNew, - days_to_expire = @daysToExpire, - long_term_update_weeks = @longTermUpdateWeeks, - email_from_name = @emailFromName, - email_from_address = @emailFromAddress, - fonts = @fonts, - heading_color = @headingColor, - line_color = @lineColor, - heading_font_size = @headingFontSize, - text_font_size = @textFontSize, - request_sort = @requestSort, - group_password = @groupPassword, - default_email_type = @defaultEmailType, - is_public = @isPublic, - time_zone_id = @timeZoneId, - page_size = @pageSize, - as_of_date_display = @asOfDateDisplay - WHERE small_group_id = @groupId" - [ "@groupId", Sql.uuid pref.SmallGroupId.Value - "@daysToKeepNew", Sql.int pref.DaysToKeepNew - "@daysToExpire", Sql.int pref.DaysToExpire - "@longTermUpdateWeeks", Sql.int pref.LongTermUpdateWeeks - "@emailFromName", Sql.string pref.EmailFromName - "@emailFromAddress", Sql.string pref.EmailFromAddress - "@fonts", Sql.string pref.Fonts - "@headingColor", Sql.string pref.HeadingColor - "@lineColor", Sql.string pref.LineColor - "@headingFontSize", Sql.int pref.HeadingFontSize - "@textFontSize", Sql.int pref.TextFontSize - "@requestSort", Sql.string (string pref.RequestSort) - "@groupPassword", Sql.string pref.GroupPassword - "@defaultEmailType", Sql.string (string pref.DefaultEmailType) - "@isPublic", Sql.bool pref.IsPublic - "@timeZoneId", Sql.string (string pref.TimeZoneId) - "@pageSize", Sql.int pref.PageSize - "@asOfDateDisplay", Sql.string (string pref.AsOfDateDisplay) ] + let savePreferences (pref: ListPreferences) = + Patch.byId Table.Group pref.SmallGroupId {| Preferences = pref |} - /// Get a small group by its ID - let tryById (groupId : SmallGroupId) = - Custom.single "SELECT * FROM pt.small_group WHERE id = @id" [ "@id", Sql.uuid groupId.Value ] mapToSmallGroup - - /// Get a small group by its ID with its list preferences populated - let tryByIdWithPreferences (groupId : SmallGroupId) = - Custom.single - "SELECT sg.*, lp.* - FROM pt.small_group sg - INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id - WHERE sg.id = @id" - [ "@id", Sql.uuid groupId.Value ] mapToSmallGroupWithPreferences + /// Get a small group by its ID (including list preferences) + let tryById groupId = + Find.byId Table.Group groupId /// Functions to manipulate users @@ -507,11 +388,11 @@ module Users = /// Retrieve all PrayerTracker users let all () = - Custom.list "SELECT * FROM pt.pt_user ORDER BY last_name, first_name" [] mapToUser + Find.allOrdered Table.User [ Field.Named "lastName"; Field.Named "firstName" ] /// Count the number of users for a church let countByChurch (churchId : ChurchId) = - Custom.scalar + BitBadger.Documents.Postgres.Custom.scalar "SELECT COUNT(u.id) AS user_count FROM pt.pt_user u WHERE EXISTS ( @@ -523,22 +404,16 @@ module Users = [ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "user_count") /// Count the number of users for a small group - let countByGroup (groupId : SmallGroupId) = - Custom.scalar "SELECT COUNT(user_id) AS user_count FROM pt.user_small_group WHERE small_group_id = @groupId" - [ "@groupId", Sql.uuid groupId.Value ] (fun row -> row.int "user_count") + let countByGroup (groupId: SmallGroupId) = + Count.byFields Table.User All [ Field.InArray "smallGroups" Table.User [ groupId ] ] /// Delete a user by its database ID - let deleteById (userId : UserId) = - Custom.nonQuery "DELETE FROM pt.pt_user WHERE id = @id" [ "@id", Sql.uuid userId.Value ] - - /// Get the IDs of the small groups for which the given user is authorized - let groupIdsByUserId (userId : UserId) = - Custom.list "SELECT small_group_id FROM pt.user_small_group WHERE user_id = @id" - [ "@id", Sql.uuid userId.Value ] (fun row -> SmallGroupId (row.uuid "small_group_id")) + let deleteById (userId: UserId) = + Delete.byId Table.User userId /// Get a list of users authorized to administer the given small group let listByGroupId (groupId : SmallGroupId) = - Custom.list + BitBadger.Documents.Postgres.Custom.list "SELECT u.* FROM pt.pt_user u INNER JOIN pt.user_small_group usg ON usg.user_id = u.id @@ -547,68 +422,26 @@ module Users = [ "@groupId", Sql.uuid groupId.Value ] mapToUser /// Save a user's information - let save (user : User) = - Custom.nonQuery - "INSERT INTO pt.pt_user ( - id, first_name, last_name, email, is_admin, password_hash - ) VALUES ( - @id, @firstName, @lastName, @email, @isAdmin, @passwordHash - ) ON CONFLICT (id) DO UPDATE - SET first_name = EXCLUDED.first_name, - last_name = EXCLUDED.last_name, - email = EXCLUDED.email, - is_admin = EXCLUDED.is_admin, - password_hash = EXCLUDED.password_hash" - [ "@id", Sql.uuid user.Id.Value - "@firstName", Sql.string user.FirstName - "@lastName", Sql.string user.LastName - "@email", Sql.string user.Email - "@isAdmin", Sql.bool user.IsAdmin - "@passwordHash", Sql.string user.PasswordHash ] + let save user = + save Table.User user /// Find a user by its e-mail address and authorized small group - let tryByEmailAndGroup email (groupId : SmallGroupId) = - Custom.single - "SELECT u.* - FROM pt.pt_user u - INNER JOIN pt.user_small_group usg ON usg.user_id = u.id AND usg.small_group_id = @groupId - WHERE u.email = @email" - [ "@email", Sql.string email; "@groupId", Sql.uuid groupId.Value ] mapToUser + let tryByEmailAndGroup (email: string) (groupId: SmallGroupId) = + Find.firstByFields + Table.User All [ Field.Equal "email" email; Field.InArray "smallGroups" Table.User [ groupId ] ] /// Find a user by their database ID - let tryById (userId : UserId) = - Custom.single "SELECT * FROM pt.pt_user WHERE id = @id" [ "@id", Sql.uuid userId.Value ] mapToUser + let tryById userId = + Find.byId Table.User userId /// Update a user's last seen date/time - let updateLastSeen (userId : UserId) (now : Instant) = - Custom.nonQuery "UPDATE pt.pt_user SET last_seen = @now WHERE id = @id" - [ "@id", Sql.uuid userId.Value; "@now", Sql.parameter (NpgsqlParameter ("@now", now)) ] + let updateLastSeen (userId: UserId) (now: Instant) = + Patch.byId Table.User userId {| LastSeen = now |} /// Update a user's password hash - let updatePassword (user : User) = - Custom.nonQuery "UPDATE pt.pt_user SET password_hash = @passwordHash WHERE id = @id" - [ "@id", Sql.uuid user.Id.Value; "@passwordHash", Sql.string user.PasswordHash ] + let updatePassword (user: User) = + Patch.byId Table.User user.Id {| PasswordHash = user.PasswordHash |} /// Update a user's authorized small groups - let updateSmallGroups (userId : UserId) groupIds = backgroundTask { - let! existingGroupIds = groupIdsByUserId userId - let toAdd = - groupIds |> List.filter (fun it -> existingGroupIds |> List.exists (fun grpId -> grpId = it) |> not) - let toDelete = - existingGroupIds |> List.filter (fun it -> groupIds |> List.exists (fun grpId -> grpId = it) |> not) - let queries = seq { - if not (List.isEmpty toAdd) then - "INSERT INTO pt.user_small_group VALUES (@userId, @smallGroupId)", - toAdd |> List.map (fun it -> [ "@userId", Sql.uuid userId.Value; "@smallGroupId", Sql.uuid it.Value ]) - if not (List.isEmpty toDelete) then - "DELETE FROM pt.user_small_group WHERE user_id = @userId AND small_group_id = @smallGroupId", - toDelete - |> List.map (fun it -> [ "@userId", Sql.uuid userId.Value; "@smallGroupId", Sql.uuid it.Value ]) - } - if not (Seq.isEmpty queries) then - let! _ = - Configuration.dataSource () - |> Sql.fromDataSource - |> Sql.executeTransactionAsync (List.ofSeq queries) - () - } + let updateSmallGroups (userId: UserId) (groupIds: SmallGroupId list) = + Patch.byId Table.User userId {| SmallGroups = groupIds |} diff --git a/src/PrayerTracker.Data/Entities.fs b/src/PrayerTracker.Data/Entities.fs index 3182857..ed9c4ac 100644 --- a/src/PrayerTracker.Data/Entities.fs +++ b/src/PrayerTracker.Data/Entities.fs @@ -148,6 +148,9 @@ type ChurchId = |> function | ChurchId guid -> guid + override this.ToString() = + this.Value.ToString "N" + /// PK type for the Member entity type MemberId = @@ -159,6 +162,9 @@ type MemberId = |> function | MemberId guid -> guid + override this.ToString() = + this.Value.ToString "N" + /// PK type for the PrayerRequest entity type PrayerRequestId = @@ -170,6 +176,9 @@ type PrayerRequestId = |> function | PrayerRequestId guid -> guid + override this.ToString() = + this.Value.ToString "N" + /// PK type for the SmallGroup entity type SmallGroupId = @@ -181,6 +190,9 @@ type SmallGroupId = |> function | SmallGroupId guid -> guid + override this.ToString() = + this.Value.ToString "N" + /// PK type for the User entity type UserId = @@ -192,6 +204,9 @@ type UserId = |> function | UserId guid -> guid + override this.ToString() = + this.Value.ToString "N" + (*-- SPECIFIC VIEW TYPES --*) /// Statistics for churches diff --git a/src/PrayerTracker.Data/PrayerTracker.Data.fsproj b/src/PrayerTracker.Data/PrayerTracker.Data.fsproj index 4007c8c..627d40c 100644 --- a/src/PrayerTracker.Data/PrayerTracker.Data.fsproj +++ b/src/PrayerTracker.Data/PrayerTracker.Data.fsproj @@ -10,7 +10,8 @@ - + + diff --git a/src/PrayerTracker/Church.fs b/src/PrayerTracker/Church.fs index 6296868..3410c3f 100644 --- a/src/PrayerTracker/Church.fs +++ b/src/PrayerTracker/Church.fs @@ -12,7 +12,7 @@ let private findStats churchId = task { let! groups = SmallGroups.countByChurch churchId let! requests = PrayerRequests.countByChurch churchId let! users = Users.countByChurch churchId - return shortGuid churchId.Value, { SmallGroups = groups; PrayerRequests = requests; Users = users } + return shortGuid churchId.Value, { SmallGroups = int groups; PrayerRequests = requests; Users = users } } // POST /church/[church-id]/delete diff --git a/src/PrayerTracker/Extensions.fs b/src/PrayerTracker/Extensions.fs index 4cd30fb..2f7cce6 100644 --- a/src/PrayerTracker/Extensions.fs +++ b/src/PrayerTracker/Extensions.fs @@ -14,11 +14,11 @@ let private jsonSettings = JsonSerializerSettings().ConfigureForNodaTime DateTim /// Extensions on the .NET session object type ISession with - + /// Set an object in the session member this.SetObject<'T> key (value: 'T) = this.SetString(key, JsonConvert.SerializeObject(value, jsonSettings)) - + /// Get an object from the session member this.TryGetObject<'T> key = match this.GetString key with @@ -28,7 +28,7 @@ type ISession with /// The currently logged on small group member this.CurrentGroup with get () = this.TryGetObject Key.Session.currentGroup - and set (v: SmallGroup option) = + and set (v: SmallGroup option) = match v with | Some group -> this.SetObject Key.Session.currentGroup group | None -> this.Remove Key.Session.currentGroup @@ -40,7 +40,7 @@ type ISession with match v with | Some user -> this.SetObject Key.Session.currentUser { user with PasswordHash = "" } | None -> this.Remove Key.Session.currentUser - + /// Current messages for the session member this.Messages with get () = @@ -53,14 +53,14 @@ open System.Security.Claims /// Extensions on the claims principal type ClaimsPrincipal with - - /// The ID of the currently logged on small group + + /// The ID of the currently logged on small group member this.SmallGroupId = this.FindFirstValue ClaimTypes.GroupSid |> Option.ofObj |> Option.map (idFromShort SmallGroupId) - - /// The ID of the currently signed-in user + + /// The ID of the currently signed-in user member this.UserId = this.FindFirstValue ClaimTypes.NameIdentifier |> Option.ofObj @@ -71,16 +71,16 @@ open Giraffe /// Extensions on the ASP.NET Core HTTP context type HttpContext with - + /// The system clock (via DI) member this.Clock = this.GetService() - + /// The current instant member this.Now = this.Clock.GetCurrentInstant() - + /// The common string localizer member _.Strings = Views.I18N.localizer.Force() - + /// The currently logged on small group (sets the value in the session if it is missing) member this.CurrentGroup() = task { match this.Session.CurrentGroup with @@ -88,7 +88,7 @@ type HttpContext with | None -> match this.User.SmallGroupId with | Some groupId -> - match! SmallGroups.tryByIdWithPreferences groupId with + match! SmallGroups.tryById groupId with | Some group -> this.Session.CurrentGroup <- Some group return Some group diff --git a/src/PrayerTracker/PrayerRequest.fs b/src/PrayerTracker/PrayerRequest.fs index bcc368e..a65b393 100644 --- a/src/PrayerTracker/PrayerRequest.fs +++ b/src/PrayerTracker/PrayerRequest.fs @@ -126,7 +126,7 @@ let expire reqId : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task // GET /prayer-requests/[group-id]/list let list groupId : HttpHandler = requireAccess [ AccessLevel.Public ] >=> fun next ctx -> task { - match! SmallGroups.tryByIdWithPreferences (SmallGroupId groupId) with + match! SmallGroups.tryById (SmallGroupId groupId) with | Some group when group.Preferences.IsPublic -> let! reqs = PrayerRequests.forGroup diff --git a/src/PrayerTracker/SmallGroup.fs b/src/PrayerTracker/SmallGroup.fs index 7d69df1..32cdd46 100644 --- a/src/PrayerTracker/SmallGroup.fs +++ b/src/PrayerTracker/SmallGroup.fs @@ -152,8 +152,8 @@ let overview : HttpHandler = requireAccess [ User ] >=> fun next ctx -> task { let! admins = Users.listByGroupId group.Id let model = { TotalActiveReqs = List.length reqs - AllReqs = reqCount - TotalMembers = mbrCount + AllReqs = int reqCount + TotalMembers = int mbrCount ActiveReqsByType = ( reqs |> Seq.ofList @@ -187,7 +187,7 @@ let save : HttpHandler = requireAccess [ Admin ] >=> validateCsrf >=> fun next c else SmallGroups.tryById (idFromShort SmallGroupId model.SmallGroupId) match tryGroup with | Some group -> - do! SmallGroups.save (model.populateGroup group) model.IsNew + do! SmallGroups.save (model.populateGroup group) let act = ctx.Strings[if model.IsNew then "Added" else "Updated"].Value.ToLower() addHtmlInfo ctx ctx.Strings["Successfully {0} group “{1}”", act, model.Name] return! redirectTo false "/small-groups" next ctx @@ -227,7 +227,7 @@ let savePreferences : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> // we can repopulate the session instance. That way, if the update fails, the page should still show the // database values, not the then out-of-sync session ones. let group = ctx.Session.CurrentGroup.Value - match! SmallGroups.tryByIdWithPreferences group.Id with + match! SmallGroups.tryById group.Id with | Some group -> let pref = model.PopulatePreferences group.Preferences do! SmallGroups.savePreferences pref @@ -241,7 +241,6 @@ let savePreferences : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> open Giraffe.ViewEngine open PrayerTracker.Views.CommonFunctions -open Microsoft.Extensions.Configuration // POST /small-group/announcement/send let sendAnnouncement : HttpHandler = requireAccess [ User ] >=> validateCsrf >=> fun next ctx -> task { diff --git a/src/PrayerTracker/User.fs b/src/PrayerTracker/User.fs index d6a83f5..6f7c185 100644 --- a/src/PrayerTracker/User.fs +++ b/src/PrayerTracker/User.fs @@ -129,7 +129,7 @@ let doLogOn : HttpHandler = requireAccess [ AccessLevel.Public ] >=> validateCsr let s = ctx.Strings match! findUserByPassword model with | Some user -> - match! SmallGroups.tryByIdWithPreferences (idFromShort SmallGroupId model.SmallGroupId) with + match! SmallGroups.tryById (idFromShort SmallGroupId model.SmallGroupId) with | Some group -> ctx.Session.CurrentUser <- Some user ctx.Session.CurrentGroup <- Some group @@ -265,7 +265,7 @@ let smallGroups usrId : HttpHandler = requireAccess [ Admin ] >=> fun next ctx - match! Users.tryById userId with | Some user -> let! groups = SmallGroups.listAll () - let! groupIds = Users.groupIdsByUserId userId + let groupIds = user.SmallGroups let curGroups = groupIds |> List.map (fun g -> shortGuid g.Value) return! viewInfo ctx