Skip to content

Commit 6305e85

Browse files
committed
Now applies item themes to all items sharing a base id
1 parent 22cf723 commit 6305e85

14 files changed

Lines changed: 238 additions & 104 deletions

File tree

src/Application/Items/Commands/AddThemesToItemsCommand.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@
77
namespace Crpg.Application.Items.Commands;
88

99
/// <summary>
10-
/// Adds a set of themes to several items at once. Existing themes on those items are preserved; themes already
11-
/// present are not duplicated.
10+
/// Adds a set of themes to several item families at once. The themes are applied to every rank variant sharing one
11+
/// of the given <see cref="Crpg.Domain.Entities.Items.Item.BaseId"/>s, so upgrades stay in sync. Existing themes on
12+
/// those items are preserved; themes already present are not duplicated.
1213
/// </summary>
1314
public record AddThemesToItemsCommand : IMediatorRequest
1415
{
15-
public IList<string> ItemIds { get; init; } = new List<string>();
16+
public IList<string> BaseIds { get; init; } = new List<string>();
1617
public IList<int> ThemeIds { get; init; } = new List<int>();
1718

1819
public class Validator : AbstractValidator<AddThemesToItemsCommand>
1920
{
2021
public Validator()
2122
{
22-
RuleFor(r => r.ItemIds).NotEmpty();
23+
RuleFor(r => r.BaseIds).NotEmpty();
2324
RuleFor(r => r.ThemeIds).NotEmpty();
2425
}
2526
}
@@ -28,15 +29,17 @@ internal class Handler(ICrpgDbContext db) : IMediatorRequestHandler<AddThemesToI
2829
{
2930
public async ValueTask<Result> Handle(AddThemesToItemsCommand req, CancellationToken cancellationToken)
3031
{
31-
var itemIds = req.ItemIds.Distinct().ToList();
32+
var baseIds = req.BaseIds.Distinct().ToList();
3233
var items = await db.Items
3334
.Include(i => i.Themes)
34-
.Where(i => itemIds.Contains(i.Id))
35+
.Where(i => baseIds.Contains(i.BaseId))
3536
.ToListAsync(cancellationToken);
36-
if (items.Count != itemIds.Count)
37+
38+
var foundBaseIds = items.Select(i => i.BaseId).ToHashSet();
39+
string? missingBaseId = baseIds.FirstOrDefault(id => !foundBaseIds.Contains(id));
40+
if (missingBaseId != null)
3741
{
38-
string missingItemId = itemIds.First(id => items.All(i => i.Id != id));
39-
return new(CommonErrors.ItemNotFound(missingItemId));
42+
return new(CommonErrors.ItemNotFound(missingBaseId));
4043
}
4144

4245
var themeIds = req.ThemeIds.Distinct().ToList();

src/Application/Items/Commands/RemoveThemesFromItemsCommand.cs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,20 @@
77
namespace Crpg.Application.Items.Commands;
88

99
/// <summary>
10-
/// Removes a set of themes from several items at once. Any other themes on those items are preserved; themes
11-
/// that aren't present are ignored.
10+
/// Removes a set of themes from several item families at once. The themes are removed from every rank variant
11+
/// sharing one of the given <see cref="Crpg.Domain.Entities.Items.Item.BaseId"/>s, so upgrades stay in sync. Any
12+
/// other themes on those items are preserved; themes that aren't present are ignored.
1213
/// </summary>
1314
public record RemoveThemesFromItemsCommand : IMediatorRequest
1415
{
15-
public IList<string> ItemIds { get; init; } = new List<string>();
16+
public IList<string> BaseIds { get; init; } = new List<string>();
1617
public IList<int> ThemeIds { get; init; } = new List<int>();
1718

1819
public class Validator : AbstractValidator<RemoveThemesFromItemsCommand>
1920
{
2021
public Validator()
2122
{
22-
RuleFor(r => r.ItemIds).NotEmpty();
23+
RuleFor(r => r.BaseIds).NotEmpty();
2324
RuleFor(r => r.ThemeIds).NotEmpty();
2425
}
2526
}
@@ -28,15 +29,17 @@ internal class Handler(ICrpgDbContext db) : IMediatorRequestHandler<RemoveThemes
2829
{
2930
public async ValueTask<Result> Handle(RemoveThemesFromItemsCommand req, CancellationToken cancellationToken)
3031
{
31-
var itemIds = req.ItemIds.Distinct().ToList();
32+
var baseIds = req.BaseIds.Distinct().ToList();
3233
var items = await db.Items
3334
.Include(i => i.Themes)
34-
.Where(i => itemIds.Contains(i.Id))
35+
.Where(i => baseIds.Contains(i.BaseId))
3536
.ToListAsync(cancellationToken);
36-
if (items.Count != itemIds.Count)
37+
38+
var foundBaseIds = items.Select(i => i.BaseId).ToHashSet();
39+
string? missingBaseId = baseIds.FirstOrDefault(id => !foundBaseIds.Contains(id));
40+
if (missingBaseId != null)
3741
{
38-
string missingItemId = itemIds.First(id => items.All(i => i.Id != id));
39-
return new(CommonErrors.ItemNotFound(missingItemId));
42+
return new(CommonErrors.ItemNotFound(missingBaseId));
4043
}
4144

4245
var themeIds = req.ThemeIds.Distinct().ToList();

src/Application/Items/Commands/SetItemThemesCommand.cs

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,24 +9,26 @@
99
namespace Crpg.Application.Items.Commands;
1010

1111
/// <summary>
12-
/// Replaces the set of themes assigned to a single item.
12+
/// Replaces the set of themes assigned to an item family. The themes are applied to every rank variant sharing the
13+
/// given <see cref="Crpg.Domain.Entities.Items.Item.BaseId"/>, so upgrades stay in sync.
1314
/// </summary>
1415
public record SetItemThemesCommand : IMediatorRequest<ItemViewModel>
1516
{
1617
[JsonIgnore]
17-
public string ItemId { get; init; } = string.Empty;
18+
public string BaseId { get; init; } = string.Empty;
1819
public IList<int> ThemeIds { get; init; } = new List<int>();
1920

2021
internal class Handler(ICrpgDbContext db, IMapper mapper) : IMediatorRequestHandler<SetItemThemesCommand, ItemViewModel>
2122
{
2223
public async ValueTask<Result<ItemViewModel>> Handle(SetItemThemesCommand req, CancellationToken cancellationToken)
2324
{
24-
var item = await db.Items
25+
var items = await db.Items
2526
.Include(i => i.Themes)
26-
.FirstOrDefaultAsync(i => i.Id == req.ItemId, cancellationToken);
27-
if (item == null)
27+
.Where(i => i.BaseId == req.BaseId)
28+
.ToListAsync(cancellationToken);
29+
if (items.Count == 0)
2830
{
29-
return new(CommonErrors.ItemNotFound(req.ItemId));
31+
return new(CommonErrors.ItemNotFound(req.BaseId));
3032
}
3133

3234
var themeIds = req.ThemeIds.Distinct().ToList();
@@ -37,12 +39,16 @@ public async ValueTask<Result<ItemViewModel>> Handle(SetItemThemesCommand req, C
3739
return new(CommonErrors.ThemeNotFound(missingThemeId));
3840
}
3941

40-
item.Themes.Clear();
41-
item.Themes.AddRange(themes);
42+
foreach (var item in items)
43+
{
44+
item.Themes.Clear();
45+
item.Themes.AddRange(themes);
46+
}
4247

4348
await db.SaveChangesAsync(cancellationToken);
4449

45-
return new(mapper.Map<ItemViewModel>(item));
50+
var representative = items.OrderBy(i => i.Rank).First();
51+
return new(mapper.Map<ItemViewModel>(representative));
4652
}
4753
}
4854
}

src/WebApi/Controllers/ItemsController.cs

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -69,26 +69,27 @@ public Task<ActionResult> RefundItem([FromRoute] string id, [FromBody] RefundIte
6969
}
7070

7171
/// <summary>
72-
/// Replaces the themes assigned to a single item.
72+
/// Replaces the themes assigned to an item family (every rank variant sharing the BaseId).
7373
/// </summary>
74-
/// <param name="id">Item id.</param>
74+
/// <param name="baseId">Item BaseId.</param>
7575
/// <param name="req">The themes to assign.</param>
7676
/// <response code="200">Ok.</response>
7777
/// <response code="404">Item or theme not found.</response>
7878
[Authorize(Policy = AdminPolicy)]
79-
[HttpPut("{id}/themes")]
80-
public async Task<ActionResult<Result<ItemViewModel>>> SetItemThemes([FromRoute] string id, [FromBody] SetItemThemesCommand req)
79+
[HttpPut("{baseId}/themes")]
80+
public async Task<ActionResult<Result<ItemViewModel>>> SetItemThemes([FromRoute] string baseId, [FromBody] SetItemThemesCommand req)
8181
{
82-
req = req with { ItemId = id };
82+
req = req with { BaseId = baseId };
8383
var result = await ResultToActionAsync(Mediator.Send(req));
8484
await EvictActiveThemeEventsCacheAsync();
8585
return result;
8686
}
8787

8888
/// <summary>
89-
/// Adds a set of themes to several items at once, preserving themes they already have.
89+
/// Adds a set of themes to several item families at once (each BaseId covers all its rank variants),
90+
/// preserving themes they already have.
9091
/// </summary>
91-
/// <param name="req">The items and themes to tag.</param>
92+
/// <param name="req">The item BaseIds and themes to tag.</param>
9293
/// <response code="204">Updated.</response>
9394
/// <response code="404">An item or theme was not found.</response>
9495
[Authorize(Policy = AdminPolicy)]
@@ -101,9 +102,10 @@ public async Task<ActionResult> AddThemesToItems([FromBody] AddThemesToItemsComm
101102
}
102103

103104
/// <summary>
104-
/// Removes a set of themes from several items at once, preserving any other themes they have.
105+
/// Removes a set of themes from several item families at once (each BaseId covers all its rank variants),
106+
/// preserving any other themes they have.
105107
/// </summary>
106-
/// <param name="req">The items and themes to untag.</param>
108+
/// <param name="req">The item BaseIds and themes to untag.</param>
107109
/// <response code="204">Updated.</response>
108110
/// <response code="404">An item or theme was not found.</response>
109111
[Authorize(Policy = AdminPolicy)]

src/WebUI/app/pages/shop.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,13 @@ const { execute: submitThemes, isLoading: savingThemes } = useAsyncCallback(asyn
250250
const chosen: ItemTheme[] = allThemes.value.filter(theme => themeIds.includes(theme.id))
251251
252252
if (themeModalMode.value === 'set' && themeEditTarget.value) {
253-
const updated = await setItemThemes(themeEditTarget.value.id, themeIds)
253+
const updated = await setItemThemes(themeEditTarget.value.baseId, themeIds)
254254
themeEditTarget.value.themes = (updated.themes ?? []).map(theme => ({ id: theme.id, name: theme.name }))
255255
toast.add({ title: t('theme.tag.notify.set'), close: false, color: 'success' })
256256
}
257257
else if (themeModalMode.value === 'add') {
258258
const items = selectedItems.value
259-
await addThemesToItems(items.map(item => item.id), themeIds)
259+
await addThemesToItems(items.map(item => item.baseId), themeIds)
260260
for (const item of items) {
261261
for (const theme of chosen) {
262262
if (!item.themes.some(t => t.id === theme.id)) {
@@ -269,7 +269,7 @@ const { execute: submitThemes, isLoading: savingThemes } = useAsyncCallback(asyn
269269
}
270270
else if (themeModalMode.value === 'remove') {
271271
const items = selectedItems.value
272-
await removeThemesFromItems(items.map(item => item.id), themeIds)
272+
await removeThemesFromItems(items.map(item => item.baseId), themeIds)
273273
for (const item of items) {
274274
item.themes = item.themes.filter(theme => !themeIds.includes(theme.id))
275275
}

src/WebUI/app/services/__tests__/theme-service.spec.ts

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ const {
2525
_postThemesEvents,
2626
_putThemesEvents,
2727
_deleteThemesEventsById,
28-
_putItemsByIdThemes,
28+
_putItemsByBaseIdThemes,
2929
_putItemsThemes,
3030
_deleteItemsThemes,
3131
} = vi.hoisted(() => ({
@@ -37,7 +37,7 @@ const {
3737
_postThemesEvents: vi.fn(),
3838
_putThemesEvents: vi.fn(),
3939
_deleteThemesEventsById: vi.fn(),
40-
_putItemsByIdThemes: vi.fn(),
40+
_putItemsByBaseIdThemes: vi.fn(),
4141
_putItemsThemes: vi.fn(),
4242
_deleteItemsThemes: vi.fn(),
4343
}))
@@ -51,7 +51,7 @@ vi.mock('#api/sdk.gen', () => ({
5151
postThemesEvents: _postThemesEvents,
5252
putThemesEvents: _putThemesEvents,
5353
deleteThemesEventsById: _deleteThemesEventsById,
54-
putItemsByIdThemes: _putItemsByIdThemes,
54+
putItemsByBaseIdThemes: _putItemsByBaseIdThemes,
5555
putItemsThemes: _putItemsThemes,
5656
deleteItemsThemes: _deleteItemsThemes,
5757
}))
@@ -163,30 +163,30 @@ describe('theme service', () => {
163163
})
164164

165165
describe('item themes', () => {
166-
it('setItemThemes puts the theme ids to the item route and unwraps the item', async () => {
166+
it('setItemThemes puts the theme ids to the item family route and unwraps the item', async () => {
167167
const item = { id: 'sword_1', themes: [{ id: 1, name: 'Viking' }] }
168-
_putItemsByIdThemes.mockResolvedValueOnce({ data: item, errors: null })
168+
_putItemsByBaseIdThemes.mockResolvedValueOnce({ data: item, errors: null })
169169

170-
const result = await setItemThemes('sword_1', [1])
170+
const result = await setItemThemes('sword', [1])
171171

172-
expect(_putItemsByIdThemes).toHaveBeenCalledWith({ path: { id: 'sword_1' }, body: { themeIds: [1] } })
172+
expect(_putItemsByBaseIdThemes).toHaveBeenCalledWith({ path: { baseId: 'sword' }, body: { themeIds: [1] } })
173173
expect(result).toEqual(item)
174174
})
175175

176-
it('addThemesToItems puts the items and themes in the body', async () => {
176+
it('addThemesToItems puts the base ids and themes in the body', async () => {
177177
_putItemsThemes.mockResolvedValueOnce({ data: undefined, errors: null })
178178

179-
await addThemesToItems(['sword_1', 'axe_2'], [1, 2])
179+
await addThemesToItems(['sword', 'axe'], [1, 2])
180180

181-
expect(_putItemsThemes).toHaveBeenCalledWith({ body: { itemIds: ['sword_1', 'axe_2'], themeIds: [1, 2] } })
181+
expect(_putItemsThemes).toHaveBeenCalledWith({ body: { baseIds: ['sword', 'axe'], themeIds: [1, 2] } })
182182
})
183183

184-
it('removeThemesFromItems deletes with the items and themes in the body', async () => {
184+
it('removeThemesFromItems deletes with the base ids and themes in the body', async () => {
185185
_deleteItemsThemes.mockResolvedValueOnce({ data: undefined, errors: null })
186186

187-
await removeThemesFromItems(['sword_1'], [3])
187+
await removeThemesFromItems(['sword'], [3])
188188

189-
expect(_deleteItemsThemes).toHaveBeenCalledWith({ body: { itemIds: ['sword_1'], themeIds: [3] } })
189+
expect(_deleteItemsThemes).toHaveBeenCalledWith({ body: { baseIds: ['sword'], themeIds: [3] } })
190190
})
191191
})
192192
})

src/WebUI/app/services/theme-service.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {
1616
deleteThemesEventsById,
1717
postThemes,
1818
postThemesEvents,
19-
putItemsByIdThemes,
19+
putItemsByBaseIdThemes,
2020
putItemsThemes,
2121
putThemes,
2222
putThemesEvents,
@@ -38,12 +38,13 @@ export const updateThemeEvent = (body: UpdateThemeEventCommand) => putThemesEven
3838

3939
export const deleteThemeEvent = (id: number) => deleteThemesEventsById({ path: { id } })
4040

41-
// Item theme tagging (admin). Single = set/replace, bulk = add/remove.
42-
export const setItemThemes = async (itemId: string, themeIds: number[]): Promise<ItemViewModel> =>
43-
(await putItemsByIdThemes({ path: { id: itemId }, body: { themeIds } })).data!
41+
// Item theme tagging (admin). Themes apply to a whole item family (BaseId = all rank variants).
42+
// Single = set/replace, bulk = add/remove.
43+
export const setItemThemes = async (baseId: string, themeIds: number[]): Promise<ItemViewModel> =>
44+
(await putItemsByBaseIdThemes({ path: { baseId }, body: { themeIds } })).data!
4445

45-
export const addThemesToItems = (itemIds: string[], themeIds: number[]) =>
46-
putItemsThemes({ body: { itemIds, themeIds } })
46+
export const addThemesToItems = (baseIds: string[], themeIds: number[]) =>
47+
putItemsThemes({ body: { baseIds, themeIds } })
4748

48-
export const removeThemesFromItems = (itemIds: string[], themeIds: number[]) =>
49-
deleteItemsThemes({ body: { itemIds, themeIds } })
49+
export const removeThemesFromItems = (baseIds: string[], themeIds: number[]) =>
50+
deleteItemsThemes({ body: { baseIds, themeIds } })

src/WebUI/generated/api/index.ts

Lines changed: 2 additions & 2 deletions
Large diffs are not rendered by default.

0 commit comments

Comments
 (0)