create-backlist 10.1.0 → 10.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,24 +1,272 @@
1
+ // Auto-generated by create-backlist — DO NOT EDIT MANUALLY
1
2
  using Microsoft.AspNetCore.Mvc;
3
+ using Microsoft.EntityFrameworkCore;
4
+ using <%= projectName %>.Data;
5
+ using <%= projectName %>.Models;
6
+ using <%= projectName %>.DTOs;
2
7
 
3
8
  namespace <%= projectName %>.Controllers;
4
9
 
10
+ /// <summary>
11
+ /// CRUD controller for <b><%= controllerName %></b> — auto-generated by Backlist.
12
+ /// </summary>
5
13
  [ApiController]
6
14
  [Route("api/[controller]")]
15
+ [Produces("application/json")]
7
16
  public class <%= controllerName %>Controller : ControllerBase
8
17
  {
9
- // Endpoints for <%= controllerName %> auto-generated by Backlist
10
-
11
- <% endpoints.forEach(endpoint => { %>
12
- <%# Convert /api/users/{id} to just {id} for the route attribute %>
13
- <% const routePath = endpoint.path.replace(`/api/${controllerName.toLowerCase()}`, '').substring(1); %>
14
- /**
15
- * <%= endpoint.method.toUpperCase() %> <%= endpoint.path %>
16
- */
17
- [Http<%= endpoint.method.charAt(0) + endpoint.method.slice(1).toLowerCase() %>("<%- routePath %>")]
18
- public IActionResult AutoGenerated_<%= endpoint.method %>_<%= routePath.replace(/{|}/g, 'By_').replace(/[^a-zA-Z0-9_]/g, '') || 'Index' %>()
19
- {
20
- // TODO: Implement logic here. You can access route parameters like: public IActionResult Get(int id)
21
- return Ok(new { message = "Auto-generated response for <%= endpoint.method.toUpperCase() %> <%= endpoint.path %>" });
22
- }
23
- <% }); %>
24
- }
18
+ private readonly ApplicationDbContext _db;
19
+ private readonly ILogger<<%= controllerName %>Controller> _logger;
20
+
21
+ public <%= controllerName %>Controller(ApplicationDbContext db, ILogger<<%= controllerName %>Controller> logger)
22
+ {
23
+ _db = db;
24
+ _logger = logger;
25
+ }
26
+
27
+ // ─────────────────────────────────────────────────────────────────────────
28
+ // GET api/<%= controllerName.toLowerCase() %>?page=1&pageSize=20&search=...
29
+ // ─────────────────────────────────────────────────────────────────────────
30
+ /// <summary>Returns a paginated list of <%= controllerName %> records.</summary>
31
+ [HttpGet]
32
+ [ProducesResponseType(typeof(PagedResult<<%= controllerName %>>), StatusCodes.Status200OK)]
33
+ public async Task<IActionResult> GetAll(
34
+ [FromQuery] int page = 1,
35
+ [FromQuery] int pageSize = 20,
36
+ [FromQuery] string? search = null)
37
+ {
38
+ if (page < 1) page = 1;
39
+ if (pageSize < 1 || pageSize > 100) pageSize = 20;
40
+
41
+ try
42
+ {
43
+ var query = _db.<%= controllerName %>s.AsNoTracking();
44
+
45
+ // Generic search across string properties
46
+ if (!string.IsNullOrWhiteSpace(search))
47
+ {
48
+ var s = search.ToLower();
49
+ query = query.Where(e =>
50
+ EF.Functions.Like(e.Id.ToString(), $"%{s}%")
51
+ <% if (typeof endpoints !== 'undefined') { endpoints.forEach(ep => { %> // extend with real string fields after scaffolding <% }); } %>
52
+ );
53
+ }
54
+
55
+ var total = await query.CountAsync();
56
+ var items = await query
57
+ .OrderByDescending(e => e.CreatedAt)
58
+ .Skip((page - 1) * pageSize)
59
+ .Take(pageSize)
60
+ .ToListAsync();
61
+
62
+ return Ok(new PagedResult<<%= controllerName %>>
63
+ {
64
+ Items = items,
65
+ Total = total,
66
+ Page = page,
67
+ PageSize = pageSize,
68
+ TotalPages = (int)Math.Ceiling(total / (double)pageSize)
69
+ });
70
+ }
71
+ catch (Exception ex)
72
+ {
73
+ _logger.LogError(ex, "Error fetching <%= controllerName %> list");
74
+ return StatusCode(500, new { error = "Internal server error." });
75
+ }
76
+ }
77
+
78
+ // ─────────────────────────────────────────────────────────────────────────
79
+ // GET api/<%= controllerName.toLowerCase() %>/{id}
80
+ // ─────────────────────────────────────────────────────────────────────────
81
+ /// <summary>Returns a single <%= controllerName %> by GUID.</summary>
82
+ [HttpGet("{id:guid}")]
83
+ [ProducesResponseType(typeof(<%= controllerName %>), StatusCodes.Status200OK)]
84
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
85
+ public async Task<IActionResult> GetById(Guid id)
86
+ {
87
+ var entity = await _db.<%= controllerName %>s.AsNoTracking()
88
+ .FirstOrDefaultAsync(e => e.Id == id);
89
+
90
+ return entity is null
91
+ ? NotFound(new { error = $"<%= controllerName %> with id '{id}' not found." })
92
+ : Ok(entity);
93
+ }
94
+
95
+ // ─────────────────────────────────────────────────────────────────────────
96
+ // POST api/<%= controllerName.toLowerCase() %>
97
+ // ─────────────────────────────────────────────────────────────────────────
98
+ /// <summary>Creates a new <%= controllerName %>.</summary>
99
+ [HttpPost]
100
+ [ProducesResponseType(typeof(<%= controllerName %>), StatusCodes.Status201Created)]
101
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
102
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
103
+ public async Task<IActionResult> Create([FromBody] Create<%= controllerName %>Dto dto)
104
+ {
105
+ if (!ModelState.IsValid)
106
+ return BadRequest(ModelState);
107
+
108
+ try
109
+ {
110
+ var entity = dto.ToEntity();
111
+ _db.<%= controllerName %>s.Add(entity);
112
+ await _db.SaveChangesAsync();
113
+
114
+ _logger.LogInformation("<%= controllerName %> created: {Id}", entity.Id);
115
+ return CreatedAtAction(nameof(GetById), new { id = entity.Id }, entity);
116
+ }
117
+ catch (DbUpdateException ex) when (ex.InnerException?.Message.Contains("unique", StringComparison.OrdinalIgnoreCase) == true)
118
+ {
119
+ return Conflict(new { error = "A record with this data already exists." });
120
+ }
121
+ catch (Exception ex)
122
+ {
123
+ _logger.LogError(ex, "Error creating <%= controllerName %>");
124
+ return StatusCode(500, new { error = "Internal server error." });
125
+ }
126
+ }
127
+
128
+ // ─────────────────────────────────────────────────────────────────────────
129
+ // PUT api/<%= controllerName.toLowerCase() %>/{id}
130
+ // ─────────────────────────────────────────────────────────────────────────
131
+ /// <summary>Fully updates an existing <%= controllerName %>.</summary>
132
+ [HttpPut("{id:guid}")]
133
+ [ProducesResponseType(typeof(<%= controllerName %>), StatusCodes.Status200OK)]
134
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
135
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
136
+ public async Task<IActionResult> Update(Guid id, [FromBody] Update<%= controllerName %>Dto dto)
137
+ {
138
+ if (!ModelState.IsValid)
139
+ return BadRequest(ModelState);
140
+
141
+ var entity = await _db.<%= controllerName %>s.FindAsync(id);
142
+ if (entity is null)
143
+ return NotFound(new { error = $"<%= controllerName %> with id '{id}' not found." });
144
+
145
+ try
146
+ {
147
+ dto.ApplyTo(entity);
148
+ entity.UpdatedAt = DateTime.UtcNow;
149
+ await _db.SaveChangesAsync();
150
+
151
+ _logger.LogInformation("<%= controllerName %> updated: {Id}", id);
152
+ return Ok(entity);
153
+ }
154
+ catch (Exception ex)
155
+ {
156
+ _logger.LogError(ex, "Error updating <%= controllerName %> {Id}", id);
157
+ return StatusCode(500, new { error = "Internal server error." });
158
+ }
159
+ }
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────
162
+ // PATCH api/<%= controllerName.toLowerCase() %>/{id}
163
+ // ─────────────────────────────────────────────────────────────────────────
164
+ /// <summary>Partially updates an existing <%= controllerName %>.</summary>
165
+ [HttpPatch("{id:guid}")]
166
+ [ProducesResponseType(typeof(<%= controllerName %>), StatusCodes.Status200OK)]
167
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
168
+ public async Task<IActionResult> Patch(Guid id, [FromBody] Patch<%= controllerName %>Dto dto)
169
+ {
170
+ var entity = await _db.<%= controllerName %>s.FindAsync(id);
171
+ if (entity is null)
172
+ return NotFound(new { error = $"<%= controllerName %> with id '{id}' not found." });
173
+
174
+ try
175
+ {
176
+ dto.ApplyPartialTo(entity);
177
+ entity.UpdatedAt = DateTime.UtcNow;
178
+ await _db.SaveChangesAsync();
179
+
180
+ return Ok(entity);
181
+ }
182
+ catch (Exception ex)
183
+ {
184
+ _logger.LogError(ex, "Error patching <%= controllerName %> {Id}", id);
185
+ return StatusCode(500, new { error = "Internal server error." });
186
+ }
187
+ }
188
+
189
+ // ─────────────────────────────────────────────────────────────────────────
190
+ // DELETE api/<%= controllerName.toLowerCase() %>/{id}
191
+ // ─────────────────────────────────────────────────────────────────────────
192
+ /// <summary>Soft-deletes a <%= controllerName %> (sets DeletedAt timestamp).</summary>
193
+ [HttpDelete("{id:guid}")]
194
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
195
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
196
+ public async Task<IActionResult> Delete(Guid id)
197
+ {
198
+ var entity = await _db.<%= controllerName %>s.FindAsync(id);
199
+ if (entity is null)
200
+ return NotFound(new { error = $"<%= controllerName %> with id '{id}' not found." });
201
+
202
+ try
203
+ {
204
+ // Soft delete — set DeletedAt instead of removing row
205
+ entity.DeletedAt = DateTime.UtcNow;
206
+ entity.UpdatedAt = DateTime.UtcNow;
207
+ await _db.SaveChangesAsync();
208
+
209
+ _logger.LogInformation("<%= controllerName %> soft-deleted: {Id}", id);
210
+ return NoContent();
211
+ }
212
+ catch (Exception ex)
213
+ {
214
+ _logger.LogError(ex, "Error deleting <%= controllerName %> {Id}", id);
215
+ return StatusCode(500, new { error = "Internal server error." });
216
+ }
217
+ }
218
+
219
+ // ─────────────────────────────────────────────────────────────────────────
220
+ // DELETE api/<%= controllerName.toLowerCase() %>/{id}/hard
221
+ // ─────────────────────────────────────────────────────────────────────────
222
+ /// <summary>Permanently removes a <%= controllerName %> record from the database.</summary>
223
+ [HttpDelete("{id:guid}/hard")]
224
+ [ProducesResponseType(StatusCodes.Status204NoContent)]
225
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
226
+ public async Task<IActionResult> HardDelete(Guid id)
227
+ {
228
+ var entity = await _db.<%= controllerName %>s.FindAsync(id);
229
+ if (entity is null)
230
+ return NotFound(new { error = $"<%= controllerName %> with id '{id}' not found." });
231
+
232
+ _db.<%= controllerName %>s.Remove(entity);
233
+ await _db.SaveChangesAsync();
234
+
235
+ _logger.LogWarning("<%= controllerName %> permanently deleted: {Id}", id);
236
+ return NoContent();
237
+ }
238
+
239
+ // ─────────────────────────────────────────────────────────────────────────
240
+ // GET api/<%= controllerName.toLowerCase() %>/count
241
+ // ─────────────────────────────────────────────────────────────────────────
242
+ /// <summary>Returns total count of active <%= controllerName %> records.</summary>
243
+ [HttpGet("count")]
244
+ [ProducesResponseType(typeof(object), StatusCodes.Status200OK)]
245
+ public async Task<IActionResult> Count()
246
+ {
247
+ var count = await _db.<%= controllerName %>s.AsNoTracking()
248
+ .Where(e => e.DeletedAt == null)
249
+ .CountAsync();
250
+ return Ok(new { count });
251
+ }
252
+
253
+ <% if (typeof endpoints !== 'undefined') { endpoints.forEach(endpoint => {
254
+ const routePath = endpoint.path
255
+ .replace('/api/' + controllerName.toLowerCase(), '')
256
+ .replace(/^\//, '');
257
+ const isStandard = ['GET','POST','PUT','PATCH','DELETE'].includes(endpoint.method.toUpperCase())
258
+ && (routePath === '' || routePath === '{id}' || routePath === '{id:guid}');
259
+ if (!isStandard) { %>
260
+ // ─────────────────────────────────────────────────────────────────────────
261
+ // <%= endpoint.method.toUpperCase() %> api/<%= controllerName.toLowerCase() %>/<%= routePath %>
262
+ // ─────────────────────────────────────────────────────────────────────────
263
+ /// <summary>Auto-generated endpoint: <%= endpoint.method.toUpperCase() %> <%= endpoint.path %></summary>
264
+ [Http<%= endpoint.method.charAt(0).toUpperCase() + endpoint.method.slice(1).toLowerCase() %>("<%= routePath %>")]
265
+ public async Task<IActionResult> Custom_<%= endpoint.method.toUpperCase() %>_<%= routePath.replace(/\{|\}/g, '').replace(/[^a-zA-Z0-9_]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '') || 'Index' %>()
266
+ {
267
+ // TODO: Implement custom logic for <%= endpoint.method.toUpperCase() %> <%= endpoint.path %>
268
+ await Task.CompletedTask;
269
+ return Ok(new { message = "Endpoint reached: <%= endpoint.method.toUpperCase() %> <%= endpoint.path %>" });
270
+ }
271
+ <% } }); } %>
272
+ }
@@ -1,15 +1,105 @@
1
- // Auto-generated by create-backlist
1
+ // Auto-generated by create-backlist — DO NOT EDIT MANUALLY
2
2
  using Microsoft.EntityFrameworkCore;
3
+ using Microsoft.EntityFrameworkCore.ChangeTracking;
3
4
  using <%= projectName %>.Models;
4
5
 
5
6
  namespace <%= projectName %>.Data
6
7
  {
8
+ /// <summary>
9
+ /// Main EF Core DbContext for <b><%= projectName %></b>.
10
+ /// Includes: soft-delete global filter, automatic audit timestamps,
11
+ /// unique indexes, and seed-data scaffolding hooks.
12
+ /// </summary>
7
13
  public class ApplicationDbContext : DbContext
8
14
  {
9
- public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options) : base(options) { }
15
+ public ApplicationDbContext(DbContextOptions<ApplicationDbContext> options)
16
+ : base(options) { }
10
17
 
18
+ // ── DbSets ────────────────────────────────────────────────────────────
11
19
  <% modelsToGenerate.forEach(model => { %>
20
+ /// <summary>Represents the <b><%= model.name %></b> table.</summary>
12
21
  public DbSet<<%= model.name %>> <%= model.name %>s { get; set; }
13
22
  <% }); %>
23
+
24
+ // ── Model Configuration ───────────────────────────────────────────────
25
+ protected override void OnModelCreating(ModelBuilder modelBuilder)
26
+ {
27
+ base.OnModelCreating(modelBuilder);
28
+
29
+ <% modelsToGenerate.forEach(model => { %>
30
+ // ── <%= model.name %> ──────────────────────────────────────────────
31
+ modelBuilder.Entity<<%= model.name %>>(entity =>
32
+ {
33
+ entity.HasKey(e => e.Id);
34
+
35
+ // Soft-delete: only return rows where DeletedAt is null
36
+ entity.HasQueryFilter(e => e.DeletedAt == null);
37
+
38
+ // Unique indexes
39
+ <% (model.fields || []).forEach(field => {
40
+ if (field.isUnique) { %>
41
+ entity.HasIndex(e => e.<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>)
42
+ .IsUnique()
43
+ .HasDatabaseName("IX_<%= model.name %>_<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>");
44
+ <% } }); %>
45
+
46
+ // Column precision for decimal fields
47
+ <% (model.fields || []).forEach(field => {
48
+ const n = String(field.name || '').toLowerCase();
49
+ const isDecimal = field.type === 'Number' && (n.includes('price') || n.includes('amount') || n.includes('total') || n.includes('cost'));
50
+ if (isDecimal) { %>
51
+ entity.Property(e => e.<%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %>)
52
+ .HasColumnType("decimal(18,2)");
53
+ <% } }); %>
54
+
55
+ // Audit timestamp defaults
56
+ entity.Property(e => e.CreatedAt).HasDefaultValueSql("NOW()");
57
+ entity.Property(e => e.UpdatedAt).HasDefaultValueSql("NOW()");
58
+
59
+ // Table name override (snake_case convention)
60
+ entity.ToTable("<%= model.name.replace(/([A-Z])/g, '_$1').toLowerCase().replace(/^_/, '') %>s");
61
+ });
62
+ <% }); %>
63
+ }
64
+
65
+ // ── Automatic Audit: SaveChanges / SaveChangesAsync ───────────────────
66
+ public override int SaveChanges(bool acceptAllChangesOnSuccess)
67
+ {
68
+ ApplyAuditInfo();
69
+ return base.SaveChanges(acceptAllChangesOnSuccess);
70
+ }
71
+
72
+ public override Task<int> SaveChangesAsync(
73
+ bool acceptAllChangesOnSuccess,
74
+ CancellationToken cancellationToken = default)
75
+ {
76
+ ApplyAuditInfo();
77
+ return base.SaveChangesAsync(acceptAllChangesOnSuccess, cancellationToken);
78
+ }
79
+
80
+ private void ApplyAuditInfo()
81
+ {
82
+ var now = DateTime.UtcNow;
83
+
84
+ foreach (EntityEntry entry in ChangeTracker.Entries())
85
+ {
86
+ if (entry.State == EntityState.Added)
87
+ {
88
+ if (entry.Properties.Any(p => p.Metadata.Name == "CreatedAt"))
89
+ entry.Property("CreatedAt").CurrentValue = now;
90
+ if (entry.Properties.Any(p => p.Metadata.Name == "UpdatedAt"))
91
+ entry.Property("UpdatedAt").CurrentValue = now;
92
+ }
93
+ else if (entry.State == EntityState.Modified)
94
+ {
95
+ if (entry.Properties.Any(p => p.Metadata.Name == "UpdatedAt"))
96
+ entry.Property("UpdatedAt").CurrentValue = now;
97
+
98
+ // Prevent CreatedAt from being overwritten on update
99
+ if (entry.Properties.Any(p => p.Metadata.Name == "CreatedAt"))
100
+ entry.Property("CreatedAt").IsModified = false;
101
+ }
102
+ }
103
+ }
14
104
  }
15
- }
105
+ }
@@ -1,51 +1,212 @@
1
- // Auto-generated by create-backlist
2
- using System;
1
+ // Auto-generated by create-backlist — DO NOT EDIT MANUALLY
3
2
  using System.ComponentModel.DataAnnotations;
3
+ using System.ComponentModel.DataAnnotations.Schema;
4
4
 
5
5
  namespace <%= projectName %>.Models
6
6
  {
7
+ /// <summary>
8
+ /// Entity model for <b><%= modelName %></b> — auto-generated by Backlist.
9
+ /// </summary>
7
10
  public class <%= modelName %>
8
11
  {
12
+ // ── Primary Key ───────────────────────────────────────────────────────
9
13
  [Key]
14
+ [DatabaseGenerated(DatabaseGeneratedOption.Identity)]
10
15
  public Guid Id { get; set; } = Guid.NewGuid();
11
16
 
12
- <% model.fields.forEach(field => { %>
13
- <%
14
- // Basic type mapping
15
- let csharpType = 'string';
16
- if (field.type === 'Number') csharpType = 'int';
17
- if (field.type === 'Boolean') csharpType = 'bool';
18
-
19
- // Small heuristic: money-like fields -> decimal
20
- const n = String(field.name || '').toLowerCase();
21
- if (field.type === 'Number' && (n.includes('price') || n.includes('amount') || n.includes('total'))) {
22
- csharpType = 'decimal';
23
- }
24
-
25
- // Required/Unique hints
26
- const isEmail = n.includes('email');
27
- %>
17
+ // ── Domain Fields ─────────────────────────────────────────────────────
18
+ <% (model.fields || []).forEach(field => {
19
+ // ── Type mapping ────────────────────────────────────────────────
20
+ let csharpType = 'string';
21
+ let defaultValue = '= string.Empty;';
22
+ let columnType = '';
28
23
 
29
- <% if (field.isUnique) { %>
30
- // NOTE: Unique constraint should be configured in DbContext (HasIndex().IsUnique()).
31
- <% } %>
24
+ const n = String(field.name || '').toLowerCase();
32
25
 
33
- <% if (isEmail) { %>
34
- [EmailAddress]
35
- <% } %>
26
+ if (field.type === 'Number') {
27
+ const isDecimal = n.includes('price') || n.includes('amount') || n.includes('total') || n.includes('cost') || n.includes('fee') || n.includes('balance');
28
+ const isLong = n.includes('count') || n.includes('views') || n.includes('hits');
29
+ csharpType = isDecimal ? 'decimal' : (isLong ? 'long' : 'int');
30
+ columnType = isDecimal ? '[Column(TypeName = "decimal(18,2)")]' : '';
31
+ defaultValue = '';
32
+ } else if (field.type === 'Boolean') {
33
+ csharpType = 'bool';
34
+ defaultValue = '= false;';
35
+ } else if (field.type === 'Date' || field.type === 'DateTime') {
36
+ csharpType = 'DateTime?';
37
+ defaultValue = '';
38
+ } else if (field.type === 'Float' || field.type === 'Double') {
39
+ csharpType = 'double';
40
+ defaultValue = '';
41
+ }
42
+
43
+ const isEmail = n.includes('email');
44
+ const isPhone = n.includes('phone') || n.includes('mobile') || n.includes('tel');
45
+ const isUrl = n.includes('url') || n.includes('link') || n.includes('website');
46
+ const isPassword = n.includes('password') || n.includes('hash');
47
+ const maxLen = isPassword ? 512 : (isUrl ? 2048 : 256);
36
48
 
37
- <% if (csharpType === 'string') { %>
38
- [Required]
39
- public string <%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %> { get; set; } = string.Empty;
40
- <% } else { %>
41
- public <%= csharpType %> <%= field.name.charAt(0).toUpperCase() + field.name.slice(1) %> { get; set; }
49
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
50
+ %>
51
+ <% if (field.isUnique) { %>
52
+ // Unique index configured in ApplicationDbContext.OnModelCreating
42
53
  <% } %>
54
+ <% if (isEmail) { %>[EmailAddress]<% } %>
55
+ <% if (isUrl) { %>[Url]<% } %>
56
+ <% if (isPhone) { %>[Phone]<% } %>
57
+ <% if (csharpType === 'string' && !isPassword) { %>[MaxLength(<%= maxLen %>)]<% } %>
58
+ <% if (csharpType === 'string') { %>[Required(ErrorMessage = "<%= propName %> is required.")]<% } %>
59
+ <% if (columnType) { %><%= columnType %><% } %>
60
+ public <%= csharpType %> <%= propName %> { get; set; } <%= defaultValue %>
43
61
 
44
62
  <% }); %>
45
63
 
64
+ // ── Audit Fields ──────────────────────────────────────────────────────
65
+ /// <summary>UTC timestamp set on row creation.</summary>
46
66
  public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
47
67
 
48
- // NOTE: Ideally update this automatically in DbContext.SaveChanges override.
68
+ /// <summary>UTC timestamp updated on every write (auto-managed by DbContext).</summary>
49
69
  public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
70
+
71
+ /// <summary>Soft-delete timestamp. NULL means the record is active.</summary>
72
+ public DateTime? DeletedAt { get; set; }
73
+
74
+ // ── Convenience ───────────────────────────────────────────────────────
75
+ /// <summary>Returns <c>true</c> when this record has been soft-deleted.</summary>
76
+ [NotMapped]
77
+ public bool IsDeleted => DeletedAt.HasValue;
78
+ }
79
+ }
80
+
81
+ // ─────────────────────────────────────────────────────────────────────────────
82
+ // DTOs — kept in the same file for simplicity; move to DTOs/ folder if desired
83
+ // ─────────────────────────────────────────────────────────────────────────────
84
+ namespace <%= projectName %>.DTOs
85
+ {
86
+ // ── Shared helper ─────────────────────────────────────────────────────────
87
+ using <%= projectName %>.Models;
88
+
89
+ /// <summary>Generic paginated response wrapper.</summary>
90
+ public class PagedResult<T>
91
+ {
92
+ public IEnumerable<T> Items { get; set; } = Enumerable.Empty<T>();
93
+ public int Total { get; set; }
94
+ public int Page { get; set; }
95
+ public int PageSize { get; set; }
96
+ public int TotalPages { get; set; }
97
+ }
98
+
99
+ // ── Create DTO ────────────────────────────────────────────────────────────
100
+ /// <summary>Payload for POST api/<%= modelName.toLowerCase() %></summary>
101
+ public class Create<%= modelName %>Dto
102
+ {
103
+ <% (model.fields || []).forEach(field => {
104
+ let csharpType = 'string';
105
+ let defaultValue = '= string.Empty;';
106
+ const n = String(field.name || '').toLowerCase();
107
+
108
+ if (field.type === 'Number') {
109
+ const isDecimal = n.includes('price') || n.includes('amount') || n.includes('total') || n.includes('cost');
110
+ csharpType = isDecimal ? 'decimal' : 'int';
111
+ defaultValue = '';
112
+ } else if (field.type === 'Boolean') {
113
+ csharpType = 'bool'; defaultValue = '= false;';
114
+ } else if (field.type === 'Date' || field.type === 'DateTime') {
115
+ csharpType = 'DateTime?'; defaultValue = '';
116
+ }
117
+
118
+ const isEmail = n.includes('email');
119
+ const isUrl = n.includes('url') || n.includes('link');
120
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
121
+ %>
122
+ <% if (isEmail) { %>[EmailAddress]<% } %>
123
+ <% if (isUrl) { %>[Url]<% } %>
124
+ <% if (csharpType === 'string') { %>[Required][MaxLength(256)]<% } %>
125
+ public <%= csharpType %> <%= propName %> { get; set; } <%= defaultValue %>
126
+ <% }); %>
127
+
128
+ /// <summary>Maps this DTO to a new <see cref="<%= modelName %>"/> entity.</summary>
129
+ public <%= modelName %> ToEntity() => new <%= modelName %>
130
+ {
131
+ <% (model.fields || []).forEach(field => {
132
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
133
+ %>
134
+ <%= propName %> = this.<%= propName %>,
135
+ <% }); %>
136
+ CreatedAt = DateTime.UtcNow,
137
+ UpdatedAt = DateTime.UtcNow,
138
+ };
139
+ }
140
+
141
+ // ── Full Update DTO ───────────────────────────────────────────────────────
142
+ /// <summary>Payload for PUT api/<%= modelName.toLowerCase() %>/{id}</summary>
143
+ public class Update<%= modelName %>Dto
144
+ {
145
+ <% (model.fields || []).forEach(field => {
146
+ let csharpType = 'string';
147
+ let defaultValue = '= string.Empty;';
148
+ const n = String(field.name || '').toLowerCase();
149
+
150
+ if (field.type === 'Number') {
151
+ const isDecimal = n.includes('price') || n.includes('amount') || n.includes('total') || n.includes('cost');
152
+ csharpType = isDecimal ? 'decimal' : 'int';
153
+ defaultValue = '';
154
+ } else if (field.type === 'Boolean') {
155
+ csharpType = 'bool'; defaultValue = '= false;';
156
+ } else if (field.type === 'Date' || field.type === 'DateTime') {
157
+ csharpType = 'DateTime?'; defaultValue = '';
158
+ }
159
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
160
+ %>
161
+ <% if (csharpType === 'string') { %>[Required][MaxLength(256)]<% } %>
162
+ public <%= csharpType %> <%= propName %> { get; set; } <%= defaultValue %>
163
+ <% }); %>
164
+
165
+ /// <summary>Applies all fields from this DTO onto an existing entity.</summary>
166
+ public void ApplyTo(<%= modelName %> entity)
167
+ {
168
+ <% (model.fields || []).forEach(field => {
169
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
170
+ %>
171
+ entity.<%= propName %> = this.<%= propName %>;
172
+ <% }); %>
173
+ }
174
+ }
175
+
176
+ // ── Partial Update DTO ────────────────────────────────────────────────────
177
+ /// <summary>Payload for PATCH api/<%= modelName.toLowerCase() %>/{id} — all fields optional.</summary>
178
+ public class Patch<%= modelName %>Dto
179
+ {
180
+ <% (model.fields || []).forEach(field => {
181
+ let csharpType = 'string?';
182
+ const n = String(field.name || '').toLowerCase();
183
+
184
+ if (field.type === 'Number') {
185
+ const isDecimal = n.includes('price') || n.includes('amount') || n.includes('total') || n.includes('cost');
186
+ csharpType = isDecimal ? 'decimal?' : 'int?';
187
+ } else if (field.type === 'Boolean') {
188
+ csharpType = 'bool?';
189
+ } else if (field.type === 'Date' || field.type === 'DateTime') {
190
+ csharpType = 'DateTime?';
191
+ }
192
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
193
+ %>
194
+ public <%= csharpType %> <%= propName %> { get; set; }
195
+ <% }); %>
196
+
197
+ /// <summary>Applies only non-null fields from this DTO onto an existing entity.</summary>
198
+ public void ApplyPartialTo(<%= modelName %> entity)
199
+ {
200
+ <% (model.fields || []).forEach(field => {
201
+ const propName = field.name.charAt(0).toUpperCase() + field.name.slice(1);
202
+ const n = String(field.name || '').toLowerCase();
203
+ let csharpType = 'string?';
204
+ if (field.type === 'Number') csharpType = 'numeric?';
205
+ else if (field.type === 'Boolean') csharpType = 'bool?';
206
+ else if (field.type === 'Date' || field.type === 'DateTime') csharpType = 'DateTime?';
207
+ %>
208
+ if (<%= propName %> is not null) entity.<%= propName %> = <%= propName %><% if (csharpType === 'string?') { %><% } %>!;
209
+ <% }); %>
210
+ }
50
211
  }
51
- }
212
+ }