WIP on doc queries (#55)

This commit is contained in:
Daniel J. Summers 2025-01-31 17:24:12 -05:00
parent bade89dd37
commit 14b0a58d98
6 changed files with 263 additions and 274 deletions

View File

@ -1,9 +1,5 @@
namespace PrayerTracker.Data namespace PrayerTracker.Data
open System
open NodaTime
open PrayerTracker.Entities
/// Table names /// Table names
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Table = module Table =
@ -29,6 +25,10 @@ module Table =
let User = "pt_user" let User = "pt_user"
open System
open NodaTime
open PrayerTracker.Entities
/// JSON serialization customizations /// JSON serialization customizations
[<RequireQualifiedAccess>] [<RequireQualifiedAccess>]
module Json = module Json =
@ -36,12 +36,10 @@ module Json =
open System.Text.Json.Serialization open System.Text.Json.Serialization
/// Convert a wrapped DU to/from its string representation /// Convert a wrapped DU to/from its string representation
type WrappedJsonConverter<'T>(wrap : string -> 'T, unwrap : 'T -> string) = type WrappedJsonConverter<'T>(wrap: string -> 'T, unwrap: 'T -> string) =
inherit JsonConverter<'T>() inherit JsonConverter<'T>()
override _.Read(reader, _, _) = override _.Read(reader, _, _) = wrap (reader.GetString())
wrap (reader.GetString()) override _.Write(writer, value, _) = writer.WriteStringValue(unwrap value)
override _.Write(writer, value, _) =
writer.WriteStringValue(unwrap value)
open System.Text.Json open System.Text.Json
open NodaTime.Serialization.SystemTextJson open NodaTime.Serialization.SystemTextJson
@ -49,6 +47,7 @@ module Json =
/// JSON serializer options to support the target domain /// JSON serializer options to support the target domain
let options = let options =
let opts = JsonSerializerOptions() let opts = JsonSerializerOptions()
[ WrappedJsonConverter<AsOfDateDisplay>(AsOfDateDisplay.Parse, string) :> JsonConverter [ WrappedJsonConverter<AsOfDateDisplay>(AsOfDateDisplay.Parse, string) :> JsonConverter
WrappedJsonConverter<EmailFormat>(EmailFormat.Parse, string) WrappedJsonConverter<EmailFormat>(EmailFormat.Parse, string)
WrappedJsonConverter<Expiration>(Expiration.Parse, string) WrappedJsonConverter<Expiration>(Expiration.Parse, string)
@ -62,11 +61,13 @@ module Json =
WrappedJsonConverter<UserId>(Guid.Parse >> UserId, string) WrappedJsonConverter<UserId>(Guid.Parse >> UserId, string)
JsonFSharpConverter() ] JsonFSharpConverter() ]
|> List.iter opts.Converters.Add |> List.iter opts.Converters.Add
let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb let _ = opts.ConfigureForNodaTime DateTimeZoneProviders.Tzdb
opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase opts.PropertyNamingPolicy <- JsonNamingPolicy.CamelCase
opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull opts.DefaultIgnoreCondition <- JsonIgnoreCondition.WhenWritingNull
opts opts
open BitBadger.Documents open BitBadger.Documents
open BitBadger.Documents.Sqlite open BitBadger.Documents.Sqlite
@ -77,106 +78,187 @@ module Connection =
open System.Text.Json open System.Text.Json
/// Ensure tables and indexes are defined /// Ensure tables and indexes are defined
let setUp () = backgroundTask { let setUp () =
backgroundTask {
Configuration.useIdField "id" Configuration.useIdField "id"
Configuration.useSerializer Configuration.useSerializer
{ new IDocumentSerializer with { new IDocumentSerializer with
member _.Serialize<'T>(it : 'T) = JsonSerializer.Serialize(it, Json.options) member _.Serialize<'T>(it: 'T) =
member _.Deserialize<'T>(it : string) = JsonSerializer.Deserialize<'T>(it, Json.options) JsonSerializer.Serialize(it, Json.options)
}
member _.Deserialize<'T>(it: string) =
JsonSerializer.Deserialize<'T>(it, Json.options) }
let! tables = Custom.list<string> "SELECT table_name FROM sqlite_master" [] _.GetString(0) let! tables = Custom.list<string> "SELECT table_name FROM sqlite_master" [] _.GetString(0)
if not (List.contains Table.Church tables) then if not (List.contains Table.Church tables) then
do! Definition.ensureTable Table.Church do! Definition.ensureTable Table.Church
if not (List.contains Table.Group tables) then if not (List.contains Table.Group tables) then
do! Definition.ensureTable Table.Group do! Definition.ensureTable Table.Group
do! Definition.ensureFieldIndex Table.Group "church" [ "churchId" ] do! Definition.ensureFieldIndex Table.Group "church" [ "churchId" ]
if not (List.contains Table.Member tables) then if not (List.contains Table.Member tables) then
do! Definition.ensureTable Table.Member do! Definition.ensureTable Table.Member
do! Definition.ensureFieldIndex Table.Member "group" [ "smallGroupId" ] do! Definition.ensureFieldIndex Table.Member "group" [ "smallGroupId" ]
if not (List.contains Table.Request tables) then if not (List.contains Table.Request tables) then
do! Definition.ensureTable Table.Request do! Definition.ensureTable Table.Request
do! Definition.ensureFieldIndex Table.Request "group" [ "smallGroupId" ] do! Definition.ensureFieldIndex Table.Request "group" [ "smallGroupId" ]
if not (List.contains Table.User tables) then if not (List.contains Table.User tables) then
do! Definition.ensureTable Table.User do! Definition.ensureTable Table.User
do! Definition.ensureFieldIndex Table.User "email" [ "email" ] do! Definition.ensureFieldIndex Table.User "email" [ "email" ]
} }
open Microsoft.Data.Sqlite
/// Helper functions for the PostgreSQL data implementation /// Helper functions for the PostgreSQL data implementation
[<AutoOpen>] [<AutoOpen>]
module private Helpers = module private Helpers =
/// Map a row to a Prayer Request instance /// Map a row to a Prayer Request instance
let mapToPrayerRequest (row : RowReader) = let mapToPrayerRequest (row: RowReader) =
{ Id = PrayerRequestId (row.uuid "id") { Id = PrayerRequestId(row.uuid "id")
UserId = UserId (row.uuid "user_id") UserId = UserId(row.uuid "user_id")
SmallGroupId = SmallGroupId (row.uuid "small_group_id") SmallGroupId = SmallGroupId(row.uuid "small_group_id")
EnteredDate = row.fieldValue<Instant> "entered_date" EnteredDate = row.fieldValue<Instant> "entered_date"
UpdatedDate = row.fieldValue<Instant> "updated_date" UpdatedDate = row.fieldValue<Instant> "updated_date"
Requestor = row.stringOrNone "requestor" Requestor = row.stringOrNone "requestor"
Text = row.string "request_text" Text = row.string "request_text"
NotifyChaplain = row.bool "notify_chaplain" NotifyChaplain = row.bool "notify_chaplain"
RequestType = PrayerRequestType.Parse (row.string "request_type") RequestType = PrayerRequestType.Parse(row.string "request_type")
Expiration = Expiration.Parse (row.string "expiration") Expiration = Expiration.Parse(row.string "expiration") }
}
/// Map a row to a Small Group information set
let mapToSmallGroupInfo (row : RowReader) = open Npgsql
{ Id = Giraffe.ShortGuid.fromGuid (row.uuid "id")
Name = row.string "group_name" /// Functions to retrieve small group information
ChurchName = row.string "church_name" module SmallGroups =
TimeZoneId = TimeZoneId (row.string "time_zone_id")
IsPublic = row.bool "is_public" /// Query to retrieve data for a small group info instance
} let private infoQuery =
$"SELECT g.data->>'id' AS id, g.data->>'groupName' AS groupName, c.data->>'churchName' AS churchName,
g.data->'preferences'->>'timeZoneId' AS timeZoneId, g.data->'preferences'->>'isPublic' AS isPublic
FROM {Table.Group} g
INNER JOIN {Table.Church} c ON c.data->>'id' = g.data->>'churchId'"
/// Query to retrieve data for a small group select list item
let private itemQuery =
$"SELECT g.data->>'groupName' AS groupName, g.data->>'id' AS id, c.data->>'churchName' AS churchName
FROM {Table.Group} g
INNER JOIN {Table.Church} c ON c.data->>'id' = g.data->>'churchId'"
/// The ORDER BY clause for select list item queries
let private itemOrderBy = "ORDER BY c.data->>'churchName', g.data->>'groupName'"
/// Map a row to a Small Group list item /// Map a row to a Small Group list item
let mapToSmallGroupItem (row : RowReader) = let private toSmallGroupItem (rdr: SqliteDataReader) =
Giraffe.ShortGuid.fromGuid (row.uuid "id"), $"""{row.string "church_name"} | {row.string "group_name"}""" (rdr.GetOrdinal >> rdr.GetString >> Guid.Parse >> Giraffe.ShortGuid.fromGuid) "id",
$"""{(rdr.GetOrdinal >> rdr.GetString) "churchName"} | {(rdr.GetOrdinal >> rdr.GetString) "groupName"}"""
/// Map a row to a User instance /// Get the group IDs for the given church
let mapToUser (row : RowReader) = let internal groupIdsByChurch (churchId: ChurchId) =
{ Id = UserId (row.uuid "id") backgroundTask {
FirstName = row.string "first_name" let! groups = Find.byFields<SmallGroup> Table.Group All [ Field.Equal "churchId" churchId ]
LastName = row.string "last_name" return groups |> List.map _.Id
Email = row.string "email"
IsAdmin = row.bool "is_admin"
PasswordHash = row.string "password_hash"
LastSeen = row.fieldValueOrNone<Instant> "last_seen"
SmallGroups = []
} }
/// Count the number of small groups for a church
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 {
use conn = Configuration.dbConn ()
use! txn = conn.BeginTransactionAsync()
let! users = Find.byFields<User> Table.User All [ Field.InArray "smallGroups" Table.User [ groupId ] ]
for user in users do
do! Patch.byId Table.User user.Id {| SmallGroups = user.SmallGroups |> List.except [ groupId ] |}
do! conn.deleteByFields Table.Request All [ Field.Equal "smallGroupId" groupId ]
do! conn.deleteById Table.Group groupId
do! txn.CommitAsync()
}
/// Get information for all small groups
let infoForAll () =
Custom.list $"{infoQuery} ORDER BY g.data->>'groupName'" [] SmallGroupInfo.FromReader
/// Get a list of small group IDs along with a description that includes the church name
let listAll () =
Custom.list $"{itemQuery} {itemOrderBy}" [] toSmallGroupItem
/// Get a list of small group IDs and descriptions for groups with a group password
let listProtected () =
Custom.list
$"{itemQuery} WHERE COALESCE(g.data->'preferences'->>'groupPassword', '') <> '' {itemOrderBy}"
[]
toSmallGroupItem
/// Get a list of small group IDs and descriptions for groups that are public or have a group password
let listPublicAndProtected () =
Custom.list
$"{infoQuery}
WHERE g.data->'preferences'->>'isPublic' = TRUE
OR COALESCE(g.data->'preferences'->>'groupPassword', '') <> ''
ORDER BY c.data->>'churchName', g.data->>'groupName'"
[]
SmallGroupInfo.FromReader
/// Log on for a small group (includes list preferences)
let logOn (groupId: SmallGroupId) (password: string) =
Find.firstByFields<SmallGroup>
Table.Group
All
[ Field.Equal "id" groupId; Field.Equal "preferences.groupPassword" password ]
/// Save a small group
let save group = save<SmallGroup> Table.Group group
/// Save a small group's list preferences
let savePreferences (groupId: SmallGroupId) (pref: ListPreferences) =
Patch.byId Table.Group groupId {| Preferences = pref |}
/// Get a small group by its ID (including list preferences)
let tryById groupId =
Find.byId<SmallGroupId, SmallGroup> Table.Group groupId
open BitBadger.Documents
open Npgsql
open Npgsql.FSharp
/// Functions to manipulate churches /// Functions to manipulate churches
module Churches = module Churches =
/// Get a list of all churches /// Get a list of all churches
let all () = let all () = Find.all<Church> Table.Church
Find.all<Church> Table.Church
/// Delete a church by its ID /// Delete a church by its ID
let deleteById (churchId: ChurchId) = backgroundTask { let deleteById churchId =
let idParam = [ [ "@churchId", Sql.uuid churchId.Value ] ] backgroundTask {
let where = "WHERE small_group_id IN (SELECT id FROM pt.small_group WHERE church_id = @churchId)" use conn = Configuration.dbConn ()
let! _ = use! txn = conn.BeginTransactionAsync()
BitBadger.Documents.Postgres.Configuration.dataSource ()
|> Sql.fromDataSource let! groupIds = SmallGroups.groupIdsByChurch churchId
|> Sql.executeTransactionAsync
[ $"DELETE FROM pt.prayer_request {where}", idParam do! Delete.byFields Table.Request All [ Field.In "smallGroupId" groupIds ]
$"DELETE FROM pt.user_small_group {where}", idParam
$"DELETE FROM pt.list_preference {where}", idParam let! users = Find.byFields<User> Table.User All [ Field.InArray "smallGroups" Table.User groupIds ]
"DELETE FROM pt.small_group WHERE church_id = @churchId", idParam
"DELETE FROM pt.church WHERE id = @churchId", idParam ] for user in users do
() do! Patch.byId Table.User user.Id {| SmallGroups = user.SmallGroups |> List.except groupIds |}
do! Delete.byFields Table.Group All [ Field.Equal "churchId" churchId ]
do! Delete.byId Table.Church churchId
do! txn.CommitAsync()
} }
/// Save a church's information /// Save a church's information
let save church = let save church = save<Church> Table.Church church
save<Church> Table.Church church
/// Find a church by its ID /// Find a church by its ID
let tryById churchId = let tryById churchId =
@ -191,17 +273,18 @@ module Members =
Count.byFields Table.Member All [ Field.Equal "smallGroupId" groupId ] Count.byFields Table.Member All [ Field.Equal "smallGroupId" groupId ]
/// Delete a small group member by its ID /// Delete a small group member by its ID
let deleteById (memberId: MemberId) = let deleteById (memberId: MemberId) = Delete.byId Table.Member memberId
Delete.byId Table.Member memberId
/// Retrieve all members for a given small group /// Retrieve all members for a given small group
let forGroup (groupId : SmallGroupId) = let forGroup (groupId: SmallGroupId) =
Find.byFieldsOrdered<Member> Find.byFieldsOrdered<Member>
Table.Member All [ Field.Equal "smallGroupId" groupId ] [ Field.Named "memberName" ] Table.Member
All
[ Field.Equal "smallGroupId" groupId ]
[ Field.Named "memberName" ]
/// Save a small group member /// Save a small group member
let save mbr = let save mbr = save<Member> Table.Member mbr
save<Member> Table.Member mbr
/// Retrieve a small group member by its ID /// Retrieve a small group member by its ID
let tryById memberId = let tryById memberId =
@ -210,20 +293,21 @@ module Members =
/// Options to retrieve a list of requests /// Options to retrieve a list of requests
type PrayerRequestOptions = type PrayerRequestOptions =
{ /// The small group for which requests should be retrieved {
SmallGroup : SmallGroup /// The small group for which requests should be retrieved
SmallGroup: SmallGroup
/// The clock instance to use for date/time manipulation /// The clock instance to use for date/time manipulation
Clock : IClock Clock: IClock
/// The date for which the list is being retrieved /// The date for which the list is being retrieved
ListDate : LocalDate option ListDate: LocalDate option
/// Whether only active requests should be retrieved /// Whether only active requests should be retrieved
ActiveOnly : bool ActiveOnly: bool
/// The page number, for paged lists /// The page number, for paged lists
PageNumber : int PageNumber: int
} }
@ -237,35 +321,42 @@ module PrayerRequests =
| SortByRequestor -> "requestor, updated_date DESC, entered_date DESC" | SortByRequestor -> "requestor, updated_date DESC, entered_date DESC"
/// Paginate a prayer request query /// Paginate a prayer request query
let private paginate (pageNbr : int) pageSize = let private paginate (pageNbr: int) pageSize =
if pageNbr > 0 then $"LIMIT {pageSize} OFFSET {(pageNbr - 1) * pageSize}" else "" if pageNbr > 0 then
$"LIMIT {pageSize} OFFSET {(pageNbr - 1) * pageSize}"
else
""
/// Count the number of prayer requests for a church /// Count the number of prayer requests for a church
let countByChurch (churchId : ChurchId) = let countByChurch (churchId: ChurchId) =
BitBadger.Documents.Postgres.Custom.scalar BitBadger.Documents.Postgres.Custom.scalar
"SELECT COUNT(id) AS req_count "SELECT COUNT(id) AS req_count
FROM pt.prayer_request FROM pt.prayer_request
WHERE small_group_id IN (SELECT id FROM pt.small_group WHERE church_id = @churchId)" 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") [ "@churchId", Sql.uuid churchId.Value ]
(fun row -> row.int "req_count")
/// Count the number of prayer requests for a small group /// Count the number of prayer requests for a small group
let countByGroup (groupId: SmallGroupId) = let countByGroup (groupId: SmallGroupId) =
Count.byFields Table.Request All [ Field.Equal "smallGroupId" groupId ] Count.byFields Table.Request All [ Field.Equal "smallGroupId" groupId ]
/// Delete a prayer request by its ID /// Delete a prayer request by its ID
let deleteById (reqId: PrayerRequestId) = let deleteById (reqId: PrayerRequestId) = Delete.byId Table.Request reqId
Delete.byId Table.Request reqId
/// Get all (or active) requests for a small group as of now or the specified date /// Get all (or active) requests for a small group as of now or the specified date
let forGroup (opts : PrayerRequestOptions) = let forGroup (opts: PrayerRequestOptions) =
let theDate = defaultArg opts.ListDate (opts.SmallGroup.LocalDateNow opts.Clock) let theDate = defaultArg opts.ListDate (opts.SmallGroup.LocalDateNow opts.Clock)
let where, parameters = let where, parameters =
if opts.ActiveOnly then if opts.ActiveOnly then
let asOf = NpgsqlParameter ( let asOf =
NpgsqlParameter(
"@asOf", "@asOf",
(theDate.AtStartOfDayInZone(opts.SmallGroup.TimeZone) (theDate.AtStartOfDayInZone(opts.SmallGroup.TimeZone)
- Duration.FromDays opts.SmallGroup.Preferences.DaysToExpire) - Duration.FromDays opts.SmallGroup.Preferences.DaysToExpire)
.ToInstant ()) .ToInstant()
)
" AND ( updated_date > @asOf " AND ( updated_date > @asOf
OR expiration = @manual OR expiration = @manual
OR request_type = @longTerm OR request_type = @longTerm
@ -276,18 +367,20 @@ module PrayerRequests =
"@longTerm", Sql.string (string LongTermRequest) "@longTerm", Sql.string (string LongTermRequest)
"@expecting", Sql.string (string Expecting) "@expecting", Sql.string (string Expecting)
"@forced", Sql.string (string Forced) ] "@forced", Sql.string (string Forced) ]
else "", [] else
"", []
BitBadger.Documents.Postgres.Custom.list BitBadger.Documents.Postgres.Custom.list
$"SELECT * $"SELECT *
FROM pt.prayer_request FROM pt.prayer_request
WHERE small_group_id = @groupId {where} WHERE small_group_id = @groupId {where}
ORDER BY {orderBy opts.SmallGroup.Preferences.RequestSort} ORDER BY {orderBy opts.SmallGroup.Preferences.RequestSort}
{paginate opts.PageNumber opts.SmallGroup.Preferences.PageSize}" {paginate opts.PageNumber opts.SmallGroup.Preferences.PageSize}"
(("@groupId", Sql.uuid opts.SmallGroup.Id.Value) :: parameters) mapToPrayerRequest (("@groupId", Sql.uuid opts.SmallGroup.Id.Value) :: parameters)
mapToPrayerRequest
/// Save a prayer request /// Save a prayer request
let save req = let save req = save<PrayerRequest> Table.Request req
save<PrayerRequest> Table.Request req
/// Search prayer requests for the given term /// Search prayer requests for the given term
let searchForGroup group searchTerm pageNbr = let searchForGroup group searchTerm pageNbr =
@ -297,7 +390,9 @@ module PrayerRequests =
SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId AND COALESCE(requestor, '') ILIKE @search SELECT * FROM pt.prayer_request WHERE small_group_id = @groupId AND COALESCE(requestor, '') ILIKE @search
ORDER BY {orderBy group.Preferences.RequestSort} ORDER BY {orderBy group.Preferences.RequestSort}
{paginate pageNbr group.Preferences.PageSize}" {paginate pageNbr group.Preferences.PageSize}"
[ "@groupId", Sql.uuid group.Id.Value; "@search", Sql.string $"%%%s{searchTerm}%%" ] mapToPrayerRequest [ "@groupId", Sql.uuid group.Id.Value
"@search", Sql.string $"%%%s{searchTerm}%%" ]
mapToPrayerRequest
/// Retrieve a prayer request by its ID /// Retrieve a prayer request by its ID
let tryById reqId = let tryById reqId =
@ -306,92 +401,15 @@ module PrayerRequests =
/// Update the expiration for the given prayer request /// Update the expiration for the given prayer request
let updateExpiration (req: PrayerRequest) withTime = let updateExpiration (req: PrayerRequest) withTime =
if withTime then if withTime then
Patch.byId Table.Request req.Id {| UpdatedDate = req.UpdatedDate; Expiration = req.Expiration |} Patch.byId
Table.Request
req.Id
{| UpdatedDate = req.UpdatedDate
Expiration = req.Expiration |}
else else
Patch.byId Table.Request req.Id {| Expiration = req.Expiration |} 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) =
Count.byFields Table.Group All [ Field.Equal "churchId" churchId ]
/// Delete a small group by its ID
let deleteById (groupId: SmallGroupId) = backgroundTask {
use conn = Configuration.dbConn ()
use txn = conn.BeginTransaction()
let! users = Find.byFields<User> Table.User All [ Field.InArray "smallGroups" Table.User [ groupId ] ]
for user in users do
do! Patch.byId Table.User user.Id {| SmallGroups = user.SmallGroups |> List.except [ groupId ] |}
do! conn.deleteByFields Table.Request All [ Field.Equal "smallGroupId" groupId ]
do! conn.deleteById Table.Group groupId
do! txn.CommitAsync()
}
/// Get information for all small groups
let infoForAll () =
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
INNER JOIN pt.list_preference lp ON lp.small_group_id = sg.id
ORDER BY sg.group_name"
[] mapToSmallGroupInfo
/// Get a list of small group IDs along with a description that includes the church name
let listAll () =
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
ORDER BY c.church_name, g.group_name"
[] mapToSmallGroupItem
/// Get a list of small group IDs and descriptions for groups with a group password
let listProtected () =
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
INNER JOIN pt.list_preference lp ON lp.small_group_id = g.id
WHERE COALESCE(lp.group_password, '') <> ''
ORDER BY c.church_name, g.group_name"
[] mapToSmallGroupItem
/// Get a list of small group IDs and descriptions for groups that are public or have a group password
let listPublicAndProtected () =
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
INNER JOIN pt.list_preference lp ON lp.small_group_id = g.id
WHERE lp.is_public = TRUE
OR COALESCE(lp.group_password, '') <> ''
ORDER BY c.church_name, g.group_name"
[] mapToSmallGroupInfo
/// Log on for a small group (includes list preferences)
let logOn (groupId: SmallGroupId) (password: string) =
Find.firstByFields<SmallGroup>
Table.Group All [ Field.Equal "id" groupId; Field.Equal "preferences.groupPassword" password ]
/// Save a small group
let save group =
save<SmallGroup> Table.Group group
/// Save a small group's list preferences
let savePreferences (pref: ListPreferences) =
Patch.byId Table.Group pref.SmallGroupId {| Preferences = pref |}
/// Get a small group by its ID (including list preferences)
let tryById groupId =
Find.byId<SmallGroupId, SmallGroup> Table.Group groupId
/// Functions to manipulate users /// Functions to manipulate users
module Users = module Users =
@ -400,44 +418,37 @@ module Users =
Find.allOrdered<User> Table.User [ Field.Named "lastName"; Field.Named "firstName" ] Find.allOrdered<User> Table.User [ Field.Named "lastName"; Field.Named "firstName" ]
/// Count the number of users for a church /// Count the number of users for a church
let countByChurch (churchId : ChurchId) = let countByChurch churchId =
BitBadger.Documents.Postgres.Custom.scalar backgroundTask {
"SELECT COUNT(u.id) AS user_count let! groupIds = SmallGroups.groupIdsByChurch churchId
FROM pt.pt_user u return! Count.byFields Table.User All [ Field.InArray "smallGroups" Table.User groupIds ]
WHERE EXISTS ( }
SELECT 1
FROM pt.user_small_group usg
INNER JOIN pt.small_group sg ON sg.id = usg.small_group_id
WHERE usg.user_id = u.id
AND sg.church_id = @churchId)"
[ "@churchId", Sql.uuid churchId.Value ] (fun row -> row.int "user_count")
/// Count the number of users for a small group /// Count the number of users for a small group
let countByGroup (groupId: SmallGroupId) = let countByGroup (groupId: SmallGroupId) =
Count.byFields Table.User All [ Field.InArray "smallGroups" Table.User [ groupId ] ] Count.byFields Table.User All [ Field.InArray "smallGroups" Table.User [ groupId ] ]
/// Delete a user by its database ID /// Delete a user by its database ID
let deleteById (userId: UserId) = let deleteById (userId: UserId) = Delete.byId Table.User userId
Delete.byId Table.User userId
/// Get a list of users authorized to administer the given small group /// Get a list of users authorized to administer the given small group
let listByGroupId (groupId : SmallGroupId) = let listByGroupId (groupId: SmallGroupId) =
BitBadger.Documents.Postgres.Custom.list Find.byFieldsOrdered<User>
"SELECT u.* Table.User
FROM pt.pt_user u All
INNER JOIN pt.user_small_group usg ON usg.user_id = u.id [ Field.InArray "smallGroups" Table.User [ groupId ] ]
WHERE usg.small_group_id = @groupId [ Field.Named "lastName"; Field.Named "firstName" ]
ORDER BY u.last_name, u.first_name"
[ "@groupId", Sql.uuid groupId.Value ] mapToUser
/// Save a user's information /// Save a user's information
let save user = let save user = save<User> Table.User user
save<User> Table.User user
/// Find a user by its e-mail address and authorized small group /// Find a user by its e-mail address and authorized small group
let tryByEmailAndGroup (email: string) (groupId: SmallGroupId) = let tryByEmailAndGroup (email: string) (groupId: SmallGroupId) =
Find.firstByFields<User> Find.firstByFields<User>
Table.User All [ Field.Equal "email" email; Field.InArray "smallGroups" Table.User [ groupId ] ] Table.User
All
[ Field.Equal "email" email
Field.InArray "smallGroups" Table.User [ groupId ] ]
/// Find a user by their database ID /// Find a user by their database ID
let tryById userId = let tryById userId =

View File

@ -209,6 +209,8 @@ type UserId =
(*-- SPECIFIC VIEW TYPES --*) (*-- SPECIFIC VIEW TYPES --*)
open Microsoft.Data.Sqlite
/// Statistics for churches /// Statistics for churches
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type ChurchStats = type ChurchStats =
@ -225,7 +227,7 @@ type ChurchStats =
/// Information needed to display the public/protected request list and small group maintenance pages /// Information needed to display the public/protected request list and small group maintenance pages
[<NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type SmallGroupInfo = type SmallGroupInfo =
{ {
/// The ID of the small group /// The ID of the small group
@ -244,12 +246,21 @@ type SmallGroupInfo =
IsPublic: bool IsPublic: bool
} }
/// Map a row to a Small Group information set
static member FromReader (rdr: SqliteDataReader) =
{ Id = Giraffe.ShortGuid.fromGuid ((rdr.GetOrdinal >> rdr.GetString >> Guid.Parse) "id")
Name = (rdr.GetOrdinal >> rdr.GetString) "groupName"
ChurchName = (rdr.GetOrdinal >> rdr.GetString) "churchName"
TimeZoneId = (rdr.GetOrdinal >> rdr.GetString >> TimeZoneId) "timeZoneId"
IsPublic = (rdr.GetOrdinal >> rdr.GetBoolean) "isPublic" }
(*-- ENTITIES --*) (*-- ENTITIES --*)
open NodaTime open NodaTime
/// This represents a church /// This represents a church
[<NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Church = type Church =
{ {
/// The ID of this church /// The ID of this church
@ -286,9 +297,6 @@ type Church =
[<NoComparison; NoEquality>] [<NoComparison; NoEquality>]
type ListPreferences = type ListPreferences =
{ {
/// The Id of the small group to which these preferences belong
SmallGroupId: SmallGroupId
/// The days after which regular requests expire /// The days after which regular requests expire
DaysToExpire: int DaysToExpire: int
@ -350,8 +358,7 @@ type ListPreferences =
/// A set of preferences with their default values /// A set of preferences with their default values
static member Empty = static member Empty =
{ SmallGroupId = SmallGroupId Guid.Empty { DaysToExpire = 14
DaysToExpire = 14
DaysToKeepNew = 7 DaysToKeepNew = 7
LongTermUpdateWeeks = 4 LongTermUpdateWeeks = 4
EmailFromName = "PrayerTracker" EmailFromName = "PrayerTracker"
@ -371,7 +378,7 @@ type ListPreferences =
/// A member of a small group /// A member of a small group
[<NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type Member = type Member =
{ {
/// The ID of the small group member /// The ID of the small group member
@ -400,7 +407,7 @@ type Member =
/// This represents a small group (Sunday School class, Bible study group, etc.) /// This represents a small group (Sunday School class, Bible study group, etc.)
[<NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type SmallGroup = type SmallGroup =
{ {
/// The ID of this small group /// The ID of this small group
@ -444,7 +451,7 @@ type SmallGroup =
/// This represents a single prayer request /// This represents a single prayer request
[<NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type PrayerRequest = type PrayerRequest =
{ {
/// The ID of this request /// The ID of this request
@ -515,7 +522,7 @@ type PrayerRequest =
/// This represents a user of PrayerTracker /// This represents a user of PrayerTracker
[<NoComparison; NoEquality>] [<CLIMutable; NoComparison; NoEquality>]
type User = type User =
{ {
/// The ID of this user /// The ID of this user
@ -556,20 +563,3 @@ type User =
PasswordHash = "" PasswordHash = ""
LastSeen = None LastSeen = None
SmallGroups = [] } SmallGroups = [] }
/// Cross-reference between user and small group
[<NoComparison; NoEquality>]
type UserSmallGroup =
{
/// The Id of the user who has access to the small group
UserId: UserId
/// The Id of the small group to which the user has access
SmallGroupId: SmallGroupId
}
/// An empty user/small group xref
static member Empty =
{ UserId = UserId Guid.Empty
SmallGroupId = SmallGroupId Guid.Empty }

View File

@ -39,8 +39,7 @@ module PgMappings =
ChurchId = ChurchId (row.uuid "church_id") ChurchId = ChurchId (row.uuid "church_id")
Name = row.string "group_name" Name = row.string "group_name"
Preferences = Preferences =
{ SmallGroupId = SmallGroupId (row.uuid "small_group_id") { DaysToKeepNew = row.int "days_to_keep_new"
DaysToKeepNew = row.int "days_to_keep_new"
DaysToExpire = row.int "days_to_expire" DaysToExpire = row.int "days_to_expire"
LongTermUpdateWeeks = row.int "long_term_update_weeks" LongTermUpdateWeeks = row.int "long_term_update_weeks"
EmailFromName = row.string "email_from_name" EmailFromName = row.string "email_from_name"

View File

@ -12,7 +12,7 @@ let private findStats churchId = task {
let! groups = SmallGroups.countByChurch churchId let! groups = SmallGroups.countByChurch churchId
let! requests = PrayerRequests.countByChurch churchId let! requests = PrayerRequests.countByChurch churchId
let! users = Users.countByChurch churchId let! users = Users.countByChurch churchId
return shortGuid churchId.Value, { SmallGroups = int groups; PrayerRequests = requests; Users = users } return shortGuid churchId.Value, { SmallGroups = int groups; PrayerRequests = requests; Users = int users }
} }
// POST /church/[church-id]/delete // POST /church/[church-id]/delete

View File

@ -230,7 +230,7 @@ let savePreferences : HttpHandler = requireAccess [ User ] >=> validateCsrf >=>
match! SmallGroups.tryById group.Id with match! SmallGroups.tryById group.Id with
| Some group -> | Some group ->
let pref = model.PopulatePreferences group.Preferences let pref = model.PopulatePreferences group.Preferences
do! SmallGroups.savePreferences pref do! SmallGroups.savePreferences group.Id pref
// Refresh session instance // Refresh session instance
ctx.Session.CurrentGroup <- Some { group with Preferences = pref } ctx.Session.CurrentGroup <- Some { group with Preferences = pref }
addInfo ctx ctx.Strings["Group preferences updated successfully"] addInfo ctx ctx.Strings["Group preferences updated successfully"]

View File

@ -121,7 +121,6 @@ let listPreferencesTests =
} }
test "Empty is as expected" { test "Empty is as expected" {
let mt = ListPreferences.Empty let mt = ListPreferences.Empty
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
Expect.equal mt.DaysToExpire 14 "The default days to expire should have been 14" Expect.equal mt.DaysToExpire 14 "The default days to expire should have been 14"
Expect.equal mt.DaysToKeepNew 7 "The default days to keep new should have been 7" Expect.equal mt.DaysToKeepNew 7 "The default days to keep new should have been 7"
Expect.equal mt.LongTermUpdateWeeks 4 "The default long term update weeks should have been 4" Expect.equal mt.LongTermUpdateWeeks 4 "The default long term update weeks should have been 4"
@ -367,13 +366,3 @@ let userTests =
Expect.equal user.Name "Unit Test" "The full name should be the first and last, separated by a space" Expect.equal user.Name "Unit Test" "The full name should be the first and last, separated by a space"
} }
] ]
[<Tests>]
let userSmallGroupTests =
testList "UserSmallGroup" [
test "Empty is as expected" {
let mt = UserSmallGroup.Empty
Expect.equal mt.UserId.Value Guid.Empty "The user ID should have been an empty GUID"
Expect.equal mt.SmallGroupId.Value Guid.Empty "The small group ID should have been an empty GUID"
}
]