Repository navigation
Expand file tree
/
Copy pathinvariants.sql
More file actions
2048 lines (1898 loc) · 153 KB
/
Copy pathinvariants.sql
File metadata and controls
2048 lines (1898 loc) · 153 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*
Invariants: rules that must hold in every loaded dataset, on every Rock version.
The loader bulk-copies around Rock's save hooks, so Rock never gets to enforce these itself. The verify tool
(verify/README.md) runs each check after a load and fails when any returns more than zero. Each check starts
with "-- check: name", says why it matters on the next "-- why:" line, and returns one number: how many rows
break the rule. @ForeignKey is the dataset's key, for example SampleData:core-1. @CardPeople and @CardHouseholds
are the dataset card's numbers from datasets/{key}.json, or null when the load used another seed than the
default, which the card was not measured at. @AdminPersonId is Rock's admin person (Person Id 1), which the load
turns into a Cast person but leaves without the dataset's ForeignKey (decision 177), so rules about this
dataset's people name it alongside @ForeignKey.
Rules that a foreign key or unique index already enforces are left out: the bulk copy checks those.
"Every family has an adult" is left out on purpose: kids whose parents are not in Rock, and Jenny Greggs's
memorial record, are child-only families, as they are in real churches.
*/
-- check: dataset-has-people
-- why: Every rule below looks only at this dataset's rows. A load that wrote nothing would pass them all.
SELECT CASE WHEN EXISTS ( SELECT 1 FROM [Person] WHERE [ForeignKey] = @ForeignKey ) THEN 0 ELSE 1 END;
-- check: admin-is-a-cast-person
-- why: Decision 177. Rock's admin person becomes the Cast person who replaces it, so signing in as admin shows real data. It keeps Rock's admin login, Rock Administrators, and an empty ForeignKey, so nothing that removes sample data by ForeignKey can remove the site's admin. Its primary family is this dataset's, and it is in no other family, so Rock's empty Admin family is gone.
SELECT CASE WHEN EXISTS (
SELECT 1
FROM [Person] AS [p]
INNER JOIN [Group] AS [f] ON [f].[Id] = [p].[PrimaryFamilyId]
WHERE [p].[Id] = @AdminPersonId AND [p].[ForeignKey] IS NULL AND [f].[ForeignKey] = @ForeignKey
AND EXISTS ( SELECT 1 FROM [UserLogin] AS [ul] WHERE [ul].[PersonId] = [p].[Id] AND [ul].[UserName] = 'admin' )
AND EXISTS ( SELECT 1 FROM [GroupMember] AS [gm] INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId] WHERE [gm].[PersonId] = [p].[Id] AND [g].[Guid] = '628C51A8-4613-43ED-A18D-4A6FB999273E' ) )
AND NOT EXISTS (
SELECT 1
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
WHERE [gm].[PersonId] = @AdminPersonId AND [gt].[Guid] = '790E3215-3B10-442B-AF69-616C0DCB998E' AND ISNULL( [g].[ForeignKey], '' ) <> @ForeignKey )
THEN 0 ELSE 1 END;
-- check: person-has-one-primary-alias
-- why: Rock finds a person through their primary alias; every audit column and gift points at it.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ( [p].[PrimaryAliasId] IS NULL
OR NOT EXISTS ( SELECT 1 FROM [PersonAlias] AS [pa] WHERE [pa].[Id] = [p].[PrimaryAliasId] AND [pa].[PersonId] = [p].[Id] AND [pa].[AliasPersonId] = [p].[Id] )
OR ( SELECT COUNT(*) FROM [PersonAlias] AS [pa] WHERE [pa].[PersonId] = [p].[Id] AND [pa].[AliasPersonId] = [p].[Id] ) <> 1 );
-- check: person-is-in-a-family
-- why: Rock expects every person to belong to at least one family; profile pages and check-in break without one.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND NOT EXISTS (
SELECT 1
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
WHERE [gm].[PersonId] = [p].[Id] AND [gt].[Guid] = '790E3215-3B10-442B-AF69-616C0DCB998E' );
-- check: primary-family-is-a-family-the-person-is-in
-- why: Rock's save hook sets the primary family and campus; the loader fills them in itself.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ( [p].[PrimaryFamilyId] IS NULL
OR NOT EXISTS ( SELECT 1 FROM [GroupMember] AS [gm] WHERE [gm].[GroupId] = [p].[PrimaryFamilyId] AND [gm].[PersonId] = [p].[Id] ) );
-- check: primary-campus-is-the-primary-family-campus
-- why: Rock shows and filters people by the campus of their primary family.
SELECT COUNT(*)
FROM [Person] AS [p]
INNER JOIN [Group] AS [f] ON [f].[Id] = [p].[PrimaryFamilyId]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ISNULL( [p].[PrimaryCampusId], -1 ) <> ISNULL( [f].[CampusId], -1 );
-- check: giving-id-matches-giving-group
-- why: Rock groups gifts by GivingId (G + giving group, or P + person). A wrong one splits or merges giving units.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ( ISNULL( [p].[GivingId], '' ) <> CASE WHEN [p].[GivingGroupId] IS NULL THEN 'P' + CAST( [p].[Id] AS VARCHAR(20) ) ELSE 'G' + CAST( [p].[GivingGroupId] AS VARCHAR(20) ) END
OR [p].[GivingLeaderId] IS NULL );
-- check: giving-leader-is-in-the-giving-unit
-- why: The giving leader receives the statement; it must be the person or an adult of their giving group.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND [p].[GivingLeaderId] <> [p].[Id]
AND NOT EXISTS ( SELECT 1 FROM [GroupMember] AS [gm] WHERE [gm].[GroupId] = [p].[GivingGroupId] AND [gm].[PersonId] = [p].[GivingLeaderId] );
-- check: birth-date-parts-agree
-- why: Rock stores the birth date three ways (date, day/month/year, and a sortable key); reports read all three.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND [p].[BirthDate] IS NOT NULL
AND ( [p].[BirthYear] <> YEAR( [p].[BirthDate] ) OR [p].[BirthMonth] <> MONTH( [p].[BirthDate] ) OR [p].[BirthDay] <> DAY( [p].[BirthDate] )
OR [p].[BirthDateKey] <> YEAR( [p].[BirthDate] ) * 10000 + MONTH( [p].[BirthDate] ) * 100 + DAY( [p].[BirthDate] ) );
-- check: phones-use-the-reserved-range
-- why: Content standard 2. Phones are 555-0100 to 555-0199 in a Phoenix-area code, so no real person gets a call.
SELECT COUNT(*)
FROM [PhoneNumber] AS [pn]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pn].[PersonId]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ( LEN( [pn].[Number] ) <> 10 OR LEFT( [pn].[Number], 3 ) NOT IN ( '602', '480', '623' ) OR SUBSTRING( [pn].[Number], 4, 5 ) <> '55501' );
-- check: emails-use-reserved-domains
-- why: Content standard 3. Emails use example.org, or rocksolidchurchdemo.com for the sample church's staff.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ISNULL( [p].[Email], '' ) <> ''
AND [p].[Email] NOT LIKE '%@example.org'
AND [p].[Email] NOT LIKE '%@rocksolidchurchdemo.com';
-- check: no-race-or-ethnicity
-- why: Content standard 8. Photo ethnicity only makes matching family photos; it never goes into Rock.
SELECT COUNT(*)
FROM [Person] AS [p]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND ( [p].[RaceValueId] IS NOT NULL OR [p].[EthnicityValueId] IS NOT NULL );
-- check: home-addresses-are-mapped
-- why: Group Finder, campus distance, and maps need a point for every home address.
SELECT COUNT(*)
FROM [GroupLocation] AS [gl]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gl].[GroupId]
INNER JOIN [Location] AS [l] ON [l].[Id] = [gl].[LocationId]
INNER JOIN [DefinedValue] AS [t] ON [t].[Id] = [gl].[GroupLocationTypeValueId]
WHERE [g].[ForeignKey] = @ForeignKey
AND [t].[Guid] = '8C52E53C-2A66-435A-AE6E-5EE307D9A0DC'
AND [l].[GeoPoint] IS NULL;
-- check: nothing-dated-in-the-future
-- why: The timeline counts back from the install day. A future gift, check-in, or step shows up in "upcoming" places.
-- Requests to serve in the weeks ahead are the one exception: they are scheduled and not attended (decision 74).
SELECT ( SELECT COUNT(*) FROM [Attendance] WHERE [ForeignKey] = @ForeignKey AND [StartDateTime] >= DATEADD( DAY, 1, CAST( GETDATE() AS DATE ) ) AND NOT ( [RequestedToAttend] = 1 AND [DidAttend] = 0 ) )
+ ( SELECT COUNT(*) FROM [FinancialTransaction] WHERE [ForeignKey] = @ForeignKey AND [TransactionDateTime] >= DATEADD( DAY, 1, CAST( GETDATE() AS DATE ) ) )
+ ( SELECT COUNT(*) FROM [Step] WHERE [ForeignKey] = @ForeignKey
AND ( [StartDateTime] >= DATEADD( DAY, 1, CAST( GETDATE() AS DATE ) ) OR [EndDateTime] >= DATEADD( DAY, 1, CAST( GETDATE() AS DATE ) ) OR [CompletedDateTime] >= DATEADD( DAY, 1, CAST( GETDATE() AS DATE ) ) ) )
+ ( SELECT COUNT(*) FROM [StepProgramCompletion] WHERE [ForeignKey] = @ForeignKey AND [EndDateTime] >= DATEADD( DAY, 1, CAST( GETDATE() AS DATE ) ) );
-- check: transaction-has-positive-details
-- why: Rock totals a gift from its details. A gift with no detail, or a zero or negative one, breaks statements.
SELECT COUNT(*)
FROM [FinancialTransaction] AS [t]
WHERE [t].[ForeignKey] = @ForeignKey
AND ( NOT EXISTS ( SELECT 1 FROM [FinancialTransactionDetail] AS [d] WHERE [d].[TransactionId] = [t].[Id] )
OR EXISTS ( SELECT 1 FROM [FinancialTransactionDetail] AS [d] WHERE [d].[TransactionId] = [t].[Id] AND [d].[Amount] <= 0 ) );
-- check: transaction-is-inside-its-batch
-- why: Batches cover a date range. A gift outside its batch's range lands in the wrong batch report.
SELECT COUNT(*)
FROM [FinancialTransaction] AS [t]
INNER JOIN [FinancialBatch] AS [b] ON [b].[Id] = [t].[BatchId]
WHERE [t].[ForeignKey] = @ForeignKey
AND ( [t].[TransactionDateTime] < [b].[BatchStartDateTime] OR [t].[TransactionDateTime] >= [b].[BatchEndDateTime] );
-- check: member-role-belongs-to-the-group-type
-- why: Rock only lets a member hold a role of their group's type. The database does not enforce it.
SELECT COUNT(*)
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [gm].[GroupRoleId]
WHERE [gm].[ForeignKey] = @ForeignKey
AND [r].[GroupTypeId] <> [g].[GroupTypeId];
-- check: one-current-history-row
-- why: Rock's history tables mark exactly one current row per group, member, and meeting location.
SELECT ( SELECT COUNT(*) FROM ( SELECT [GroupId] FROM [GroupHistorical] WHERE [ForeignKey] = @ForeignKey GROUP BY [GroupId] HAVING SUM( CASE WHEN [CurrentRowIndicator] = 1 THEN 1 ELSE 0 END ) <> 1 ) AS [x] )
+ ( SELECT COUNT(*) FROM ( SELECT [GroupMemberId] FROM [GroupMemberHistorical] WHERE [ForeignKey] = @ForeignKey GROUP BY [GroupMemberId] HAVING SUM( CASE WHEN [CurrentRowIndicator] = 1 THEN 1 ELSE 0 END ) <> 1 ) AS [x] )
+ ( SELECT COUNT(*) FROM ( SELECT [GroupLocationId] FROM [GroupLocationHistorical] WHERE [ForeignKey] = @ForeignKey GROUP BY [GroupLocationId] HAVING SUM( CASE WHEN [CurrentRowIndicator] = 1 THEN 1 ELSE 0 END ) <> 1 ) AS [x] );
-- check: photo-is-a-stored-person-image
-- why: A photo id must point at a Person Image file whose bytes are in the database, or the profile shows a broken image.
SELECT COUNT(*)
FROM [Person] AS [p]
LEFT JOIN [BinaryFile] AS [f] ON [f].[Id] = [p].[PhotoId]
LEFT JOIN [BinaryFileType] AS [ft] ON [ft].[Id] = [f].[BinaryFileTypeId]
WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND [p].[PhotoId] IS NOT NULL
AND ( [f].[Id] IS NULL
OR [ft].[Guid] <> '03BD8476-8A9F-4078-B628-5B538F967AFC'
OR NOT EXISTS ( SELECT 1 FROM [BinaryFileData] AS [d] WHERE [d].[Id] = [f].[Id] AND DATALENGTH( [d].[Content] ) > 0 ) );
-- check: only-this-dataset
-- why: A database holds one dataset. Rows from another would mean a load on a database that was not fresh.
SELECT ( SELECT COUNT(*) FROM [Person] WHERE [ForeignKey] LIKE 'SampleData:%' AND [ForeignKey] <> @ForeignKey )
+ ( SELECT COUNT(*) FROM [Group] WHERE [ForeignKey] LIKE 'SampleData:%' AND [ForeignKey] <> @ForeignKey )
+ ( SELECT COUNT(*) FROM [FinancialTransaction] WHERE [ForeignKey] LIKE 'SampleData:%' AND [ForeignKey] <> @ForeignKey );
-- check: note-is-on-a-person-of-this-dataset
-- why: A person note's EntityId must point at a loaded person, or the note is an orphan Rock never shows.
SELECT COUNT(*)
FROM [Note] AS [n]
INNER JOIN [NoteType] AS [nt] ON [nt].[Id] = [n].[NoteTypeId]
INNER JOIN [EntityType] AS [et] ON [et].[Id] = [nt].[EntityTypeId] AND [et].[Name] = 'Rock.Model.Person'
WHERE [n].[ForeignKey] = @ForeignKey
AND NOT EXISTS ( SELECT 1 FROM [Person] AS [p] WHERE [p].[Id] = [n].[EntityId] AND ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId ) );
-- check: note-reply-matches-its-parent
-- why: Rock shows a reply under its parent, so it must be on the same entity, of the same type, and written after it.
SELECT COUNT(*)
FROM [Note] AS [r]
INNER JOIN [Note] AS [n] ON [n].[Id] = [r].[ParentNoteId]
WHERE [r].[ForeignKey] = @ForeignKey
AND ( [r].[EntityId] <> [n].[EntityId] OR [r].[NoteTypeId] <> [n].[NoteTypeId] OR [r].[CreatedDateTime] < [n].[CreatedDateTime] );
-- check: note-author-is-not-the-noted-person
-- why: Staff write notes about other people; a note about its own author looks like a load error. Rock's own Event Registration notes are left out: Rock writes one on each registrant as the registrar, who is often the registrant (decision 213).
SELECT COUNT(*)
FROM [Note] AS [n]
INNER JOIN [NoteType] AS [nt] ON [nt].[Id] = [n].[NoteTypeId]
INNER JOIN [EntityType] AS [et] ON [et].[Id] = [nt].[EntityTypeId] AND [et].[Name] = 'Rock.Model.Person'
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [n].[CreatedByPersonAliasId]
WHERE [n].[ForeignKey] = @ForeignKey
AND [nt].[Guid] <> 'BBADA8EF-23FC-4B46-B7A7-0F6D31F8C045'
AND [pa].[PersonId] = [n].[EntityId];
-- check: person-note-looks-saved-by-rock
-- why: Rock's save hook approves notes whose type needs no approval, and its editor sets EditedDateTime and a private note's caption. Unsent flags would make Rock's Send Note Notifications job email staff about old notes. Rock's Event Registration notes come from Rock's code, not its editor, so they have no EditedDateTime (decision 213).
SELECT COUNT(*)
FROM [Note] AS [n]
INNER JOIN [NoteType] AS [nt] ON [nt].[Id] = [n].[NoteTypeId]
INNER JOIN [EntityType] AS [et] ON [et].[Id] = [nt].[EntityTypeId] AND [et].[Name] = 'Rock.Model.Person'
WHERE [n].[ForeignKey] = @ForeignKey
AND ( [n].[ApprovalStatus] <> 1
OR [n].[NotificationsSent] = 0
OR [n].[ApprovalsSent] = 0
OR ( [n].[EditedDateTime] IS NULL AND [nt].[Guid] <> 'BBADA8EF-23FC-4B46-B7A7-0F6D31F8C045' )
OR [n].[CreatedByPersonAliasId] IS NULL
OR ( [n].[IsPrivateNote] = 1 AND ISNULL( [n].[Caption], '' ) <> 'You - Personal Note' ) );
-- check: no-high-schooler-in-adult-groups
-- why: Small groups and serving teams are adult-only. Nobody joins or leads one while still in high school: a membership starts after June 1 of the person's graduation year, the day after Rock's last day of grade 12 (decisions 169 and 205).
SELECT COUNT(*)
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [gm].[PersonId]
WHERE [gm].[ForeignKey] = @ForeignKey
AND [gt].[Guid] IN ( '50FCFB30-F51A-49DF-86F4-2B176EA1820B', '2C42B2D4-1C5F-4AD5-A9AD-08631B872AC4' )
AND [p].[GraduationYear] IS NOT NULL
AND [gm].[DateTimeAdded] < DATEFROMPARTS( [p].[GraduationYear], 6, 2 );
-- check: step-matches-its-membership
-- why: A Small Group, Serve, or Lead step records one membership (decision 86): same person, group, and dates, In Progress only while the member is Pending. And every membership that counts has its step.
WITH [Memberships] AS (
SELECT [p].[PrimaryAliasId], [gm].[GroupId], [gt].[Guid] AS [GroupTypeGuid], [r].[IsLeader], [gm].[GroupMemberStatus], [gm].[DateTimeAdded],
CASE WHEN [gm].[GroupMemberStatus] = 0 OR [gm].[IsArchived] = 1 THEN ISNULL( [gm].[InactiveDateTime], [gm].[ArchivedDateTime] ) END AS [MemberEnded],
CASE WHEN [g].[IsActive] = 0 OR [g].[IsArchived] = 1 THEN ISNULL( [g].[InactiveDateTime], [g].[ArchivedDateTime] ) END AS [GroupEnded]
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [gm].[GroupRoleId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [gm].[PersonId]
WHERE [gm].[ForeignKey] = @ForeignKey
AND [gt].[Guid] IN ( '50FCFB30-F51A-49DF-86F4-2B176EA1820B', '2C42B2D4-1C5F-4AD5-A9AD-08631B872AC4' )
),
[Expected] AS (
SELECT [PrimaryAliasId], [GroupId], CAST( 'EFA15A4F-5666-4153-B92F-AF3ECD73C504' AS UNIQUEIDENTIFIER ) AS [StepTypeGuid], [DateTimeAdded], [MemberEnded], [GroupEnded], 0 AS [IsInProgress]
FROM [Memberships] WHERE [GroupTypeGuid] = '50FCFB30-F51A-49DF-86F4-2B176EA1820B' AND [GroupMemberStatus] <> 2
UNION ALL
SELECT [PrimaryAliasId], [GroupId], CAST( '71E66730-8F7D-4EEF-9C53-524C4BDE5E59' AS UNIQUEIDENTIFIER ), [DateTimeAdded], [MemberEnded], [GroupEnded], CASE WHEN [GroupMemberStatus] = 2 THEN 1 ELSE 0 END
FROM [Memberships] WHERE [GroupTypeGuid] = '2C42B2D4-1C5F-4AD5-A9AD-08631B872AC4'
UNION ALL
SELECT [PrimaryAliasId], [GroupId], CAST( 'CFF0E432-3AB6-4290-8F6E-C1A3D8D8F375' AS UNIQUEIDENTIFIER ), [DateTimeAdded], [MemberEnded], [GroupEnded], 0
FROM [Memberships] WHERE [IsLeader] = 1 AND [GroupMemberStatus] <> 2
),
[ExpectedSteps] AS (
SELECT [PrimaryAliasId], [GroupId], [StepTypeGuid], [DateTimeAdded], [IsInProgress],
CASE WHEN [MemberEnded] IS NULL THEN [GroupEnded] WHEN [GroupEnded] IS NULL OR [MemberEnded] < [GroupEnded] THEN [MemberEnded] ELSE [GroupEnded] END AS [Ended]
FROM [Expected]
),
[Steps] AS (
SELECT [s].[PersonAliasId], [s].[RelatedEntityId], [st].[Guid] AS [StepTypeGuid], [s].[StartDateTime], [s].[EndDateTime], CASE WHEN [ss].[IsCompleteStatus] = 1 THEN 0 ELSE 1 END AS [IsInProgress]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [StepStatus] AS [ss] ON [ss].[Id] = [s].[StepStatusId]
WHERE [s].[ForeignKey] = @ForeignKey
AND [st].[Guid] IN ( 'EFA15A4F-5666-4153-B92F-AF3ECD73C504', '71E66730-8F7D-4EEF-9C53-524C4BDE5E59', 'CFF0E432-3AB6-4290-8F6E-C1A3D8D8F375' )
)
SELECT ( SELECT COUNT(*) FROM [Steps] AS [s] WHERE NOT EXISTS (
SELECT 1 FROM [ExpectedSteps] AS [e]
WHERE [e].[PrimaryAliasId] = [s].[PersonAliasId] AND [e].[GroupId] = [s].[RelatedEntityId] AND [e].[StepTypeGuid] = [s].[StepTypeGuid]
AND [e].[DateTimeAdded] = [s].[StartDateTime] AND ISNULL( [e].[Ended], '9999-01-01' ) = ISNULL( [s].[EndDateTime], '9999-01-01' ) AND [e].[IsInProgress] = [s].[IsInProgress] ) )
+ ( SELECT COUNT(*) FROM [ExpectedSteps] AS [e] WHERE NOT EXISTS (
SELECT 1 FROM [Steps] AS [s]
WHERE [e].[PrimaryAliasId] = [s].[PersonAliasId] AND [e].[GroupId] = [s].[RelatedEntityId] AND [e].[StepTypeGuid] = [s].[StepTypeGuid] AND [e].[DateTimeAdded] = [s].[StartDateTime] ) );
-- check: step-looks-saved-by-rock
-- why: Rock's step model fills the date keys from the dates, a Complete step has a completion date and an In Progress one has none, and the sample data puts every step on the person's primary alias and campus.
SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [StepStatus] AS [ss] ON [ss].[Id] = [s].[StepStatusId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [s].[ForeignKey] = @ForeignKey
AND ( ISNULL( [s].[StartDateKey], 0 ) <> ISNULL( CONVERT( INT, FORMAT( [s].[StartDateTime], 'yyyyMMdd' ) ), 0 )
OR ISNULL( [s].[EndDateKey], 0 ) <> ISNULL( CONVERT( INT, FORMAT( [s].[EndDateTime], 'yyyyMMdd' ) ), 0 )
OR ISNULL( [s].[CompletedDateKey], 0 ) <> ISNULL( CONVERT( INT, FORMAT( [s].[CompletedDateTime], 'yyyyMMdd' ) ), 0 )
OR ( [ss].[IsCompleteStatus] = 1 AND [s].[CompletedDateTime] IS NULL )
OR ( [ss].[IsCompleteStatus] = 0 AND [s].[CompletedDateTime] IS NOT NULL )
OR [s].[StartDateTime] IS NULL
OR ISNULL( [s].[CampusId], 0 ) <> ISNULL( [p].[PrimaryCampusId], 0 )
OR [s].[PersonAliasId] <> [p].[PrimaryAliasId] );
-- check: step-has-its-person-history
-- why: Rock's step save hook writes person history: one "Added" row per step on the day it was made, and a status change on the day a step that started In Progress was completed. The person's History tab shows each step.
WITH [StepHistory] AS (
SELECT [h].*
FROM [History] AS [h]
INNER JOIN [Category] AS [c] ON [c].[Id] = [h].[CategoryId]
WHERE [h].[ForeignKey] = @ForeignKey AND [c].[Guid] = '517BB2D3-0A50-4132-818E-63BB3C81EAE9'
)
SELECT ( SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
WHERE [s].[ForeignKey] = @ForeignKey
AND ( SELECT COUNT(*) FROM [StepHistory] AS [h]
WHERE [h].[RelatedEntityId] = [s].[Id] AND [h].[EntityId] = [pa].[PersonId] AND [h].[Verb] = 'STEPADDED'
AND [h].[ValueName] = [st].[Name] AND [h].[CreatedDateTime] = [s].[StartDateTime] ) <> 1 )
+ ( SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
WHERE [s].[ForeignKey] = @ForeignKey AND [s].[CompletedDateTime] > [s].[StartDateTime]
AND NOT EXISTS ( SELECT 1 FROM [StepHistory] AS [h]
WHERE [h].[RelatedEntityId] = [s].[Id] AND [h].[EntityId] = [pa].[PersonId] AND [h].[Verb] = 'MODIFY'
AND [h].[ValueName] = 'Step Status' AND [h].[CreatedDateTime] = [s].[CompletedDateTime] ) )
+ ( SELECT COUNT(*)
FROM [StepHistory] AS [h]
WHERE NOT EXISTS ( SELECT 1 FROM [Step] AS [s]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
WHERE [s].[Id] = [h].[RelatedEntityId] AND [pa].[PersonId] = [h].[EntityId] ) );
-- check: baptism-step-agrees-with-baptism-date
-- why: The Baptism step and Rock's Baptism Date person attribute always agree, and the step's Baptized Here matches the person's.
WITH [Dates] AS (
SELECT [av].[EntityId] AS [PersonId], [av].[ValueAsDateTime]
FROM [AttributeValue] AS [av]
INNER JOIN [Attribute] AS [a] ON [a].[Id] = [av].[AttributeId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [av].[EntityId]
WHERE [a].[Guid] = 'D42763FA-28E9-4A55-A25A-48998D7D7FEF' AND ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId ) AND ISNULL( [av].[Value], '' ) <> ''
),
[Baptisms] AS (
SELECT [pa].[PersonId], [s].[Id], [s].[StartDateTime], [s].[CompletedDateTime]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
WHERE [st].[Guid] = '801CC43C-0641-4271-939E-75E428F31D06' AND [s].[ForeignKey] = @ForeignKey
),
[Here] AS (
SELECT [b].[Id],
( SELECT [av].[Value] FROM [AttributeValue] AS [av] INNER JOIN [Attribute] AS [a] ON [a].[Id] = [av].[AttributeId] WHERE [a].[Guid] = '343A557E-92CC-4A75-B251-0DA732F5EB4E' AND [av].[EntityId] = [b].[Id] ) AS [OnStep],
( SELECT [av].[Value] FROM [AttributeValue] AS [av] INNER JOIN [Attribute] AS [a] ON [a].[Id] = [av].[AttributeId] WHERE [a].[Guid] = '87986F92-4AC3-49D2-9753-328588F3BB7A' AND [av].[EntityId] = [b].[PersonId] ) AS [OnPerson]
FROM [Baptisms] AS [b]
)
SELECT ( SELECT COUNT(*) FROM [Dates] AS [d] WHERE NOT EXISTS ( SELECT 1 FROM [Baptisms] AS [b] WHERE [b].[PersonId] = [d].[PersonId] AND [b].[StartDateTime] = [d].[ValueAsDateTime] AND [b].[CompletedDateTime] = [d].[ValueAsDateTime] ) )
+ ( SELECT COUNT(*) FROM [Baptisms] AS [b] WHERE NOT EXISTS ( SELECT 1 FROM [Dates] AS [d] WHERE [d].[PersonId] = [b].[PersonId] AND [d].[ValueAsDateTime] = [b].[StartDateTime] ) )
+ ( SELECT COUNT(*) FROM [Here] WHERE [OnStep] IS NULL OR [OnPerson] IS NULL OR [OnStep] <> [OnPerson] );
-- check: serve-step-follows-starting-point
-- why: Rock's one Discipleship Path prerequisite: Serve needs a completed Starting Point Class first (decision 84). Every volunteer has one before their Serve step starts.
SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
WHERE [st].[Guid] = '71E66730-8F7D-4EEF-9C53-524C4BDE5E59' AND [s].[ForeignKey] = @ForeignKey
AND NOT EXISTS (
SELECT 1
FROM [Step] AS [c]
INNER JOIN [StepType] AS [ct] ON [ct].[Id] = [c].[StepTypeId]
INNER JOIN [StepStatus] AS [cs] ON [cs].[Id] = [c].[StepStatusId]
WHERE [ct].[Guid] = 'F3585131-3EBF-49C4-83A0-3F88271A9B9B' AND [c].[PersonAliasId] = [s].[PersonAliasId]
AND [cs].[IsCompleteStatus] = 1 AND [c].[CompletedDateTime] < [s].[StartDateTime] );
-- check: give-step-agrees-with-journey-stage
-- why: A Give step runs from the week a giver becomes Consistent to the week they become Lapsed or Former (decision 85). So a giver Rock shows as Consistent has an open Give step, and an open Give step belongs to a Consistent or Occasional giver.
WITH [Stages] AS (
SELECT [p].[PrimaryAliasId], [av].[Value] AS [Stage]
FROM [Person] AS [p]
INNER JOIN [AttributeValue] AS [av] ON [av].[EntityId] = [p].[Id]
INNER JOIN [Attribute] AS [a] ON [a].[Id] = [av].[AttributeId]
WHERE [a].[Guid] = '13C55AEA-6D88-4470-B3AE-EE5138F044DF' AND ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId )
AND [p].[RecordTypeValueId] <> ( SELECT [Id] FROM [DefinedValue] WHERE [Guid] = 'BF64ADD3-E70A-44CE-9C4B-E76BBED37550' )
),
[OpenGive] AS (
SELECT DISTINCT [s].[PersonAliasId]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
WHERE [st].[Guid] = 'CE640CD3-DD6E-4BF3-ADDD-953F086E6E14' AND [s].[ForeignKey] = @ForeignKey AND [s].[EndDateTime] IS NULL
)
SELECT ( SELECT COUNT(*) FROM [Stages] AS [x] WHERE [x].[Stage] = '2' AND NOT EXISTS ( SELECT 1 FROM [OpenGive] AS [o] WHERE [o].[PersonAliasId] = [x].[PrimaryAliasId] ) )
+ ( SELECT COUNT(*) FROM [OpenGive] AS [o] WHERE NOT EXISTS ( SELECT 1 FROM [Stages] AS [x] WHERE [x].[PrimaryAliasId] = [o].[PersonAliasId] AND [x].[Stage] IN ( '2', '3' ) ) );
-- check: program-completions-follow-rocks-rule
-- why: Rock's Update Step Program Completions job records a completion for each person whose Complete steps cover every active step type in a program, dated from those steps, and points every step of theirs in the program at it. It runs for every program the dataset has steps in. Rock's job must find nothing to add or fix after install.
WITH [Programs] AS (
SELECT DISTINCT [st].[StepProgramId] AS [Id]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
WHERE [s].[ForeignKey] = @ForeignKey
),
[PeopleWithAllSteps] AS (
SELECT [st].[StepProgramId], [s].[PersonAliasId],
MIN( COALESCE( [s].[StartDateTime], [s].[CreatedDateTime] ) ) AS [StartDateTime],
MAX( COALESCE( [s].[CompletedDateTime], [s].[EndDateTime] ) ) AS [EndDateTime],
MAX( [s].[CampusId] ) AS [CampusId]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [StepStatus] AS [ss] ON [ss].[Id] = [s].[StepStatusId] AND [ss].[IsCompleteStatus] = 1
WHERE [st].[StepProgramId] IN ( SELECT [Id] FROM [Programs] ) AND [s].[ForeignKey] = @ForeignKey
GROUP BY [st].[StepProgramId], [s].[PersonAliasId]
HAVING COUNT( DISTINCT [s].[StepTypeId] ) = ( SELECT COUNT(*) FROM [StepType] AS [t] WHERE [t].[StepProgramId] = [st].[StepProgramId] AND [t].[IsActive] = 1 )
),
[Completions] AS (
SELECT [c].[Id], [c].[StepProgramId], [c].[PersonAliasId], [c].[StartDateTime], [c].[EndDateTime], [c].[CampusId], [c].[StartDateKey]
FROM [StepProgramCompletion] AS [c]
WHERE [c].[StepProgramId] IN ( SELECT [Id] FROM [Programs] ) AND [c].[ForeignKey] = @ForeignKey
)
SELECT ( SELECT COUNT(*) FROM [PeopleWithAllSteps] AS [w] WHERE NOT EXISTS (
SELECT 1 FROM [Completions] AS [c]
WHERE [c].[StepProgramId] = [w].[StepProgramId] AND [c].[PersonAliasId] = [w].[PersonAliasId] AND [c].[StartDateTime] = [w].[StartDateTime]
AND ISNULL( [c].[EndDateTime], '9999-01-01' ) = ISNULL( [w].[EndDateTime], '9999-01-01' )
AND ISNULL( [c].[CampusId], 0 ) = ISNULL( [w].[CampusId], 0 ) AND [c].[StartDateKey] = CONVERT( INT, FORMAT( [w].[StartDateTime], 'yyyyMMdd' ) ) ) )
+ ( SELECT COUNT(*) FROM [Completions] AS [c] WHERE NOT EXISTS ( SELECT 1 FROM [PeopleWithAllSteps] AS [w] WHERE [w].[StepProgramId] = [c].[StepProgramId] AND [w].[PersonAliasId] = [c].[PersonAliasId] ) )
+ ( SELECT COUNT(*) FROM ( SELECT [StepProgramId], [PersonAliasId] FROM [Completions] GROUP BY [StepProgramId], [PersonAliasId] HAVING COUNT(*) > 1 ) AS [x] )
+ ( SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
LEFT JOIN [Completions] AS [c] ON [c].[StepProgramId] = [st].[StepProgramId] AND [c].[PersonAliasId] = [s].[PersonAliasId]
WHERE [st].[StepProgramId] IN ( SELECT [Id] FROM [Programs] ) AND [s].[ForeignKey] = @ForeignKey
AND ISNULL( [s].[StepProgramCompletionId], 0 ) <> ISNULL( [c].[Id], 0 ) );
-- check: people-match-the-dataset-card
-- why: Rock's installer shows the people count from datasets/{key}.json (@CardPeople). Returns how many people the load is off by when that is more than 2%.
SELECT CASE WHEN @CardPeople IS NOT NULL AND ABS( [c].[People] - @CardPeople ) > @CardPeople * 0.02 THEN ABS( [c].[People] - @CardPeople ) ELSE 0 END
FROM ( SELECT COUNT(*) AS [People] FROM [Person] AS [p] WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId ) ) AS [c];
-- check: households-match-the-dataset-card
-- why: Rock's installer shows the household count from datasets/{key}.json (@CardHouseholds). Returns how many households the load is off by when that is more than 2%.
SELECT CASE WHEN @CardHouseholds IS NOT NULL AND ABS( [c].[Households] - @CardHouseholds ) > @CardHouseholds * 0.02 THEN ABS( [c].[Households] - @CardHouseholds ) ELSE 0 END
FROM (
SELECT COUNT(*) AS [Households]
FROM [Group] AS [g]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
WHERE [g].[ForeignKey] = @ForeignKey AND [gt].[Guid] = '790E3215-3B10-442B-AF69-616C0DCB998E' ) AS [c];
-- check: student-step-belongs-to-a-student
-- why: The Student Path follows student check-ins (decision 115). Every Student Path step belongs to someone who was in grades 7 to 12 on its start and had checked in to the student service by then. Mission Trip is for high school students, and Student Leader for juniors and seniors. A student's grade comes from their graduation year and moves up the day after each June 1, as Rock does (decision 205).
WITH [StudentSteps] AS (
SELECT [st].[Guid] AS [StepTypeGuid], [s].[StartDateTime], [pa].[PersonId], [p].[GraduationYear],
12 - ( [p].[GraduationYear] - CASE WHEN CAST( [s].[StartDateTime] AS DATE ) > DATEFROMPARTS( YEAR( [s].[StartDateTime] ), 6, 1 ) THEN YEAR( [s].[StartDateTime] ) + 1 ELSE YEAR( [s].[StartDateTime] ) END ) AS [Grade]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [StepProgram] AS [sp] ON [sp].[Id] = [st].[StepProgramId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [sp].[Guid] = 'C8AD9B16-5132-4ACB-954B-1E61C734AA67' AND [s].[ForeignKey] = @ForeignKey
)
SELECT COUNT(*)
FROM [StudentSteps] AS [x]
WHERE [x].[GraduationYear] IS NULL
OR [x].[Grade] > 12
OR [x].[Grade] < CASE [x].[StepTypeGuid] WHEN '8464EF05-F8E2-4DB3-9AC4-BC1EEE18CBC9' THEN 9 WHEN '724C69AF-20BC-4299-8FD5-A84ED2B4D42D' THEN 11 ELSE 7 END
OR NOT EXISTS (
SELECT 1
FROM [Attendance] AS [a]
INNER JOIN [AttendanceOccurrence] AS [o] ON [o].[Id] = [a].[OccurrenceId]
INNER JOIN [Group] AS [g] ON [g].[Id] = [o].[GroupId]
INNER JOIN [PersonAlias] AS [apa] ON [apa].[Id] = [a].[PersonAliasId]
WHERE [apa].[PersonId] = [x].[PersonId] AND [a].[DidAttend] = 1 AND [o].[OccurrenceDate] <= [x].[StartDateTime]
AND [g].[Name] IN ( 'Grades 7-8', 'Grades 9-12' ) AND [g].[GroupTypeId] IN ( SELECT [Id] FROM [GroupType] WHERE [Guid] IN ( '7A17235B-69AD-439B-BAB0-1A0A472DB96F', '9A88743B-F336-4404-B877-2A623689195D' ) ) );
-- check: student-path-decisions-and-leaders
-- why: Follow Jesus is a decision made once. Student Leader is held once, and ends at graduation in May of the student's graduation year, or stays open while they are still in school.
SELECT ( SELECT COUNT(*)
FROM ( SELECT [s].[PersonAliasId]
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
WHERE [st].[Guid] IN ( 'FAC9E970-7434-4067-9E1C-180043CE6FCB', '724C69AF-20BC-4299-8FD5-A84ED2B4D42D' ) AND [s].[ForeignKey] = @ForeignKey
GROUP BY [s].[PersonAliasId], [st].[Guid]
HAVING COUNT(*) > 1 ) AS [x] )
+ ( SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [st].[Guid] = '724C69AF-20BC-4299-8FD5-A84ED2B4D42D' AND [s].[ForeignKey] = @ForeignKey
AND ( ( [s].[EndDateTime] IS NOT NULL AND ( YEAR( [s].[EndDateTime] ) <> [p].[GraduationYear] OR MONTH( [s].[EndDateTime] ) <> 5 ) )
OR ( [s].[EndDateTime] IS NULL AND DATEFROMPARTS( [p].[GraduationYear], 6, 1 ) < CAST( GETDATE() AS DATE ) ) ) );
-- check: student-baptism-follows-the-student-service
-- why: A student's baptism goes on the Discipleship Path's Baptism step (decision 87). A child is baptized here, on a Sunday, after they started coming to the student service. The Baptism Date and Baptized Here agree with the step (checked above).
SELECT COUNT(*)
FROM [Step] AS [s]
INNER JOIN [StepType] AS [st] ON [st].[Id] = [s].[StepTypeId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [s].[PersonAliasId]
WHERE [st].[Guid] = '801CC43C-0641-4271-939E-75E428F31D06' AND [s].[ForeignKey] = @ForeignKey
AND EXISTS (
SELECT 1
FROM [GroupMember] AS [gm]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [gm].[GroupRoleId]
WHERE [gm].[PersonId] = [pa].[PersonId] AND [r].[Guid] = 'C8B1814F-6AA7-4055-B2D7-48FE20429CB9' )
AND ( DATEDIFF( DAY, '1900-01-07', [s].[StartDateTime] ) % 7 <> 0
OR NOT EXISTS (
SELECT 1
FROM [Attendance] AS [a]
INNER JOIN [AttendanceOccurrence] AS [o] ON [o].[Id] = [a].[OccurrenceId]
INNER JOIN [Group] AS [g] ON [g].[Id] = [o].[GroupId]
INNER JOIN [PersonAlias] AS [apa] ON [apa].[Id] = [a].[PersonAliasId]
WHERE [apa].[PersonId] = [pa].[PersonId] AND [a].[DidAttend] = 1 AND [o].[OccurrenceDate] <= [s].[StartDateTime]
AND [g].[Name] IN ( 'Grades 7-8', 'Grades 9-12' ) AND [g].[GroupTypeId] IN ( SELECT [Id] FROM [GroupType] WHERE [Guid] IN ( '7A17235B-69AD-439B-BAB0-1A0A472DB96F', '9A88743B-F336-4404-B877-2A623689195D' ) ) )
OR NOT EXISTS (
SELECT 1
FROM [AttributeValue] AS [av]
INNER JOIN [Attribute] AS [at] ON [at].[Id] = [av].[AttributeId]
WHERE [at].[Guid] = '343A557E-92CC-4A75-B251-0DA732F5EB4E' AND [av].[EntityId] = [s].[Id] AND [av].[Value] = 'True' ) );
-- check: young-kid-checks-in-with-the-family
-- why: Kids from the nursery through 6th grade come only with their family (decision 146). Each of their check-ins is made by an adult of one of their families who was in the worship service that Sunday. A child whose families have no living, active adult checks in on their own, as before.
SELECT COUNT(*)
FROM [Attendance] AS [a]
INNER JOIN [AttendanceOccurrence] AS [o] ON [o].[Id] = [a].[OccurrenceId]
INNER JOIN [Group] AS [g] ON [g].[Id] = [o].[GroupId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [a].[PersonAliasId]
WHERE [a].[ForeignKey] = @ForeignKey AND [a].[DidAttend] = 1 AND [a].[AttendanceCodeId] IS NOT NULL
AND [g].[GroupTypeId] IN ( SELECT [Id] FROM [GroupType] WHERE [Guid] IN ( 'CADB2D12-7836-44BC-8EEA-3C6AB22FD5E8', 'E3C8F7D6-5CEB-43BB-802F-66C3E734049E' ) )
AND EXISTS (
SELECT 1
FROM [GroupMember] AS [kid]
INNER JOIN [GroupMember] AS [adult] ON [adult].[GroupId] = [kid].[GroupId]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [adult].[GroupRoleId]
INNER JOIN [Person] AS [ap] ON [ap].[Id] = [adult].[PersonId]
WHERE [kid].[PersonId] = [pa].[PersonId] AND [kid].[GroupTypeId] = [adult].[GroupTypeId]
AND [r].[Guid] = '2639F9A5-2AAE-4E48-A8C3-4FFE86681E42'
AND [ap].[IsDeceased] = 0 AND [ap].[RecordStatusValueId] = ( SELECT [Id] FROM [DefinedValue] WHERE [Guid] = '618F906C-C33D-4FA3-8AEF-E58CB7B63F1E' ) )
AND NOT EXISTS (
SELECT 1
FROM [PersonAlias] AS [byAlias]
INNER JOIN [GroupMember] AS [byMember] ON [byMember].[PersonId] = [byAlias].[PersonId]
INNER JOIN [GroupTypeRole] AS [byRole] ON [byRole].[Id] = [byMember].[GroupRoleId]
INNER JOIN [GroupMember] AS [kid] ON [kid].[GroupId] = [byMember].[GroupId] AND [kid].[PersonId] = [pa].[PersonId]
INNER JOIN [PersonAlias] AS [wpa] ON [wpa].[PersonId] = [byAlias].[PersonId]
INNER JOIN [Attendance] AS [w] ON [w].[PersonAliasId] = [wpa].[Id] AND [w].[DidAttend] = 1
INNER JOIN [AttendanceOccurrence] AS [wo] ON [wo].[Id] = [w].[OccurrenceId] AND [wo].[SundayDate] = [o].[SundayDate]
INNER JOIN [Group] AS [wg] ON [wg].[Id] = [wo].[GroupId]
INNER JOIN [GroupType] AS [wgt] ON [wgt].[Id] = [wg].[GroupTypeId]
WHERE [byAlias].[Id] = [a].[CheckedInByPersonAliasId]
AND [byRole].[Guid] = '2639F9A5-2AAE-4E48-A8C3-4FFE86681E42'
AND [wg].[Name] = 'Weekend Service' AND [wgt].[Guid] = '235BAE2B-5760-4763-AADF-3938F34BA100' );
-- check: onsite-gift-on-a-family-sunday
-- why: Checks and cash go in the offering only on a Sunday the family came (decision 146). A student who comes on their own does not make it a family Sunday, so every gift in a Sunday Offering batch falls on a Sunday when an adult of the giver's family attended.
SELECT COUNT(*)
FROM [FinancialTransaction] AS [t]
INNER JOIN [FinancialBatch] AS [b] ON [b].[Id] = [t].[BatchId]
INNER JOIN [PersonAlias] AS [gpa] ON [gpa].[Id] = [t].[AuthorizedPersonAliasId]
WHERE [t].[ForeignKey] = @ForeignKey AND [b].[Name] LIKE 'Sunday Offering %'
AND NOT EXISTS (
SELECT 1
FROM [GroupMember] AS [giver]
INNER JOIN [GroupMember] AS [adult] ON [adult].[GroupId] = [giver].[GroupId]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [adult].[GroupRoleId]
INNER JOIN [PersonAlias] AS [apa] ON [apa].[PersonId] = [adult].[PersonId]
INNER JOIN [Attendance] AS [a] ON [a].[PersonAliasId] = [apa].[Id] AND [a].[DidAttend] = 1
INNER JOIN [AttendanceOccurrence] AS [o] ON [o].[Id] = [a].[OccurrenceId]
WHERE [giver].[PersonId] = [gpa].[PersonId] AND [giver].[GroupTypeId] = [adult].[GroupTypeId]
AND [giver].[GroupTypeId] = ( SELECT [Id] FROM [GroupType] WHERE [Guid] = '790E3215-3B10-442B-AF69-616C0DCB998E' )
AND [r].[Guid] = '2639F9A5-2AAE-4E48-A8C3-4FFE86681E42'
AND [o].[SundayDate] = CAST( [t].[TransactionDateTime] AS DATE ) );
-- check: student-check-in-fits-the-grade
-- why: Only students in grades 7 to 12 check in to the student service, in the room for their grade on that day. A recent graduate's student check-ins end when they graduate (decision 148). The grade comes from the graduation year and moves up the day after each June 1, as Rock does (decision 205).
SELECT COUNT(*)
FROM (
SELECT [g].[Name],
12 - ( [p].[GraduationYear] - CASE WHEN CAST( [o].[OccurrenceDate] AS DATE ) > DATEFROMPARTS( YEAR( [o].[OccurrenceDate] ), 6, 1 ) THEN YEAR( [o].[OccurrenceDate] ) + 1 ELSE YEAR( [o].[OccurrenceDate] ) END ) AS [Grade]
FROM [Attendance] AS [a]
INNER JOIN [AttendanceOccurrence] AS [o] ON [o].[Id] = [a].[OccurrenceId]
INNER JOIN [Group] AS [g] ON [g].[Id] = [o].[GroupId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [a].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [a].[ForeignKey] = @ForeignKey AND [a].[DidAttend] = 1
AND [g].[Name] IN ( 'Grades 7-8', 'Grades 9-12' ) AND [g].[GroupTypeId] IN ( SELECT [Id] FROM [GroupType] WHERE [Guid] IN ( '7A17235B-69AD-439B-BAB0-1A0A472DB96F', '9A88743B-F336-4404-B877-2A623689195D' ) )
) AS [x]
WHERE [x].[Grade] IS NULL
OR ( [x].[Name] = 'Grades 7-8' AND [x].[Grade] NOT BETWEEN 7 AND 8 )
OR ( [x].[Name] = 'Grades 9-12' AND [x].[Grade] NOT BETWEEN 9 AND 12 );
-- check: campus-account-dates-match-fund
-- why: Rock's giving pages hide an account outside its dates. A campus account with other dates than its fund would take gifts the fund says it cannot.
SELECT COUNT(*)
FROM [FinancialAccount] AS [child]
INNER JOIN [FinancialAccount] AS [fund] ON [fund].[Id] = [child].[ParentAccountId]
WHERE [child].[ForeignKey] = @ForeignKey AND [child].[CampusId] IS NOT NULL
AND ( ISNULL( [child].[StartDate], '1900-01-01' ) <> ISNULL( [fund].[StartDate], '1900-01-01' )
OR ISNULL( [child].[EndDate], '1900-01-01' ) <> ISNULL( [fund].[EndDate], '1900-01-01' ) );
-- check: pledge-is-on-a-top-level-fund
-- why: Rock's Pledge Analytics and statements count gifts to the pledged account and its child accounts. A pledge on a campus account would miss gifts to the fund's other campus accounts.
SELECT COUNT(*)
FROM [FinancialPledge] AS [fp]
INNER JOIN [FinancialAccount] AS [fa] ON [fa].[Id] = [fp].[AccountId]
WHERE [fp].[ForeignKey] = @ForeignKey AND [fa].[ParentAccountId] IS NOT NULL;
-- check: pledge-dates-fit-the-fund
-- why: Rock saves the date keys from the dates, and a pledge runs inside its fund's dates. Pledge Analytics and statements filter on both.
SELECT COUNT(*)
FROM [FinancialPledge] AS [fp]
INNER JOIN [FinancialAccount] AS [fa] ON [fa].[Id] = [fp].[AccountId]
WHERE [fp].[ForeignKey] = @ForeignKey
AND ( [fp].[StartDate] > [fp].[EndDate]
OR [fp].[StartDateKey] <> CONVERT( INT, FORMAT( [fp].[StartDate], 'yyyyMMdd' ) )
OR [fp].[EndDateKey] <> CONVERT( INT, FORMAT( [fp].[EndDate], 'yyyyMMdd' ) )
OR [fp].[StartDate] < ISNULL( [fa].[StartDate], [fp].[StartDate] )
OR [fp].[EndDate] > ISNULL( [fa].[EndDate], [fp].[EndDate] ) );
-- check: one-pledge-per-giving-unit-and-fund
-- why: Pledge Analytics adds up pledges by giving unit. Two pledges to one fund from one unit would double the amount it shows as pledged.
SELECT COUNT(*)
FROM (
SELECT [p].[GivingId], [fp].[AccountId]
FROM [FinancialPledge] AS [fp]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [fp].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [fp].[ForeignKey] = @ForeignKey
GROUP BY [p].[GivingId], [fp].[AccountId]
HAVING COUNT(*) > 1
) AS [x];
-- check: gift-posts-to-campus-account
-- why: A fund with campus accounts takes gifts only through them, so campus giving reports add up. A gift to the fund itself would count toward no campus.
SELECT COUNT(*)
FROM [FinancialTransactionDetail] AS [ftd]
INNER JOIN [FinancialAccount] AS [fa] ON [fa].[Id] = [ftd].[AccountId]
WHERE [ftd].[ForeignKey] = @ForeignKey
AND EXISTS ( SELECT 1 FROM [FinancialAccount] AS [child] WHERE [child].[ParentAccountId] = [fa].[Id] AND [child].[CampusId] IS NOT NULL );
-- check: communication-list-members-allow-mass-email
-- why: Decision 133. Nobody who chose "No mass emails" or "Do not email" is on a communication list, so a list send never reaches someone who asked not to get one.
SELECT COUNT(*)
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [gm].[PersonId]
WHERE [gt].[Guid] = 'D1D95777-FFA3-CBB3-4A6D-658706DAED33' AND [g].[ForeignKey] = @ForeignKey
AND [gm].[GroupMemberStatus] = 1 AND [p].[EmailPreference] <> 0;
-- check: newsletter-members-belong-to-the-campus
-- why: A campus newsletter holds that campus's adults with an active email (decision 133), so a campus's news never goes to another campus. An email that bounced after the person joined carries Rock's bounce note.
SELECT COUNT(*)
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [gm].[PersonId]
WHERE [gt].[Guid] = 'D1D95777-FFA3-CBB3-4A6D-658706DAED33' AND [g].[ForeignKey] = @ForeignKey AND [g].[CampusId] IS NOT NULL
AND ( [p].[PrimaryCampusId] <> [g].[CampusId] OR ISNULL( [p].[Email], '' ) = '' OR [p].[AgeClassification] <> 1
OR ( [p].[IsEmailActive] = 0 AND ISNULL( [p].[EmailNote], '' ) NOT LIKE 'Email experienced a Hard Bounce%' ) );
-- check: volunteers-list-follows-the-teams
-- why: The Volunteers list is everyone active on a serving team (decision 92), so a message to volunteers reaches exactly the people who serve. A volunteer who left the list from one of its emails stays on it as Inactive.
WITH [Serving] AS (
SELECT DISTINCT [gm].[PersonId]
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [gm].[PersonId]
WHERE [gt].[Guid] = '2C42B2D4-1C5F-4AD5-A9AD-08631B872AC4' AND ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId ) AND [g].[IsActive] = 1 AND [g].[IsArchived] = 0
AND [gm].[GroupMemberStatus] = 1 AND [gm].[IsArchived] = 0 AND [p].[EmailPreference] = 0
),
[Listed] AS (
SELECT [gm].[PersonId]
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
WHERE [g].[Guid] = '7C040D39-B8F0-481E-95B3-9526D566C47F' AND [g].[ForeignKey] = @ForeignKey
)
SELECT ( SELECT COUNT(*) FROM [Serving] AS [s] WHERE NOT EXISTS ( SELECT 1 FROM [Listed] AS [l] WHERE [l].[PersonId] = [s].[PersonId] ) )
+ ( SELECT COUNT(*) FROM [Listed] AS [l] WHERE NOT EXISTS ( SELECT 1 FROM [Serving] AS [s] WHERE [s].[PersonId] = [l].[PersonId] ) );
-- check: small-group-leaders-list-follows-the-groups
-- why: The Small Group Leaders list is everyone who leads an active small group (decision 92), so leader messages reach exactly the leaders. A leader who left the list from one of its emails stays on it as Inactive.
WITH [Leading] AS (
SELECT DISTINCT [gm].[PersonId]
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [gm].[GroupRoleId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [gm].[PersonId]
WHERE [gt].[Guid] = '50FCFB30-F51A-49DF-86F4-2B176EA1820B' AND ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId ) AND [g].[IsActive] = 1 AND [g].[IsArchived] = 0
AND [gm].[GroupMemberStatus] = 1 AND [gm].[IsArchived] = 0 AND [r].[IsLeader] = 1 AND [p].[EmailPreference] = 0
),
[Listed] AS (
SELECT [gm].[PersonId]
FROM [GroupMember] AS [gm]
INNER JOIN [Group] AS [g] ON [g].[Id] = [gm].[GroupId]
WHERE [g].[Guid] = '3D755601-598D-4E8F-A13D-9DF0F0087CA9' AND [g].[ForeignKey] = @ForeignKey
)
SELECT ( SELECT COUNT(*) FROM [Leading] AS [x] WHERE NOT EXISTS ( SELECT 1 FROM [Listed] AS [l] WHERE [l].[PersonId] = [x].[PersonId] ) )
+ ( SELECT COUNT(*) FROM [Listed] AS [l] WHERE NOT EXISTS ( SELECT 1 FROM [Leading] AS [x] WHERE [x].[PersonId] = [l].[PersonId] ) );
-- check: group-sync-targets-a-sync-group
-- why: Decision 186. Rock's group detail shows the sync panel only on group types that allow group sync, and a sync's role must be one of its group's roles, so every sync opens and edits in Rock. Its data view is a person view, as Rock's sync requires.
SELECT COUNT(*)
FROM [GroupSync] AS [s]
INNER JOIN [Group] AS [g] ON [g].[Id] = [s].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
INNER JOIN [GroupTypeRole] AS [r] ON [r].[Id] = [s].[GroupTypeRoleId]
INNER JOIN [DataView] AS [dv] ON [dv].[Id] = [s].[SyncDataViewId]
WHERE [s].[ForeignKey] = @ForeignKey
AND ( [gt].[AllowGroupSync] = 0 OR [r].[GroupTypeId] <> [g].[GroupTypeId]
OR [dv].[EntityTypeId] <> ( SELECT [Id] FROM [EntityType] WHERE [Name] = 'Rock.Model.Person' ) );
-- check: group-sync-refreshed-at-install
-- why: Decision 187. Each sync is written as the job leaves it after a run on install day, so Rock's group detail shows when it last ran and a sync with an interval waits for it.
SELECT COUNT(*)
FROM [GroupSync]
WHERE [ForeignKey] = @ForeignKey AND ( [LastRefreshDateTime] IS NULL OR [LastRefreshDateTime] < [CreatedDateTime] );
-- check: church-data-view-is-filed
-- why: Decision 185. Every data view the church adds sits in a child of the church's own Rock Solid Church category, so it never mixes with the views Rock ships, and later views have a place to go.
SELECT COUNT(*)
FROM [DataView] AS [dv]
LEFT JOIN [Category] AS [c] ON [c].[Id] = [dv].[CategoryId]
LEFT JOIN [Category] AS [parent] ON [parent].[Id] = [c].[ParentCategoryId]
WHERE [dv].[ForeignKey] = @ForeignKey
AND ( [c].[Id] IS NULL OR ISNULL( [c].[ForeignKey], '' ) <> @ForeignKey OR ISNULL( [parent].[ForeignKey], '' ) <> @ForeignKey );
-- check: data-view-filter-tree-is-whole
-- why: Rock reads a view's filters from its root down. Every church view has a root filter, every filter belongs to its view, a group of filters names no component, and each single filter names its component and selection.
SELECT ( SELECT COUNT(*) FROM [DataView] WHERE [ForeignKey] = @ForeignKey AND [DataViewFilterId] IS NULL )
+ ( SELECT COUNT(*)
FROM [DataViewFilter] AS [f]
WHERE [f].[ForeignKey] = @ForeignKey
AND ( [f].[DataViewId] IS NULL
OR ( [f].[ExpressionType] = 0 AND ( [f].[EntityTypeId] IS NULL OR ISNULL( [f].[Selection], '' ) = '' ) )
OR ( [f].[ExpressionType] <> 0 AND [f].[EntityTypeId] IS NOT NULL )
OR ( [f].[ParentId] IS NULL AND NOT EXISTS ( SELECT 1 FROM [DataView] AS [dv] WHERE [dv].[DataViewFilterId] = [f].[Id] ) ) ) );
-- check: system-phone-numbers-are-reserved-and-unused
-- why: Content standard 2 and decision 93. The church's texting numbers are active, can text, use the reserved range, and are never a loaded person's number, so SMS Conversations never seems to text a member from their own phone.
SELECT COUNT(*)
FROM [SystemPhoneNumber] AS [spn]
WHERE [spn].[ForeignKey] = @ForeignKey
AND ( [spn].[IsActive] = 0 OR [spn].[IsSmsEnabled] = 0 OR [spn].[IsSmsForwardingEnabled] = 1
OR LEN( [spn].[Number] ) <> 12 OR LEFT( [spn].[Number], 2 ) <> '+1' OR SUBSTRING( [spn].[Number], 3, 3 ) NOT IN ( '602', '480', '623' ) OR SUBSTRING( [spn].[Number], 6, 5 ) <> '55501'
OR EXISTS ( SELECT 1 FROM [PhoneNumber] AS [pn] INNER JOIN [Person] AS [p] ON [p].[Id] = [pn].[PersonId] WHERE ( [p].[ForeignKey] = @ForeignKey OR [p].[Id] = @AdminPersonId ) AND [pn].[Number] = SUBSTRING( [spn].[Number], 3, 10 ) ) );
-- check: communication-is-sent-and-never-sends-again
-- why: Decision 95. Rock's send job picks up any communication that is not Approved with a send time, so every sample communication is Approved, has its send time, and has the date key Rock fills from it. The one scheduled communication, which waits for a future send date (decision 248), is checked by scheduled-communication-waits-for-its-date.
SELECT COUNT(*)
FROM [Communication] AS [c]
WHERE [c].[ForeignKey] = @ForeignKey
AND NOT ( [c].[SendDateTime] IS NULL AND [c].[FutureSendDateTime] > GETDATE() )
AND ( [c].[Status] <> 3 OR [c].[SendDateTime] IS NULL OR [c].[SendDateKey] IS NULL
OR [c].[SendDateKey] <> CONVERT( INT, CONVERT( CHAR(8), [c].[SendDateTime], 112 ) ) );
-- check: no-recipient-waits-to-send
-- why: Decision 95. A Pending or Sending recipient is one Rock's send job would still send to. Only the scheduled communication's recipients wait, as Pending (decision 248).
SELECT COUNT(*)
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [cr].[Status] IN ( 0, 5 )
AND NOT ( [cr].[Status] = 0 AND [c].[SendDateTime] IS NULL AND [c].[FutureSendDateTime] > GETDATE() );
-- check: scheduled-communication-waits-for-its-date
-- why: Decisions 220 and 248. A load writes at most one communication that sends after install. It is a bulk email, Approved and reviewed, with a future send date, no send date, and no communication list, so Rock's Send Communications job sends it on its date to exactly these people. Every recipient is Pending with no send times, and has an email on example.org or none, so it can never reach a real address.
SELECT CASE WHEN ( SELECT COUNT(*) FROM [Communication] AS [c] WHERE [c].[ForeignKey] = @ForeignKey AND [c].[SendDateTime] IS NULL ) > 1 THEN 1 ELSE 0 END
+ ( SELECT COUNT(*)
FROM [Communication] AS [c]
WHERE [c].[ForeignKey] = @ForeignKey AND [c].[SendDateTime] IS NULL
AND ( [c].[Status] <> 3 OR [c].[CommunicationType] <> 1 OR [c].[IsBulkCommunication] = 0 OR [c].[ReviewerPersonAliasId] IS NULL OR [c].[ReviewedDateTime] IS NULL
OR [c].[FutureSendDateTime] IS NULL OR [c].[FutureSendDateTime] <= GETDATE() OR [c].[SendDateKey] IS NOT NULL OR [c].[ListGroupId] IS NOT NULL
OR NOT EXISTS ( SELECT 1 FROM [CommunicationRecipient] AS [cr] WHERE [cr].[CommunicationId] = [c].[Id] )
OR EXISTS ( SELECT 1
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [cr].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [cr].[CommunicationId] = [c].[Id]
AND ( [cr].[Status] <> 0 OR [cr].[SendDateTime] IS NOT NULL OR [cr].[DeliveredDateTime] IS NOT NULL
OR ( ISNULL( [p].[Email], '' ) <> '' AND [p].[Email] NOT LIKE '%@example.org' ) ) ) ) );
-- check: email-recipient-states-agree
-- why: Communication Detail counts and charts each status from these columns. A delivered or opened email was sent and confirmed, an opened one has its first open after the send, and a failed one says why. Only a bounce fails after Rock sent it.
SELECT COUNT(*)
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [c].[CommunicationType] = 1
AND ( ( [cr].[Status] IN ( 1, 4 ) AND ( [cr].[SendDateTime] IS NULL OR [cr].[DeliveredDateTime] IS NULL OR [cr].[DeliveredDateTime] < [cr].[SendDateTime] ) )
OR ( [cr].[Status] = 4 AND ( [cr].[OpenedDateTime] IS NULL OR [cr].[OpenedDateTime] < [cr].[SendDateTime] OR [cr].[OpenedClient] IS NULL ) )
OR ( [cr].[Status] <> 4 AND [cr].[OpenedDateTime] IS NOT NULL )
OR ( [cr].[Status] = 2 AND ( [cr].[StatusNote] IS NULL OR ( [cr].[SendDateTime] IS NOT NULL AND [cr].[StatusNote] NOT LIKE '5__ 5.%' ) ) ) );
-- check: opens-and-clicks-follow-an-open
-- why: Rock records an open or a click as an interaction on the communication's component, pointing at the recipient. Only an opened recipient has them, the first is an open at the recipient's open time, and nothing comes before the send.
SELECT COUNT(*)
FROM [Interaction] AS [i]
INNER JOIN [InteractionComponent] AS [ic] ON [ic].[Id] = [i].[InteractionComponentId]
INNER JOIN [InteractionChannel] AS [ch] ON [ch].[Id] = [ic].[InteractionChannelId]
LEFT JOIN [CommunicationRecipient] AS [cr] ON [cr].[Id] = [i].[EntityId] AND [cr].[CommunicationId] = [ic].[EntityId]
WHERE [ch].[Guid] = 'C88A187F-0343-4E7C-AF3F-79A8989DFA65' AND [i].[ForeignKey] = @ForeignKey
AND ( [cr].[Id] IS NULL OR [cr].[Status] <> 4 OR [i].[InteractionDateTime] < [cr].[OpenedDateTime] OR [i].[PersonAliasId] <> [cr].[PersonAliasId]
OR ( [i].[Operation] = 'Click' AND ISNULL( [i].[InteractionData], '' ) NOT LIKE 'https://rocksolidchurchdemo.com/%' ) );
-- check: unsubscribe-matches-the-person
-- why: Decision 131. A list unsubscribe leaves the person Inactive in that list, a Bulk one leaves them on "No mass emails" or stricter, and Rock records a level with every unsubscribe time.
SELECT COUNT(*)
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [cr].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [cr].[ForeignKey] = @ForeignKey
AND ( ( [cr].[UnsubscribeDateTime] IS NULL AND [cr].[UnsubscribeLevel] IS NOT NULL ) OR ( [cr].[UnsubscribeDateTime] IS NOT NULL AND [cr].[UnsubscribeLevel] IS NULL )
OR ( [cr].[UnsubscribeLevel] = 3 AND NOT EXISTS ( SELECT 1 FROM [GroupMember] AS [gm] WHERE [gm].[GroupId] = [c].[ListGroupId] AND [gm].[PersonId] = [p].[Id] AND [gm].[GroupMemberStatus] = 0 ) )
OR ( [cr].[UnsubscribeLevel] = 2 AND [p].[EmailPreference] = 0 )
OR ( [cr].[UnsubscribeLevel] = 4 AND NOT EXISTS ( SELECT 1 FROM [CommunicationFlowInstanceRecipient] AS [fr] WHERE [fr].[UnsubscribeCommunicationRecipientId] = [cr].[Id] AND [fr].[Status] = 2 AND [fr].[InactiveReason] = 1 ) ) );
-- check: flow-unsubscribe-leaves-the-flow
-- why: Decision 246. Leaving a flow from an email's link marks that run's row Unsubscribed, as Rock's job does, and every later run of the flow has the person Inactive as Unsubscribed From Flow, so no later send reaches them.
SELECT COUNT(*)
FROM [CommunicationFlowInstanceRecipient] AS [fr]
INNER JOIN [CommunicationRecipient] AS [cr] ON [cr].[Id] = [fr].[UnsubscribeCommunicationRecipientId]
INNER JOIN [CommunicationFlowInstance] AS [fi] ON [fi].[Id] = [fr].[CommunicationFlowInstanceId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [fr].[RecipientPersonAliasId]
WHERE [fr].[ForeignKey] = @ForeignKey
AND ( [cr].[UnsubscribeLevel] <> 4 OR [fr].[Status] <> 2 OR [fr].[InactiveReason] <> 1
OR EXISTS (
SELECT 1
FROM [CommunicationFlowInstanceRecipient] AS [later]
INNER JOIN [CommunicationFlowInstance] AS [li] ON [li].[Id] = [later].[CommunicationFlowInstanceId]
INNER JOIN [PersonAlias] AS [lpa] ON [lpa].[Id] = [later].[RecipientPersonAliasId]
WHERE [li].[CommunicationFlowId] = [fi].[CommunicationFlowId] AND [lpa].[PersonId] = [pa].[PersonId] AND [li].[StartDate] > [cr].[UnsubscribeDateTime]
AND NOT ( [later].[Status] = 2 AND [later].[InactiveReason] = 7 ) ) );
-- check: sent-email-has-its-history
-- why: Rock's email transport writes a "Sent" line on the person's History tab for every email it sends, so every sent email recipient has one.
SELECT ABS( ( SELECT COUNT(*) FROM [CommunicationRecipient] AS [cr] INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [c].[CommunicationType] = 1 AND [cr].[SendDateTime] IS NOT NULL )
- ( SELECT COUNT(*) FROM [History] AS [h] WHERE [h].[ForeignKey] = @ForeignKey AND [h].[Verb] = 'SENT' AND [h].[ValueName] = 'Communication' ) );
-- check: serving-email-follows-a-request
-- why: Decision 159. Rock's Send Group Schedule Notifications job sends a confirmation the day a volunteer is asked to serve, and a reminder two days before a Sunday they did not decline. Each serving email has one recipient and the request it came from.
WITH [Serving] AS (
SELECT [c].[Id], [c].[SendDateTime], [sc].[Guid] AS [Kind]
FROM [Communication] AS [c]
INNER JOIN [SystemCommunication] AS [sc] ON [sc].[Id] = [c].[SystemCommunicationId]
WHERE [c].[ForeignKey] = @ForeignKey AND [sc].[Guid] IN ( 'BA1716E0-6B31-4E93-ABA1-42B3C81FDBDC', '8A20FE79-B73C-447A-82B1-416F9B50C038' )
), [Sent] AS (
SELECT [s].[Kind], [pa].[PersonId], CONVERT( DATE, [s].[SendDateTime] ) AS [Day], COUNT(*) AS [Recipients]
FROM [Serving] AS [s]
INNER JOIN [CommunicationRecipient] AS [cr] ON [cr].[CommunicationId] = [s].[Id]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [cr].[PersonAliasId]
GROUP BY [s].[Id], [s].[Kind], [pa].[PersonId], CONVERT( DATE, [s].[SendDateTime] )
), [Asked] AS (
SELECT DISTINCT [pa].[PersonId], CONVERT( DATE, [a].[CreatedDateTime] ) AS [Day]
FROM [Attendance] AS [a]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [a].[PersonAliasId]
WHERE [a].[RequestedToAttend] = 1 AND [a].[ScheduleConfirmationSent] = 1
), [Reminded] AS (
SELECT DISTINCT [pa].[PersonId], CONVERT( DATE, DATEADD( DAY, -2, [a].[StartDateTime] ) ) AS [Day]
FROM [Attendance] AS [a]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [a].[PersonAliasId]
WHERE [a].[RequestedToAttend] = 1 AND [a].[ScheduleReminderSent] = 1 AND ISNULL( [a].[RSVP], 3 ) <> 0
)
SELECT ( SELECT COUNT(*) FROM [Serving] WHERE NOT EXISTS ( SELECT 1 FROM [CommunicationRecipient] AS [cr] WHERE [cr].[CommunicationId] = [Serving].[Id] ) )
+ ( SELECT COUNT(*) FROM [Sent] AS [s]
WHERE [s].[Recipients] <> 1
OR ( [s].[Kind] = 'BA1716E0-6B31-4E93-ABA1-42B3C81FDBDC' AND NOT EXISTS ( SELECT 1 FROM [Asked] AS [q] WHERE [q].[PersonId] = [s].[PersonId] AND [q].[Day] = [s].[Day] ) )
OR ( [s].[Kind] = '8A20FE79-B73C-447A-82B1-416F9B50C038' AND NOT EXISTS ( SELECT 1 FROM [Reminded] AS [r] WHERE [r].[PersonId] = [s].[PersonId] AND [r].[Day] = [s].[Day] ) ) );
-- check: serving-request-is-not-in-the-future
-- why: Decision 165. A request to serve, its answer, and its last change all happened by install day, even for a Sunday weeks ahead.
SELECT COUNT(*)
FROM [Attendance] AS [a]
WHERE [a].[ForeignKey] = @ForeignKey AND [a].[RequestedToAttend] = 1
AND ( [a].[CreatedDateTime] > GETDATE() OR [a].[RSVPDateTime] > GETDATE() OR [a].[ModifiedDateTime] > GETDATE() );
-- check: serving-flag-has-its-email
-- why: Decision 165. Rock's scheduling job marks a confirmation or a reminder sent only when it emailed the volunteer, and it reminded only volunteers who confirmed. In the 2 years of communication history before install day, each flag has its email.
WITH [Window] AS (
SELECT DATEADD( DAY, 1, DATEADD( YEAR, -2, CONVERT( DATE, GETDATE() ) ) ) AS [Start], CONVERT( DATE, GETDATE() ) AS [Today]
), [Emailed] AS (
SELECT DISTINCT [sc].[Guid] AS [Kind], [pa].[PersonId], CONVERT( DATE, [c].[SendDateTime] ) AS [Day]
FROM [Communication] AS [c]
INNER JOIN [SystemCommunication] AS [sc] ON [sc].[Id] = [c].[SystemCommunicationId]
INNER JOIN [CommunicationRecipient] AS [cr] ON [cr].[CommunicationId] = [c].[Id]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [cr].[PersonAliasId]
WHERE [c].[ForeignKey] = @ForeignKey AND [sc].[Guid] IN ( 'BA1716E0-6B31-4E93-ABA1-42B3C81FDBDC', '8A20FE79-B73C-447A-82B1-416F9B50C038' )
), [Flagged] AS (
SELECT [pa].[PersonId], [a].[RSVP], [a].[ScheduleConfirmationSent], [a].[ScheduleReminderSent], [a].[StartDateTime],
CONVERT( DATE, [a].[CreatedDateTime] ) AS [AskedDay], CONVERT( DATE, DATEADD( DAY, -ISNULL( [gt].[ScheduleReminderEmailOffsetDays], 2 ), [a].[StartDateTime] ) ) AS [ReminderDay]
FROM [Attendance] AS [a]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [a].[PersonAliasId]
INNER JOIN [AttendanceOccurrence] AS [ao] ON [ao].[Id] = [a].[OccurrenceId]
INNER JOIN [Group] AS [g] ON [g].[Id] = [ao].[GroupId]
INNER JOIN [GroupType] AS [gt] ON [gt].[Id] = [g].[GroupTypeId]
WHERE [a].[ForeignKey] = @ForeignKey AND [a].[RequestedToAttend] = 1 AND ( [a].[ScheduleConfirmationSent] = 1 OR [a].[ScheduleReminderSent] = 1 )
)
SELECT COUNT(*)
FROM [Flagged] AS [f]
CROSS JOIN [Window] AS [w]
WHERE ( [f].[ScheduleReminderSent] = 1 AND [f].[ReminderDay] < [w].[Today] AND ISNULL( [f].[RSVP], 3 ) <> 1 )
OR ( [f].[ScheduleConfirmationSent] = 1 AND [f].[AskedDay] >= [w].[Start] AND [f].[AskedDay] < [w].[Today]
AND NOT EXISTS ( SELECT 1 FROM [Emailed] AS [e] WHERE [e].[Kind] = 'BA1716E0-6B31-4E93-ABA1-42B3C81FDBDC' AND [e].[PersonId] = [f].[PersonId] AND [e].[Day] = [f].[AskedDay] ) )
OR ( [f].[ScheduleReminderSent] = 1 AND [f].[ReminderDay] >= [w].[Start] AND [f].[ReminderDay] < [w].[Today]
AND NOT EXISTS ( SELECT 1 FROM [Emailed] AS [e] WHERE [e].[Kind] = '8A20FE79-B73C-447A-82B1-416F9B50C038' AND [e].[PersonId] = [f].[PersonId] AND [e].[Day] = [f].[ReminderDay] ) );
-- check: serving-job-has-nothing-to-send
-- why: Decision 95. Rock's scheduling job emails any volunteer it can reach whose request for a coming Sunday is not yet marked confirmed, and reminds confirmed volunteers not yet reminded, so every such request is marked.
SELECT COUNT(*)
FROM [Attendance] AS [a]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [a].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [a].[ForeignKey] = @ForeignKey AND [a].[RequestedToAttend] = 1 AND [a].[StartDateTime] >= CONVERT( DATE, GETDATE() )
AND ISNULL( [p].[Email], '' ) <> '' AND [p].[IsEmailActive] = 1 AND [p].[EmailPreference] <> 2
AND ( [a].[ScheduleConfirmationSent] = 0 OR ( [a].[ScheduleReminderSent] = 0 AND [a].[RSVP] = 1 ) );
-- check: one-to-one-email-skips-do-not-email
-- why: Rock sends a personal email to someone on "No mass emails", but never to someone on "Do not email". Every one-to-one email to a person on "Do not email" failed.
SELECT COUNT(*)
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [cr].[PersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [c].[CommunicationType] = 1 AND [c].[IsBulkCommunication] = 0 AND [p].[EmailPreference] = 2
AND [cr].[Status] <> 2;
-- check: sent-push-is-delivered-with-its-history
-- why: Decision 161. Rock Mobile Push marks a sent push Delivered with its send and delivered times and writes a "Push Notification" line on the person's History. A person with no device that takes notifications fails with Rock's note.
SELECT ( SELECT COUNT(*)
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [c].[CommunicationType] = 3
AND ( ( [cr].[Status] IN ( 1, 4 ) AND ( [cr].[SendDateTime] IS NULL OR [cr].[DeliveredDateTime] IS NULL OR [cr].[TransportEntityTypeName] <> 'Rock.Communication.Transport.RockMobilePush' ) )
OR ( [cr].[Status] = 4 AND ( [cr].[OpenedDateTime] IS NULL OR [cr].[OpenedDateTime] < [cr].[SendDateTime] ) )
OR ( [cr].[Status] = 2 AND ISNULL( [cr].[StatusNote], '' ) <> 'No Personal Devices with Messaging Enabled' )
OR [cr].[Status] NOT IN ( 1, 2, 4 ) OR [c].[PushData] IS NULL ) )
+ ABS( ( SELECT COUNT(*) FROM [CommunicationRecipient] AS [cr] INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [c].[CommunicationType] = 3 AND [cr].[SendDateTime] IS NOT NULL )
- ( SELECT COUNT(*) FROM [History] AS [h] WHERE [h].[ForeignKey] = @ForeignKey AND [h].[Verb] = 'SENT' AND [h].[ValueName] = 'Push Notification' ) );
-- check: push-goes-to-a-device-that-takes-notifications
-- why: Rock Mobile Push sends to every active device on the person with notifications on and a registration token. A delivered push had one when it was sent, and a failed one had none.
SELECT COUNT(*)
FROM [CommunicationRecipient] AS [cr]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [cr].[CommunicationId]
LEFT JOIN (
SELECT [pd].[PersonAliasId], MIN( [pd].[CreatedDateTime] ) AS [FirstSeen]
FROM [PersonalDevice] AS [pd]
WHERE [pd].[IsActive] = 1 AND [pd].[NotificationsEnabled] = 1 AND ISNULL( [pd].[DeviceRegistrationId], '' ) <> ''
GROUP BY [pd].[PersonAliasId]
) AS [d] ON [d].[PersonAliasId] = [cr].[PersonAliasId]
WHERE [cr].[ForeignKey] = @ForeignKey AND [c].[CommunicationType] = 3
AND ( CASE WHEN [d].[FirstSeen] <= [c].[SendDateTime] THEN 1 ELSE 0 END ) <> ( CASE WHEN [cr].[Status] IN ( 1, 4 ) THEN 1 ELSE 0 END );
-- check: app-devices-never-reach-a-phone
-- why: Decision 95. Every sample device's registration token starts with "sample-data-", which Firebase never issues, so a push sent from a sample database can never reach a real phone.
SELECT COUNT(*)
FROM [PersonalDevice] AS [pd]
WHERE [pd].[ForeignKey] = @ForeignKey AND ISNULL( [pd].[DeviceRegistrationId], '' ) NOT LIKE 'sample-data-%';
-- check: open-flow-run-is-allowed
-- why: Decision 220, which retires decision 95's rule that nothing sends after install. A flow run may still be sending at install only in an active On-Demand or recurring flow, and its next message is due today or later, so Rock's Process Communication Flows job sends no backlog on its first run. Every message a load writes already went out.
SELECT ( SELECT COUNT(*)
FROM [CommunicationFlowInstance] AS [i]
INNER JOIN [CommunicationFlow] AS [f] ON [f].[Id] = [i].[CommunicationFlowId]
OUTER APPLY ( SELECT TOP 1 [fc].[Order], [c].[SendDateTime]
FROM [CommunicationFlowInstanceCommunication] AS [ic]
INNER JOIN [CommunicationFlowCommunication] AS [fc] ON [fc].[Id] = [ic].[CommunicationFlowCommunicationId]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [ic].[CommunicationId]
WHERE [ic].[CommunicationFlowInstanceId] = [i].[Id]
ORDER BY [fc].[Order] DESC ) AS [last]
OUTER APPLY ( SELECT TOP 1 [fc].[DaysToWait]
FROM [CommunicationFlowCommunication] AS [fc]
WHERE [fc].[CommunicationFlowId] = [f].[Id] AND [fc].[Order] > ISNULL( [last].[Order], -1 )
ORDER BY [fc].[Order] ) AS [next]
WHERE [i].[ForeignKey] = @ForeignKey AND [i].[IsMessagingCompleted] = 0
AND ( [f].[IsActive] = 0 OR [f].[TriggerType] NOT IN ( 1, 2 ) OR [next].[DaysToWait] IS NULL
OR DATEADD( DAY, [next].[DaysToWait], CAST( ISNULL( [last].[SendDateTime], [i].[StartDate] ) AS DATE ) ) < CAST( GETDATE() AS DATE ) ) )
+ ( SELECT COUNT(*)
FROM [CommunicationFlowInstanceCommunication] AS [ic]
INNER JOIN [Communication] AS [c] ON [c].[Id] = [ic].[CommunicationId]
WHERE [ic].[ForeignKey] = @ForeignKey AND ( [c].[SendDateTime] IS NULL OR [c].[SendDateTime] > GETDATE() ) );
-- check: open-flow-run-reaches-only-reserved-contact-points
-- why: Decision 220. Rock's job sends a run's remaining messages after install, so everyone still Active in an open run has an email on example.org or none, and only phones in the reserved 555-0100 to 555-0199 range. Staff emails are on rocksolidchurchdemo.com, a real domain that receives mail.
SELECT COUNT(*)
FROM [CommunicationFlowInstanceRecipient] AS [r]
INNER JOIN [CommunicationFlowInstance] AS [i] ON [i].[Id] = [r].[CommunicationFlowInstanceId]
INNER JOIN [PersonAlias] AS [pa] ON [pa].[Id] = [r].[RecipientPersonAliasId]
INNER JOIN [Person] AS [p] ON [p].[Id] = [pa].[PersonId]
WHERE [i].[ForeignKey] = @ForeignKey AND [i].[IsMessagingCompleted] = 0 AND [r].[Status] = 1
AND ( ( ISNULL( [p].[Email], '' ) <> '' AND [p].[Email] NOT LIKE '%@example.org' )
OR EXISTS ( SELECT 1 FROM [PhoneNumber] AS [ph] WHERE [ph].[PersonId] = [p].[Id] AND [ph].[Number] NOT LIKE '___55501__' ) );
-- check: recurring-flow-audience-has-the-guard
-- why: Decision 220. Rock's job starts a recurring flow's next run on its schedule date after install, from its audience view, and a paused flow can be turned on with one click. So every recurring flow has a schedule and an audience view that includes Reserved or No Email directly, which keeps every real address, such as staff on rocksolidchurchdemo.com, out of every future run.
SELECT COUNT(*)