diff --git a/.gitignore b/.gitignore index 3e759b7..3158d0b 100644 --- a/.gitignore +++ b/.gitignore @@ -328,3 +328,6 @@ ASALocalRun/ # MFractors (Xamarin productivity tool) working folder .mfractor/ + +### --- ### +src/PrayerTracker/appsettings.json diff --git a/src/PrayerTracker.Data/AppDbContext.fs b/src/PrayerTracker.Data/AppDbContext.fs new file mode 100644 index 0000000..c4199c2 --- /dev/null +++ b/src/PrayerTracker.Data/AppDbContext.fs @@ -0,0 +1,86 @@ +namespace PrayerTracker + +open Microsoft.EntityFrameworkCore +open PrayerTracker.Entities + +/// EF Core data context for PrayerTracker +[] +type AppDbContext (options : DbContextOptions) = + inherit DbContext (options) + + [] + val mutable private churches : DbSet + [] + val mutable private members : DbSet + [] + val mutable private prayerRequests : DbSet + [] + val mutable private preferences : DbSet + [] + val mutable private smallGroups : DbSet + [] + val mutable private timeZones : DbSet + [] + val mutable private users : DbSet + [] + val mutable private userGroupXref : DbSet + + /// Churches + member this.Churches + with get() = this.churches + and set v = this.churches <- v + + /// Small group members + member this.Members + with get() = this.members + and set v = this.members <- v + + /// Prayer requests + member this.PrayerRequests + with get() = this.prayerRequests + and set v = this.prayerRequests <- v + + /// Request list preferences (by class) + member this.Preferences + with get() = this.preferences + and set v = this.preferences <- v + + /// Small groups + member this.SmallGroups + with get() = this.smallGroups + and set v = this.smallGroups <- v + + /// Time zones + member this.TimeZones + with get() = this.timeZones + and set v = this.timeZones <- v + + /// Users + member this.Users + with get() = this.users + and set v = this.users <- v + + /// User / small group cross-reference + member this.UserGroupXref + with get() = this.userGroupXref + and set v = this.userGroupXref <- v + + /// F#-style async for saving changes + member this.AsyncSaveChanges () = + this.SaveChangesAsync () |> Async.AwaitTask + + override __.OnModelCreating (modelBuilder : ModelBuilder) = + base.OnModelCreating modelBuilder + + modelBuilder.HasDefaultSchema "pt" |> ignore + + [ Church.configureEF + ListPreferences.configureEF + Member.configureEF + PrayerRequest.configureEF + SmallGroup.configureEF + TimeZone.configureEF + User.configureEF + UserSmallGroup.configureEF + ] + |> List.iter (fun x -> x modelBuilder) diff --git a/src/PrayerTracker.Data/DataAccess.fs b/src/PrayerTracker.Data/DataAccess.fs new file mode 100644 index 0000000..018234f --- /dev/null +++ b/src/PrayerTracker.Data/DataAccess.fs @@ -0,0 +1,300 @@ +[] +module PrayerTracker.DataAccess + +open FSharp.Control.Tasks.ContextInsensitive +open Microsoft.EntityFrameworkCore +open PrayerTracker.Entities +open System.Collections.Generic +open System.Linq + +/// EF can return null for record types with the CLIMutable attribute; this converts a possibly-null record type to an +/// option +let optRec<'T> (r : 'T) = + match box r with null -> None | _ -> Some r + +type AppDbContext with + + (*-- DISCONNECTED DATA EXTENSIONS --*) + + /// Add an entity entry to the tracked data context with the status of Added + member this.AddEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = + this.Entry<'TEntity>(e).State <- EntityState.Added + + /// Add an entity entry to the tracked data context with the status of Updated + member this.UpdateEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = + this.Entry<'TEntity>(e).State <- EntityState.Modified + + /// Add an entity entry to the tracked data context with the status of Deleted + member this.RemoveEntry<'TEntity when 'TEntity : not struct> (e : 'TEntity) = + this.Entry<'TEntity>(e).State <- EntityState.Deleted + + (*-- CHURCH EXTENSIONS --*) + + /// Find a church by its Id + member this.TryChurchById cId = + task { + let! church = this.Churches.AsNoTracking().FirstOrDefaultAsync (fun c -> c.churchId = cId) + return optRec church + } + + /// Find all churches + member this.AllChurches () = + task { + let! churches = this.Churches.AsNoTracking().OrderBy(fun c -> c.name).ToListAsync () + return List.ofSeq churches + } + + (*-- MEMBER EXTENSIONS --*) + + /// Get a small group member by its Id + member this.TryMemberById mId = + task { + let! mbr = this.Members.AsNoTracking().FirstOrDefaultAsync (fun m -> m.memberId = mId) + return optRec mbr + } + + /// Find all members for a small group + member this.AllMembersForSmallGroup gId = + task { + let! mbrs = + this.Members.AsNoTracking() + .Where(fun m -> m.smallGroupId = gId) + .OrderBy(fun m -> m.memberName) + .ToListAsync () + return List.ofSeq mbrs + } + + /// Count members for a small group + member this.CountMembersForSmallGroup gId = + this.Members.CountAsync (fun m -> m.smallGroupId = gId) + + (*-- PRAYER REQUEST EXTENSIONS *) + + /// Get a prayer request by its Id + member this.TryRequestById reqId = + task { + let! req = this.PrayerRequests.AsNoTracking().FirstOrDefaultAsync (fun pr -> pr.prayerRequestId = reqId) + return optRec req + } + + /// Get all (or active) requests for a small group as of now or the specified date + member this.AllRequestsForSmallGroup (grp : SmallGroup) clock listDate activeOnly : PrayerRequest seq = + let theDate = match listDate with Some dt -> dt | _ -> grp.localDateNow clock + upcast ( + this.PrayerRequests.AsNoTracking().Where(fun pr -> pr.smallGroupId = grp.smallGroupId) + |> function + // Filter + | query when activeOnly -> + let asOf = theDate.AddDays(-(float grp.preferences.daysToExpire)).Date + query.Where(fun pr -> + (pr.updatedDate > asOf + || pr.doNotExpire + || RequestType.Recurring = pr.requestType + || RequestType.Expecting = pr.requestType) + && not pr.isManuallyExpired) + | query -> query + |> function + // Sort + | query when grp.preferences.requestSort = "D" -> + query.OrderByDescending(fun pr -> pr.updatedDate) + .ThenByDescending(fun pr -> pr.enteredDate) + .ThenBy(fun pr -> pr.requestor) + | query -> + query.OrderBy(fun pr -> pr.requestor) + .ThenByDescending(fun pr -> pr.updatedDate) + .ThenByDescending(fun pr -> pr.enteredDate)) + + /// Count prayer requests for the given small group Id + member this.CountRequestsBySmallGroup gId = + this.PrayerRequests.CountAsync (fun pr -> pr.smallGroupId = gId) + + /// Count prayer requests for the given church Id + member this.CountRequestsByChurch cId = + this.PrayerRequests.CountAsync (fun pr -> pr.smallGroup.churchId = cId) + + (*-- SMALL GROUP EXTENSIONS --*) + + /// Find a small group by its Id + member this.TryGroupById gId = + task { + let! grp = + this.SmallGroups.AsNoTracking() + .Include(fun sg -> sg.preferences) + .FirstOrDefaultAsync (fun sg -> sg.smallGroupId = gId) + return optRec grp + } + + /// Get small groups that are public or password protected + member this.PublicAndProtectedGroups () = + task { + let! grps = + this.SmallGroups.AsNoTracking() + .Include(fun sg -> sg.preferences) + .Include(fun sg -> sg.church) + .Where(fun sg -> + sg.preferences.isPublic || (sg.preferences.groupPassword <> null && sg.preferences.groupPassword <> "")) + .OrderBy(fun sg -> sg.church.name) + .ThenBy(fun sg -> sg.name) + .ToListAsync () + return List.ofSeq grps + } + + /// Get small groups that are password protected + member this.ProtectedGroups () = + task { + let! grps = + this.SmallGroups.AsNoTracking() + .Include(fun sg -> sg.church) + .Where(fun sg -> sg.preferences.groupPassword <> null && sg.preferences.groupPassword <> "") + .OrderBy(fun sg -> sg.church.name) + .ThenBy(fun sg -> sg.name) + .ToListAsync () + return List.ofSeq grps + } + + /// Get all small groups + member this.AllGroups () = + task { + let! grps = + this.SmallGroups.AsNoTracking() + .Include(fun sg -> sg.church) + .Include(fun sg -> sg.preferences) + .Include(fun sg -> sg.preferences.timeZone) + .OrderBy(fun sg -> sg.name) + .ToListAsync () + return List.ofSeq grps + } + + /// Get a small group list by their Id, with their church prepended to their name + member this.GroupList () = + task { + let! grps = + this.SmallGroups.AsNoTracking() + .Include(fun sg -> sg.church) + .OrderBy(fun sg -> sg.church.name) + .ThenBy(fun sg -> sg.name) + .ToListAsync () + return grps + |> Seq.map (fun grp -> grp.smallGroupId.ToString "N", sprintf "%s | %s" grp.church.name grp.name) + |> Map.ofSeq + } + + /// Log on a small group + member this.TryGroupLogOnByPassword gId pw = + task { + let! grp = this.TryGroupById gId + match grp with + | None -> return None + | Some g -> + match pw = g.preferences.groupPassword with + | true -> return grp + | _ -> return None + } + + /// Check a cookie log on for a small group + member this.TryGroupLogOnByCookie gId pwHash (hasher : string -> string) = + task { + let! grp = this.TryGroupById gId + match grp with + | None -> return None + | Some g -> + match pwHash = hasher g.preferences.groupPassword with + | true -> return grp + | _ -> return None + } + + /// Count small groups for the given church Id + member this.CountGroupsByChurch cId = + this.SmallGroups.CountAsync (fun sg -> sg.churchId = cId) + + (*-- TIME ZONE EXTENSIONS --*) + + /// Get a time zone by its Id + member this.TryTimeZoneById tzId = + task { + let! tz = this.TimeZones.FirstOrDefaultAsync (fun t -> t.timeZoneId = tzId) + return optRec tz + } + + /// Get all time zones + member this.AllTimeZones () = + task { + let! tzs = this.TimeZones.OrderBy(fun t -> t.sortOrder).ToListAsync () + return List.ofSeq tzs + } + + (*-- USER EXTENSIONS --*) + + /// Find a user by its Id + member this.TryUserById uId = + task { + let! user = this.Users.AsNoTracking().FirstOrDefaultAsync (fun u -> u.userId = uId) + return optRec user + } + + /// Find a user by its e-mail address and authorized small group + member this.TryUserByEmailAndGroup email gId = + task { + let! user = + this.Users.AsNoTracking().FirstOrDefaultAsync (fun u -> + u.emailAddress = email + && u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) + return optRec user + } + + /// Find a user by its Id (tracked entity), eagerly loading the user's groups + member this.TryUserByIdWithGroups uId = + task { + let! user = this.Users.Include(fun u -> u.smallGroups).FirstOrDefaultAsync (fun u -> u.userId = uId) + return optRec user + } + + /// Get a list of all users + member this.AllUsers () = + task { + let! usrs = this.Users.AsNoTracking().OrderBy(fun u -> u.lastName).ThenBy(fun u -> u.firstName).ToListAsync () + return List.ofSeq usrs + } + + /// Get all PrayerTracker users as members (used to send e-mails) + member this.AllUsersAsMembers () = + task { + let! usrs = + this.Users.AsNoTracking().OrderBy(fun u -> u.lastName).ThenBy(fun u -> u.firstName).ToListAsync () + return usrs + |> Seq.map (fun u -> { Member.empty with email = u.emailAddress; memberName = u.fullName }) + |> List.ofSeq + } + + /// Find a user based on their credentials + member this.TryUserLogOnByPassword email pwHash gId = + task { + let! user = + this.Users.FirstOrDefaultAsync (fun u -> + u.emailAddress = email + && u.passwordHash = pwHash + && u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) + return optRec user + } + + /// Find a user based on credentials stored in a cookie + member this.TryUserLogOnByCookie uId gId pwHash = + task { + let! user = this.TryUserByIdWithGroups uId + match user with + | None -> return None + | Some u -> + match pwHash = u.passwordHash && u.smallGroups |> Seq.exists (fun xref -> xref.smallGroupId = gId) with + | true -> + this.Entry(u).State <- EntityState.Detached + return Some { u with passwordHash = ""; salt = None; smallGroups = List() } + | _ -> return None + } + + /// Count the number of users for a small group + member this.CountUsersBySmallGroup gId = + this.Users.CountAsync (fun u -> u.smallGroups.Any (fun xref -> xref.smallGroupId = gId)) + + /// Count the number of users for a church + member this.CountUsersByChurch cId = + this.Users.CountAsync (fun u -> u.smallGroups.Any (fun xref -> xref.smallGroup.churchId = cId)) diff --git a/src/PrayerTracker.Data/Entities.fs b/src/PrayerTracker.Data/Entities.fs new file mode 100644 index 0000000..681d994 --- /dev/null +++ b/src/PrayerTracker.Data/Entities.fs @@ -0,0 +1,565 @@ +namespace PrayerTracker.Entities + +open FSharp.EFCore.OptionConverter +open Microsoft.EntityFrameworkCore +open NodaTime +open System +open System.Collections.Generic + +(*-- CONSTANTS --*) + +/// Constants to use for the e-mail type parameter +[] +module EmailType = + /// HTML e-mail + [] + let Html = "Html" + /// Plain Text e-mail + [] + let PlainText = "PlainText" + /// E-mail with the list as an attached PDF + [] + let AttachedPdf = "AttachedPdf" + +/// These values match those in the RequestType document store +[] +module RequestType = + /// Current Requests (follow expiration rules) + let Current = "Current" + /// Long-Term / Recurring Requests (do not automatically expire) + let Recurring = "Recurring" + /// Praise Reports (follow expiration rules) + let Praise = "Praise" + /// Expectant Mothers (do not automatically expire) + let Expecting = "Expecting" + /// Announcements (follow expiration rules) + let Announcement = "Announcement" + +(*-- SUPPORT TYPES --*) + +/// Statistics for churches +[] +type ChurchStats = + { /// The number of small groups in the church + smallGroups : int + /// The number of prayer requests in the church + prayerRequests : int + /// The number of users who can access small groups in the church + users : int + } + +/// PK type for the Church entity +type ChurchId = Guid + +/// PK type for the Member entity +type MemberId = Guid + +/// PK type for the PrayerRequest entity +type PrayerRequestId = Guid + +/// PK type for the SmallGroup entity +type SmallGroupId = Guid + +/// PK type for the TimeZone entity +type TimeZoneId = string + +/// PK type for the User entity +type UserId = Guid + +/// PK for User/SmallGroup cross-reference table +type UserSmallGroupKey = + { userId : UserId + smallGroupId : SmallGroupId + } + +(*-- ENTITIES --*) + +/// This represents a church +type [] Church = + { /// The Id of this church + churchId : ChurchId + /// The name of the church + name : string + /// The city where the church is + city : string + /// The state where the church is + st : string + /// Does this church have an active interface with Virtual Prayer Room? + hasInterface : bool + /// The address for the interface + interfaceAddress : string option + + /// Small groups for this church + smallGroups : ICollection + } + with + /// An empty church + // aww... how sad :( + static member empty = + { churchId = Guid.Empty + name = "" + city = "" + st = "" + hasInterface = false + interfaceAddress = None + smallGroups = List () + } + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "Church" |> ignore + m.Property(fun e -> e.churchId).HasColumnName "ChurchId" |> ignore + m.Property(fun e -> e.name).HasColumnName("Name").IsRequired () |> ignore + m.Property(fun e -> e.city).HasColumnName("City").IsRequired () |> ignore + m.Property(fun e -> e.st).HasColumnName("ST").IsRequired().HasMaxLength 2 |> ignore + m.Property(fun e -> e.hasInterface).HasColumnName "HasVirtualPrayerRoomInterface" |> ignore + m.Property(fun e -> e.interfaceAddress).HasColumnName "InterfaceAddress" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("interfaceAddress") + .SetValueConverter(OptionConverter ()) + + +/// Preferences for the form and format of the prayer request list +and [] ListPreferences = + { /// The Id of the small group to which these preferences belong + smallGroupId : SmallGroupId + /// The days after which regular requests expire + daysToExpire : int + /// The number of days a new or updated request is considered new + daysToKeepNew : int + /// The number of weeks after which long-term requests are flagged for follow-up + longTermUpdateWeeks : int + /// The name from which e-mails are sent + emailFromName : string + /// The e-mail address from which e-mails are sent + emailFromAddress : string + /// The fonts to use in generating the list of prayer requests + listFonts : string + /// The color for the prayer request list headings + headingColor : string + /// The color for the lines offsetting the prayer request list headings + lineColor : string + /// The font size for the headings on the prayer request list + headingFontSize : int + /// The font size for the text on the prayer request list + textFontSize : int + /// The order in which the prayer requests are sorted + requestSort : string + /// The password used for "small group login" (view-only request list) + groupPassword : string + /// The default e-mail type for this class + defaultEmailType : string + /// Whether this class makes its request list public + isPublic : bool + /// The time zone which this class uses (use tzdata names) + timeZoneId : TimeZoneId + /// The time zone information + timeZone : TimeZone + } + with + /// A set of preferences with their default values + static member empty = + { smallGroupId = Guid.Empty + daysToExpire = 14 + daysToKeepNew = 7 + longTermUpdateWeeks = 4 + emailFromName = "PrayerTracker" + emailFromAddress = "prayer@djs-consulting.com" + listFonts = "Century Gothic,Tahoma,Luxi Sans,sans-serif" + headingColor = "maroon" + lineColor = "navy" + headingFontSize = 16 + textFontSize = 12 + requestSort = "D" + groupPassword = "" + defaultEmailType = EmailType.Html + isPublic = false + timeZoneId = "America/Denver" + timeZone = TimeZone.empty + } + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "ListPreference" |> ignore + m.HasKey (fun e -> e.smallGroupId :> obj) |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.daysToKeepNew) + .HasColumnName("DaysToKeepNew") + .IsRequired() + .HasDefaultValue(7) + |> ignore + m.Property(fun e -> e.daysToExpire) + .HasColumnName("DaysToExpire") + .IsRequired() + .HasDefaultValue(14) + |> ignore + m.Property(fun e -> e.longTermUpdateWeeks) + .HasColumnName("LongTermUpdateWeeks") + .IsRequired() + .HasDefaultValue(4) + |> ignore + m.Property(fun e -> e.emailFromName) + .HasColumnName("EmailFromName") + .IsRequired() + .HasDefaultValue("PrayerTracker") + |> ignore + m.Property(fun e -> e.emailFromAddress) + .HasColumnName("EmailFromAddress") + .IsRequired() + .HasDefaultValue("prayer@djs-consulting.com") + |> ignore + m.Property(fun e -> e.listFonts) + .HasColumnName("ListFonts") + .IsRequired() + .HasDefaultValue("Century Gothic,Tahoma,Luxi Sans,sans-serif") + |> ignore + m.Property(fun e -> e.headingColor) + .HasColumnName("HeadingColor") + .IsRequired() + .HasDefaultValue("maroon") + |> ignore + m.Property(fun e -> e.lineColor) + .HasColumnName("LineColor") + .IsRequired() + .HasDefaultValue("navy") + |> ignore + m.Property(fun e -> e.headingFontSize) + .HasColumnName("HeadingFontSize") + .IsRequired() + .HasDefaultValue(16) + |> ignore + m.Property(fun e -> e.textFontSize) + .HasColumnName("TextFontSize") + .IsRequired() + .HasDefaultValue(12) + |> ignore + m.Property(fun e -> e.requestSort) + .HasColumnName("RequestSort") + .IsRequired() + .HasMaxLength(1) + .HasDefaultValue("D") + |> ignore + m.Property(fun e -> e.groupPassword) + .HasColumnName("GroupPassword") + .IsRequired() + .HasDefaultValue("") + |> ignore + m.Property(fun e -> e.defaultEmailType) + .HasColumnName("DefaultEmailType") + .IsRequired() + .HasDefaultValue(EmailType.Html) + |> ignore + m.Property(fun e -> e.isPublic) + .HasColumnName("IsPublic") + .IsRequired() + .HasDefaultValue(false) + |> ignore + m.Property(fun e -> e.timeZoneId) + .HasColumnName("TimeZoneId") + .IsRequired() + .HasDefaultValue("America/Denver") + |> ignore) + |> ignore + + +/// A member of a small group +and [] Member = + { /// The Id of the member + memberId : MemberId + /// The Id of the small group to which this member belongs + smallGroupId : SmallGroupId + /// The name of the member + memberName : string + /// The e-mail address for the member + email : string + /// The type of e-mail preferred by this member (see constants) + format : string option + /// The small group to which this member belongs + smallGroup : SmallGroup + } + with + /// An empty member + static member empty = + { memberId = Guid.Empty + smallGroupId = Guid.Empty + memberName = "" + email = "" + format = None + smallGroup = SmallGroup.empty + } + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "Member" |> ignore + m.Property(fun e -> e.memberId).HasColumnName "MemberId" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.memberName).HasColumnName("MemberName").IsRequired() |> ignore + m.Property(fun e -> e.email).HasColumnName("Email").IsRequired() |> ignore + m.Property(fun e -> e.format).HasColumnName "Format" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("format").SetValueConverter(OptionConverter ()) + + +/// This represents a single prayer request +and [] PrayerRequest = + { /// The Id of this request + prayerRequestId : PrayerRequestId + /// The type of the request + requestType : string + /// The user who entered the request + userId : UserId + /// The small group to which this request belongs + smallGroupId : SmallGroupId + /// The date/time on which this request was entered + enteredDate : DateTime + /// The date/time this request was last updated + updatedDate : DateTime + /// The name of the requestor or subject, or title of announcement + requestor : string option + /// The text of the request + text : string + /// Whether this request is exempt from standard expiration rules + doNotExpire : bool + /// Whether the chaplain should be notified for this request + notifyChaplain : bool + /// Whether this request has been expired manually + isManuallyExpired : bool + /// The user who entered this request + user : User + /// The small group to which this request belongs + smallGroup : SmallGroup + } + with + /// An empty request + static member empty = + { prayerRequestId = Guid.Empty + requestType = RequestType.Current + userId = Guid.Empty + smallGroupId = Guid.Empty + enteredDate = DateTime.MinValue + updatedDate = DateTime.MinValue + requestor = None + text = "" + doNotExpire = false + notifyChaplain = false + isManuallyExpired = false + user = User.empty + smallGroup = SmallGroup.empty + } + /// Is this request expired? + member this.isExpired (curr : DateTime) expDays = + match this.isManuallyExpired with + | true -> true // Manual expiration + | false -> + let nonExpiringTypes = [ RequestType.Recurring; RequestType.Expecting ] + match this.doNotExpire || List.contains this.requestType nonExpiringTypes with + | true -> false // No expiration + | false -> curr.AddDays(-(float expDays)) > this.updatedDate // Automatic expiration + + /// Is an update required for this long-term request? + member this.updateRequired curr expDays updWeeks = + match this.isExpired curr expDays with + | true -> false + | _ -> curr.AddDays(-(float (updWeeks * 7))) > this.updatedDate + + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "PrayerRequest" |> ignore + m.Property(fun e -> e.prayerRequestId).HasColumnName "PrayerRequestId" |> ignore + m.Property(fun e -> e.requestType).HasColumnName("RequestType").IsRequired() |> ignore + m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.enteredDate).HasColumnName "EnteredDate" |> ignore + m.Property(fun e -> e.updatedDate).HasColumnName "UpdatedDate" |> ignore + m.Property(fun e -> e.requestor).HasColumnName "Requestor" |> ignore + m.Property(fun e -> e.text).HasColumnName("Text").IsRequired() |> ignore + m.Property(fun e -> e.doNotExpire).HasColumnName "DoNotExpire" |> ignore + m.Property(fun e -> e.notifyChaplain).HasColumnName "NotifyChaplain" |> ignore + m.Property(fun e -> e.isManuallyExpired).HasColumnName "IsManuallyExpired" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("requestor") + .SetValueConverter(OptionConverter ()) + + +/// This represents a small group (Sunday School class, Bible study group, etc.) +and [] SmallGroup = + { /// The Id of this small group + smallGroupId : SmallGroupId + /// The church to which this group belongs + churchId : ChurchId + /// The name of the group + name : string + /// The church to which this small group belongs + church : Church + /// The preferences for the request list + preferences : ListPreferences + /// The members of the group + members : ICollection + /// Prayer requests for this small group + prayerRequests : ICollection + /// The users authorized to manage this group + users : ICollection + } + with + /// An empty small group + static member empty = + { smallGroupId = Guid.Empty + churchId = Guid.Empty + name = "" + church = Church.empty + preferences = ListPreferences.empty + members = List () + prayerRequests = List () + users = List () + } + + /// Get the local date for this group + member this.localTimeNow (clock : IClock) = + match clock with null -> nullArg "clock" | _ -> () + let tz = + match DateTimeZoneProviders.Tzdb.Ids.Contains this.preferences.timeZoneId with + | true -> DateTimeZoneProviders.Tzdb.[this.preferences.timeZoneId] + | false -> DateTimeZone.Utc + clock.GetCurrentInstant().InZone(tz).ToDateTimeUnspecified() + + /// Get the local date for this group + member this.localDateNow clock = + (this.localTimeNow clock).Date + + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "SmallGroup" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.Property(fun e -> e.churchId).HasColumnName "ChurchId" |> ignore + m.Property(fun e -> e.name).HasColumnName("Name").IsRequired() |> ignore + m.HasOne(fun e -> e.preferences) |> ignore) + |> ignore + + +/// This represents a time zone in which a class may reside +and [] TimeZone = + { /// The Id for this time zone (uses tzdata names) + timeZoneId : TimeZoneId + /// The description of this time zone + description : string + /// The order in which this timezone should be displayed + sortOrder : int + /// Whether this timezone is active + isActive : bool + } + with + /// An empty time zone + static member empty = + { timeZoneId = "" + description = "" + sortOrder = 0 + isActive = false + } + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "TimeZone" |> ignore + m.Property(fun e -> e.timeZoneId).HasColumnName "TimeZoneId" |> ignore + m.Property(fun e -> e.description).HasColumnName("Description").IsRequired() |> ignore + m.Property(fun e -> e.sortOrder).HasColumnName "SortOrder" |> ignore + m.Property(fun e -> e.isActive).HasColumnName "IsActive" |> ignore) + |> ignore + + +/// This represents a user of PrayerTracker +and [] User = + { /// The Id of this user + userId : UserId + /// The first name of this user + firstName : string + /// The last name of this user + lastName : string + /// The e-mail address of the user + emailAddress : string + /// Whether this user is a PrayerTracker system administrator + isAdmin : bool + /// The user's hashed password + passwordHash : string + /// The salt for the user's hashed password + salt : Guid option + /// The small groups which this user is authorized + smallGroups : ICollection + } + with + /// An empty user + static member empty = + { userId = Guid.Empty + firstName = "" + lastName = "" + emailAddress = "" + isAdmin = false + passwordHash = "" + salt = None + smallGroups = List () + } + /// The full name of the user + member this.fullName = + sprintf "%s %s" this.firstName this.lastName + + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "User" |> ignore + m.Ignore(fun e -> e.fullName :> obj) |> ignore + m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore + m.Property(fun e -> e.firstName).HasColumnName("FirstName").IsRequired() |> ignore + m.Property(fun e -> e.lastName).HasColumnName("LastName").IsRequired() |> ignore + m.Property(fun e -> e.emailAddress).HasColumnName("EmailAddress").IsRequired() |> ignore + m.Property(fun e -> e.isAdmin).HasColumnName "IsSystemAdmin" |> ignore + m.Property(fun e -> e.passwordHash).HasColumnName("PasswordHash").IsRequired() |> ignore + m.Property(fun e -> e.salt).HasColumnName "Salt" |> ignore) + |> ignore + mb.Model.FindEntityType(typeof).FindProperty("salt") + .SetValueConverter(OptionConverter ()) + + +/// Cross-reference between user and small group +and [] 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 + /// The user who has access to the small group + user : User + /// The small group to which the user has access + smallGroup : SmallGroup + } + with + /// An empty user/small group xref + static member empty = + { userId = Guid.Empty + smallGroupId = Guid.Empty + user = User.empty + smallGroup = SmallGroup.empty + } + /// Configure EF for this entity + static member internal configureEF (mb : ModelBuilder) = + mb.Entity ( + fun m -> + m.ToTable "User_SmallGroup" |> ignore + m.HasKey(fun e -> { userId = e.userId; smallGroupId = e.smallGroupId } :> obj) |> ignore + m.Property(fun e -> e.userId).HasColumnName "UserId" |> ignore + m.Property(fun e -> e.smallGroupId).HasColumnName "SmallGroupId" |> ignore + m.HasOne(fun e -> e.user) + .WithMany(fun e -> e.smallGroups :> IEnumerable) + .HasForeignKey(fun e -> e.userId :> obj) + |> ignore + m.HasOne(fun e -> e.smallGroup) + .WithMany(fun e -> e.users :> IEnumerable) + .HasForeignKey(fun e -> e.smallGroupId :> obj) + |> ignore) + |> ignore diff --git a/src/PrayerTracker.Data/Migrations/20161217153124_InitialDatabase.fs b/src/PrayerTracker.Data/Migrations/20161217153124_InitialDatabase.fs new file mode 100644 index 0000000..aea51af --- /dev/null +++ b/src/PrayerTracker.Data/Migrations/20161217153124_InitialDatabase.fs @@ -0,0 +1,510 @@ +namespace PrayerTracker.Migrations + +open Microsoft.EntityFrameworkCore +open Microsoft.EntityFrameworkCore.Infrastructure +open Microsoft.EntityFrameworkCore.Migrations +open Microsoft.EntityFrameworkCore.Migrations.Operations +open Microsoft.EntityFrameworkCore.Migrations.Operations.Builders +open Npgsql.EntityFrameworkCore.PostgreSQL.Metadata +open PrayerTracker +open PrayerTracker.Entities +open System + + +type ChurchTable = + { churchId : OperationBuilder + city : OperationBuilder + hasInterface : OperationBuilder + interfaceAddress : OperationBuilder + name : OperationBuilder + st : OperationBuilder + } + +type ListPreferencesTable = + { smallGroupId : OperationBuilder + daysToExpire : OperationBuilder + daysToKeepNew : OperationBuilder + defaultEmailType : OperationBuilder + emailFromAddress : OperationBuilder + emailFromName : OperationBuilder + groupPassword : OperationBuilder + headingColor : OperationBuilder + headingFontSize : OperationBuilder + isPublic : OperationBuilder + lineColor : OperationBuilder + listFonts : OperationBuilder + longTermUpdateWeeks : OperationBuilder + requestSort : OperationBuilder + textFontSize : OperationBuilder + timeZoneId : OperationBuilder + } + +type MemberTable = + { memberId : OperationBuilder + email : OperationBuilder + format : OperationBuilder + memberName : OperationBuilder + smallGroupId : OperationBuilder + } + +type PrayerRequestTable = + { prayerRequestId : OperationBuilder + doNotExpire : OperationBuilder + enteredDate : OperationBuilder + isManuallyExpired : OperationBuilder + notifyChaplain : OperationBuilder + requestType : OperationBuilder + requestor : OperationBuilder + smallGroupId : OperationBuilder + text : OperationBuilder + updatedDate : OperationBuilder + userId : OperationBuilder + } + +type SmallGroupTable = + { smallGroupId : OperationBuilder + churchId : OperationBuilder + name : OperationBuilder + } + +type TimeZoneTable = + { timeZoneId : OperationBuilder + description : OperationBuilder + isActive : OperationBuilder + sortOrder : OperationBuilder + } + +type UserSmallGroupTable = + { userId : OperationBuilder + smallGroupId : OperationBuilder + } + +type UserTable = + { userId : OperationBuilder + emailAddress : OperationBuilder + firstName : OperationBuilder + isAdmin : OperationBuilder + lastName : OperationBuilder + passwordHash : OperationBuilder + salt : OperationBuilder + } + +[)>] +[] +type InitialDatabase () = + inherit Migration () + override __.Up (migrationBuilder : MigrationBuilder) = + migrationBuilder.EnsureSchema (name = "pt") + |> ignore + + migrationBuilder.CreateTable ( + name = "Church", + schema = "pt", + columns = + (fun table -> + { churchId = table.Column (name = "ChurchId", nullable = false) + city = table.Column (name = "City", nullable = false) + hasInterface = table.Column (name = "HasVirtualPrayerRoomInterface", nullable = false) + interfaceAddress = table.Column (name = "InterfaceAddress", nullable = true) + name = table.Column (name = "Name", nullable = false) + st = table.Column (name = "ST", maxLength = Nullable 2, nullable = false) + }), + constraints = + fun table -> + table.PrimaryKey ("PK_Church", fun x -> upcast x.churchId) |> ignore) + |> ignore + + migrationBuilder.CreateTable ( + name = "TimeZone", + schema = "pt", + columns = + (fun table -> + { timeZoneId = table.Column (name = "TimeZoneId", nullable = false) + description = table.Column (name = "Description", nullable = false) + isActive = table.Column (name = "IsActive", nullable = false) + sortOrder = table.Column (name = "SortOrder", nullable = false) + }), + constraints = + fun table -> + table.PrimaryKey ("PK_TimeZone", fun x -> upcast x.timeZoneId) |> ignore) + |> ignore + + migrationBuilder.CreateTable ( + name = "User", + schema = "pt", + columns = + (fun table -> + { userId = table.Column (name = "UserId", nullable = false) + emailAddress = table.Column (name = "EmailAddress", nullable = false) + firstName = table.Column (name = "FirstName", nullable = false) + isAdmin = table.Column (name = "IsSystemAdmin", nullable = false) + lastName = table.Column (name = "LastName", nullable = false) + passwordHash = table.Column (name = "PasswordHash", nullable = false) + salt = table.Column (name = "Salt", nullable = true) + }), + constraints = + fun table -> + table.PrimaryKey("PK_User", fun x -> upcast x.userId) |> ignore) + |> ignore + + migrationBuilder.CreateTable ( + name = "SmallGroup", + schema = "pt", + columns = + (fun table -> + { smallGroupId = table.Column (name = "SmallGroupId", nullable = false) + churchId = table.Column (name = "ChurchId", nullable = false) + name = table.Column (name = "Name", nullable = false) + }), + constraints = + fun table -> + table.PrimaryKey ("PK_SmallGroup", fun x -> upcast x.smallGroupId) |> ignore + table.ForeignKey ( + name = "FK_SmallGroup_Church_ChurchId", + column = (fun x -> upcast x.churchId), + principalSchema = "pt", + principalTable = "Church", + principalColumn = "ChurchId", + onDelete = ReferentialAction.Cascade) + |> ignore) + |> ignore + + migrationBuilder.CreateTable ( + name = "ListPreference", + schema = "pt", + columns = + (fun table -> + { smallGroupId = table.Column (name = "SmallGroupId", nullable = false) + daysToExpire = table.Column (name = "DaysToExpire", nullable = false, defaultValue = 14) + daysToKeepNew = table.Column (name = "DaysToKeepNew", nullable = false, defaultValue = 7) + defaultEmailType = table.Column (name = "DefaultEmailType", nullable = false, defaultValue = "Html") + emailFromAddress = table.Column (name = "EmailFromAddress", nullable = false, defaultValue = "prayer@djs-consulting.com") + emailFromName = table.Column (name = "EmailFromName", nullable = false, defaultValue = "PrayerTracker") + groupPassword = table.Column (name = "GroupPassword", nullable = false, defaultValue = "") + headingColor = table.Column (name = "HeadingColor", nullable = false, defaultValue = "maroon") + headingFontSize = table.Column (name = "HeadingFontSize", nullable = false, defaultValue = 16) + isPublic = table.Column (name = "IsPublic", nullable = false, defaultValue = false) + lineColor = table.Column (name = "LineColor", nullable = false, defaultValue = "navy") + listFonts = table.Column (name = "ListFonts", nullable = false, defaultValue = "Century Gothic,Tahoma,Luxi Sans,sans-serif") + longTermUpdateWeeks = table.Column (name = "LongTermUpdateWeeks", nullable = false, defaultValue = 4) + requestSort = table.Column (name = "RequestSort", maxLength = Nullable 1, nullable = false, defaultValue = "D") + textFontSize = table.Column (name = "TextFontSize", nullable = false, defaultValue = 12) + timeZoneId = table.Column (name = "TimeZoneId", nullable = false, defaultValue = "America/Denver") + }), + constraints = + fun table -> + table.PrimaryKey ("PK_ListPreference", fun x -> upcast x.smallGroupId) |> ignore + table.ForeignKey ( + name = "FK_ListPreference_SmallGroup_SmallGroupId", + column = (fun x -> upcast x.smallGroupId), + principalSchema = "pt", + principalTable = "SmallGroup", + principalColumn = "SmallGroupId", + onDelete = ReferentialAction.Cascade) + |> ignore + table.ForeignKey ( + name = "FK_ListPreference_TimeZone_TimeZoneId", + column = (fun x -> upcast x.timeZoneId), + principalSchema = "pt", + principalTable = "TimeZone", + principalColumn = "TimeZoneId", + onDelete = ReferentialAction.Cascade) + |> ignore) + |> ignore + + migrationBuilder.CreateTable ( + name = "Member", + schema = "pt", + columns = + (fun table -> + { memberId = table.Column (name = "MemberId", nullable = false) + email = table.Column (name = "Email", nullable = false) + format = table.Column (name = "Format", nullable = true) + memberName = table.Column (name = "MemberName", nullable = false) + smallGroupId = table.Column (name = "SmallGroupId", nullable = false) + }), + constraints = + fun table -> + table.PrimaryKey ("PK_Member", fun x -> upcast x.memberId) |> ignore + table.ForeignKey ( + name = "FK_Member_SmallGroup_SmallGroupId", + column = (fun x -> upcast x.smallGroupId), + principalSchema = "pt", + principalTable = "SmallGroup", + principalColumn = "SmallGroupId", + onDelete = ReferentialAction.Cascade) + |> ignore) + |> ignore + + migrationBuilder.CreateTable ( + name = "PrayerRequest", + schema = "pt", + columns = + (fun table -> + { prayerRequestId = table.Column (name = "PrayerRequestId", nullable = false) + doNotExpire = table.Column (name = "DoNotExpire", nullable = false) + enteredDate = table.Column (name = "EnteredDate", nullable = false) + isManuallyExpired = table.Column (name = "IsManuallyExpired", nullable = false) + notifyChaplain = table.Column (name = "NotifyChaplain", nullable = false) + requestType = table.Column (name = "RequestType", nullable = false) + requestor = table.Column (name = "Requestor", nullable = true) + smallGroupId = table.Column (name = "SmallGroupId", nullable = false) + text = table.Column (name = "Text", nullable = false) + updatedDate = table.Column (name = "UpdatedDate", nullable = false) + userId = table.Column (name = "UserId", nullable = false) + }), + constraints = + fun table -> + table.PrimaryKey ("PK_PrayerRequest", fun x -> upcast x.prayerRequestId) |> ignore + table.ForeignKey ( + name = "FK_PrayerRequest_SmallGroup_SmallGroupId", + column = (fun x -> upcast x.smallGroupId), + principalSchema = "pt", + principalTable = "SmallGroup", + principalColumn = "SmallGroupId", + onDelete = ReferentialAction.Cascade) + |> ignore + table.ForeignKey ( + name = "FK_PrayerRequest_User_UserId", + column = (fun x -> upcast x.userId), + principalSchema = "pt", + principalTable = "User", + principalColumn = "UserId", + onDelete = ReferentialAction.Cascade) + |> ignore) + |> ignore + + migrationBuilder.CreateTable( + name = "User_SmallGroup", + schema = "pt", + columns = + (fun table -> + { userId = table.Column (name = "UserId", nullable = false) + smallGroupId = table.Column (name = "SmallGroupId", nullable = false) + }), + constraints = + fun table -> + table.PrimaryKey ("PK_User_SmallGroup", fun x -> upcast x) |> ignore + table.ForeignKey ( + name = "FK_User_SmallGroup_SmallGroup_SmallGroupId", + column = (fun x -> upcast x.smallGroupId), + principalSchema = "pt", + principalTable = "SmallGroup", + principalColumn = "SmallGroupId", + onDelete = ReferentialAction.Cascade) + |> ignore + table.ForeignKey ( + name = "FK_User_SmallGroup_User_UserId", + column = (fun x -> upcast x.userId), + principalSchema = "pt", + principalTable = "User", + principalColumn = "UserId", + onDelete = ReferentialAction.Cascade) + |> ignore) + |> ignore + + migrationBuilder.CreateIndex (name = "IX_ListPreference_TimeZoneId", schema = "pt", table = "ListPreference", column = "TimeZoneId") |> ignore + migrationBuilder.CreateIndex (name = "IX_Member_SmallGroupId", schema = "pt", table = "Member", column = "SmallGroupId") |> ignore + migrationBuilder.CreateIndex (name = "IX_PrayerRequest_SmallGroupId", schema = "pt", table = "PrayerRequest", column = "SmallGroupId") |> ignore + migrationBuilder.CreateIndex (name = "IX_PrayerRequest_UserId", schema = "pt", table = "PrayerRequest", column = "UserId") |> ignore + migrationBuilder.CreateIndex (name = "IX_SmallGroup_ChurchId", schema = "pt", table = "SmallGroup", column = "ChurchId") |> ignore + migrationBuilder.CreateIndex (name = "IX_User_SmallGroup_SmallGroupId", schema = "pt", table = "User_SmallGroup", column = "SmallGroupId") |> ignore + + override __.Down (migrationBuilder : MigrationBuilder) = + migrationBuilder.DropTable (name = "ListPreference", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "Member", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "PrayerRequest", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "User_SmallGroup", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "TimeZone", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "SmallGroup", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "User", schema = "pt") |> ignore + migrationBuilder.DropTable (name = "Church", schema = "pt") |> ignore + + + override __.BuildTargetModel (modelBuilder : ModelBuilder) = + modelBuilder + .HasDefaultSchema("pt") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752") + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("churchId").ValueGeneratedOnAdd() |> ignore + b.Property("city").IsRequired() |> ignore + b.Property("hasInterface") |> ignore + b.Property("interfaceAddress") |> ignore + b.Property("name").IsRequired() |> ignore + b.Property("st").IsRequired().HasMaxLength(2) |> ignore + b.HasKey("churchId") |> ignore + b.ToTable("Church") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("smallGroupId") |> ignore + b.Property("daysToExpire").ValueGeneratedOnAdd().HasDefaultValue(14) |> ignore + b.Property("daysToKeepNew").ValueGeneratedOnAdd().HasDefaultValue(7) |> ignore + b.Property("defaultEmailType").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("Html") |> ignore + b.Property("emailFromAddress").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("prayer@djs-consulting.com") |> ignore + b.Property("emailFromName").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("PrayerTracker") |> ignore + b.Property("groupPassword").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("") |> ignore + b.Property("headingColor").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("maroon") |> ignore + b.Property("headingFontSize").ValueGeneratedOnAdd().HasDefaultValue(16) |> ignore + b.Property("isPublic").ValueGeneratedOnAdd().HasDefaultValue(false) |> ignore + b.Property("lineColor").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("navy") |> ignore + b.Property("listFonts").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("Century Gothic,Tahoma,Luxi Sans,sans-serif") |> ignore + b.Property("longTermUpdateWeeks").ValueGeneratedOnAdd().HasDefaultValue(4) |> ignore + b.Property("requestSort").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("D").HasMaxLength(1) |> ignore + b.Property("textFontSize").ValueGeneratedOnAdd().HasDefaultValue(12) |> ignore + b.Property("timeZoneId").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("America/Denver") |> ignore + b.HasKey("smallGroupId") |> ignore + b.HasIndex("timeZoneId") |> ignore + b.ToTable("ListPreference") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("memberId").ValueGeneratedOnAdd() |> ignore + b.Property("email").IsRequired() |> ignore + b.Property("format") |> ignore + b.Property("memberName").IsRequired() |> ignore + b.Property("smallGroupId") |> ignore + b.HasKey("memberId") |> ignore + b.HasIndex("smallGroupId") |> ignore + b.ToTable("Member") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("prayerRequestId").ValueGeneratedOnAdd() |> ignore + b.Property("doNotExpire") |> ignore + b.Property("enteredDate") |> ignore + b.Property("isManuallyExpired") |> ignore + b.Property("notifyChaplain") |> ignore + b.Property("requestType").IsRequired() |> ignore + b.Property("requestor") |> ignore + b.Property("smallGroupId") |> ignore + b.Property("text").IsRequired() |> ignore + b.Property("updatedDate") |> ignore + b.Property("userId") |> ignore + b.HasKey("prayerRequestId") |> ignore + b.HasIndex("smallGroupId") |> ignore + b.HasIndex("userId") |> ignore + b.ToTable("PrayerRequest") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("smallGroupId").ValueGeneratedOnAdd() |> ignore + b.Property("churchId") |> ignore + b.Property("name").IsRequired() |> ignore + b.HasKey("smallGroupId") |> ignore + b.HasIndex("churchId") |> ignore + b.ToTable("SmallGroup") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("timeZoneId").ValueGeneratedOnAdd() |> ignore + b.Property("description").IsRequired() |> ignore + b.Property("isActive") |> ignore + b.Property("sortOrder") |> ignore + b.HasKey("timeZoneId") |> ignore + b.ToTable("TimeZone") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("userId").ValueGeneratedOnAdd() |> ignore + b.Property("emailAddress").IsRequired() |> ignore + b.Property("firstName").IsRequired() |> ignore + b.Property("isAdmin") |> ignore + b.Property("lastName").IsRequired() |> ignore + b.Property("passwordHash").IsRequired() |> ignore + b.Property("salt") |> ignore + b.HasKey("userId") |> ignore + b.ToTable("User") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("userId") |> ignore + b.Property("smallGroupId") |> ignore + b.HasKey("userId", "smallGroupId") |> ignore + b.HasIndex("smallGroupId") |> ignore + b.ToTable("User_SmallGroup") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup") + .WithOne("preferences") + .HasForeignKey("PrayerTracker.Entities.ListPreferences", "smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore + b.HasOne("PrayerTracker.Entities.TimeZone", "timeZone") + .WithMany() + .HasForeignKey("timeZoneId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup", "smallGroup") + .WithMany("members") + .HasForeignKey("smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup", "smallGroup") + .WithMany("prayerRequests") + .HasForeignKey("smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore + b.HasOne("PrayerTracker.Entities.User", "user") + .WithMany() + .HasForeignKey("userId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.Church", "Church") + .WithMany("SmallGroups") + .HasForeignKey("ChurchId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup", "smallGroup") + .WithMany("users") + .HasForeignKey("smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore + b.HasOne("PrayerTracker.Entities.User", "user") + .WithMany("smallGroups") + .HasForeignKey("userId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore diff --git a/src/PrayerTracker.Data/Migrations/AppDbContextModelSnapshot.fs b/src/PrayerTracker.Data/Migrations/AppDbContextModelSnapshot.fs new file mode 100644 index 0000000..3bd392b --- /dev/null +++ b/src/PrayerTracker.Data/Migrations/AppDbContextModelSnapshot.fs @@ -0,0 +1,199 @@ +namespace PrayerTracker.Migrations + +open Microsoft.EntityFrameworkCore +open Microsoft.EntityFrameworkCore.Infrastructure +open Npgsql.EntityFrameworkCore.PostgreSQL.Metadata +open PrayerTracker +open PrayerTracker.Entities +open System + +[)>] +type AppDbContextModelSnapshot () = + inherit ModelSnapshot () + + override __.BuildModel (modelBuilder : ModelBuilder) = + modelBuilder + .HasDefaultSchema("pt") + .HasAnnotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.SerialColumn) + .HasAnnotation("ProductVersion", "1.1.0-rtm-22752") + |> ignore + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("churchId").ValueGeneratedOnAdd() |> ignore + b.Property("city").IsRequired() |> ignore + b.Property("hasInterface") |> ignore + b.Property("interfaceAddress") |> ignore + b.Property("name").IsRequired() |> ignore + b.Property("st").IsRequired().HasMaxLength(2) |> ignore + b.HasKey("churchId") |> ignore + b.ToTable("Church") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("smallGroupId") |> ignore + b.Property("daysToExpire").ValueGeneratedOnAdd().HasDefaultValue(14) |> ignore + b.Property("daysToKeepNew").ValueGeneratedOnAdd().HasDefaultValue(7) |> ignore + b.Property("defaultEmailType").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("Html") |> ignore + b.Property("emailFromAddress").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("prayer@djs-consulting.com") |> ignore + b.Property("emailFromName").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("PrayerTracker") |> ignore + b.Property("groupPassword").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("") |> ignore + b.Property("headingColor").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("maroon") |> ignore + b.Property("headingFontSize").ValueGeneratedOnAdd().HasDefaultValue(16) |> ignore + b.Property("isPublic").ValueGeneratedOnAdd().HasDefaultValue(false) |> ignore + b.Property("lineColor").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("navy") |> ignore + b.Property("listFonts").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("Century Gothic,Tahoma,Luxi Sans,sans-serif") |> ignore + b.Property("longTermUpdateWeeks").ValueGeneratedOnAdd().HasDefaultValue(4) |> ignore + b.Property("requestSort").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("D").HasMaxLength(1) |> ignore + b.Property("textFontSize").ValueGeneratedOnAdd().HasDefaultValue(12) |> ignore + b.Property("timeZoneId").IsRequired().ValueGeneratedOnAdd().HasDefaultValue("America/Denver") |> ignore + b.HasKey("smallGroupId") |> ignore + b.HasIndex("timeZoneId") |> ignore + b.ToTable("ListPreference") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("memberId").ValueGeneratedOnAdd() |> ignore + b.Property("email").IsRequired() |> ignore + b.Property("format") |> ignore + b.Property("memberName").IsRequired() |> ignore + b.Property("smallGroupId") |> ignore + b.HasKey("memberId") |> ignore + b.HasIndex("smallGroupId") |> ignore + b.ToTable("Member") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("prayerRequestId").ValueGeneratedOnAdd() |> ignore + b.Property("doNotExpire") |> ignore + b.Property("enteredDate") |> ignore + b.Property("isManuallyExpired") |> ignore + b.Property("notifyChaplain") |> ignore + b.Property("requestType").IsRequired() |> ignore + b.Property("requestor") |> ignore + b.Property("smallGroupId") |> ignore + b.Property("text").IsRequired() |> ignore + b.Property("updatedDate") |> ignore + b.Property("userId") |> ignore + b.HasKey("prayerRequestId") |> ignore + b.HasIndex("smallGroupId") |> ignore + b.HasIndex("userId") |> ignore + b.ToTable("PrayerRequest") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("smallGroupId").ValueGeneratedOnAdd() |> ignore + b.Property("churchId") |> ignore + b.Property("name").IsRequired() |> ignore + b.HasKey("smallGroupId") |> ignore + b.HasIndex("churchId") |> ignore + b.ToTable("SmallGroup") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("timeZoneId").ValueGeneratedOnAdd() |> ignore + b.Property("description").IsRequired() |> ignore + b.Property("isActive") |> ignore + b.Property("sortOrder") |> ignore + b.HasKey("timeZoneId") |> ignore + b.ToTable("TimeZone") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("userId").ValueGeneratedOnAdd() |> ignore + b.Property("emailAddress").IsRequired() |> ignore + b.Property("firstName").IsRequired() |> ignore + b.Property("isAdmin") |> ignore + b.Property("lastName").IsRequired() |> ignore + b.Property("passwordHash").IsRequired() |> ignore + b.Property("salt") |> ignore + b.HasKey("userId") |> ignore + b.ToTable("User") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.Property("userId") |> ignore + b.Property("smallGroupId") |> ignore + b.HasKey("userId", "smallGroupId") |> ignore + b.HasIndex("smallGroupId") |> ignore + b.ToTable("User_SmallGroup") |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup") + .WithOne("preferences") + .HasForeignKey("PrayerTracker.Entities.ListPreferences", "smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore + b.HasOne("PrayerTracker.Entities.TimeZone", "timeZone") + .WithMany() + .HasForeignKey("timeZoneId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup", "smallGroup") + .WithMany("members") + .HasForeignKey("smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup", "smallGroup") + .WithMany("prayerRequests") + .HasForeignKey("smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore + b.HasOne("PrayerTracker.Entities.User", "user") + .WithMany() + .HasForeignKey("userId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.Church", "Church") + .WithMany("SmallGroups") + .HasForeignKey("ChurchId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore + + modelBuilder.Entity ( + typeof, + fun b -> + b.HasOne("PrayerTracker.Entities.SmallGroup", "smallGroup") + .WithMany("users") + .HasForeignKey("smallGroupId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore + b.HasOne("PrayerTracker.Entities.User", "user") + .WithMany("smallGroups") + .HasForeignKey("userId") + .OnDelete(DeleteBehavior.Cascade) + |> ignore) + |> ignore diff --git a/src/PrayerTracker.Data/PrayerTracker.Data.fsproj b/src/PrayerTracker.Data/PrayerTracker.Data.fsproj new file mode 100644 index 0000000..0c40295 --- /dev/null +++ b/src/PrayerTracker.Data/PrayerTracker.Data.fsproj @@ -0,0 +1,28 @@ + + + + netstandard2.0 + 7.0.0.0 + 7.0.0.0 + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PrayerTracker.Tests/Data/EntitiesTests.fs b/src/PrayerTracker.Tests/Data/EntitiesTests.fs new file mode 100644 index 0000000..17ed70a --- /dev/null +++ b/src/PrayerTracker.Tests/Data/EntitiesTests.fs @@ -0,0 +1,219 @@ +module PrayerTracker.Entities.EntitiesTests + +open Expecto +open System +open System.Linq +open NodaTime.Testing +open NodaTime + +[] +let churchTests = + testList "Church" [ + test "empty is as expected" { + let mt = Church.empty + Expect.equal mt.churchId Guid.Empty "The church ID should have been an empty GUID" + Expect.equal mt.name "" "The name should have been blank" + Expect.equal mt.city "" "The city should have been blank" + Expect.equal mt.st "" "The state should have been blank" + Expect.isFalse mt.hasInterface "The church should not show that it has an interface" + Expect.isNone mt.interfaceAddress "The interface address should not exist" + Expect.isNotNull mt.smallGroups "The small groups navigation property should not be null" + Expect.isEmpty mt.smallGroups "There should be no small groups for an empty church" + } + ] + +[] +let listPreferencesTests = + testList "ListPreferences" [ + test "empty is as expected" { + let mt = ListPreferences.empty + Expect.equal mt.smallGroupId 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.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.emailFromName "PrayerTracker" "The default e-mail from name should have been PrayerTracker" + Expect.equal mt.emailFromAddress "prayer@djs-consulting.com" + "The default e-mail from address should have been prayer@djs-consulting.com" + Expect.equal mt.listFonts "Century Gothic,Tahoma,Luxi Sans,sans-serif" "The default list fonts were incorrect" + Expect.equal mt.headingColor "maroon" "The default heading text color should have been maroon" + Expect.equal mt.lineColor "navy" "The default heding line color should have been navy" + Expect.equal mt.headingFontSize 16 "The default heading font size should have been 16" + Expect.equal mt.textFontSize 12 "The default text font size should have been 12" + Expect.equal mt.requestSort "D" "The default request sort should have been D (date)" + Expect.equal mt.groupPassword "" "The default group password should have been blank" + Expect.equal mt.defaultEmailType EmailType.Html "The default e-mail type should have been HTML" + Expect.isFalse mt.isPublic "The isPublic flag should not have been set" + Expect.equal mt.timeZoneId "America/Denver" "The default time zone should have been America/Denver" + Expect.equal mt.timeZone.timeZoneId "" "The default preferences should have included an empty time zone" + } + ] + +[] +let memberTests = + testList "Member" [ + test "empty is as expected" { + let mt = Member.empty + Expect.equal mt.memberId Guid.Empty "The member ID should have been an empty GUID" + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should have been an empty GUID" + Expect.equal mt.memberName "" "The member name should have been blank" + Expect.equal mt.email "" "The member e-mail address should have been blank" + Expect.isNone mt.format "The preferred e-mail format should not exist" + Expect.equal mt.smallGroup.smallGroupId Guid.Empty "The small group should have been an empty one" + } + ] + +[] +let prayerRequestTests = + testList "PrayerRequest" [ + test "empty is as expected" { + let mt = PrayerRequest.empty + Expect.equal mt.prayerRequestId Guid.Empty "The request ID should have been an empty GUID" + Expect.equal mt.requestType RequestType.Current "The request type should have been Current" + Expect.equal mt.userId Guid.Empty "The user ID should have been an empty GUID" + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should have been an empty GUID" + Expect.equal mt.enteredDate DateTime.MinValue "The entered date should have been the minimum" + Expect.equal mt.updatedDate DateTime.MinValue "The updated date should have been the minimum" + Expect.isNone mt.requestor "The requestor should not exist" + Expect.equal mt.text "" "The request text should have been blank" + Expect.isFalse mt.doNotExpire "The do not expire flag should not have been set" + Expect.isFalse mt.notifyChaplain "The notify chaplain flag should not have been set" + Expect.isFalse mt.isManuallyExpired "The is manually expired flag should not have been set" + Expect.equal mt.user.userId Guid.Empty "The user should have been an empty one" + Expect.equal mt.smallGroup.smallGroupId Guid.Empty "The small group should have been an empty one" + } + test "isExpired always returns false for expecting requests" { + let req = { PrayerRequest.empty with requestType = RequestType.Expecting } + Expect.isFalse (req.isExpired DateTime.Now 0) "An expecting request should never be considered expired" + } + test "isExpired always returns false for never-expired requests" { + let req = { PrayerRequest.empty with updatedDate = DateTime.Now.AddMonths -1; doNotExpire = true } + Expect.isFalse (req.isExpired DateTime.Now 4) "A never-expired request should never be considered expired" + } + test "isExpired always returns false for recurring requests" { + let req = { PrayerRequest.empty with requestType = RequestType.Recurring } + Expect.isFalse (req.isExpired DateTime.Now 0) "A recurring/long-term request should never be considered expired" + } + test "isExpired always returns true for manually expired requests" { + let req = { PrayerRequest.empty with updatedDate = DateTime.Now; isManuallyExpired = true } + Expect.isTrue (req.isExpired DateTime.Now 5) "A manually expired request should always be considered expired" + } + test "isExpired returns false for non-expired requests" { + let req = { PrayerRequest.empty with updatedDate = DateTime.Now.AddDays -5. } + Expect.isFalse (req.isExpired DateTime.Now 7) "A request updated 5 days ago should not be considered expired" + } + test "isExpired returns true for expired requests" { + let req = { PrayerRequest.empty with updatedDate = DateTime.Now.AddDays -8. } + Expect.isTrue (req.isExpired DateTime.Now 7) "A request updated 8 days ago should be considered expired" + } + test "updateRequired returns false for expired requests" { + let req = { PrayerRequest.empty with isManuallyExpired = true } + Expect.isFalse (req.updateRequired DateTime.Now 7 4) "An expired request should not require an update" + } + test "updateRequired returns false when an update is not required for an active request" { + let req = + { PrayerRequest.empty with + requestType = RequestType.Recurring + updatedDate = DateTime.Now.AddDays -14. + } + Expect.isFalse (req.updateRequired DateTime.Now 7 4) + "An active request updated 14 days ago should not require an update until 28 days" + } + test "updateRequired returns true when an update is required for an active request" { + let req = + { PrayerRequest.empty with + requestType = RequestType.Recurring + updatedDate = DateTime.Now.AddDays -34. + } + Expect.isTrue (req.updateRequired DateTime.Now 7 4) + "An active request updated 34 days ago should require an update (past 28 days)" + } + ] + +[] +let smallGroupTests = + testList "SmallGroup" [ + let now = DateTime (2017, 5, 12, 12, 15, 0, DateTimeKind.Utc) + let withFakeClock f () = + FakeClock (Instant.FromDateTimeUtc now) |> f + yield test "empty is as expected" { + let mt = SmallGroup.empty + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should have been an empty GUID" + Expect.equal mt.churchId Guid.Empty "The church ID should have been an empty GUID" + Expect.equal mt.name "" "The name should have been blank" + Expect.equal mt.church.churchId Guid.Empty "The church should have been an empty one" + Expect.isNotNull mt.members "The members navigation property should not be null" + Expect.isEmpty mt.members "There should be no members for an empty small group" + Expect.isNotNull mt.prayerRequests "The prayer requests navigation property should not be null" + Expect.isEmpty mt.prayerRequests "There should be no prayer requests for an empty small group" + Expect.isNotNull mt.users "The users navigation property should not be null" + Expect.isEmpty mt.users "There should be no users for an empty small group" + } + yield! testFixture withFakeClock [ + "localTimeNow adjusts the time ahead of UTC", + fun clock -> + let grp = { SmallGroup.empty with preferences = { ListPreferences.empty with timeZoneId = "Europe/Berlin" } } + Expect.isGreaterThan (grp.localTimeNow clock) now "UTC to Europe/Berlin should have added hours" + "localTimeNow adjusts the time behind UTC", + fun clock -> + Expect.isLessThan (SmallGroup.empty.localTimeNow clock) now + "UTC to America/Denver should have subtracted hours" + "localTimeNow returns UTC when the time zone is invalid", + fun clock -> + let grp = { SmallGroup.empty with preferences = { ListPreferences.empty with timeZoneId = "garbage" } } + Expect.equal (grp.localTimeNow clock) now "UTC should have been returned for an invalid time zone" + ] + yield test "localTimeNow fails when clock is not passed" { + Expect.throws (fun () -> (SmallGroup.empty.localTimeNow >> ignore) null) + "Should have raised an exception for null clock" + } + yield test "localDateNow returns the date portion" { + let now' = DateTime (2017, 5, 12, 1, 15, 0, DateTimeKind.Utc) + let clock = FakeClock (Instant.FromDateTimeUtc now') + Expect.isLessThan (SmallGroup.empty.localDateNow clock) now.Date "The date should have been a day earlier" + } + ] + +[] +let timeZoneTests = + testList "TimeZone" [ + test "empty is as expected" { + let mt = TimeZone.empty + Expect.equal mt.timeZoneId "" "The time zone ID should have been blank" + Expect.equal mt.description "" "The description should have been blank" + Expect.equal mt.sortOrder 0 "The sort order should have been zero" + Expect.isFalse mt.isActive "The is-active flag should not have been set" + } + ] + +[] +let userTests = + testList "User" [ + test "empty is as expected" { + let mt = User.empty + Expect.equal mt.userId Guid.Empty "The user ID should have been an empty GUID" + Expect.equal mt.firstName "" "The first name should have been blank" + Expect.equal mt.lastName "" "The last name should have been blank" + Expect.equal mt.emailAddress "" "The e-mail address should have been blank" + Expect.isFalse mt.isAdmin "The is admin flag should not have been set" + Expect.equal mt.passwordHash "" "The password hash should have been blank" + Expect.isNone mt.salt "The password salt should not exist" + Expect.isNotNull mt.smallGroups "The small groups navigation property should not have been null" + Expect.isEmpty mt.smallGroups "There should be no small groups for an empty user" + } + test "fullName concatenates first and last names" { + let user = { User.empty with firstName = "Unit"; lastName = "Test" } + Expect.equal user.fullName "Unit Test" "The full name should be the first and last, separated by a space" + } + ] + +[] +let userSmallGroupTests = + testList "UserSmallGroup" [ + test "empty is as expected" { + let mt = UserSmallGroup.empty + Expect.equal mt.userId Guid.Empty "The user ID should have been an empty GUID" + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should have been an empty GUID" + Expect.equal mt.user.userId Guid.Empty "The user should have been an empty one" + Expect.equal mt.smallGroup.smallGroupId Guid.Empty "The small group should have been an empty one" + } + ] diff --git a/src/PrayerTracker.Tests/PrayerTracker.Tests.fsproj b/src/PrayerTracker.Tests/PrayerTracker.Tests.fsproj new file mode 100644 index 0000000..b7dc751 --- /dev/null +++ b/src/PrayerTracker.Tests/PrayerTracker.Tests.fsproj @@ -0,0 +1,33 @@ + + + + Exe + netcoreapp2.1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/PrayerTracker.Tests/Program.fs b/src/PrayerTracker.Tests/Program.fs new file mode 100644 index 0000000..c3e7e04 --- /dev/null +++ b/src/PrayerTracker.Tests/Program.fs @@ -0,0 +1,5 @@ +open Expecto + +[] +let main argv = + runTestsInAssembly defaultConfig argv diff --git a/src/PrayerTracker.Tests/TestLocalization.fs b/src/PrayerTracker.Tests/TestLocalization.fs new file mode 100644 index 0000000..10ac210 --- /dev/null +++ b/src/PrayerTracker.Tests/TestLocalization.fs @@ -0,0 +1,14 @@ +module PrayerTracker.Tests.TestLocalization + +open Microsoft.Extensions.Localization +open Microsoft.Extensions.Logging.Abstractions +open Microsoft.Extensions.Options +open PrayerTracker + +let _s = + let asm = typeof.Assembly.GetName().Name + let opts = + { new IOptions with + member __.Value with get () = LocalizationOptions (ResourcesPath = "Resources") + } + ResourceManagerStringLocalizerFactory(opts, new NullLoggerFactory ()).Create("Common", asm) diff --git a/src/PrayerTracker.Tests/UI/CommonFunctionsTests.fs b/src/PrayerTracker.Tests/UI/CommonFunctionsTests.fs new file mode 100644 index 0000000..916f8af --- /dev/null +++ b/src/PrayerTracker.Tests/UI/CommonFunctionsTests.fs @@ -0,0 +1,208 @@ +module PrayerTracker.UI.CommonFunctionsTests + +open Expecto +open Giraffe.GiraffeViewEngine +open Microsoft.AspNetCore.Mvc.Localization +open Microsoft.Extensions.Localization +open PrayerTracker.Tests.TestLocalization +open PrayerTracker.Views +open System.IO + + +[] +let encLocTextTests = + testList "encLocText" [ + test "succeeds" { + let enc = encLocText (LocalizedString ("test", "test&")) |> renderHtmlNode + Expect.equal enc "test&" "string not encoded correctly" + } + ] + +[] +let iconSizedTests = + testList "iconSized" [ + test "succeeds" { + let ico = iconSized 18 "tom-&-jerry" |> renderHtmlNode + Expect.equal ico "tom-&-jerry" "icon HTML not correct" + } + ] + +[] +let iconTests = + testList "icon" [ + test "succeeds" { + let ico = icon "bob-&-tom" |> renderHtmlNode + Expect.equal ico "bob-&-tom" "icon HTML not correct" + } + ] + +[] +let namedColorListTests = + testList "namedColorList" [ + test "succeeds with default values" { + let expected = + [ "" + ] + |> String.concat "" + let selectList = namedColorList "the-name" "" [] _s |> renderHtmlNode + Expect.equal expected selectList "The default select list was not generated correctly" + } + test "succeeds with a selected value" { + let selectList = namedColorList "the-name" "white" [] _s |> renderHtmlNode + Expect.stringContains selectList " selected>white" "Selected option not generated correctly" + } + test "succeeds with extra attributes" { + let selectList = namedColorList "the-name" "" [ _id "myId" ] _s |> renderHtmlNode + Expect.stringStarts selectList "" + "Unselected radio button not generated correctly" + } + test "succeeds when selected" { + let rad = radio "a-name" "anId" "unit" "unit" |> renderHtmlNode + Expect.equal rad "" + "Selected radio button not generated correctly" + } + ] + +[] +let rawLocTextTests = + testList "rawLocText" [ + test "succeeds" { + use sw = new StringWriter () + let raw = rawLocText sw (LocalizedHtmlString ("test", "test&")) |> renderHtmlNode + Expect.equal raw "test&" "string not written correctly" + } + ] + +[] +let selectDefaultTests = + testList "selectDefault" [ + test "succeeds" { + Expect.equal (selectDefault "a&b") "— a&b —" "Default selection not generated correctly" + } + ] + +[] +let selectListTests = + testList "selectList" [ + test "succeeds with minimum options" { + let theList = selectList "a-list" "" [] [] |> renderHtmlNode + Expect.equal theList "" "Empty select list not generated correctly" + } + test "succeeds with all options" { + let theList = + [ "tom", "Tom&" + "bob", "Bob" + "jan", "Jan" + ] + |> selectList "the-list" "bob" [ _style "ugly" ] + |> renderHtmlNode + let expected = + [ "" + ] + |> String.concat "" + Expect.equal theList expected "Filled select list not generated correctly" + } + ] + +[] +let spaceTests = + testList "space" [ + test "succeeds" { + Expect.equal (renderHtmlNode space) " " "space literal not correct" + } + ] + + +[] +let submitTests = + testList "submit" [ + test "succeeds" { + let btn = submit [ _class "slick" ] "file-ico" _s.["a&b"] |> renderHtmlNode + Expect.equal + btn + "" + "Submit button not generated correctly" + } + ] + +[] +let tableSummaryTests = + testList "tableSummary" [ + test "succeeds for no entries" { + let sum = tableSummary 0 _s |> renderHtmlNode + Expect.equal sum "
No Entries to Display
" + "Summary for no items is incorrect" + } + test "succeeds for one entry" { + let sum = tableSummary 1 _s |> renderHtmlNode + Expect.equal sum "
Displaying 1 Entry
" + "Summary for one item is incorrect" + } + test "succeeds for many entries" { + let sum = tableSummary 5 _s |> renderHtmlNode + Expect.equal sum "
Displaying 5 Entries
" + "Summary for many items is incorrect" + } + ] + +module TimeZones = + + open PrayerTracker.Views.CommonFunctions.TimeZones + + [] + let nameTests = + testList "TimeZones.name" [ + test "succeeds for US Eastern time" { + Expect.equal (name "America/New_York" _s |> string) "Eastern" "US Eastern time zone not returned correctly" + } + test "succeeds for US Central time" { + Expect.equal (name "America/Chicago" _s |> string) "Central" "US Central time zone not returned correctly" + } + test "succeeds for US Mountain time" { + Expect.equal (name "America/Denver" _s |> string) "Mountain" "US Mountain time zone not returned correctly" + } + test "succeeds for US Mountain (AZ) time" { + Expect.equal (name "America/Phoenix" _s |> string) "Mountain (Arizona)" + "US Mountain (AZ) time zone not returned correctly" + } + test "succeeds for US Pacific time" { + Expect.equal (name "America/Los_Angeles" _s |> string) "Pacific" "US Pacific time zone not returned correctly" + } + test "succeeds for Central European time" { + Expect.equal (name "Europe/Berlin" _s |> string) "Central European" + "Central European time zone not returned correctly" + } + test "fails for unexpected time zone" { + Expect.equal (name "Wakanda" _s |> string) "Wakanda" "Unexpected time zone should have returned the original ID" + } + ] diff --git a/src/PrayerTracker.Tests/UI/UtilsTests.fs b/src/PrayerTracker.Tests/UI/UtilsTests.fs new file mode 100644 index 0000000..3533049 --- /dev/null +++ b/src/PrayerTracker.Tests/UI/UtilsTests.fs @@ -0,0 +1,129 @@ +module PrayerTracker.UI.UtilsTests + +open Expecto +open PrayerTracker + +[] +let ckEditorToTextTests = + testList "ckEditorToText" [ + test "replaces newline/tab sequence with nothing" { + Expect.equal (ckEditorToText "Here is some \n\ttext") "Here is some text" + "Newline/tab sequence should have been removed" + } + test "replaces   with a space" { + Expect.equal (ckEditorToText "Test text") "Test text" "  should have been replaced with a space" + } + test "replaces double space with one non-breaking space and one regular space" { + Expect.equal (ckEditorToText "Test text") "Test  text" + "double space should have been replaced with one non-breaking space and one regular space" + } + test "replaces paragraph break with two line breaks" { + Expect.equal (ckEditorToText "some

text") "some

text" + "paragraph break should have been replaced with two line breaks" + } + test "removes start and end paragraph tags" { + Expect.equal (ckEditorToText "

something something

") "something something" + "start/end paragraph tags should have been removed" + } + test "trims the result" { + Expect.equal (ckEditorToText " abc ") "abc" "Should have trimmed the resulting text" + } + test "does all the replacements and removals at one time" { + Expect.equal (ckEditorToText "

Paragraph 1\n\t line two

Paragraph 2 x

") + "Paragraph 1 line two

Paragraph 2  x" + "all replacements and removals were not made correctly" + } + ] + +[] +let htmlToPlainTextTests = + testList "htmlToPlainText" [ + test "decodes HTML-encoded entities" { + Expect.equal (htmlToPlainText "1 > 0") "1 > 0" "HTML-encoded entities should have been decoded" + } + test "trims the input HTML" { + Expect.equal (htmlToPlainText " howdy ") "howdy" "HTML input string should have been trimmed" + } + test "replaces line breaks with new lines" { + Expect.equal (htmlToPlainText "Lots
of
new
lines") "Lots\nof\nnew\nlines" + "Break tags should have been converted to newline characters" + } + test "replaces non-breaking spaces with spaces" { + Expect.equal (htmlToPlainText "Here is some more text") "Here is some more text" + "Non-breaking spaces should have been replaced with spaces" + } + test "does all replacements at one time" { + Expect.equal (htmlToPlainText " < <
test") "< <\ntest" "All replacements were not made correctly" + } + test "does not fail when passed null" { + Expect.equal (htmlToPlainText null) "" "Should return an empty string for null input" + } + test "does not fail when passed an empty string" { + Expect.equal (htmlToPlainText "") "" "Should return an empty string when given an empty string" + } + ] + +[] +let replaceFirstTests = + testList "replaceFirst" [ + test "replaces the first occurrence when it is found at the beginning of the string" { + let testString = "unit unit unit" + Expect.equal (replaceFirst "unit" "test" testString) "test unit unit" + "First occurrence of a substring was not replaced properly at the beginning of the string" + } + test "replaces the first occurrence when it is found in the center of the string" { + let testString = "test unit test" + Expect.equal (replaceFirst "unit" "test" testString) "test test test" + "First occurrence of a substring was not replaced properly when it is in the center of the string" + } + test "returns the original string if the replacement isn't found" { + let testString = "unit tests" + Expect.equal (replaceFirst "tested" "testing" testString) "unit tests" + "String which did not have the target substring was not returned properly" + } + ] + +[] +let sndAsStringTests = + testList "sndAsString" [ + test "converts the second item to a string" { + Expect.equal (sndAsString ("a", 5)) "5" "The second part of the tuple should have been converted to a string" + } + ] + +[] +let stripTagsTests = + let testString = "

Here is some text

and some more

" + testList "stripTags" [ + test "does nothing if all tags are allowed" { + Expect.equal (stripTags [ "p"; "br" ] testString) testString + "There should have been no replacements in the target string" + } + test "strips the start/end tag for non allowed tag" { + Expect.equal (stripTags [ "br" ] testString) "Here is some text

and some more" + "There should have been no \"p\" tag, but all \"br\" tags, in the returned string" + } + test "strips void/self-closing tags" { + Expect.equal (stripTags [] testString) "Here is some text and some more" + "There should have been no tags; all void and self-closing tags should have been stripped" + } + ] + +[] +let wordWrapTests = + testList "wordWrap" [ + test "breaks where it is supposed to" { + let testString = "The quick brown fox jumps over the lazy dog\nIt does!" + Expect.equal (wordWrap 20 testString) "The quick brown fox\njumps over the lazy\ndog\nIt does!\n" + "Line not broken correctly" + } + test "wraps long line without a space" { + let testString = "Asamatteroffact, the dog does too" + Expect.equal (wordWrap 10 testString) "Asamattero\nffact, the\ndog does\ntoo\n" + "Longer line not broken correctly" + } + test "preserves blank lines" { + let testString = "Here is\n\na string with blank lines" + Expect.equal (wordWrap 80 testString) testString "Blank lines were not preserved" + } + ] diff --git a/src/PrayerTracker.Tests/UI/ViewModelsTests.fs b/src/PrayerTracker.Tests/UI/ViewModelsTests.fs new file mode 100644 index 0000000..b5bb580 --- /dev/null +++ b/src/PrayerTracker.Tests/UI/ViewModelsTests.fs @@ -0,0 +1,605 @@ +module PrayerTracker.UI.ViewModelsTests + +open Expecto +open Microsoft.AspNetCore.Html +open PrayerTracker.Entities +open PrayerTracker.Tests.TestLocalization +open PrayerTracker.Utils +open PrayerTracker.ViewModels +open System + + +/// Filter function that filters nothing +let countAll _ = true + + +module ReferenceListTests = + + [] + let emailTypeListTests = + testList "ReferenceList.emailTypeList" [ + test "includes default type" { + let typs = ReferenceList.emailTypeList EmailType.Html _s + Expect.hasCountOf typs 3u countAll "There should have been 3 e-mail type options returned" + let top = Seq.head typs + Expect.equal (fst top) "" "The default option should have been blank" + Expect.equal (snd top).Value "Group Default (HTML Format)" "The default option label was incorrect" + let nxt = typs |> Seq.skip 1 |> Seq.head + Expect.equal (fst nxt) EmailType.Html "The 2nd option should have been HTML" + let lst = typs |> Seq.last + Expect.equal (fst lst) EmailType.PlainText "The 3rd option should have been plain text" + } + ] + + [] + let expirationListTests = + testList "ReferenceList.expirationList" [ + test "excludes immediate expiration if not required" { + let exps = ReferenceList.expirationList _s false + Expect.hasCountOf exps 2u countAll "There should have been 2 expiration types returned" + Expect.exists exps (fun exp -> fst exp = "N") "The option for normal expiration was not found" + Expect.exists exps (fun exp -> fst exp = "Y") "The option for \"never expire\" was not found" + } + test "includes immediate expiration if required" { + let exps = ReferenceList.expirationList _s true + Expect.hasCountOf exps 3u countAll "There should have been 3 expiration types returned" + Expect.exists exps (fun exp -> fst exp = "N") "The option for normal expiration was not found" + Expect.exists exps (fun exp -> fst exp = "Y") "The option for \"never expire\" was not found" + Expect.exists exps (fun exp -> fst exp = "X") "The option for \"expire immediately\" was not found" + } + ] + + [] + let requestTypeListTests = + testList "ReferenceList.requestTypeList" [ + let withList f () = + (ReferenceList.requestTypeList >> f) _s + yield! testFixture withList [ + yield "returns 5 types", + fun typs -> Expect.hasCountOf typs 5u countAll "There should have been 5 request types returned" + yield! [ RequestType.Current; RequestType.Recurring; RequestType.Praise; RequestType.Expecting; + RequestType.Announcement + ] + |> List.map (fun typ -> + sprintf "contains \"%s\"" typ, + fun typs -> + Expect.isSome (typs |> List.tryFind (fun x -> fst x = typ)) + (sprintf "The \"%s\" option was not found" typ)) + ] + ] + + +[] +let announcementTests = + let empty = { sendToClass = "N"; text = "

unit testing

"; addToRequestList = None; requestType = None } + testList "Announcement" [ + test "plainText strips HTML" { + let ann = { empty with text = "

unit testing

" } + Expect.equal (ann.plainText ()) "unit testing" "Plain text should have stripped HTML" + } + test "plainText wraps at 74 characters" { + let ann = { empty with text = String.replicate 80 "x" } + let txt = (ann.plainText ()).Split "\n" + Expect.hasCountOf txt 3u countAll "There should have been two lines of plain text returned" + Expect.stringHasLength txt.[0] 74 "The first line should have been wrapped at 74 characters" + Expect.stringHasLength txt.[1] 6 "The second line should have had the remaining 6 characters" + Expect.stringHasLength txt.[2] 0 "The third line should have been blank" + } + test "plainText wraps at 74 characters and strips HTML" { + let ann = { empty with text = sprintf "%s" (String.replicate 80 "z") } + let txt = ann.plainText () + Expect.stringStarts txt "zzz" "HTML should have been stripped from the front of the plain text" + Expect.equal (txt.ToCharArray ()).[74] '\n' "The text should have been broken at 74 characters" + } + ] + +[] +let appViewInfoTests = + testList "AppViewInfo" [ + test "fresh is constructed properly" { + let vi = AppViewInfo.fresh + Expect.isEmpty vi.style "There should have been no styles set" + Expect.isEmpty vi.script "There should have been no scripts set" + Expect.equal vi.helpLink.Url HelpPage.None.Url "The help link should have been set to none" + Expect.isEmpty vi.messages "There should have been no messages set" + Expect.equal vi.version "" "The version should have been blank" + Expect.isGreaterThan vi.requestStart DateTime.MinValue.Ticks "The request start time should have been set" + Expect.isNone vi.user "There should not have been a user" + Expect.isNone vi.group "There should not have been a small group" + } + ] + +[] +let assignGroupsTests = + testList "AssignGroups" [ + test "fromUser populates correctly" { + let usr = { User.empty with userId = Guid.NewGuid (); firstName = "Alice"; lastName = "Bob" } + let asg = AssignGroups.fromUser usr + Expect.equal asg.userId usr.userId "The user ID was not filled correctly" + Expect.equal asg.userName usr.fullName "The user name was not filled correctly" + Expect.equal asg.smallGroups "" "The small group string was not filled correctly" + } + ] + +[] +let editChurchTests = + testList "EditChurch" [ + test "fromChurch populates correctly when interface exists" { + let church = + { Church.empty with + churchId = Guid.NewGuid () + name = "Unit Test" + city = "Testlandia" + st = "UT" + hasInterface = true + interfaceAddress = Some "https://test-dem-units.test" + } + let edit = EditChurch.fromChurch church + Expect.equal edit.churchId church.churchId "The church ID was not filled correctly" + Expect.equal edit.name church.name "The church name was not filled correctly" + Expect.equal edit.city church.city "The church's city was not filled correctly" + Expect.equal edit.st church.st "The church's state was not filled correctly" + Expect.isSome edit.hasInterface "The church should show that it has an interface" + Expect.equal edit.hasInterface (Some true) "The hasInterface flag should be true" + Expect.isSome edit.interfaceAddress "The interface address should exist" + Expect.equal edit.interfaceAddress church.interfaceAddress "The interface address was not filled correctly" + } + test "fromChurch populates correctly when interface does not exist" { + let edit = + EditChurch.fromChurch + { Church.empty with + churchId = Guid.NewGuid () + name = "Unit Test" + city = "Testlandia" + st = "UT" + } + Expect.isNone edit.hasInterface "The church should not show that it has an interface" + Expect.isNone edit.interfaceAddress "The interface address should not exist" + } + test "empty is as expected" { + let edit = EditChurch.empty + Expect.equal edit.churchId Guid.Empty "The church ID should be the empty GUID" + Expect.equal edit.name "" "The church name should be blank" + Expect.equal edit.city "" "The church's city should be blank" + Expect.equal edit.st "" "The church's state should be blank" + Expect.isNone edit.hasInterface "The church should not show that it has an interface" + Expect.isNone edit.interfaceAddress "The interface address should not exist" + } + test "isNew works on a new church" { + Expect.isTrue (EditChurch.empty.isNew ()) "An empty GUID should be flagged as a new church" + } + test "isNew works on an existing church" { + Expect.isFalse ({ EditChurch.empty with churchId = Guid.NewGuid () }.isNew ()) + "A non-empty GUID should not be flagged as a new church" + } + test "populateChurch works correctly when an interface exists" { + let edit = + { EditChurch.empty with + churchId = Guid.NewGuid () + name = "Test Baptist Church" + city = "Testerville" + st = "TE" + hasInterface = Some true + interfaceAddress = Some "https://test.units" + } + let church = edit.populateChurch Church.empty + Expect.notEqual church.churchId edit.churchId "The church ID should not have been modified" + Expect.equal church.name edit.name "The church name was not updated correctly" + Expect.equal church.city edit.city "The church's city was not updated correctly" + Expect.equal church.st edit.st "The church's state was not updated correctly" + Expect.isTrue church.hasInterface "The church should show that it has an interface" + Expect.isSome church.interfaceAddress "The interface address should exist" + Expect.equal church.interfaceAddress edit.interfaceAddress "The interface address was not updated correctly" + } + test "populateChurch works correctly when an interface does not exist" { + let church = + { EditChurch.empty with + name = "Test Baptist Church" + city = "Testerville" + st = "TE" + }.populateChurch Church.empty + Expect.isFalse church.hasInterface "The church should show that it has an interface" + Expect.isNone church.interfaceAddress "The interface address should exist" + } + ] + +[] +let editMemberTests = + testList "EditMember" [ + test "fromMember populates with group default format" { + let mbr = + { Member.empty with + memberId = Guid.NewGuid () + memberName = "Test Name" + email = "test_units@example.com" + } + let edit = EditMember.fromMember mbr + Expect.equal edit.memberId mbr.memberId "The member ID was not filled correctly" + Expect.equal edit.memberName mbr.memberName "The member name was not filled correctly" + Expect.equal edit.emailAddress mbr.email "The e-mail address was not filled correctly" + Expect.equal edit.emailType "" "The e-mail type should have been blank for group default" + } + test "fromMember populates with specific format" { + let edit = EditMember.fromMember { Member.empty with format = Some EmailType.Html } + Expect.equal edit.emailType EmailType.Html "The e-mail type was not filled correctly" + } + test "empty is as expected" { + let edit = EditMember.empty + Expect.equal edit.memberId Guid.Empty "The member ID should have been an empty GUID" + Expect.equal edit.memberName "" "The member name should have been blank" + Expect.equal edit.emailAddress "" "The e-mail address should have been blank" + Expect.equal edit.emailType "" "The e-mail type should have been blank" + } + test "isNew works for a new member" { + Expect.isTrue (EditMember.empty.isNew ()) "An empty GUID should be flagged as a new member" + } + test "isNew works for an existing member" { + Expect.isFalse ({ EditMember.empty with memberId = Guid.NewGuid () }.isNew ()) + "A non-empty GUID should not be flagged as a new member" + } + ] + +[] +let editPreferencesTests = + testList "EditPreferences" [ + test "fromPreferences succeeds for named colors and private list" { + let prefs = ListPreferences.empty + let edit = EditPreferences.fromPreferences prefs + Expect.equal edit.expireDays prefs.daysToExpire "The expiration days were not filled correctly" + Expect.equal edit.daysToKeepNew prefs.daysToKeepNew "The days to keep new were not filled correctly" + Expect.equal edit.longTermUpdateWeeks prefs.longTermUpdateWeeks "The weeks for update were not filled correctly" + Expect.equal edit.requestSort prefs.requestSort "The request sort was not filled correctly" + Expect.equal edit.emailFromName prefs.emailFromName "The e-mail from name was not filled correctly" + Expect.equal edit.emailFromAddress prefs.emailFromAddress "The e-mail from address was not filled correctly" + Expect.equal edit.defaultEmailType prefs.defaultEmailType "The default e-mail type was not filled correctly" + Expect.equal edit.headingLineType "Name" "The heading line color type was not derived correctly" + Expect.equal edit.headingLineColor prefs.lineColor "The heading line color was not filled correctly" + Expect.equal edit.headingTextType "Name" "The heading text color type was not derived correctly" + Expect.equal edit.headingTextColor prefs.headingColor "The heading text color was not filled correctly" + Expect.equal edit.listFonts prefs.listFonts "The list fonts were not filled correctly" + Expect.equal edit.headingFontSize prefs.headingFontSize "The heading font size was not filled correctly" + Expect.equal edit.listFontSize prefs.textFontSize "The list text font size was not filled correctly" + Expect.equal edit.timeZone prefs.timeZoneId "The time zone was not filled correctly" + Expect.isSome edit.groupPassword "The group password should have been set" + Expect.equal edit.groupPassword (Some prefs.groupPassword) "The group password was not filled correctly" + Expect.equal edit.listVisibility RequestVisibility.``private`` "The list visibility was not derived correctly" + } + test "fromPreferences succeeds for RGB line color and password-protected list" { + let prefs = { ListPreferences.empty with lineColor = "#ff0000"; groupPassword = "pw" } + let edit = EditPreferences.fromPreferences prefs + Expect.equal edit.headingLineType "RGB" "The heading line color type was not derived correctly" + Expect.equal edit.headingLineColor prefs.lineColor "The heading line color was not filled correctly" + Expect.isSome edit.groupPassword "The group password should have been set" + Expect.equal edit.groupPassword (Some prefs.groupPassword) "The group password was not filled correctly" + Expect.equal edit.listVisibility RequestVisibility.passwordProtected + "The list visibility was not derived correctly" + } + test "fromPreferences succeeds for RGB text color and public list" { + let prefs = { ListPreferences.empty with headingColor = "#0000ff"; isPublic = true } + let edit = EditPreferences.fromPreferences prefs + Expect.equal edit.headingTextType "RGB" "The heading text color type was not derived correctly" + Expect.equal edit.headingTextColor prefs.headingColor "The heading text color was not filled correctly" + Expect.isSome edit.groupPassword "The group password should have been set" + Expect.equal edit.groupPassword (Some "") "The group password was not filled correctly" + Expect.equal edit.listVisibility RequestVisibility.``public`` "The list visibility was not derived correctly" + } + ] + +[] +let editRequestTests = + testList "EditRequest" [ + test "empty is as expected" { + let mt = EditRequest.empty + Expect.equal mt.requestId Guid.Empty "The request ID should be an empty GUID" + Expect.equal mt.requestType "" "The request type should have been blank" + Expect.isNone mt.enteredDate "The entered date should have been None" + Expect.isNone mt.skipDateUpdate "The \"skip date update\" flag should have been None" + Expect.isNone mt.requestor "The requestor should have been None" + Expect.equal mt.expiration "N" "The expiration should have been \"N\"" + Expect.equal mt.text "" "The text should have been blank" + } + test "fromRequest succeeds when a request has the do-not-expire flag set" { + let req = + { PrayerRequest.empty with + prayerRequestId = Guid.NewGuid () + requestType = RequestType.Current + requestor = Some "Me" + doNotExpire = true + text = "the text" + } + let edit = EditRequest.fromRequest req + Expect.equal edit.requestId req.prayerRequestId "The request ID was not filled correctly" + Expect.equal edit.requestType req.requestType "The request type was not filled correctly" + Expect.equal edit.requestor req.requestor "The requestor was not filled correctly" + Expect.equal edit.expiration "Y" "The expiration should have been \"Y\" since the do-not-expire flag was set" + Expect.equal edit.text req.text "The text was not filled correctly" + } + test "fromRequest succeeds when a request has the do-not-expire flag unset" { + let req = + { PrayerRequest.empty with + requestor = None + doNotExpire = false + } + let edit = EditRequest.fromRequest req + Expect.equal edit.requestor req.requestor "The requestor was not filled correctly" + Expect.equal edit.expiration "N" "The expiration should have been \"N\" since the do-not-expire flag was not set" + } + test "isNew works for a new request" { + Expect.isTrue (EditRequest.empty.isNew ()) "An empty GUID should be flagged as a new request" + } + test "isNew works for an existing request" { + Expect.isFalse ({ EditRequest.empty with requestId = Guid.NewGuid () }.isNew ()) + "A non-empty GUID should not be flagged as a new request" + } + ] + +[] +let editSmallGroupTests = + testList "EditSmallGroup" [ + test "fromGroup succeeds" { + let grp = + { SmallGroup.empty with + smallGroupId = Guid.NewGuid () + name = "test group" + churchId = Guid.NewGuid () + } + let edit = EditSmallGroup.fromGroup grp + Expect.equal edit.smallGroupId grp.smallGroupId "The small group ID was not filled correctly" + Expect.equal edit.name grp.name "The name was not filled correctly" + Expect.equal edit.churchId grp.churchId "The church ID was not filled correctly" + } + test "empty is as expected" { + let mt = EditSmallGroup.empty + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should be an empty GUID" + Expect.equal mt.name "" "The name should be blank" + Expect.equal mt.churchId Guid.Empty "The church ID should be an empty GUID" + } + test "isNew works for a new small group" { + Expect.isTrue (EditSmallGroup.empty.isNew ()) "An empty GUID should be flagged as a new small group" + } + test "isNew works for an existing small group" { + Expect.isFalse ({ EditSmallGroup.empty with smallGroupId = Guid.NewGuid () }.isNew ()) + "A non-empty GUID should not be flagged as a new small group" + } + test "populateGroup succeeds" { + let edit = + { EditSmallGroup.empty with + name = "test name" + churchId = Guid.NewGuid () + } + let grp = edit.populateGroup SmallGroup.empty + Expect.equal grp.name edit.name "The name was not populated correctly" + Expect.equal grp.churchId edit.churchId "The church ID was not populated correctly" + } + ] + +[] +let editUserTests = + testList "EditUser" [ + test "empty is as expected" { + let mt = EditUser.empty + Expect.equal mt.userId Guid.Empty "The user ID should be an empty GUID" + Expect.equal mt.firstName "" "The first name should be blank" + Expect.equal mt.lastName "" "The last name should be blank" + Expect.equal mt.emailAddress "" "The e-mail address should be blank" + Expect.equal mt.password "" "The password should be blank" + Expect.equal mt.passwordConfirm "" "The confirmed password should be blank" + Expect.isNone mt.isAdmin "The isAdmin flag should be None" + } + test "fromUser succeeds" { + let usr = + { User.empty with + userId = Guid.NewGuid () + firstName = "user" + lastName = "test" + emailAddress = "a@b.c" + } + let edit = EditUser.fromUser usr + Expect.equal edit.userId usr.userId "The user ID was not filled correctly" + Expect.equal edit.firstName usr.firstName "The first name was not filled correctly" + Expect.equal edit.lastName usr.lastName "The last name was not filled correctly" + Expect.equal edit.emailAddress usr.emailAddress "The e-mail address was not filled correctly" + Expect.isNone edit.isAdmin "The isAdmin flag was not filled correctly" + } + test "isNew works for a new user" { + Expect.isTrue (EditUser.empty.isNew ()) "An empty GUID should be flagged as a new user" + } + test "isNew works for an existing user" { + Expect.isFalse ({ EditUser.empty with userId = Guid.NewGuid () }.isNew ()) + "A non-empty GUID should not be flagged as a new user" + } + test "populateUser succeeds" { + let edit = + { EditUser.empty with + firstName = "name" + lastName = "eman" + emailAddress = "n@m.e" + isAdmin = Some true + password = "testpw" + } + let hasher = fun x -> x + "+" + let usr = edit.populateUser User.empty hasher + Expect.equal usr.firstName edit.firstName "The first name was not populated correctly" + Expect.equal usr.lastName edit.lastName "The last name was not populated correctly" + Expect.equal usr.emailAddress edit.emailAddress "The e-mail address was not populated correctly" + Expect.isTrue usr.isAdmin "The isAdmin flag was not populated correctly" + Expect.equal usr.passwordHash (hasher edit.password) "The password hash was not populated correctly" + } + ] + +[] +let groupLogOnTests = + testList "GroupLogOn" [ + test "empty is as expected" { + let mt = GroupLogOn.empty + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should be an empty GUID" + Expect.equal mt.password "" "The password should be blank" + Expect.isNone mt.rememberMe "Remember Me should be None" + } + ] + +[] +let requestListTests = + testList "RequestList" [ + let withRequestList f () = + { requests = [ + { PrayerRequest.empty with + requestType = RequestType.Current + requestor = Some "Zeb" + text = "zyx" + updatedDate = DateTime.Today + } + { PrayerRequest.empty with + requestType = RequestType.Current + requestor = Some "Aaron" + text = "abc" + updatedDate = DateTime.Today - TimeSpan.FromDays 9. + } + { PrayerRequest.empty with + requestType = RequestType.Praise + text = "nmo" + updatedDate = DateTime.Today + } + ] + date = DateTime.Today + listGroup = SmallGroup.empty + showHeader = false + recipients = [] + canEmail = false + } + |> f + yield! testFixture withRequestList [ + "asHtml succeeds without header", + fun reqList -> + let htmlList = { reqList with listGroup = { reqList.listGroup with name = "Test HTML Group" } } + let html = htmlList.asHtml _s + Expect.equal -1 (html.IndexOf "Test HTML Group") "The small group name should not have existed (no header)" + let curReqHeading = + [ "" + "" + "
" + "    Current Requests   
" + ] + |> String.concat "" + Expect.stringContains html curReqHeading "Heading for category \"Current Requests\" not found" + let curReqHtml = + [ "
    " + "
  • " + "Zeb — zyx
  • " + "
  • " + "Aaron — abc
" + ] + |> String.concat "" + Expect.stringContains html curReqHtml "Expected HTML for \"Current Requests\" requests not found" + let praiseHeading = + [ "" + "" + "
" + "    Praise Reports   
" + ] + |> String.concat "" + Expect.stringContains html praiseHeading "Heading for category \"Praise Reports\" not found" + let praiseHtml = + [ "
    " + "
  • " + "nmo
" + ] + |> String.concat "" + Expect.stringContains html praiseHtml "Expected HTML for \"Praise Reports\" requests not found" + "asHtml succeeds with header", + fun reqList -> + let htmlList = + { reqList with + listGroup = { reqList.listGroup with name = "Test HTML Group" } + showHeader = true + } + let html = htmlList.asHtml _s + let lstHeading = + [ "
" + "Prayer Requests
" + "Test HTML Group
" + htmlList.date.ToString "MMMM d, yyyy" + "

" + ] + |> String.concat "" + Expect.stringContains html lstHeading "Expected HTML for the list heading not found" + // spot check; without header test tests this exhaustively + Expect.stringContains html "Zeb — zyx" "Expected requests not found" + "asText succeeds", + fun reqList -> + let textList = { reqList with listGroup = { reqList.listGroup with name = "Test Group" } } + let text = textList.asText _s + Expect.stringContains text (textList.listGroup.name + "\n") "Small group name not found" + Expect.stringContains text "Prayer Requests\n" "List heading not found" + Expect.stringContains text ((textList.date.ToString "MMMM d, yyyy") + "\n \n") "List date not found" + Expect.stringContains text "--------------------\n CURRENT REQUESTS\n--------------------\n" + "Heading for category \"Current Requests\" not found" + Expect.stringContains text " + Zeb - zyx\n" "First request not found" + Expect.stringContains text " - Aaron - abc\n \n" "Second request not found; should have been end of category" + Expect.stringContains text "------------------\n PRAISE REPORTS\n------------------\n" + "Heading for category \"Praise Reports\" not found" + Expect.stringContains text " + nmo\n \n" "Last request not found" + "isNew succeeds for both old and new requests", + fun reqList -> + let reqs = reqList.requestsInCategory RequestType.Current + Expect.hasCountOf reqs 2u countAll "There should have been two requests" + Expect.isTrue (reqList.isNew (List.head reqs)) "The first request should have been new" + Expect.isFalse (reqList.isNew (List.last reqs)) "The second request should not have been new" + "requestsInCategory succeeds when requests exist", + fun reqList -> + let reqs = reqList.requestsInCategory RequestType.Current + Expect.hasCountOf reqs 2u countAll "There should have been two requests" + let first = List.head reqs + Expect.equal first.text "zyx" "The requests should be sorted by updated date descending" + "requestsInCategory succeeds when requests do not exist", + fun reqList -> + Expect.isEmpty (reqList.requestsInCategory "ABC") "There should have been no category \"ABC\" requests" + "requestsInCategory succeeds and sorts by requestor", + fun reqList -> + let newList = + { reqList with + listGroup = + { reqList.listGroup with preferences = { reqList.listGroup.preferences with requestSort = "R" } } + } + let reqs = newList.requestsInCategory RequestType.Current + Expect.hasCountOf reqs 2u countAll "There should have been two requests" + let first = List.head reqs + Expect.equal first.text "abc" "The requests should be sorted by requestor" + ] + ] + +[] +let userLogOnTests = + testList "UserLogOn" [ + test "empty is as expected" { + let mt = UserLogOn.empty + Expect.equal mt.emailAddress "" "The e-mail address should be blank" + Expect.equal mt.password "" "The password should be blank" + Expect.equal mt.smallGroupId Guid.Empty "The small group ID should be an empty GUID" + Expect.isNone mt.rememberMe "Remember Me should be None" + Expect.isNone mt.redirectUrl "Redirect URL should be None" + } + ] + +[] +let userMessageTests = + testList "UserMessage" [ + test "Error is constructed properly" { + let msg = UserMessage.Error + Expect.equal msg.level "ERROR" "Incorrect message level" + Expect.equal msg.text HtmlString.Empty "Text should have been blank" + Expect.isNone msg.description "Description should have been None" + } + test "Warning is constructed properly" { + let msg = UserMessage.Warning + Expect.equal msg.level "WARNING" "Incorrect message level" + Expect.equal msg.text HtmlString.Empty "Text should have been blank" + Expect.isNone msg.description "Description should have been None" + } + test "Info is constructed properly" { + let msg = UserMessage.Info + Expect.equal msg.level "Info" "Incorrect message level" + Expect.equal msg.text HtmlString.Empty "Text should have been blank" + Expect.isNone msg.description "Description should have been None" + } + ] diff --git a/src/PrayerTracker.UI/Church.fs b/src/PrayerTracker.UI/Church.fs new file mode 100644 index 0000000..12de97d --- /dev/null +++ b/src/PrayerTracker.UI/Church.fs @@ -0,0 +1,106 @@ +module PrayerTracker.Views.Church + +open Giraffe.GiraffeViewEngine +open PrayerTracker.Entities +open PrayerTracker.ViewModels +open System +open System.Collections.Generic + +/// View for the church edit page +let edit (m : EditChurch) ctx vi = + let pageTitle = match m.isNew () with true -> "Add a New Church" | false -> "Edit Church" + let s = I18N.localizer.Force () + [ form [ _action "/church/save"; _method "post"; _class "pt-center-columns" ] [ + style [ _scoped ] + [ rawText "#name { width: 20rem; } #city { width: 10rem; } #st { width: 3rem; } #interfaceAddress { width: 30rem; }" ] + csrfToken ctx + input [ _type "hidden"; _name "churchId"; _value (m.churchId.ToString "N") ] + div [ _class "pt-field-row" ] [ + div [ _class "pt-field" ] [ + label [ _for "name" ] [ encLocText s.["Church Name"] ] + input [ _type "text"; _name "name"; _id "name"; _required; _autofocus; _value m.name ] + ] + div [ _class "pt-field" ] [ + label [ _for "City"] [ encLocText s.["City"] ] + input [ _type "text"; _name "city"; _id "city"; _required; _value m.city ] + ] + div [ _class "pt-field" ] [ + label [ _for "ST" ] [ encLocText s.["State"] ] + input [ _type "text"; _name "st"; _id "st"; _required; _minlength "2"; _maxlength "2"; _value m.st ] + ] + ] + div [ _class "pt-field-row" ] [ + div [ _class "pt-checkbox-field" ] [ + input [ yield _type "checkbox" + yield _name "hasInterface" + yield _id "hasInterface" + yield _value "True" + match m.hasInterface with Some x when x -> yield _checked | _ -> () ] + label [ _for "hasInterface" ] [ encLocText s.["Has an interface with Virtual Prayer Room"] ] + ] + ] + div [ _class "pt-field-row pt-fadeable"; _id "divInterfaceAddress" ] [ + div [ _class "pt-field" ] [ + label [ _for "interfaceAddress" ] [ encLocText s.["VPR Interface URL"] ] + input [ _type "url"; _name "interfaceAddress"; _id "interfaceAddress"; + _value (match m.interfaceAddress with Some ia -> ia | None -> "") ] + ] + ] + div [ _class "pt-field-row" ] [ submit [] "save" s.["Save Church"] ] + ] + script [] [ rawText "PT.onLoad(PT.church.edit.onPageLoad)" ] + ] + |> Layout.Content.standard + |> Layout.standard vi pageTitle + + +/// View for church maintenance page +let maintain (churches : Church list) (stats : Map) ctx vi = + let s = I18N.localizer.Force () + [ div [ _class "pt-center-text" ] [ + br [] + a [ _href (sprintf "/church/%s/edit" emptyGuid); _title s.["Add a New Church"].Value ] + [ icon "add_circle"; rawText "  "; encLocText s.["Add a New Church"] ] + br [] + br [] + ] + tableSummary churches.Length s + table [ _class "pt-table pt-action-table" ] [ + thead [] [ + tr [] [ + th [] [ encLocText s.["Actions"] ] + th [] [ encLocText s.["Name"] ] + th [] [ encLocText s.["Location"] ] + th [] [ encLocText s.["Groups"] ] + th [] [ encLocText s.["Requests"] ] + th [] [ encLocText s.["Users"] ] + th [] [ encLocText s.["Interface?"] ] + ] + ] + churches + |> List.map (fun ch -> + let chId = ch.churchId.ToString "N" + let delAction = sprintf "/church/%s/delete" chId + let delPrompt = s.["Are you want to delete this {0}? This action cannot be undone.", + sprintf "%s (%s)" (s.["Church"].Value.ToLower ()) ch.name] + tr [] [ + td [] [ + a [ _href (sprintf "/church/%s/edit" chId); _title s.["Edit This Church"].Value ] [ icon "edit" ] + a [ _href delAction + _title s.["Delete This Church"].Value + _onclick (sprintf "return PT.confirmDelete('%s','%A')" delAction delPrompt) ] + [ icon "delete_forever" ] + ] + td [] [ encodedText ch.name ] + td [] [ encodedText ch.city; rawText ", "; encodedText ch.st ] + td [ _class "pt-right-text" ] [ rawText (stats.[chId].smallGroups.ToString "N0") ] + td [ _class "pt-right-text" ] [ rawText (stats.[chId].prayerRequests.ToString "N0") ] + td [ _class "pt-right-text" ] [ rawText (stats.[chId].users.ToString "N0") ] + td [ _class "pt-center-text" ] [ encLocText s.[match ch.hasInterface with true -> "Yes" | false -> "No"] ] + ]) + |> tbody [] + ] + form [ _id "DeleteForm"; _action ""; _method "post" ] [ csrfToken ctx ] + ] + |> Layout.Content.wide + |> Layout.standard vi "Maintain Churches" diff --git a/src/PrayerTracker.UI/CommonFunctions.fs b/src/PrayerTracker.UI/CommonFunctions.fs new file mode 100644 index 0000000..9c9a45b --- /dev/null +++ b/src/PrayerTracker.UI/CommonFunctions.fs @@ -0,0 +1,143 @@ +[] +module PrayerTracker.Views.CommonFunctions + +open Giraffe +open Giraffe.GiraffeViewEngine +open Microsoft.AspNetCore.Antiforgery +open Microsoft.AspNetCore.Http +open Microsoft.AspNetCore.Mvc.Localization +open Microsoft.Extensions.Localization +open System.IO +open System.Text.Encodings.Web + +/// Encoded text for a localized string +let encLocText (text : LocalizedString) = encodedText text.Value + +/// Raw text for a localized HTML string +let rawLocText (writer : StringWriter) (text : LocalizedHtmlString) = + text.WriteTo (writer, HtmlEncoder.Default) + let txt = string writer + writer.GetStringBuilder().Clear () |> ignore + rawText txt + +/// A space (used for back-to-back localization string breaks) +let space = rawText " " + +/// Generate a Material Design icon +let icon name = i [ _class "material-icons" ] [ rawText name ] + +/// Generate a Material Design icon, specifying the point size (must be defined in CSS) +let iconSized size name = i [ _class (sprintf "material-icons md-%i" size) ] [ rawText name ] + +/// Generate a CSRF prevention token +let csrfToken (ctx : HttpContext) = + let antiForgery = ctx.GetService () + let tokenSet = antiForgery.GetAndStoreTokens ctx + input [ _type "hidden"; _name tokenSet.FormFieldName; _value tokenSet.RequestToken ] + +/// Create a summary for a table of items +let tableSummary itemCount (s : IStringLocalizer) = + div [ _class "pt-center-text" ] [ + small [] [ + match itemCount with + | 0 -> s.["No Entries to Display"] + | 1 -> s.["Displaying {0} Entry", itemCount] + | _ -> s.["Displaying {0} Entries", itemCount] + |> encLocText + ] + ] + +/// Generate a list of named HTML colors +let namedColorList name selected attrs (s : IStringLocalizer) = + /// The list of HTML named colors (name, display, text color) + seq { + yield ("aqua", s.["Aqua"], "black") + yield ("black", s.["Black"], "white") + yield ("blue", s.["Blue"], "white") + yield ("fuchsia", s.["Fuchsia"], "black") + yield ("gray", s.["Gray"], "white") + yield ("green", s.["Green"], "white") + yield ("lime", s.["Lime"], "black") + yield ("maroon", s.["Maroon"], "white") + yield ("navy", s.["Navy"], "white") + yield ("olive", s.["Olive"], "white") + yield ("purple", s.["Purple"], "white") + yield ("red", s.["Red"], "black") + yield ("silver", s.["Silver"], "black") + yield ("teal", s.["Teal"], "white") + yield ("white", s.["White"], "black") + yield ("yellow", s.["Yellow"], "black") + } + |> Seq.map (fun color -> + let (colorName, dispText, txtColor) = color + option [ yield _value colorName + yield _style (sprintf "background-color:%s;color:%s;" colorName txtColor) + match colorName = selected with true -> yield _selected | false -> () ] [ + encodedText (dispText.Value.ToLower ()) + ]) + |> List.ofSeq + |> select (_name name :: attrs) + +/// Generate an input[type=radio] that is selected if its value is the current value +let radio name domId value current = + input [ yield _type "radio" + yield _name name + yield _id domId + yield _value value + match value = current with true -> yield _checked | false -> () ] + +/// Generate a select list with the current value selected +let selectList name selected attrs items = + items + |> Seq.map (fun (value, text) -> + option [ yield _value value + match value = selected with true -> yield _selected | false -> () ] [ encodedText text ]) + |> List.ofSeq + |> select (List.concat [ [ _name name; _id name ]; attrs ]) + +/// Generate the text for a default entry at the top of a select list +let selectDefault text = sprintf "— %s —" text + +/// Generate a standard submit button with icon and text +let submit attrs ico text = button (_type "submit" :: attrs) [ icon ico; rawText "  "; encLocText text ] + +/// An empty GUID string (used for "add" actions) +let emptyGuid = System.Guid.Empty.ToString "N" + + +/// blockquote tag +let blockquote = tag "blockquote" + +/// role attribute +let _role = attr "role" +/// aria-* attribute +let _aria typ = attr (sprintf "aria-%s" typ) +/// onclick attribute +let _onclick = attr "onclick" +/// onsubmit attribute +let _onsubmit = attr "onsubmit" + +/// scoped flag (used for