Skip to content

Commit f86e298

Browse files
authored
Merge pull request #588 from Chris0Jeky/feature/264-contact-card-yaml-parser
Implement contact-card YAML front matter parser/serializer
2 parents 7ca8eac + ff5b743 commit f86e298

File tree

4 files changed

+734
-0
lines changed

4 files changed

+734
-0
lines changed
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
namespace Taskdeck.Application.DTOs;
2+
3+
/// <summary>
4+
/// Represents the structured contact fields stored as YAML front matter
5+
/// in a card description for the card-first Outreach CRM.
6+
/// </summary>
7+
public sealed class ContactCardFrontMatter
8+
{
9+
/// <summary>Card type discriminator (always "contact").</summary>
10+
public string Type { get; set; } = "contact";
11+
12+
/// <summary>Contact display name.</summary>
13+
public string? DisplayName { get; set; }
14+
15+
/// <summary>Relationship tier: A, B, or C.</summary>
16+
public string? RelationshipTier { get; set; }
17+
18+
/// <summary>Company or organisation name.</summary>
19+
public string? Company { get; set; }
20+
21+
/// <summary>Job title or role.</summary>
22+
public string? Role { get; set; }
23+
24+
/// <summary>IANA timezone identifier (e.g. "Europe/London").</summary>
25+
public string? LocationTz { get; set; }
26+
27+
/// <summary>Contact handles keyed by platform (e.g. linkedin_url, github, email).</summary>
28+
public Dictionary<string, string>? Handles { get; set; }
29+
30+
/// <summary>Freeform tags for filtering and grouping.</summary>
31+
public List<string>? Tags { get; set; }
32+
33+
/// <summary>How you originally met or discovered this contact.</summary>
34+
public string? Source { get; set; }
35+
36+
/// <summary>Relationship status: cold, warm, active, referral, interviewing, closed.</summary>
37+
public string? Status { get; set; }
38+
39+
/// <summary>Cadence template identifier (e.g. "warm-3-7-21").</summary>
40+
public string? CadenceId { get; set; }
41+
42+
/// <summary>Date of last interaction (ISO 8601 date string).</summary>
43+
public string? LastTouchAt { get; set; }
44+
45+
/// <summary>Date of next planned interaction (ISO 8601 date string).</summary>
46+
public string? NextTouchAt { get; set; }
47+
48+
/// <summary>Private notes about the contact.</summary>
49+
public string? NotesPrivate { get; set; }
50+
}
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
using Taskdeck.Application.DTOs;
2+
using YamlDotNet.Serialization;
3+
using YamlDotNet.Serialization.NamingConventions;
4+
5+
namespace Taskdeck.Application.Services;
6+
7+
/// <summary>
8+
/// Parses and serializes YAML front matter in card descriptions for the
9+
/// card-first Outreach CRM contact model.
10+
///
11+
/// Front matter is delimited by a pair of <c>---</c> lines at the start
12+
/// of the description. Content after the closing delimiter is preserved
13+
/// as the body (timeline, freeform notes, etc.).
14+
/// </summary>
15+
public static class ContactCardYamlParser
16+
{
17+
private const string FrontMatterDelimiter = "---";
18+
19+
private static readonly IDeserializer Deserializer = new DeserializerBuilder()
20+
.WithNamingConvention(UnderscoredNamingConvention.Instance)
21+
.IgnoreUnmatchedProperties()
22+
.Build();
23+
24+
private static readonly ISerializer YamlSerializer = new SerializerBuilder()
25+
.WithNamingConvention(UnderscoredNamingConvention.Instance)
26+
.ConfigureDefaultValuesHandling(DefaultValuesHandling.OmitNull)
27+
.Build();
28+
29+
private static readonly HashSet<string> ValidTiers =
30+
new(StringComparer.OrdinalIgnoreCase) { "A", "B", "C" };
31+
32+
private static readonly HashSet<string> ValidStatuses =
33+
new(StringComparer.OrdinalIgnoreCase)
34+
{
35+
"cold", "warm", "active", "referral", "interviewing", "closed"
36+
};
37+
38+
/// <summary>
39+
/// Result of parsing a card description that may contain YAML front matter.
40+
/// </summary>
41+
/// <param name="FrontMatter">Parsed contact fields, or null when no front matter is present.</param>
42+
/// <param name="Body">Content after the closing front matter delimiter (may be empty).</param>
43+
/// <param name="Errors">Validation/parsing errors. Empty list means success.</param>
44+
public sealed record ParseResult(
45+
ContactCardFrontMatter? FrontMatter,
46+
string Body,
47+
IReadOnlyList<string> Errors);
48+
49+
/// <summary>
50+
/// Parse a card description, extracting YAML front matter and body content.
51+
/// Returns explicit errors for malformed YAML rather than throwing exceptions.
52+
/// </summary>
53+
public static ParseResult Parse(string? description)
54+
{
55+
if (string.IsNullOrEmpty(description))
56+
{
57+
return new ParseResult(null, string.Empty, Array.Empty<string>());
58+
}
59+
60+
var (yamlBlock, body, extractionError) = ExtractFrontMatterBlock(description);
61+
62+
if (extractionError is not null)
63+
{
64+
return new ParseResult(null, description, new[] { extractionError });
65+
}
66+
67+
if (yamlBlock is null)
68+
{
69+
// No front matter present — not an error, just a plain description.
70+
return new ParseResult(null, description, Array.Empty<string>());
71+
}
72+
73+
try
74+
{
75+
var frontMatter = Deserializer.Deserialize<ContactCardFrontMatter>(yamlBlock);
76+
77+
if (frontMatter is null)
78+
{
79+
return new ParseResult(null, body, new[] { "YAML front matter block is empty." });
80+
}
81+
82+
var validationErrors = Validate(frontMatter);
83+
if (validationErrors.Count > 0)
84+
{
85+
return new ParseResult(frontMatter, body, validationErrors);
86+
}
87+
88+
return new ParseResult(frontMatter, body, Array.Empty<string>());
89+
}
90+
catch (YamlDotNet.Core.YamlException ex)
91+
{
92+
var message = $"Invalid YAML in front matter: {ex.InnerException?.Message ?? ex.Message}";
93+
return new ParseResult(null, body, new[] { message });
94+
}
95+
}
96+
97+
/// <summary>
98+
/// Serialize a <see cref="ContactCardFrontMatter"/> and body back into
99+
/// a card description string with YAML front matter delimiters.
100+
/// </summary>
101+
public static string Serialize(ContactCardFrontMatter frontMatter, string? body = null)
102+
{
103+
ArgumentNullException.ThrowIfNull(frontMatter);
104+
105+
var yaml = YamlSerializer.Serialize(frontMatter).TrimEnd();
106+
107+
var parts = new List<string>
108+
{
109+
FrontMatterDelimiter,
110+
yaml,
111+
FrontMatterDelimiter
112+
};
113+
114+
if (!string.IsNullOrEmpty(body))
115+
{
116+
parts.Add(body);
117+
}
118+
119+
return string.Join("\n", parts);
120+
}
121+
122+
/// <summary>
123+
/// Validate structural constraints on the front matter fields.
124+
/// Returns an empty list when valid.
125+
/// </summary>
126+
internal static IReadOnlyList<string> Validate(ContactCardFrontMatter fm)
127+
{
128+
var errors = new List<string>();
129+
130+
if (!string.IsNullOrEmpty(fm.Type)
131+
&& !string.Equals(fm.Type, "contact", StringComparison.OrdinalIgnoreCase))
132+
{
133+
errors.Add($"Unsupported front matter type '{fm.Type}'. Expected 'contact'.");
134+
}
135+
136+
if (!string.IsNullOrEmpty(fm.RelationshipTier) && !ValidTiers.Contains(fm.RelationshipTier))
137+
{
138+
errors.Add($"Invalid relationship_tier '{fm.RelationshipTier}'. Expected one of: A, B, C.");
139+
}
140+
141+
if (!string.IsNullOrEmpty(fm.Status) && !ValidStatuses.Contains(fm.Status))
142+
{
143+
errors.Add($"Invalid status '{fm.Status}'. Expected one of: cold, warm, active, referral, interviewing, closed.");
144+
}
145+
146+
if (!string.IsNullOrEmpty(fm.LastTouchAt) && !DateOnly.TryParse(fm.LastTouchAt, out _))
147+
{
148+
errors.Add($"Invalid last_touch_at format '{fm.LastTouchAt}'. Expected ISO 8601 date (YYYY-MM-DD).");
149+
}
150+
151+
if (!string.IsNullOrEmpty(fm.NextTouchAt) && !DateOnly.TryParse(fm.NextTouchAt, out _))
152+
{
153+
errors.Add($"Invalid next_touch_at format '{fm.NextTouchAt}'. Expected ISO 8601 date (YYYY-MM-DD).");
154+
}
155+
156+
return errors;
157+
}
158+
159+
// ── Private helpers ──────────────────────────────────────────────
160+
161+
/// <summary>
162+
/// Extract the YAML block and body from a description string.
163+
/// Returns (yamlBlock, body, error). When no front matter delimiters
164+
/// are found, yamlBlock is null and body equals the full description.
165+
/// </summary>
166+
private static (string? YamlBlock, string Body, string? Error) ExtractFrontMatterBlock(string description)
167+
{
168+
// Normalise line endings to \n for consistent splitting.
169+
var normalised = description.Replace("\r\n", "\n").Replace("\r", "\n");
170+
171+
// Front matter must start at the very beginning of the description.
172+
if (!normalised.StartsWith(FrontMatterDelimiter, StringComparison.Ordinal))
173+
{
174+
return (null, description, null);
175+
}
176+
177+
// Find the first line after the opening delimiter.
178+
var firstNewline = normalised.IndexOf('\n');
179+
if (firstNewline < 0)
180+
{
181+
// Only "---" with no content at all.
182+
return (null, description, null);
183+
}
184+
185+
// Verify the opening line is exactly "---" (possibly with trailing whitespace).
186+
var openingLine = normalised[..firstNewline].TrimEnd();
187+
if (openingLine != FrontMatterDelimiter)
188+
{
189+
return (null, description, null);
190+
}
191+
192+
// Search for the closing "---" line.
193+
var searchStart = firstNewline + 1;
194+
var closingIndex = -1;
195+
196+
while (searchStart < normalised.Length)
197+
{
198+
var lineEnd = normalised.IndexOf('\n', searchStart);
199+
var line = lineEnd >= 0
200+
? normalised[searchStart..lineEnd]
201+
: normalised[searchStart..];
202+
203+
if (line.TrimEnd() == FrontMatterDelimiter)
204+
{
205+
closingIndex = searchStart;
206+
break;
207+
}
208+
209+
if (lineEnd < 0)
210+
{
211+
break;
212+
}
213+
214+
searchStart = lineEnd + 1;
215+
}
216+
217+
if (closingIndex < 0)
218+
{
219+
return (null, description, "Opening '---' found but no closing '---' delimiter.");
220+
}
221+
222+
var yamlBlock = normalised[(firstNewline + 1)..closingIndex];
223+
var afterClosing = normalised.IndexOf('\n', closingIndex);
224+
var body = afterClosing >= 0 && afterClosing + 1 < normalised.Length
225+
? normalised[(afterClosing + 1)..]
226+
: string.Empty;
227+
228+
return (yamlBlock, body, null);
229+
}
230+
}

backend/src/Taskdeck.Application/Taskdeck.Application.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.5" />
2121
<PackageReference Include="Microsoft.IdentityModel.Tokens" Version="8.17.0" />
2222
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.17.0" />
23+
<PackageReference Include="YamlDotNet" Version="16.3.0" />
2324
</ItemGroup>
2425

2526
</Project>

0 commit comments

Comments
 (0)