Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
91.18% covered (success)
91.18%
31 / 34
25.00% covered (danger)
25.00%
1 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
SentEmailRepository
91.18% covered (success)
91.18%
31 / 34
25.00% covered (danger)
25.00%
1 / 4
12.10
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 record
93.33% covered (success)
93.33%
14 / 15
0.00% covered (danger)
0.00%
0 / 1
5.01
 findByInvestorId
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
3.01
 resolveInvestorId
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
3.02
1<?php
2
3declare(strict_types=1);
4
5namespace App\Domain\Notification\Repository;
6
7use App\Domain\Notification\Data\EmailMessage;
8use App\Domain\Notification\Data\SentEmailData;
9use App\Support\Row;
10use PDO;
11use RuntimeException;
12
13/**
14 * Stores and retrieves the per-recipient log of app-sent email (FSC-110).
15 */
16final readonly class SentEmailRepository
17{
18    public function __construct(
19        private PDO $pdo,
20    ) {}
21
22    /**
23     * Persist a record of an email that was sent. Sensitive emails keep
24     * metadata only — the body is never stored, so reset tokens / PII don't
25     * land in the database. The recipient is resolved to an investor (when one
26     * exists) so the in-app history can scope to the logged-in user.
27     * @param EmailMessage $message
28     */
29    public function record(EmailMessage $message): void
30    {
31        $storeBody = !$message->sensitive;
32
33        $stmt = $this->pdo->prepare(
34            'INSERT INTO sent_emails
35                (investor_id, recipient_email, subject, body_html, body_text, is_sensitive)
36             VALUES
37                (:investor_id, :recipient_email, :subject, :body_html, :body_text, :is_sensitive)',
38        );
39        if ($stmt === false) {
40            throw new RuntimeException('Failed to prepare statement');
41        }
42
43        $stmt->execute([
44            'investor_id' => $this->resolveInvestorId($message->to),
45            'recipient_email' => $message->to,
46            'subject' => $message->subject,
47            'body_html' => $storeBody ? $message->bodyHtml : null,
48            'body_text' => $storeBody ? $message->bodyText : null,
49            // Bind as a literal so PDO doesn't coerce PHP false to '' (which
50            // Postgres rejects for a boolean column).
51            'is_sensitive' => $message->sensitive ? 'true' : 'false',
52        ]);
53    }
54
55    /**
56     * @param int $investorId
57     * @return list<SentEmailData>
58     */
59    public function findByInvestorId(int $investorId): array
60    {
61        $sql = <<<SQL
62                SELECT
63                    sent_email_id   AS "sentEmailId",
64                    investor_id     AS "investorId",
65                    recipient_email AS "recipientEmail",
66                    subject,
67                    body_html       AS "bodyHtml",
68                    body_text       AS "bodyText",
69                    is_sensitive    AS "isSensitive",
70                    sent_at         AS "sentAt"
71                FROM sent_emails
72                WHERE investor_id = :investor_id
73                ORDER BY sent_at DESC
74            SQL;
75
76        $stmt = $this->pdo->prepare($sql);
77        if ($stmt === false) {
78            throw new RuntimeException('Failed to prepare statement');
79        }
80        $stmt->execute(['investor_id' => $investorId]);
81
82        $emails = [];
83        foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) {
84            $emails[] = SentEmailData::fromRow(Row::from($row));
85        }
86
87        return $emails;
88    }
89
90    private function resolveInvestorId(string $email): ?int
91    {
92        $stmt = $this->pdo->prepare(
93            'SELECT investor_id FROM investors WHERE email = :email LIMIT 1',
94        );
95        if ($stmt === false) {
96            throw new RuntimeException('Failed to prepare statement');
97        }
98        $stmt->execute(['email' => $email]);
99        $value = $stmt->fetchColumn();
100
101        return $value === false ? null : (int)$value;
102    }
103}