Compare commits

...

232 commits

Author SHA1 Message Date
238bc3e59a add routes for jobs search, create, and accessories CRUD 2026-01-04 02:50:13 -08:00
d108427645 add job accessories CRUD
- add accessory_type to blueprint
- add index, show, create, update, destroy actions
- editors only for mutations
2026-01-04 02:50:08 -08:00
1272aba2d1 add jobs create endpoint 2026-01-04 02:50:03 -08:00
93220df4cd add jobs search endpoint with pg_search
- add en_search and ja_search scopes to Job model
- add jobs action to SearchController with filtering
- supports row, proficiency, master_level, ultimate_mastery, accessory filters
2026-01-04 02:49:59 -08:00
53cb15fa27 fix augment_type enum conflict with ActiveRecord none method 2025-12-31 22:39:34 -08:00
c6d117fc09 Merge branch 'next-main' of https://github.com/jedmund/hensei-api into next-main 2025-12-31 22:20:43 -08:00
7bda9a1432
Improve collection sync with scoped filtering and orphan handling (#201)
* collection sync with orphan handling

- preview_sync endpoint shows what'll get deleted before you commit
- import services handle reconciliation (find missing items, delete them)
- grid items get flagged as orphaned when their collection source is gone
- party exposes has_orphaned_items
- blueprints include orphaned field

* scope artifact sync by element and proficiency

- accept filter param in preview_sync and import
- only check/delete items matching active filter
- prevents accidental deletion of filtered-out items

* scope weapon sync by element and proficiency

- accept filter param in preview_sync and import
- element checks collection_weapon first, falls back to weapon
- proficiency joins through weapon table

* scope summon sync by element

- accept filter param in preview_sync and import
- element joins through summon table
2025-12-31 22:20:10 -08:00
1f80e4189f
Add weapon stat modifiers for AX skills and befoulments (#202)
* add weapon_stat_modifiers table for ax skills and befoulments

* add fk columns for ax modifiers and befoulments, replace has_ax_skills with augment_type

* update models for weapon_stat_modifier fks and befoulments

* update blueprints for weapon_stat_modifier serialization

* update import service for weapon_stat_modifier fks and befoulments

* add weapon_stat_modifiers controller and update params for fks

* update tests and factories for weapon_stat_modifier fks

* fix remaining has_ax_skills and ax_modifier references

* add ax_modifier and befoulment_modifier to eager loading

* fix ax modifier column naming and migration approach

* add game_skill_ids for befoulment modifiers
2025-12-31 22:20:00 -08:00
964b73fda1 fix spec for 8 character positions in unlimited raids 2025-12-31 19:33:04 -08:00
a6b7e26210
collection sync with orphan handling (#200)
- preview_sync endpoint shows what'll get deleted before you commit
- import services handle reconciliation (find missing items, delete them)
- grid items get flagged as orphaned when their collection source is gone
- party exposes has_orphaned_items
- blueprints include orphaned field
2025-12-23 22:44:35 -08:00
5c578ee527 auto-compute forge chain fields from forged_from
- add before_save callback to calculate forge_order and forge_chain_id
- add validation to prevent circular forge chains
2025-12-22 19:52:59 -08:00
3abc10a5e2 fix weapon series filter to use weapon_series_id 2025-12-22 01:12:26 -08:00
65ad500550 expose extra_prerequisite and forge chain in weapon api 2025-12-21 22:14:41 -08:00
85d9060dc9 add forge chain support to weapons 2025-12-21 22:14:32 -08:00
405d0ea88c add extra_prerequisite to weapons for extra grid positions 2025-12-21 22:14:23 -08:00
56ee499908 allow 8 character positions for unlimited raids 2025-12-21 13:46:55 -08:00
c99180b299 update database rake tasks 2025-12-21 02:56:31 -08:00
c39cc5d240 expose video_url and summon_count in party api 2025-12-21 02:56:27 -08:00
25aa1f3a62 add video_url and summon_count to parties 2025-12-21 02:56:22 -08:00
847a487920 fix character transcendence in roster endpoint 2025-12-20 20:12:43 -08:00
4bc2b7055e add canonical uncap data to roster endpoint 2025-12-20 19:47:17 -08:00
7a084920f1 raids controller and model updates 2025-12-20 04:13:48 -08:00
5c38629f1f add crew roster endpoint for checking member collections 2025-12-20 04:13:27 -08:00
e87650a2b0 add season and series to unified search for characters 2025-12-20 04:13:16 -08:00
5ea5388bed validate mainhand weapon proficiency matches job 2025-12-20 02:13:28 -08:00
3afee9463f add unlimited flag to raid groups 2025-12-20 01:58:23 -08:00
7222353d29 add collection counts endpoint 2025-12-19 15:52:40 -08:00
5da86c5405 fix collection filters to support comma-separated array params 2025-12-19 12:26:26 -08:00
693962ce3b fix proficiency filter for quirk and standard artifacts 2025-12-19 01:07:41 -08:00
ab19403904 add skill filtering and batch_destroy for collection artifacts 2025-12-19 00:39:52 -08:00
9ce86b22b4 add batch_destroy endpoints for collection items 2025-12-19 00:39:47 -08:00
efa8dec43a allow empty base_values for special skills 2025-12-18 23:37:50 -08:00
9b63e99788 ignore public/assets 2025-12-18 23:16:07 -08:00
4e82e552d5 support extension stats format in character import
- new format with granblue_id at top level
- map gbf awakening types to hensei slugs
- import perpetuity rings and awakening data
2025-12-18 23:15:48 -08:00
98c3eee313 add sorting support to search endpoints 2025-12-18 23:15:37 -08:00
b3dadf24ef add excused field to gw individual scores
- excused boolean and excuse_reason fields
- excuse_reason only visible to crew officers
- include excused in blueprints
2025-12-18 23:15:27 -08:00
b7aeb2bdfe add show/update endpoints for artifact skills
- show and update actions with editor role protection
- include game_name field in blueprint
- clear cache after updates
2025-12-18 23:14:41 -08:00
db2aa43d81 add game_name columns for artifact skill matching
separate game names (used for import matching) from display names
2025-12-18 22:48:08 -08:00
3390eaf755 remove game_skill_id from artifact_skills (not needed)
added then removed - name matching approach doesn't need it
2025-12-18 22:30:59 -08:00
b033d7f74e update artifact import tests for name matching
use name field instead of skill_id in test data
check quality instead of strength in assertions
add japanese name matching test
2025-12-18 22:30:50 -08:00
86e5b9fffb compute strength from quality at display time
blueprint looks up skill and calculates strength
validation checks quality range instead of strength value
2025-12-18 22:30:44 -08:00
3b9eab8b79 switch artifact import to name matching, store quality
match skills by name field instead of skill_id
store raw quality (1-5) instead of computed strength
2025-12-18 22:30:39 -08:00
6c12a202ff add name-based skill lookup for artifact import
- cached_by_name indexes by both EN and JP names
- find_by_name looks up skill by either language
- strength_for_quality computes strength from quality tier
2025-12-18 22:30:33 -08:00
af061f3ab2 add aux_weapon flag to jobs 2025-12-18 21:34:30 -08:00
0c595792f7 support boomerang players in gw scores
- aggregate scores across all membership periods for a user
- add gap markers for events where player wasn't in crew
- add membership history endpoint for editing multiple periods
2025-12-18 19:35:39 -08:00
1520fd8d2f optimize gw_scores endpoints with SQL aggregation 2025-12-18 17:55:19 -08:00
ad5c9893e4 include phantoms in active/retired member filters 2025-12-18 17:48:49 -08:00
687f7ae926 support username lookup for member gw scores 2025-12-18 13:20:38 -08:00
4a6ae93d20 add gw scores history endpoints for members and phantoms 2025-12-18 11:02:59 -08:00
cc722b9660 exclude claimed phantoms from gw event player list 2025-12-18 00:41:25 -08:00
e60f3c48d6 allow retired_at in member/phantom update params 2025-12-18 00:35:47 -08:00
3e21cb697d fix confirm_claim not setting deleted_at 2025-12-18 00:35:41 -08:00
42f3d3a9cf include crew_total_score in gw events index 2025-12-17 23:03:22 -08:00
5afd31fdb6 soft delete phantoms after claim confirmation
keeps phantom records for logging, excludes from all queries
2025-12-17 20:08:28 -08:00
de72d21e24 add decline/pending endpoints for phantom claims
- decline_claim action lets assigned user reject assignment
- pending_phantom_claims endpoint for user's pending claims
- with_crew blueprint view for phantom claims context
2025-12-17 18:28:23 -08:00
75862aec03 add sorting and filtering to collection weapons 2025-12-16 21:15:44 -08:00
0c9d1d8e06 add element variant downloading for null element weapons 2025-12-16 21:15:39 -08:00
7e548109d6 improve wiki import suggestions
- strip _note suffix from null element weapon IDs
- look up weapon series by name to return UUID
2025-12-16 21:12:16 -08:00
9f2d9abdb5 add max_level to wiki import suggestions 2025-12-16 17:12:02 -08:00
7f2db88a6c Fix merge conflict 2025-12-15 17:53:06 -08:00
00a9b61d92 add migration to remove standard series from db 2025-12-15 17:51:51 -08:00
3ac6829a45 add migration to remove standard series from db 2025-12-15 17:48:17 -08:00
244e3f51eb remove Standard series, shift IDs down 2025-12-15 17:47:23 -08:00
93e3526d1e fix comma-separated series parsing in batch import 2025-12-15 17:47:00 -08:00
579736e981 remove gacha_available from character parsers/importers 2025-12-15 16:53:44 -08:00
c17dbfbcc7 add download_image endpoint for job skills 2025-12-15 16:09:02 -08:00
d613da4428 add job skill image downloader 2025-12-15 16:08:57 -08:00
b341185b54 add image_id and action_id fields to job_skills 2025-12-15 16:08:53 -08:00
834192dc11 fix /jobs/:id/skills to return job's own skills, add emp_skills endpoint 2025-12-15 14:30:32 -08:00
b458335e31 add update endpoint for jobs 2025-12-15 14:21:25 -08:00
b91ef0a4dd standardize links format in blueprints
return wiki: {en, ja} + gamewith, kamigame at top level
2025-12-15 12:47:11 -08:00
056aa3676f move gacha from characters to weapons
weapons have gacha boolean now, characters don't
2025-12-15 12:46:43 -08:00
d54af86dc1 remove Standard season, remap values to start at 1 2025-12-15 09:51:19 -08:00
df6d811736 parse gacha fields from wiki data for characters 2025-12-15 09:51:15 -08:00
f28e61b303 add Holiday to CHARACTER_SERIES enum 2025-12-15 09:50:53 -08:00
acf8010669 accept wiki_raw in entity create/update 2025-12-14 21:50:30 -08:00
ee96bf3ce8 fix weapon proficiency parsing from wiki data 2025-12-14 21:50:25 -08:00
b141cd07c4 fix wiki field names for uncap parsing
- characters/summons use max_evo, weapons use evo_max
- characters: 5=FLB, 6=ULB
- summons: 4=FLB, 5=ULB, 6=trans
- keep 5star fallback for legacy character data
2025-12-14 21:13:59 -08:00
f083258552 fix wiki suggestion parsing: element case, stats fields, url extraction 2025-12-14 19:36:03 -08:00
57b5cd0d33 extract summon_id from wiki data in batch preview 2025-12-14 16:29:29 -08:00
f589f58eb5 batch_preview: accept pre-fetched wiki_data from client 2025-12-14 13:12:25 -08:00
9e72e828f7 fix wiki fetch: add user-agent header and proper error handling 2025-12-14 12:57:50 -08:00
4d75835c71 move WikiError into Granblue namespace to fix production autoloading 2025-12-14 12:41:07 -08:00
65a10abe6d update schema files 2025-12-14 11:58:45 -08:00
1054920fcb add gbf series mapping for weapon/summon imports 2025-12-14 11:58:42 -08:00
3601579a7b fix weapon importer to use series slug 2025-12-14 11:58:38 -08:00
e1d212c764 update summon model and blueprint for series lookup 2025-12-14 11:58:34 -08:00
c4e42b0968 update character model and blueprint for series lookup 2025-12-14 11:58:30 -08:00
ead6f45802 add routes for character/summon series 2025-12-14 11:58:26 -08:00
3b5b8412d3 add summon series lookup table 2025-12-14 11:58:22 -08:00
e7e9bd0f86 add character series lookup table 2025-12-14 11:58:10 -08:00
1caffecdad add next-api.granblue.team to allowed hosts 2025-12-14 01:48:15 -08:00
349b542c0e fix rescue_from order so StandardError is checked last 2025-12-14 01:48:11 -08:00
d4131cf51d allow :retired param in membership and phantom player controllers 2025-12-14 01:48:06 -08:00
371f2a29dd fix artifact import: preload queries, handle symbol keys 2025-12-14 01:42:06 -08:00
5666ee300c fix import_params to pass nested game data 2025-12-14 01:42:05 -08:00
513f8c6a66 use 1-based values for collection_privacy enum 2025-12-14 01:23:42 -08:00
07f23e2b74 add /users/me settings endpoint 2025-12-14 01:23:37 -08:00
e0ba2d98c3 add show_granblue_id to users 2025-12-14 01:23:33 -08:00
f4ef04881e add bulk_create endpoint for phantom players
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 23:33:49 -08:00
fb253adf45 add catch-all exception handler to log 500 errors 2025-12-13 21:41:29 -08:00
4224dcb257 move recruited_by to separate view to avoid N+1 2025-12-13 21:40:42 -08:00
981feff814 add grid_artifact and weapon_series to party show includes 2025-12-13 21:39:47 -08:00
56280eb0ff preload crews and favorites to fix remaining N+1s 2025-12-13 21:38:54 -08:00
6f3f0d92ff fix N+1 queries in parties index 2025-12-13 21:32:37 -08:00
ef7c158736 add implementation plans for artifacts, character series, weapon series
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 20:54:48 -08:00
b8947dbaf3 add artifact import service spec
tests parsing game artifact data with skill decoding

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 20:54:42 -08:00
272f612357 add import services for characters, weapons, summons
parses game JSON inventory data and creates collection records

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-13 20:54:38 -08:00
c498278c89 fix array extraction order in artifact import 2025-12-13 20:12:00 -08:00
36dc4207c9 increase awakening_level max to 20 2025-12-13 20:11:53 -08:00
aa198f072b add import endpoints to collection controllers 2025-12-13 20:10:18 -08:00
860177c0a4 add show_gamertag to user 2025-12-13 20:09:52 -08:00
534414939b add current_membership to crew response 2025-12-13 20:09:44 -08:00
4db5f4224e return artifact enum values as integers 2025-12-13 20:09:34 -08:00
0a069c0324 fix N+1 query in by_event: include user for membership 2025-12-04 03:05:53 -08:00
d4c88997ff add retired flag to phantom players 2025-12-04 03:02:46 -08:00
26718b5a3e gw event improvements: status field, members_during_event endpoint 2025-12-04 03:02:35 -08:00
7d27d3c8b1 allow officers to update joined_at on members and phantoms 2025-12-04 03:02:27 -08:00
0337bc1e92 add by-event score endpoints that auto-create participation 2025-12-04 03:02:22 -08:00
b4f4f9c304 fix total_score to sum individual honors instead of crew scores 2025-12-04 03:02:18 -08:00
5968ed74d5 add joined_at to memberships and phantoms for historical data
- editable field separate from created_at
- active_during scope uses joined_at for filtering
- backfills from created_at in migration
2025-12-04 03:02:13 -08:00
50e2318f59 add filter support to crew members endpoint
GET /crew/members now accepts ?filter param:
- active (default): active members only
- retired: retired members only
- phantom: phantom players only
- all: all members + phantoms

Response includes both members[] and phantoms[] arrays.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 00:07:22 -08:00
4c8f4ffcf3 add phantom players for non-registered crew members
- phantom_players table for tracking scores of non-user members
- claim flow: officer assigns phantom to user, user confirms, scores transfer
- CRUD endpoints plus /assign and /confirm_claim actions
- model/request specs for all functionality (37 examples)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:55:15 -08:00
a3a0138526 add request specs for crew controllers (phases 1-2)
- add crews_spec.rb (18 examples)
- add crew_memberships_spec.rb (13 examples)
- add crew_invitations_spec.rb (15 examples)
- fix crew_memberships authorize_crew_captain! as before_action
- update crew_invitations factory to auto-set invited_by officer

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:47:25 -08:00
7f57c2c3ee fix gw controller params and add request specs
- use gw_participation_id param (matches route param name)
- use gw_crew_score root key for consistency
- add crew_gw_participations request specs
- add gw_crew_scores request specs
- add gw_individual_scores request specs
- fix batch authorization to return early

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:40:38 -08:00
f2a058b6b2 add GW events and scoring system
- create gw_events, crew_gw_participations, gw_crew_scores, gw_individual_scores
- add models, blueprints, controllers for GW tracking
- add model specs and gw_events controller specs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-03 23:34:54 -08:00
4d30363187 Add model specs for collection sync functionality
Test sync_from_collection! and out_of_sync? methods for
GridCharacter, GridWeapon, GridSummon, and GridArtifact models.
2025-12-03 23:08:01 -08:00
8fca01d6c7 update crew plan with Phase 2 completion 2025-12-03 23:06:31 -08:00
b75a905e2e add crew invitations system
- create crew_invitations table with status enum
- add CrewInvitation model with accept/reject flow
- add CrewInvitationsController for send/accept/reject
- add invitation error classes
- add invitation routes nested under crews
- add pending invitations endpoint for current user
- 38 passing specs for model and controller
2025-12-03 23:06:07 -08:00
c3e992a0dd add gamertag to user blueprint 2025-12-03 23:01:13 -08:00
0599db101f update crew plan with Phase 1 completion 2025-12-03 22:52:23 -08:00
872b6fdb59 add crew specs and fix error handling
- add transactional fixtures to rails_helper for test isolation
- restructure crew errors to CrewErrors module for Zeitwerk
- add rescue_from for CrewErrors::CrewError in api_controller
- add model specs for Crew and CrewMembership (34 examples)
- add controller specs for crews and memberships (28 examples)
- add crew-related specs to User model (22 examples)
- add factories for crews and crew_memberships
2025-12-03 22:51:34 -08:00
274881e894 add collection refs and out_of_sync to grid blueprints 2025-12-03 22:48:32 -08:00
233dd4fe95 add sync endpoints and collection_id params to controllers 2025-12-03 22:46:17 -08:00
a76d0719c9 add collection refs and sync methods to grid models 2025-12-03 22:42:29 -08:00
e98e59491d add crew controllers, blueprints, routes, and errors
- CrewsController: create, show, update, members, leave, transfer_captain
- CrewMembershipsController: update, destroy, promote, demote
- CrewAuthorizationConcern for member/officer/captain checks
- blueprints for serialization
- custom error classes for crew operations
2025-12-03 22:41:25 -08:00
9b01aa0ff3 add crew and crew_membership models with migrations
- crews table with name, gamertag, granblue_crew_id, description
- crew_memberships with role enum (member/vice_captain/captain)
- partial unique index ensures one active crew per user
- updated User model with crew associations and helper methods
2025-12-03 22:41:19 -08:00
35b8a674ab add collection references to grid tables 2025-12-03 22:40:34 -08:00
658d3d9c49 add rarity filter to collection artifacts index 2025-12-03 21:26:46 -08:00
6cf85a5b3e add artifact import from game data
- Add ArtifactImportService to parse game JSON and create collection artifacts
- Maps game skill_id to our (group, modifier) format using verified mapping
- Handles skill quality -> strength lookup via ArtifactSkill.base_values
- Supports duplicate detection via game_id, with optional update_existing flag
- Quirk artifacts get proficiency from game data; skills stored as empty
- Add POST /collection/artifacts/import endpoint
- Add game_id column to collection_artifacts, collection_weapons,
  collection_summons for tracking game inventory instance IDs
2025-12-03 14:20:21 -08:00
1cbfa90428 update artifact skill tier ratings based on community data
- Remap Group I skills: TA Rate and Debuff Success as ideal;
  Elemental ATK as good; ATK/HP/CA DMG/DEF as neutral;
  Skill DMG/Crit/DA/Debuff Resist as bad
- Remap Group II skills: all caps and supplemental damage as ideal;
  tradeoff skills as good; chain amplify/situational defensive as neutral;
  regeneration and turn-based reduction as bad
- Remap Group III skills: 10+ turn amp and stackable supp as ideal;
  CD reduction/cap up/farming skills as good; conditional buffs as neutral;
  healing bonus/linked/buff removal/turn skip as bad
- Fix synergy pairs to use correct modifier references matching
  actual skill definitions from artifact_skills.json
2025-12-03 13:51:20 -08:00
4715591545 add artifact image download endpoints
- POST /artifacts/:id/download_image (sync single size)
- POST /artifacts/:id/download_images (async all sizes)
- GET /artifacts/:id/download_status (poll job status)
2025-12-03 13:37:24 -08:00
70e6d50371 add artifact image download service and job 2025-12-03 13:37:10 -08:00
e5d80bde23 add ArtifactDownloader for artifact images
downloads square (s) and wide (m) sizes from game CDN
2025-12-03 13:37:04 -08:00
fd1c363352 add POST /artifacts/grade endpoint
allows grading artifact skills without persisting a record
2025-12-03 13:32:57 -08:00
c3dbab896c include grade in artifact blueprints by default 2025-12-03 13:32:53 -08:00
7cf237b7b3 update ArtifactGrader with scrap/keep/reroll actions
recommendations now suggest:
- scrap: low score, bad skills, or no valuable skills
- keep: high score or all ideal skills
- reroll: mediocre artifacts with improvement potential
2025-12-03 13:32:48 -08:00
623661eb2c fix artifact test factories and specs
use unique granblue_ids, default to empty skills, fix element matching
2025-12-03 13:27:40 -08:00
8787aa34a3 include reroll_slot and grades in artifact responses 2025-12-03 13:27:35 -08:00
58cb970457 add ArtifactGrader service for skill evaluation
grades artifacts based on skill tiers, base strength, and synergy.
provides reroll recommendations for suboptimal lines.
2025-12-03 13:27:31 -08:00
e6539ad7e1 add reroll_slot to artifact models 2025-12-03 13:27:26 -08:00
183641b842 add data migrations for artifacts and artifact_skills 2025-12-03 13:00:45 -08:00
c0f13c6b9c update data_schema 2025-12-03 12:59:11 -08:00
e6438eaabe support series_slug filter in weapon_keys endpoint 2025-12-03 12:59:07 -08:00
97cb59894a include weapon_series flags in series response 2025-12-03 12:59:04 -08:00
233b3430ef add artifact specs and factories 2025-12-03 12:58:49 -08:00
cc7ac1956b add artifact controllers and routes 2025-12-03 12:58:44 -08:00
069118cbe9 add artifact blueprints 2025-12-03 12:58:40 -08:00
d6d655297b add artifact seed data 2025-12-03 12:58:35 -08:00
c19259c84a add artifact models with skill validations 2025-12-03 12:58:32 -08:00
210af50477 add migrations for artifacts feature 2025-12-03 12:58:22 -08:00
83d065e2f9 split weapon_series data migration into three separate files 2025-12-03 12:56:51 -08:00
f64fd63b6c add series= setter for weapon, include flags in series list 2025-12-03 12:38:41 -08:00
38f126f2ef fix weapon_series boolean flags and ordering 2025-12-03 12:33:55 -08:00
3a8d42f800 fix weapon_series data migration to match canonical order 2025-12-03 12:14:44 -08:00
4a51b18ab8 update schema files 2025-12-03 10:47:06 -08:00
efe9abed60 add test factories and fixtures for weapon_series 2025-12-03 10:46:54 -08:00
12e3965325 add data migration for weapon_series 2025-12-03 10:46:41 -08:00
20ea6e4fd8 update weapon processor to use weapon_series 2025-12-03 10:46:09 -08:00
bd5f1b0240 add weapon_series API endpoints and update blueprints 2025-12-03 10:45:57 -08:00
c395acaefc update models to use weapon_series associations 2025-12-03 10:45:48 -08:00
9d6dd335ae add weapon_series and weapon_key_series tables and models 2025-12-03 10:45:25 -08:00
e944f93ca3 fix collection_weapon awakening_level default
set model-level default so validation passes before db default applies
2025-12-03 09:03:42 -08:00
99292f20ef add batch endpoints for collection items
POST /collection/{characters,weapons,summons}/batch
2025-12-03 09:03:37 -08:00
4a471dd273 add filtering/sorting params to collection characters endpoint 2025-12-02 17:19:15 -08:00
689aa96645 always include awakening field in collection character response 2025-12-02 17:19:11 -08:00
e97b0ade55 add default awakening, sorting, filtering scopes to CollectionCharacter 2025-12-02 17:19:07 -08:00
5bc179afa8 unify collection api: single endpoint for all users
- restructure routes: read via /users/:id/collection/*, write via /collection/*
- add user lookup + privacy check to collection_characters_controller
- add race, proficiency, gender scopes to model
- delete old collection_controller
2025-12-02 15:31:39 -08:00
301f323ee1 add rake tasks to populate gacha fields from wiki_raw 2025-12-02 07:26:19 -08:00
32bc9f5872 fix /users/me endpoint to use current_user 2025-12-02 06:54:12 -08:00
9c5c859da6 add season/series/promotions filters to search endpoints 2025-12-02 05:54:52 -08:00
c1a5d62a12 add promotions parsing to weapon and summon parsers 2025-12-02 05:51:42 -08:00
7aa0521ca4 add promotions to weapon and summon importers 2025-12-02 05:51:30 -08:00
e0a82bc7a4 add season/series/gacha_available to importer and parser 2025-12-02 05:48:22 -08:00
033e50a1c8 add force option to base downloader 2025-12-02 05:26:41 -08:00
208d1f4836 add formal promotion to enums 2025-12-02 05:25:04 -08:00
cb016580bd add recruited_by field to character blueprint 2025-12-02 05:25:01 -08:00
05dd8996a4 weapons/summons controllers: permit promotions param 2025-12-02 04:40:22 -08:00
0dba56c55d weapon/summon blueprints: serialize promotions 2025-12-02 04:39:49 -08:00
e81c55905c weapons/summons: add promotion scopes and helpers 2025-12-02 04:39:30 -08:00
49e52fffb5 add rake tasks for gacha promotions migration and verification 2025-12-02 04:39:23 -08:00
6f646101f2 add promotions integer array to weapons and summons 2025-12-02 04:39:15 -08:00
dd0662e639 add auto_populate task for character season/series from name patterns 2025-12-02 04:27:57 -08:00
284ee441f1 add rake tasks for character season/series CSV export and import 2025-12-02 04:20:29 -08:00
db048dc4e9 characters: permit season, series, gacha_available params 2025-12-02 04:18:37 -08:00
a3c33ce06a add season/series fields to CharacterBlueprint 2025-12-02 04:13:43 -08:00
afa1c5154f add season/series validations, scopes, helpers to Character 2025-12-02 04:09:55 -08:00
24d8d20ff8 add CHARACTER_SEASONS and CHARACTER_SERIES enums 2025-12-02 04:07:38 -08:00
6e62053754 add season, series, gacha_available to characters 2025-12-02 04:06:53 -08:00
6254bfde6f add nicknames, links to entity blueprints; recruits returns character info 2025-12-02 02:14:52 -08:00
f5760b1833 add batch_preview endpoint for entity import 2025-12-01 23:39:49 -08:00
707c0436c5 api: add update endpoints for characters, weapons, and summons
Add PATCH/PUT update actions to all three entity controllers with
editor role authorization. Routes updated to include :update action.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:23:24 -08:00
29cb276a2a weapons: add validate, create, download endpoints
Add weapon entity creation API following the established pattern from
characters and summons controllers.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-01 03:04:15 -08:00
d70683ea1f character validator: skip CRL in dev 2025-12-01 02:41:21 -08:00
a27bdecde0 summons: add validate, create, download endpoints 2025-12-01 02:41:16 -08:00
deb5cfddb2 add character creation + image download API
- validate granblue_id via HEAD request to GBF CDN
- create characters with editor role check (role >= 7)
- async image download job with Redis status polling
- download_status endpoint for progress tracking
2025-12-01 00:48:40 -08:00
6e657b2518 add next.granblue.team to cors origins 2025-11-30 20:24:32 -08:00
5547c2b4b8 simplify redis config to use REDIS_URL only 2025-11-30 20:24:29 -08:00
8aedb36fac use DATABASE_URL for database configuration 2025-11-30 20:19:40 -08:00
916b72d58d fix grid_characters: don't overwrite resolved IDs in assign_raw_attributes 2025-11-30 20:17:07 -08:00
af202716a2 add related characters endpoint 2025-11-30 20:16:46 -08:00
be5be0c3fe fix blueprints: use correct association names instead of 'object' 2025-11-29 17:41:29 -08:00
144b5cab58 Return proper REST response for deleting a party for more endpoints 2025-09-22 02:51:50 -07:00
4eee998cea Return proper REST response for deleting a party 2025-09-22 00:50:30 -07:00
02d189e18a Update test suite for grid_ prefix on non-GET endpoints
- Update all POST endpoints in tests from /api/v1/{weapons,characters,summons} to /api/v1/grid_{weapons,characters,summons}
- Update custom action endpoints (update_uncap, resolve, update_quick_summon) to use grid_ prefix
- Fix routes configuration to use :create instead of :post in resources definition
- Add custom DELETE routes that accept ID in request body

All 44 grid controller tests now pass with the new endpoint naming convention.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-19 23:50:14 -07:00
f413471c94 Move endpoints to grid_ prefix 2025-09-19 23:38:50 -07:00
5c6865ce0d Add dates view to search endpoint responses 2025-09-19 23:38:06 -07:00
f0e44249b7 Use full view for individual resource show endpoints 2025-09-19 23:37:38 -07:00
7a1e2fc8f9 Update uncap level endpoints to use uncap view
- Change response view from :nested/:full to :uncap for update_uncap_level
- Add default value of 0 for transcendence_step when not provided
- Fix max_uncap_level method call in GridSummonsController
2025-09-19 23:36:43 -07:00
f66e4d5a48 Add transcendence_step to uncap view in grid blueprints 2025-09-19 23:36:24 -07:00
4e5bb350d1 Make transcendence_step optional in grid models
- Change transcendence_step validation from required to optional
- Allow nil values but maintain numeric validation when present
- Add nil check in GridCharacter transcendence validation
2025-09-19 23:36:04 -07:00
2860552c94 Fix total_pages calculation to respect X-Per-Page header
The total_pages method was using the hardcoded SEARCH_PER_PAGE constant
instead of the dynamic search_page_size value from the X-Per-Page header.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 05:59:41 -07:00
42c811c112 Make search parameter optional for search endpoints
Allow search endpoints to work without the 'search' key in the request body.
When no search key is provided, return an empty hash to show all items.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 05:54:40 -07:00
8225340eec Fix error handling for ActionController::ParameterMissing
The render_unprocessable_entity_response method was calling to_hash
on all exceptions, but ActionController::ParameterMissing doesn't
have that method. Updated to handle different exception types properly.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-17 05:51:58 -07:00
07e5488e0b Add custom page size support via X-Per-Page header
- Add page_size helper method to read from X-Per-Page header
- Set min (1) and max (100) bounds for page sizes
- Update all paginated endpoints to use dynamic page size
- Maintain backward compatibility with default sizes
2025-09-17 05:44:14 -07:00
819a61015f Add routes for drag-drop endpoints
- Position update routes for weapons, characters, summons
- Swap routes for all grid item types
- Batch grid update route
2025-09-16 03:36:10 -07:00
8c05cd838c Add batch grid update endpoint
- POST /parties/:id/grid_update for atomic multi-operations
- Support move, swap, and remove operations
- Validate all operations before executing
- Use transaction for atomicity
- Optional character sequence maintenance
2025-09-16 03:33:02 -07:00
311e8254d0 Add summon position update and swap endpoints
- PUT /parties/:party_id/grid_summons/:id/position
- POST /parties/:party_id/grid_summons/swap
- Restrict main and friend summon positions
- Validate sub/subaura slot transitions
2025-09-16 03:32:45 -07:00
5ed8a68ab6 Add character position update and swap endpoints
- PUT /parties/:party_id/grid_characters/:id/position
- POST /parties/:party_id/grid_characters/swap
- Auto-compact main slots to maintain sequential filling
- Handle main/extra slot transitions
2025-09-16 03:28:26 -07:00
197577d951 Add weapon position update and swap endpoints
- PUT /parties/:party_id/grid_weapons/:id/position
- POST /parties/:party_id/grid_weapons/swap
- Validate positions and handle mainhand/extra slots
- Use transactions for atomic swaps
2025-09-16 03:28:02 -07:00
7ab6355f17 Add error classes for drag-drop validation
- InvalidPositionError for out-of-bounds positions
- PositionOccupiedError for occupied slot conflicts
2025-09-16 03:27:19 -07:00
f7015d04dd Add UUID and granblue_id resolution support
- Create IdResolvable concern for flexible ID lookups
- Update character/summon/weapon controllers to use concern
- Support both UUID and granblue_id in API calls
2025-09-16 03:26:15 -07:00
345 changed files with 37568 additions and 455 deletions

4
.env
View file

@ -1 +1,5 @@
RAILS_LOG_TO_STDOUT=true RAILS_LOG_TO_STDOUT=true
OPENAI_API_KEY="not-needed-for-local"
OPENAI_BASE_URL="http://192.168.1.246:8000/v1"
OPENAI_MODEL="cpatonn/Qwen3-Coder-30B-A3B-Instruct-AWQ-4bit"

1
.gitignore vendored
View file

@ -59,3 +59,4 @@ config/application.yml
# Ignore AI Codebase-generated files # Ignore AI Codebase-generated files
codebase.md codebase.md
mise.toml mise.toml
public/assets

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactBlueprint < ApiBlueprint
field :name do |a|
{
en: a.name_en,
ja: a.name_jp
}
end
fields :granblue_id, :rarity
# Return proficiency as integer (nil for quirk artifacts)
field :proficiency do |a|
a.proficiency_before_type_cast
end
field :release_date, if: ->(_field, a, _options) { a.release_date.present? }
end
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactSkillBlueprint < ApiBlueprint
field :name do |s|
{
en: s.name_en,
ja: s.name_jp
}
end
field :game_name do |s|
{
en: s.game_name_en,
ja: s.game_name_jp
}
end
fields :skill_group, :modifier, :polarity
field :base_values do |s|
s.base_values
end
field :growth, if: ->(_field, s, _options) { s.growth.present? } do |s|
s.growth.to_f
end
field :suffix do |s|
{
en: s.suffix_en,
ja: s.suffix_jp
}
end
end
end
end

View file

@ -11,7 +11,34 @@ module Api
end end
fields :granblue_id, :character_id, :rarity, fields :granblue_id, :character_id, :rarity,
:element, :gender, :special :element, :gender, :special, :season
field :season_name do |c|
c.season_name
end
field :series do |c|
# Use new lookup table if available
if c.character_series_records.any?
c.character_series_records.ordered.map do |cs|
{
id: cs.id,
slug: cs.slug,
name: {
en: cs.name_en,
ja: cs.name_jp
}
}
end
else
# Legacy fallback - return integer array
c.series
end
end
field :series_names do |c|
c.series_names
end
field :uncap do |c| field :uncap do |c|
{ {
@ -38,6 +65,60 @@ module Api
AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening)) AwakeningBlueprint.render_as_hash(OpenStruct.new(awakening))
end end
end end
field :nicknames do |c|
{
en: c.nicknames_en,
ja: c.nicknames_jp
}
end
field :wiki do |c|
{
en: c.wiki_en,
ja: c.wiki_ja
}
end
fields :gamewith, :kamigame
end
# Separate view for recruitment info - only include when needed (e.g., character detail page)
view :with_recruitment do
include_view :full
field :recruited_by do |c|
weapon = Weapon.find_by(recruits: c.granblue_id)
next nil unless weapon
{
id: weapon.id,
granblue_id: weapon.granblue_id,
name: {
en: weapon.name_en,
ja: weapon.name_jp
},
promotions: weapon.promotions,
promotion_names: weapon.promotion_names
}
end
end
# Separate view for raw data - only used by dedicated endpoint
view :raw do
excludes :name, :granblue_id, :character_id, :rarity, :element, :gender, :special, :uncap, :race, :proficiency
field :wiki_raw do |c|
c.wiki_raw
end
field :game_raw_en do |c|
c.game_raw_en
end
field :game_raw_jp do |c|
c.game_raw_jp
end
end end
view :stats do view :stats do

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Api
module V1
class CharacterSeriesBlueprint < ApiBlueprint
field :name do |cs|
{
en: cs.name_en,
ja: cs.name_jp
}
end
fields :slug, :order
view :full do
field :character_count do |cs|
cs.characters.count
end
end
end
end
end

View file

@ -0,0 +1,64 @@
# frozen_string_literal: true
module Api
module V1
class CollectionArtifactBlueprint < ApiBlueprint
identifier :id
fields :level, :nickname, :reroll_slot, :created_at, :updated_at
# Return element as integer
field :element do |obj|
obj.element_before_type_cast
end
# Proficiency is only present on quirk artifacts, return as integer
field :proficiency, if: ->(_field, obj, _options) { obj.proficiency.present? } do |obj|
obj.proficiency_before_type_cast
end
field :skills do |obj|
[
[obj.skill1, 1],
[obj.skill2, 2],
[obj.skill3, 3],
[obj.skill4, 4]
].map do |skill, slot|
next nil if skill.blank? || skill == {}
# Determine skill group based on slot
group = case slot
when 1, 2 then 1 # Group I
when 3 then 2 # Group II
when 4 then 3 # Group III
end
# Look up skill and compute strength from quality
modifier = skill['modifier']
quality = skill['quality'] || 1
level = skill['level'] || 1
artifact_skill = ArtifactSkill.find_skill(group, modifier)
strength = artifact_skill&.strength_for_quality(quality)
{
modifier: modifier,
strength: strength,
level: level
}
end
end
# Include grade and recommendation by default
field :grade do |obj|
ArtifactGrader.new(obj).grade
end
association :artifact, blueprint: ArtifactBlueprint
view :full do
association :artifact, blueprint: ArtifactBlueprint
end
end
end
end

View file

@ -0,0 +1,26 @@
module Api
module V1
class CollectionCharacterBlueprint < ApiBlueprint
identifier :id
fields :uncap_level, :transcendence_step, :perpetuity,
:ring1, :ring2, :ring3, :ring4, :earring,
:created_at, :updated_at
field :awakening do |obj|
if obj.awakening.present?
{
type: AwakeningBlueprint.render_as_hash(obj.awakening),
level: obj.awakening_level
}
end
end
association :character, blueprint: CharacterBlueprint
view :full do
association :character, blueprint: CharacterBlueprint, view: :full
end
end
end
end

View file

@ -0,0 +1,11 @@
module Api
module V1
class CollectionJobAccessoryBlueprint < ApiBlueprint
identifier :id
fields :created_at, :updated_at
association :job_accessory, blueprint: JobAccessoryBlueprint
end
end
end

View file

@ -0,0 +1,16 @@
module Api
module V1
class CollectionSummonBlueprint < ApiBlueprint
identifier :id
fields :uncap_level, :transcendence_step,
:created_at, :updated_at
association :summon, blueprint: SummonBlueprint
view :full do
association :summon, blueprint: SummonBlueprint, view: :full
end
end
end
end

View file

@ -0,0 +1,50 @@
module Api
module V1
class CollectionWeaponBlueprint < ApiBlueprint
identifier :id
fields :uncap_level, :transcendence_step, :element,
:created_at, :updated_at
field :ax, if: ->(_, obj, _) { obj.ax_modifier1.present? } do |obj|
skills = []
if obj.ax_modifier1.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier1),
strength: obj.ax_strength1
}
end
if obj.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.ax_modifier2),
strength: obj.ax_strength2
}
end
skills
end
field :befoulment, if: ->(_, obj, _) { obj.befoulment_modifier.present? } do |obj|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(obj.befoulment_modifier),
strength: obj.befoulment_strength,
exorcism_level: obj.exorcism_level
}
end
field :awakening, if: ->(_, obj, _) { obj.awakening.present? } do |obj|
{
type: AwakeningBlueprint.render_as_hash(obj.awakening),
level: obj.awakening_level
}
end
association :weapon, blueprint: WeaponBlueprint
association :weapon_keys, blueprint: WeaponKeyBlueprint,
if: ->(_, obj, _) { obj.weapon_keys.any? }
view :full do
association :weapon, blueprint: WeaponBlueprint, view: :full
end
end
end
end

View file

@ -0,0 +1,49 @@
# frozen_string_literal: true
module Api
module V1
class CrewBlueprint < ApiBlueprint
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
view :minimal do
fields :name, :gamertag
end
view :full do
fields :name, :gamertag, :granblue_crew_id, :description, :created_at
field :member_count do |crew|
crew.active_memberships.count
end
field :captain do |crew|
captain = crew.captain
UserBlueprint.render_as_hash(captain, view: :minimal) if captain
end
field :vice_captains do |crew|
UserBlueprint.render_as_hash(crew.vice_captains, view: :minimal)
end
field :current_membership do |crew, options|
current_user = options[:current_user]
next nil unless current_user
membership = crew.crew_memberships.find_by(user_id: current_user.id, retired: false)
CrewMembershipBlueprint.render_as_hash(membership) if membership
end
end
view :with_members do
include_view :full
field :members do |crew|
CrewMembershipBlueprint.render_as_hash(
crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc),
view: :with_user
)
end
end
end
end
end

View file

@ -0,0 +1,65 @@
# frozen_string_literal: true
module Api
module V1
class CrewGwParticipationBlueprint < ApiBlueprint
fields :preliminary_ranking, :final_ranking
field :total_score do |participation|
participation.total_individual_honors
end
field :wins do |participation|
participation.wins_count
end
field :losses do |participation|
participation.losses_count
end
view :summary do
# summary uses base fields only (no gw_event)
end
view :with_event do
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
end
view :with_crew do
field :crew do |participation|
CrewBlueprint.render_as_hash(participation.crew, view: :minimal)
end
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
end
view :full do
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
field :crew_scores do |participation|
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
end
end
view :with_individual_scores do
field :gw_event do |participation|
GwEventBlueprint.render_as_hash(participation.gw_event)
end
field :crew_scores do |participation|
GwCrewScoreBlueprint.render_as_hash(participation.gw_crew_scores.order(:round))
end
field :individual_scores do |participation, options|
GwIndividualScoreBlueprint.render_as_hash(
participation.gw_individual_scores.includes(:crew_membership).order(:round),
view: :with_member,
current_user: options[:current_user]
)
end
end
end
end
end

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
module Api
module V1
class CrewInvitationBlueprint < ApiBlueprint
fields :status, :expires_at, :created_at
view :default do
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
end
end
view :with_user do
field :user do |invitation|
UserBlueprint.render_as_hash(invitation.user, view: :minimal)
end
field :invited_by do |invitation|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
end
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :minimal)
end
end
view :for_invitee do
field :crew do |invitation|
CrewBlueprint.render_as_hash(invitation.crew, view: :full)
end
field :invited_by do |invitation|
UserBlueprint.render_as_hash(invitation.invited_by, view: :minimal)
end
end
end
end
end

View file

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Api
module V1
class CrewMembershipBlueprint < ApiBlueprint
fields :role, :retired, :retired_at, :joined_at, :created_at
view :with_user do
fields :role, :retired, :retired_at, :joined_at, :created_at
field :user do |membership|
UserBlueprint.render_as_hash(membership.user, view: :minimal)
end
end
view :with_crew do
fields :role, :retired, :retired_at, :joined_at, :created_at
field :crew do |membership|
CrewBlueprint.render_as_hash(membership.crew, view: :minimal)
end
end
end
end
end

View file

@ -0,0 +1,54 @@
# frozen_string_literal: true
module Api
module V1
class GridArtifactBlueprint < ApiBlueprint
fields :level, :reroll_slot, :orphaned
field :collection_artifact_id
field :out_of_sync, if: ->(_field, ga, _options) { ga.collection_artifact_id.present? } do |ga|
ga.out_of_sync?
end
# Return element as integer
field :element do |obj|
obj.element_before_type_cast
end
# Proficiency is only present on quirk artifacts, return as integer
field :proficiency, if: ->(_field, obj, _options) { obj.proficiency.present? } do |obj|
obj.proficiency_before_type_cast
end
field :skills do |obj|
[obj.skill1, obj.skill2, obj.skill3, obj.skill4].map do |skill|
next nil if skill.blank? || skill == {}
{
modifier: skill['modifier'],
strength: skill['strength'],
level: skill['level']
}
end
end
# Include grade and recommendation by default
field :grade do |obj|
ArtifactGrader.new(obj).grade
end
view :nested do
association :artifact, blueprint: ArtifactBlueprint
end
view :full do
include_view :nested
association :grid_character, blueprint: GridCharacterBlueprint
end
view :destroyed do
fields :created_at, :updated_at
end
end
end
end

View file

@ -9,18 +9,25 @@ module Api
gc.transcendence_step gc.transcendence_step
end end
field :collection_character_id
field :out_of_sync, if: ->(_field, gc, _options) { gc.collection_character_id.present? } do |gc|
gc.out_of_sync?
end
view :preview do view :preview do
association :character, name: :object, blueprint: CharacterBlueprint association :character, blueprint: CharacterBlueprint
end end
view :nested do view :nested do
include_view :mastery_bonuses include_view :mastery_bonuses
association :character, name: :object, blueprint: CharacterBlueprint, view: :full association :character, blueprint: CharacterBlueprint, view: :full
association :grid_artifact, blueprint: GridArtifactBlueprint, view: :nested,
if: ->(_field_name, gc, _options) { gc.grid_artifact.present? }
end end
view :uncap do view :uncap do
association :party, blueprint: PartyBlueprint association :party, blueprint: PartyBlueprint
fields :position, :uncap_level fields :position, :uncap_level, :transcendence_step
end end
view :destroyed do view :destroyed do

View file

@ -3,14 +3,19 @@
module Api module Api
module V1 module V1
class GridSummonBlueprint < ApiBlueprint class GridSummonBlueprint < ApiBlueprint
fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step fields :main, :friend, :position, :quick_summon, :uncap_level, :transcendence_step, :orphaned
field :collection_summon_id
field :out_of_sync, if: ->(_field, gs, _options) { gs.collection_summon_id.present? } do |gs|
gs.out_of_sync?
end
view :preview do view :preview do
association :summon, name: :object, blueprint: SummonBlueprint association :summon, blueprint: SummonBlueprint
end end
view :nested do view :nested do
association :summon, name: :object, blueprint: SummonBlueprint, view: :full association :summon, blueprint: SummonBlueprint, view: :full
end end
view :full do view :full do

View file

@ -3,18 +3,41 @@
module Api module Api
module V1 module V1
class GridWeaponBlueprint < ApiBlueprint class GridWeaponBlueprint < ApiBlueprint
fields :mainhand, :position, :uncap_level, :transcendence_step, :element fields :mainhand, :position, :uncap_level, :transcendence_step, :element, :orphaned
field :collection_weapon_id
field :out_of_sync, if: ->(_field, gw, _options) { gw.collection_weapon_id.present? } do |gw|
gw.out_of_sync?
end
view :preview do view :preview do
association :weapon, name: :object, blueprint: WeaponBlueprint association :weapon, blueprint: WeaponBlueprint
end end
view :nested do view :nested do
field :ax, if: ->(_field_name, w, _options) { w.weapon.present? && w.weapon.ax } do |w| field :ax, if: ->(_field_name, w, _options) { w.ax_modifier1.present? } do |w|
[ skills = []
{ modifier: w.ax_modifier1, strength: w.ax_strength1 }, if w.ax_modifier1.present?
{ modifier: w.ax_modifier2, strength: w.ax_strength2 } skills << {
] modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier1),
strength: w.ax_strength1
}
end
if w.ax_modifier2.present?
skills << {
modifier: WeaponStatModifierBlueprint.render_as_hash(w.ax_modifier2),
strength: w.ax_strength2
}
end
skills
end
field :befoulment, if: ->(_field_name, w, _options) { w.befoulment_modifier.present? } do |w|
{
modifier: WeaponStatModifierBlueprint.render_as_hash(w.befoulment_modifier),
strength: w.befoulment_strength,
exorcism_level: w.exorcism_level
}
end end
field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w| field :awakening, if: ->(_field_name, w, _options) { w.awakening.present? } do |w|
@ -24,15 +47,15 @@ module Api
} }
end end
association :weapon, name: :object, blueprint: WeaponBlueprint, view: :full, association :weapon, blueprint: WeaponBlueprint, view: :full,
if: ->(_field_name, w, _options) { w.weapon.present? } if: ->(_field_name, w, _options) { w.weapon.present? }
association :weapon_keys, association :weapon_keys,
blueprint: WeaponKeyBlueprint, blueprint: WeaponKeyBlueprint,
if: ->(_field_name, w, _options) { if: ->(_field_name, w, _options) {
w.weapon.present? && w.weapon.present? &&
w.weapon.series.present? && w.weapon.weapon_series.present? &&
[2, 3, 17, 24, 34].include?(w.weapon.series) w.weapon.weapon_series.has_weapon_keys
} }
end end
@ -43,7 +66,7 @@ module Api
view :uncap do view :uncap do
association :party, blueprint: PartyBlueprint association :party, blueprint: PartyBlueprint
fields :position, :uncap_level fields :position, :uncap_level, :transcendence_step
end end
view :destroyed do view :destroyed do

View file

@ -0,0 +1,14 @@
# frozen_string_literal: true
module Api
module V1
class GwCrewScoreBlueprint < ApiBlueprint
fields :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id, :victory
# Return round as integer value instead of enum string
field :round do |score|
GwCrewScore.rounds[score.round]
end
end
end
end

View file

@ -0,0 +1,34 @@
# frozen_string_literal: true
module Api
module V1
class GwEventBlueprint < ApiBlueprint
fields :start_date, :end_date, :event_number
field :element do |event|
GwEvent.elements[event.element]
end
field :status do |event|
if event.active?
'active'
elsif event.upcoming?
'upcoming'
else
'finished'
end
end
# Include crew's total score if participation data is provided
field :crew_total_score, if: ->(_fn, event, options) { options[:participations]&.key?(event.id) } do |event, options|
options[:participations][event.id]&.total_individual_honors
end
view :with_participation do
field :participation, if: ->(_fn, _obj, options) { options[:participation].present? } do |_, options|
CrewGwParticipationBlueprint.render_as_hash(options[:participation], view: :summary)
end
end
end
end
end

View file

@ -0,0 +1,41 @@
# frozen_string_literal: true
module Api
module V1
class GwIndividualScoreBlueprint < ApiBlueprint
fields :round, :score, :is_cumulative, :excused
field :player_name do |score|
score.player_name
end
field :player_type do |score|
if score.crew_membership_id.present?
'member'
elsif score.phantom_player_id.present?
'phantom'
end
end
# Only return excuse_reason to crew officers
field :excuse_reason do |score, options|
current_user = options[:current_user]
score.excuse_reason if current_user&.crew_officer?
end
view :with_member do
field :member do |score|
if score.crew_membership.present?
CrewMembershipBlueprint.render_as_hash(score.crew_membership, view: :with_user)
end
end
field :phantom do |score|
if score.phantom_player.present?
PhantomPlayerBlueprint.render_as_hash(score.phantom_player)
end
end
end
end
end
end

View file

@ -14,7 +14,7 @@ module Api
name: :job, name: :job,
blueprint: JobBlueprint blueprint: JobBlueprint
fields :granblue_id, :rarity, :release_date fields :granblue_id, :rarity, :release_date, :accessory_type
end end
end end
end end

View file

@ -19,7 +19,7 @@ module Api
fields :granblue_id, :row, :order, fields :granblue_id, :row, :order,
:master_level, :ultimate_mastery, :master_level, :ultimate_mastery,
:accessory, :accessory_type :accessory, :accessory_type, :aux_weapon
end end
end end
end end

View file

@ -14,7 +14,7 @@ module Api
name: :job, name: :job,
blueprint: JobBlueprint blueprint: JobBlueprint
fields :slug, :color, :main, :base, :sub, :emp, :order fields :slug, :color, :main, :base, :sub, :emp, :order, :image_id, :action_id
end end
end end
end end

View file

@ -6,12 +6,12 @@ module Api
# Base fields that are always needed # Base fields that are always needed
fields :local_id, :description, :shortcode, :visibility, fields :local_id, :description, :shortcode, :visibility,
:name, :element, :extra, :charge_attack, :name, :element, :extra, :charge_attack,
:button_count, :turn_count, :chain_count, :clear_time, :button_count, :turn_count, :chain_count, :summon_count, :clear_time,
:full_auto, :auto_guard, :auto_summon, :full_auto, :auto_guard, :auto_summon, :video_url,
:created_at, :updated_at :created_at, :updated_at
fields :local_id, :description, :charge_attack, fields :local_id, :description, :charge_attack,
:button_count, :turn_count, :chain_count, :button_count, :turn_count, :chain_count, :summon_count,
:master_level, :ultimate_mastery :master_level, :ultimate_mastery
# Party associations # Party associations
@ -28,7 +28,16 @@ module Api
# Metadata associations # Metadata associations
field :favorited do |party, options| field :favorited do |party, options|
party.favorited?(options[:current_user]) # Use preloaded favorite_party_ids if available, otherwise fall back to query
if options[:favorite_party_ids]
options[:favorite_party_ids].include?(party.id)
else
party.favorited?(options[:current_user])
end
end
field :has_orphaned_items do |party|
party.has_orphaned_items?
end end
# For collection views # For collection views

View file

@ -0,0 +1,40 @@
# frozen_string_literal: true
module Api
module V1
class PhantomPlayerBlueprint < ApiBlueprint
fields :name, :granblue_id, :notes, :claim_confirmed, :retired, :retired_at, :joined_at
field :claimed do |phantom|
phantom.claimed_by_id.present?
end
view :with_claimed_by do
field :claimed_by do |phantom|
phantom.claimed_by ? UserBlueprint.render_as_hash(phantom.claimed_by, view: :minimal) : nil
end
end
view :with_scores do
include_view :with_claimed_by
field :total_score do |phantom|
phantom.gw_individual_scores.sum(:score)
end
field :score_count do |phantom|
phantom.gw_individual_scores.count
end
end
# Used for pending phantom claims - includes crew info for context
view :with_crew do
include_view :with_claimed_by
field :crew do |phantom|
phantom.crew ? CrewBlueprint.render_as_hash(phantom.crew, view: :minimal) : nil
end
end
end
end
end

View file

@ -4,6 +4,8 @@ module Api
module V1 module V1
class RaidBlueprint < ApiBlueprint class RaidBlueprint < ApiBlueprint
view :nested do view :nested do
identifier :id
field :name do |raid| field :name do |raid|
{ {
en: raid.name_en, en: raid.name_en,
@ -18,7 +20,6 @@ module Api
view :full do view :full do
include_view :nested include_view :nested
association :group, blueprint: RaidGroupBlueprint, view: :flat
end end
end end
end end

View file

@ -4,6 +4,8 @@ module Api
module V1 module V1
class RaidGroupBlueprint < ApiBlueprint class RaidGroupBlueprint < ApiBlueprint
view :flat do view :flat do
identifier :id
field :name do |group| field :name do |group|
{ {
en: group.name_en, en: group.name_en,
@ -11,7 +13,7 @@ module Api
} }
end end
fields :difficulty, :order, :section, :extra, :guidebooks, :hl fields :difficulty, :order, :section, :extra, :guidebooks, :hl, :unlimited
end end
view :full do view :full do

View file

@ -5,6 +5,27 @@ module Api
class SearchBlueprint < Blueprinter::Base class SearchBlueprint < Blueprinter::Base
identifier :searchable_id identifier :searchable_id
fields :searchable_type, :granblue_id, :name_en, :name_jp, :element fields :searchable_type, :granblue_id, :name_en, :name_jp, :element
# Character-specific fields (nil for non-characters)
field :season do |document|
document.searchable_type == 'Character' ? document.searchable&.season : nil
end
field :series do |document|
next nil unless document.searchable_type == 'Character'
character = document.searchable
next nil unless character
# Return series as array of objects with id, slug, and name
character.character_series_records.ordered.map do |series|
{
id: series.id,
slug: series.slug,
name: { en: series.name_en, ja: series.name_jp }
}
end
end
end end
end end
end end

View file

@ -10,7 +10,24 @@ module Api
} }
end end
fields :granblue_id, :element, :rarity, :max_level fields :granblue_id, :element, :rarity, :max_level, :promotions
field :promotion_names do |s|
s.promotion_names
end
field :series do |s|
if s.summon_series.present?
{
id: s.summon_series_id,
slug: s.summon_series.slug,
name: {
en: s.summon_series.name_en,
ja: s.summon_series.name_jp
}
}
end
end
field :uncap do |s| field :uncap do |s|
{ {
@ -52,6 +69,39 @@ module Api
view :full do view :full do
include_view :stats include_view :stats
include_view :dates include_view :dates
field :nicknames do |s|
{
en: s.nicknames_en,
ja: s.nicknames_jp
}
end
field :wiki do |s|
{
en: s.wiki_en,
ja: s.wiki_ja
}
end
fields :gamewith, :kamigame
end
# Separate view for raw data - only used by dedicated endpoint
view :raw do
excludes :name, :granblue_id, :element, :rarity, :max_level, :uncap
field :wiki_raw do |s|
s.wiki_raw
end
field :game_raw_en do |s|
s.game_raw_en
end
field :game_raw_jp do |s|
s.game_raw_jp
end
end end
end end
end end

View file

@ -0,0 +1,22 @@
# frozen_string_literal: true
module Api
module V1
class SummonSeriesBlueprint < ApiBlueprint
field :name do |ss|
{
en: ss.name_en,
ja: ss.name_jp
}
end
fields :slug, :order
view :full do
field :summon_count do |ss|
ss.summons.count
end
end
end
end
end

View file

@ -4,13 +4,23 @@ module Api
module V1 module V1
class UserBlueprint < ApiBlueprint class UserBlueprint < ApiBlueprint
view :minimal do view :minimal do
fields :username, :language, :private, :gender, :theme, :role fields :username, :language, :private, :gender, :theme, :role, :granblue_id, :show_gamertag, :show_granblue_id
# Return collection_privacy as integer (enum returns string by default)
field :collection_privacy do |user|
User.collection_privacies[user.collection_privacy]
end
field :avatar do |user| field :avatar do |user|
{ {
picture: user.picture, picture: user.picture,
element: user.element element: user.element
} }
end end
# Use preloaded active_crew_membership to avoid N+1
field :gamertag, if: ->(_, user, _) {
user.show_gamertag && user.active_crew_membership&.crew&.gamertag.present?
} do |user|
user.active_crew_membership.crew.gamertag
end
end end
view :profile do view :profile do
@ -25,7 +35,9 @@ module Api
fields :username, :token fields :username, :token
end end
# Settings view includes all user data + email (only for authenticated user viewing own settings)
view :settings do view :settings do
include_view :minimal
fields :email fields :email
end end
end end

View file

@ -13,14 +13,41 @@ module Api
# Primary information # Primary information
fields :granblue_id, :element, :proficiency, fields :granblue_id, :element, :proficiency,
:max_level, :max_skill_level, :max_awakening_level, :limit, :rarity, :max_level, :max_skill_level, :max_awakening_level, :limit, :rarity,
:series, :ax, :ax_type :ax, :ax_type, :gacha, :promotions, :forge_order
# Series - returns full object with flags if weapon_series is present, fallback to legacy integer
field :series do |w|
if w.weapon_series.present?
{
id: w.weapon_series_id,
slug: w.weapon_series.slug,
name: {
en: w.weapon_series.name_en,
ja: w.weapon_series.name_jp
},
has_weapon_keys: w.weapon_series.has_weapon_keys,
has_awakening: w.weapon_series.has_awakening,
augment_type: w.weapon_series.augment_type,
extra: w.weapon_series.extra,
element_changeable: w.weapon_series.element_changeable
}
else
# Legacy fallback for backwards compatibility
w.series
end
end
field :promotion_names do |w|
w.promotion_names
end
# Uncap information # Uncap information
field :uncap do |w| field :uncap do |w|
{ {
flb: w.flb, flb: w.flb,
ulb: w.ulb, ulb: w.ulb,
transcendence: w.transcendence transcendence: w.transcendence,
extra_prerequisite: w.extra_prerequisite
} }
end end
@ -57,6 +84,89 @@ module Api
association :awakenings, association :awakenings,
blueprint: AwakeningBlueprint, blueprint: AwakeningBlueprint,
if: ->(_field_name, weapon, _options) { weapon.awakenings.any? } if: ->(_field_name, weapon, _options) { weapon.awakenings.any? }
field :nicknames do |w|
{
en: w.nicknames_en,
ja: w.nicknames_jp
}
end
field :wiki do |w|
{
en: w.wiki_en,
ja: w.wiki_ja
}
end
fields :gamewith, :kamigame
field :recruits do |w|
next nil unless w.recruits.present?
character = Character.find_by(granblue_id: w.recruits)
next nil unless character
{
id: character.id,
granblue_id: character.granblue_id,
name: {
en: character.name_en,
ja: character.name_jp
}
}
end
# Forge chain fields
field :forged_from do |w|
next nil unless w.forged_from.present?
parent = w.forged_from_weapon
next nil unless parent
{
id: parent.id,
granblue_id: parent.granblue_id,
name: {
en: parent.name_en,
ja: parent.name_jp
}
}
end
field :forge_chain do |w|
next nil unless w.forge_chain_id.present?
w.forge_chain.map do |weapon|
{
id: weapon.id,
granblue_id: weapon.granblue_id,
name: {
en: weapon.name_en,
ja: weapon.name_jp
},
forge_order: weapon.forge_order
}
end
end
end
# Separate view for raw data - only used by dedicated endpoint
view :raw do
excludes :name, :granblue_id, :element, :proficiency, :max_level, :max_skill_level,
:max_awakening_level, :limit, :rarity, :series, :ax, :ax_type, :uncap
field :wiki_raw do |w|
w.wiki_raw
end
field :game_raw_en do |w|
w.game_raw_en
end
field :game_raw_jp do |w|
w.game_raw_jp
end
end end
end end
end end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module Api
module V1
class WeaponSeriesBlueprint < ApiBlueprint
field :name do |ws|
{
en: ws.name_en,
ja: ws.name_jp
}
end
fields :slug, :order, :extra, :element_changeable, :has_weapon_keys,
:has_awakening, :augment_type
view :full do
field :weapon_count do |ws|
ws.weapons.count
end
end
end
end
end

View file

@ -0,0 +1,10 @@
# frozen_string_literal: true
module Api
module V1
class WeaponStatModifierBlueprint < Blueprinter::Base
identifier :id
fields :slug, :name_en, :name_jp, :category, :stat, :polarity, :suffix
end
end
end

View file

@ -9,8 +9,18 @@ module Api
##### Constants ##### Constants
COLLECTION_PER_PAGE = 15 COLLECTION_PER_PAGE = 15
SEARCH_PER_PAGE = 10 SEARCH_PER_PAGE = 10
MAX_PER_PAGE = 100
MIN_PER_PAGE = 1
##### Errors ##### Errors
# Catch-all for unhandled exceptions - log details and return 500
# NOTE: Must be defined FIRST so it's checked LAST (Rails matches bottom-to-top)
rescue_from StandardError do |e|
Rails.logger.error "[500 Error] #{e.class}: #{e.message}"
Rails.logger.error e.backtrace&.first(20)&.join("\n")
render json: { error: 'Internal Server Error', message: e.message }, status: :internal_server_error
end
rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity_response
rescue_from ActiveRecord::RecordNotDestroyed, with: :render_unprocessable_entity_response rescue_from ActiveRecord::RecordNotDestroyed, with: :render_unprocessable_entity_response
rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response_without_object rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response_without_object
@ -24,10 +34,24 @@ module Api
rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response rescue_from Api::V1::UnauthorizedError, with: :render_unauthorized_response
rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response rescue_from ActionController::ParameterMissing, with: :render_unprocessable_entity_response
# Collection errors
rescue_from CollectionErrors::CollectionError do |e|
render json: e.to_hash, status: e.http_status
end
# Crew errors
rescue_from CrewErrors::CrewError do |e|
render json: e.to_hash, status: e.http_status
end
rescue_from GranblueError do |e| rescue_from GranblueError do |e|
render_error(e) render_error(e)
end end
rescue_from Api::V1::GranblueError do |e|
render_error(e)
end
##### Hooks ##### Hooks
before_action :current_user before_action :current_user
before_action :default_content_type before_action :default_content_type
@ -86,7 +110,17 @@ module Api
end end
def render_unprocessable_entity_response(exception) def render_unprocessable_entity_response(exception)
render json: ErrorBlueprint.render_as_json(nil, errors: exception.to_hash), error_data = if exception.respond_to?(:to_hash)
exception.to_hash
elsif exception.is_a?(ActionController::ParameterMissing)
{ message: exception.message, param: exception.param }
elsif exception.respond_to?(:message)
{ message: exception.message }
else
exception
end
render json: ErrorBlueprint.render_as_json(nil, errors: error_data),
status: :unprocessable_entity status: :unprocessable_entity
end end
@ -121,12 +155,41 @@ module Api
raise UnauthorizedError unless current_user raise UnauthorizedError unless current_user
end end
# Returns the requested page size within valid bounds
# Falls back to default if not specified or invalid
# Reads from X-Per-Page header
def page_size(default = COLLECTION_PER_PAGE)
per_page_header = request.headers['X-Per-Page']
return default unless per_page_header.present?
requested_size = per_page_header.to_i
return default if requested_size <= 0
[[requested_size, MAX_PER_PAGE].min, MIN_PER_PAGE].max
end
# Returns the requested page size for search operations
def search_page_size
page_size(SEARCH_PER_PAGE)
end
def n_plus_one_detection def n_plus_one_detection
Prosopite.scan Prosopite.scan
yield yield
ensure ensure
Prosopite.finish Prosopite.finish
end end
# Returns pagination metadata for will_paginate collections
# @param collection [ActiveRecord::Relation] Paginated collection using will_paginate
# @return [Hash] Pagination metadata with count, total_pages, and per_page
def pagination_meta(collection)
{
count: collection.total_entries,
total_pages: collection.total_pages,
per_page: collection.limit_value || collection.per_page
}
end
end end
end end
end end

View file

@ -0,0 +1,70 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactSkillsController < Api::V1::ApiController
before_action :set_artifact_skill, only: %w[show update]
before_action :ensure_editor_role, only: %w[update]
# GET /artifact_skills
def index
@skills = ArtifactSkill.all
@skills = @skills.where(skill_group: params[:group]) if params[:group].present?
@skills = @skills.where(polarity: params[:polarity]) if params[:polarity].present?
render json: ArtifactSkillBlueprint.render(@skills, root: :artifact_skills)
end
# GET /artifact_skills/for_slot/:slot
# Returns skills valid for a specific slot (1-4)
def for_slot
slot = params[:slot].to_i
unless (1..4).cover?(slot)
return render json: { error: 'Slot must be between 1 and 4' }, status: :unprocessable_entity
end
@skills = ArtifactSkill.for_slot(slot)
render json: ArtifactSkillBlueprint.render(@skills, root: :artifact_skills)
end
# GET /artifact_skills/:id
def show
render json: ArtifactSkillBlueprint.render(@skill)
end
# PATCH/PUT /artifact_skills/:id
def update
if @skill.update(artifact_skill_params)
ArtifactSkill.clear_cache!
render json: ArtifactSkillBlueprint.render(@skill)
else
render_validation_error_response(@skill)
end
end
private
def set_artifact_skill
@skill = ArtifactSkill.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def artifact_skill_params
params.permit(
:skill_group, :modifier,
:name_en, :name_jp,
:game_name_en, :game_name_jp,
:suffix_en, :suffix_jp,
:growth, :polarity,
base_values: []
)
end
end
end
end

View file

@ -0,0 +1,118 @@
# frozen_string_literal: true
module Api
module V1
class ArtifactsController < Api::V1::ApiController
before_action :set_artifact, only: %i[show download_image download_images download_status]
# GET /artifacts
def index
@artifacts = Artifact.all
@artifacts = @artifacts.where(rarity: params[:rarity]) if params[:rarity].present?
@artifacts = @artifacts.where(proficiency: params[:proficiency]) if params[:proficiency].present?
render json: ArtifactBlueprint.render(@artifacts, root: :artifacts)
end
# GET /artifacts/:id
def show
render json: ArtifactBlueprint.render(@artifact)
end
# POST /artifacts/grade
# Grades artifact skills without persisting. Accepts skill data and returns grade/recommendation.
#
# @param artifact_id [String] Optional - ID of base artifact (for quirk detection)
# @param skill1 [Hash] Skill data with modifier, strength, level
# @param skill2 [Hash] Skill data with modifier, strength, level
# @param skill3 [Hash] Skill data with modifier, strength, level
# @param skill4 [Hash] Skill data with modifier, strength, level
def grade
artifact_data = build_gradeable_artifact
grader = ArtifactGrader.new(artifact_data)
render json: { grade: grader.grade }
end
# POST /artifacts/:id/download_image
# Synchronously downloads a single image size for the artifact
#
# @param size [String] Required - 'square' or 'wide'
# @param force [Boolean] Optional - Force re-download even if exists
def download_image
size = params[:size]
force = params[:force] == true || params[:force] == 'true'
unless %w[square wide].include?(size)
return render json: { error: "Invalid size. Must be 'square' or 'wide'" }, status: :bad_request
end
service = ArtifactImageDownloadService.new(@artifact, force: force, size: size, storage: :s3)
result = service.download
if result.success?
render json: { success: true, images: result.images }
else
render json: { success: false, error: result.error }, status: :unprocessable_entity
end
end
# POST /artifacts/:id/download_images
# Asynchronously downloads all images for the artifact via background job
#
# @param options.force [Boolean] Optional - Force re-download even if exists
# @param options.size [String] Optional - 'square', 'wide', or 'all' (default)
def download_images
options = params[:options] || {}
force = options[:force] == true || options[:force] == 'true'
size = options[:size] || 'all'
DownloadArtifactImagesJob.perform_later(@artifact.id, force: force, size: size)
render json: {
status: 'queued',
message: "Image download queued for artifact #{@artifact.granblue_id}",
artifact_id: @artifact.id
}, status: :accepted
end
# GET /artifacts/:id/download_status
# Returns the current status of a background download job
def download_status
status = DownloadArtifactImagesJob.status(@artifact.id)
render json: status
end
private
def set_artifact
@artifact = Artifact.find(params[:id])
rescue ActiveRecord::RecordNotFound
render_not_found_response('artifact')
end
def build_gradeable_artifact
base_artifact = params[:artifact_id].present? ? Artifact.find_by(id: params[:artifact_id]) : nil
# Build a simple struct that responds to what ArtifactGrader needs
OpenStruct.new(
skill1: grade_params[:skill1] || {},
skill2: grade_params[:skill2] || {},
skill3: grade_params[:skill3] || {},
skill4: grade_params[:skill4] || {},
artifact: base_artifact || OpenStruct.new(quirk?: false)
)
end
def grade_params
params.permit(
:artifact_id,
skill1: %i[modifier strength level],
skill2: %i[modifier strength level],
skill3: %i[modifier strength level],
skill4: %i[modifier strength level]
)
end
end
end
end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Api
module V1
class CharacterSeriesController < Api::V1::ApiController
before_action :set_character_series, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /character_series
def index
character_series = CharacterSeries.ordered
render json: CharacterSeriesBlueprint.render(character_series)
end
# GET /character_series/:id
def show
render json: CharacterSeriesBlueprint.render(@character_series, view: :full)
end
# POST /character_series
def create
character_series = CharacterSeries.new(character_series_params)
if character_series.save
render json: CharacterSeriesBlueprint.render(character_series, view: :full), status: :created
else
render_validation_error_response(character_series)
end
end
# PATCH/PUT /character_series/:id
def update
if @character_series.update(character_series_params)
render json: CharacterSeriesBlueprint.render(@character_series, view: :full)
else
render_validation_error_response(@character_series)
end
end
# DELETE /character_series/:id
def destroy
if @character_series.characters.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete series with associated characters',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@character_series.destroy!
head :no_content
end
end
private
def set_character_series
# Support lookup by slug or UUID
@character_series = CharacterSeries.find_by(slug: params[:id]) || CharacterSeries.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[CHARACTER_SERIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def character_series_params
params.require(:character_series).permit(:name_en, :name_jp, :slug, :order)
end
end
end
end

View file

@ -3,16 +3,237 @@
module Api module Api
module V1 module V1
class CharactersController < Api::V1::ApiController class CharactersController < Api::V1::ApiController
before_action :set include IdResolvable
include BatchPreviewable
before_action :set, only: %i[show related download_image download_images download_status update raw fetch_wiki]
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
# GET /characters/:id
def show def show
render json: CharacterBlueprint.render(@character) render json: CharacterBlueprint.render(@character, view: :full)
end
# GET /characters/:id/related
def related
return render json: [] unless @character.character_id
related = Character.where(character_id: @character.character_id)
.where.not(id: @character.id)
render json: CharacterBlueprint.render(related)
end
# POST /characters
# Creates a new character record
def create
character = Character.new(character_params)
if character.save
render json: CharacterBlueprint.render(character, view: :full), status: :created
else
render_validation_error_response(character)
end
end
# PATCH/PUT /characters/:id
# Updates an existing character record
def update
if @character.update(character_params)
render json: CharacterBlueprint.render(@character, view: :full)
else
render_validation_error_response(@character)
end
end
# GET /characters/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = CharacterImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /characters/:id/download_image
# Synchronously downloads a single image for a character
def download_image
size = params[:size]
transformation = params[:transformation]
force = params[:force] == true
# Validate size
valid_sizes = Granblue::Downloaders::CharacterDownloader::SIZES
unless valid_sizes.include?(size)
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
end
# Validate transformation for characters (01, 02, 03, 04)
valid_transformations = %w[01 02 03 04]
if transformation.present? && !valid_transformations.include?(transformation)
return render json: { error: "Invalid transformation. Must be one of: #{valid_transformations.join(', ')}" }, status: :unprocessable_entity
end
# Build variant ID
variant_id = transformation.present? ? "#{@character.granblue_id}_#{transformation}" : "#{@character.granblue_id}_01"
begin
downloader = Granblue::Downloaders::CharacterDownloader.new(
@character.granblue_id,
storage: :s3,
force: force,
verbose: true
)
# Call the download_variant method directly for a single variant/size
downloader.send(:download_variant, variant_id, size)
render json: {
success: true,
character_id: @character.id,
granblue_id: @character.granblue_id,
size: size,
transformation: transformation,
message: 'Image downloaded successfully'
}
rescue StandardError => e
Rails.logger.error "[CHARACTERS] Image download error for #{@character.id}: #{e.message}"
render json: { success: false, error: e.message }, status: :internal_server_error
end
end
# POST /characters/:id/download_images
# Triggers async image download for a character
def download_images
# Queue the download job
DownloadCharacterImagesJob.perform_later(
@character.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadCharacterImagesJob.update_status(
@character.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
character_id: @character.id,
granblue_id: @character.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /characters/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadCharacterImagesJob.status(@character.id)
render json: status.merge(
character_id: @character.id,
granblue_id: @character.granblue_id
)
end
# GET /characters/:id/raw
# Returns raw wiki and game data for database viewing
def raw
render json: CharacterBlueprint.render(@character, view: :raw)
end
# POST /characters/batch_preview
# Fetches wiki data and suggestions for multiple wiki page names
def batch_preview
wiki_pages = params[:wiki_pages]
wiki_data = params[:wiki_data] || {}
unless wiki_pages.is_a?(Array) && wiki_pages.any?
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
end
# Limit to 10 pages
wiki_pages = wiki_pages.first(10)
results = wiki_pages.map do |wiki_page|
process_wiki_preview(wiki_page, :character, wiki_raw: wiki_data[wiki_page])
end
render json: { results: results }
end
# POST /characters/:id/fetch_wiki
# Fetches and stores wiki data for this character
def fetch_wiki
unless @character.wiki_en.present?
return render json: { error: 'No wiki page configured for this character' }, status: :unprocessable_entity
end
begin
wiki_text = Granblue::Parsers::Wiki.new.fetch(@character.wiki_en)
# Handle redirects
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
@character.update!(wiki_en: redirect_target)
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
end
@character.update!(wiki_raw: wiki_text)
render json: CharacterBlueprint.render(@character, view: :raw)
rescue Granblue::WikiError => e
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
rescue StandardError => e
Rails.logger.error "[CHARACTERS] Wiki fetch error for #{@character.id}: #{e.message}"
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
end
end end
private private
def set def set
@character = Character.where(granblue_id: params[:id]).first @character = find_by_any_id(Character, params[:id])
render_not_found_response('character') unless @character
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[CHARACTERS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def character_params
params.require(:character).permit(
:granblue_id, :name_en, :name_jp, :rarity, :element,
:proficiency1, :proficiency2, :gender, :race1, :race2,
:flb, :ulb, :special, :season,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
:base_da, :base_ta, :ougi_ratio, :ougi_ratio_flb,
:release_date, :flb_date, :ulb_date,
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
nicknames_en: [], nicknames_jp: [], character_id: [], series: []
)
end end
end end
end end

View file

@ -0,0 +1,253 @@
# frozen_string_literal: true
module Api
module V1
class CollectionArtifactsController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_artifact_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_artifact_for_write, only: %i[update destroy]
def index
@collection_artifacts = @target_user.collection_artifacts.includes(:artifact)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_artifacts = @collection_artifacts.where(artifact_id: params[:artifact_id]) if params[:artifact_id]
@collection_artifacts = @collection_artifacts.where(element: array_param(:element)) if params[:element]
@collection_artifacts = @collection_artifacts.by_proficiency(array_param(:proficiency)) if params[:proficiency].present?
@collection_artifacts = @collection_artifacts.joins(:artifact).where(artifacts: { rarity: array_param(:rarity) }) if params[:rarity]
# Skill filters - each slot uses OR logic, slots combined with AND logic
@collection_artifacts = @collection_artifacts.with_skill_in_slot(1, params[:skill1]) if params[:skill1].present?
@collection_artifacts = @collection_artifacts.with_skill_in_slot(2, params[:skill2]) if params[:skill2].present?
@collection_artifacts = @collection_artifacts.with_skill_in_slot(3, params[:skill3]) if params[:skill3].present?
@collection_artifacts = @collection_artifacts.with_skill_in_slot(4, params[:skill4]) if params[:skill4].present?
@collection_artifacts = @collection_artifacts.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifacts,
root: :artifacts,
meta: pagination_meta(@collection_artifacts)
)
end
def show
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifact,
view: :full
)
end
def create
@collection_artifact = current_user.collection_artifacts.build(collection_artifact_params)
if @collection_artifact.save
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifact,
view: :full
), status: :created
else
render_validation_error_response(@collection_artifact)
end
end
def update
if @collection_artifact.update(collection_artifact_params)
render json: Api::V1::CollectionArtifactBlueprint.render(
@collection_artifact,
view: :full
)
else
render_validation_error_response(@collection_artifact)
end
end
def destroy
@collection_artifact.destroy
head :no_content
end
# POST /collection/artifacts/batch
# Creates multiple collection artifacts in a single request
def batch
items = batch_artifact_params[:collection_artifacts] || []
created = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
collection_artifact = current_user.collection_artifacts.build(item_params)
if collection_artifact.save
created << collection_artifact
else
errors << {
index: index,
artifact_id: item_params[:artifact_id],
error: collection_artifact.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionArtifactBlueprint.render(
created,
root: :artifacts,
meta: { created: created.size, errors: errors }
), status: status
end
# POST /collection/artifacts/import
# Imports artifacts from game JSON data
#
# @param data [Hash] Game data containing artifact list
# @param update_existing [Boolean] Whether to update existing artifacts (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = ArtifactImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created&.size || 0,
updated: result.updated&.size || 0,
skipped: result.skipped&.size || 0,
errors: result.errors || [],
reconciliation: result.reconciliation
}, status: status
end
# POST /collection/artifacts/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing artifact list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = ArtifactImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |ca|
{
id: ca.id,
game_id: ca.game_id,
name: ca.artifact&.name_en,
granblue_id: ca.artifact&.granblue_id,
element: ca.element,
level: ca.level
}
end,
count: items_to_delete.size
}
end
# DELETE /collection/artifacts/batch_destroy
# Deletes multiple collection artifacts in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_artifacts.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: 'User not found' }, status: :not_found
end
def check_collection_access
return if @target_user.nil?
return if @target_user.collection_viewable_by?(current_user)
render json: { error: 'You do not have permission to view this collection' }, status: :forbidden
end
def set_collection_artifact_for_read
@collection_artifact = @target_user.collection_artifacts.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('artifact', params[:id])
end
def set_collection_artifact_for_write
@collection_artifact = current_user.collection_artifacts.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('artifact', params[:id])
end
def collection_artifact_params
params.require(:collection_artifact).permit(
:artifact_id, :element, :proficiency, :level, :nickname, :reroll_slot,
skill1: %i[modifier strength level],
skill2: %i[modifier strength level],
skill3: %i[modifier strength level],
skill4: %i[modifier strength level]
)
end
def batch_artifact_params
params.permit(collection_artifacts: [
:artifact_id, :element, :proficiency, :level, :nickname, :reroll_slot,
{ skill1: %i[modifier strength level] },
{ skill2: %i[modifier strength level] },
{ skill3: %i[modifier strength level] },
{ skill4: %i[modifier strength level] }
])
end
def import_params
{
update_existing: params[:update_existing],
is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -0,0 +1,225 @@
module Api
module V1
class CollectionCharactersController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_character_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import]
before_action :set_collection_character_for_write, only: %i[update destroy]
def index
@collection_characters = @target_user.collection_characters
.includes(:character, :awakening)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_characters = @collection_characters.by_element(array_param(:element)) if params[:element]
@collection_characters = @collection_characters.by_rarity(array_param(:rarity)) if params[:rarity]
@collection_characters = @collection_characters.by_race(array_param(:race)) if params[:race]
@collection_characters = @collection_characters.by_proficiency(array_param(:proficiency)) if params[:proficiency]
@collection_characters = @collection_characters.by_gender(array_param(:gender)) if params[:gender]
# Apply sorting
@collection_characters = @collection_characters.sorted_by(params[:sort])
# Apply pagination
@collection_characters = @collection_characters.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_characters,
root: :characters,
meta: pagination_meta(@collection_characters)
)
end
def show
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_character,
view: :full
)
end
def create
@collection_character = current_user.collection_characters.build(collection_character_params)
if @collection_character.save
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_character,
view: :full
), status: :created
else
# Check for duplicate character error
if @collection_character.errors[:character_id].any? { |e| e.include?('already exists') }
raise CollectionErrors::DuplicateCharacter.new(@collection_character.character_id)
end
render_validation_error_response(@collection_character)
end
end
def update
if @collection_character.update(collection_character_params)
render json: Api::V1::CollectionCharacterBlueprint.render(
@collection_character,
view: :full
)
else
render_validation_error_response(@collection_character)
end
end
def destroy
@collection_character.destroy
head :no_content
end
# POST /collection/characters/batch
# Creates multiple collection characters in a single request
def batch
items = batch_character_params[:collection_characters] || []
created = []
skipped = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
# Check if already exists (skip duplicates)
if current_user.collection_characters.exists?(character_id: item_params[:character_id])
skipped << { index: index, character_id: item_params[:character_id], reason: 'already_exists' }
next
end
collection_character = current_user.collection_characters.build(item_params)
if collection_character.save
created << collection_character
else
errors << {
index: index,
character_id: item_params[:character_id],
error: collection_character.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionCharacterBlueprint.render(
created,
root: :characters,
meta: { created: created.size, skipped: skipped.size, skipped_items: skipped, errors: errors }
), status: status
end
# DELETE /collection/characters/batch_destroy
# Deletes multiple collection characters in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_characters.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
# POST /collection/characters/import
# Imports characters from game JSON data
#
# @param data [Hash] Game data containing character list
# @param update_existing [Boolean] Whether to update existing characters (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = CharacterImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created.size,
updated: result.updated.size,
skipped: result.skipped.size,
errors: result.errors
}, status: status
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil? # Already handled by set_target_user
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
def set_collection_character_for_read
@collection_character = @target_user.collection_characters.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('character', params[:id])
end
def set_collection_character_for_write
@collection_character = current_user.collection_characters.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('character', params[:id])
end
def collection_character_params
params.require(:collection_character).permit(
:character_id, :uncap_level, :transcendence_step, :perpetuity,
:awakening_id, :awakening_level,
ring1: %i[modifier strength],
ring2: %i[modifier strength],
ring3: %i[modifier strength],
ring4: %i[modifier strength],
earring: %i[modifier strength]
)
end
def batch_character_params
params.permit(collection_characters: [
:character_id, :uncap_level, :transcendence_step, :perpetuity,
:awakening_id, :awakening_level,
ring1: %i[modifier strength],
ring2: %i[modifier strength],
ring3: %i[modifier strength],
ring4: %i[modifier strength],
earring: %i[modifier strength]
])
end
def import_params
{
update_existing: params[:update_existing],
data: params[:data]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -0,0 +1,34 @@
module Api
module V1
class CollectionController < ApiController
before_action :set_target_user
before_action :check_collection_access
# GET /api/v1/users/:user_id/collection/counts
# Returns total counts for all collection entity types
def counts
render json: {
characters: @target_user.collection_characters.count,
weapons: @target_user.collection_weapons.count,
summons: @target_user.collection_summons.count,
artifacts: @target_user.collection_artifacts.count
}
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil?
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
end
end
end

View file

@ -0,0 +1,60 @@
module Api
module V1
class CollectionJobAccessoriesController < ApiController
before_action :restrict_access
before_action :set_collection_job_accessory, only: [:show, :destroy]
def index
@collection_accessories = current_user.collection_job_accessories
.includes(job_accessory: :job)
@collection_accessories = @collection_accessories.by_job(params[:job_id]) if params[:job_id]
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
@collection_accessories,
root: :collection_job_accessories
)
end
def show
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
@collection_job_accessory
)
end
def create
@collection_accessory = current_user.collection_job_accessories
.build(collection_job_accessory_params)
if @collection_accessory.save
render json: Api::V1::CollectionJobAccessoryBlueprint.render(
@collection_accessory
), status: :created
else
# Check for duplicate job accessory error
if @collection_accessory.errors[:job_accessory_id].any? { |e| e.include?('already exists') }
raise CollectionErrors::DuplicateJobAccessory.new(@collection_accessory.job_accessory_id)
end
render_validation_error_response(@collection_accessory)
end
end
def destroy
@collection_job_accessory.destroy
head :no_content
end
private
def set_collection_job_accessory
@collection_job_accessory = current_user.collection_job_accessories.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('job accessory', params[:id])
end
def collection_job_accessory_params
params.require(:collection_job_accessory).permit(:job_accessory_id)
end
end
end
end

View file

@ -0,0 +1,237 @@
module Api
module V1
class CollectionSummonsController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_summon_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_summon_for_write, only: %i[update destroy]
def index
@collection_summons = @target_user.collection_summons
.includes(:summon)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_summons = @collection_summons.by_summon(params[:summon_id]) if params[:summon_id]
@collection_summons = @collection_summons.by_element(array_param(:element)) if params[:element]
@collection_summons = @collection_summons.by_rarity(array_param(:rarity)) if params[:rarity]
@collection_summons = @collection_summons.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summons,
root: :summons,
meta: pagination_meta(@collection_summons)
)
end
def show
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summon,
view: :full
)
end
def create
@collection_summon = current_user.collection_summons.build(collection_summon_params)
if @collection_summon.save
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summon,
view: :full
), status: :created
else
render_validation_error_response(@collection_summon)
end
end
def update
if @collection_summon.update(collection_summon_params)
render json: Api::V1::CollectionSummonBlueprint.render(
@collection_summon,
view: :full
)
else
render_validation_error_response(@collection_summon)
end
end
def destroy
@collection_summon.destroy
head :no_content
end
# POST /collection/summons/batch
# Creates multiple collection summons in a single request
# Unlike characters, summons can have duplicates (user can own multiple copies)
def batch
items = batch_summon_params[:collection_summons] || []
created = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
collection_summon = current_user.collection_summons.build(item_params)
if collection_summon.save
created << collection_summon
else
errors << {
index: index,
summon_id: item_params[:summon_id],
error: collection_summon.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionSummonBlueprint.render(
created,
root: :summons,
meta: { created: created.size, errors: errors }
), status: status
end
# DELETE /collection/summons/batch_destroy
# Deletes multiple collection summons in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_summons.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
# POST /collection/summons/import
# Imports summons from game JSON data
#
# @param data [Hash] Game data containing summon list
# @param update_existing [Boolean] Whether to update existing summons (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = SummonImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created.size,
updated: result.updated.size,
skipped: result.skipped.size,
errors: result.errors,
reconciliation: result.reconciliation
}, status: status
end
# POST /collection/summons/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing summon list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = SummonImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |cs|
{
id: cs.id,
game_id: cs.game_id,
name: cs.summon&.name_en,
granblue_id: cs.summon&.granblue_id,
uncap_level: cs.uncap_level,
transcendence_step: cs.transcendence_step
}
end,
count: items_to_delete.size
}
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil? # Already handled by set_target_user
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
def set_collection_summon_for_read
@collection_summon = @target_user.collection_summons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('summon', params[:id])
end
def set_collection_summon_for_write
@collection_summon = current_user.collection_summons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('summon', params[:id])
end
def collection_summon_params
params.require(:collection_summon).permit(
:summon_id, :uncap_level, :transcendence_step
)
end
def batch_summon_params
params.permit(collection_summons: [
:summon_id, :uncap_level, :transcendence_step
])
end
def import_params
{
update_existing: params[:update_existing],
is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -0,0 +1,255 @@
module Api
module V1
class CollectionWeaponsController < ApiController
# Read actions: look up user from params, check privacy
before_action :set_target_user, only: %i[index show]
before_action :check_collection_access, only: %i[index show]
before_action :set_collection_weapon_for_read, only: %i[show]
# Write actions: require auth, use current_user
before_action :restrict_access, only: %i[create update destroy batch batch_destroy import preview_sync]
before_action :set_collection_weapon_for_write, only: %i[update destroy]
def index
@collection_weapons = @target_user.collection_weapons
.includes(:weapon, :awakening,
:weapon_key1, :weapon_key2,
:weapon_key3, :weapon_key4,
:ax_modifier1, :ax_modifier2,
:befoulment_modifier)
# Apply filters (array_param splits comma-separated values for OR logic)
@collection_weapons = @collection_weapons.by_weapon(params[:weapon_id]) if params[:weapon_id]
@collection_weapons = @collection_weapons.by_element(array_param(:element)) if params[:element]
@collection_weapons = @collection_weapons.by_rarity(array_param(:rarity)) if params[:rarity]
@collection_weapons = @collection_weapons.by_proficiency(array_param(:proficiency)) if params[:proficiency]
@collection_weapons = @collection_weapons.by_series(array_param(:series)) if params[:series]
@collection_weapons = @collection_weapons.sorted_by(params[:sort])
@collection_weapons = @collection_weapons.paginate(page: params[:page], per_page: params[:limit] || 50)
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapons,
root: :weapons,
meta: pagination_meta(@collection_weapons)
)
end
def show
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapon,
view: :full
)
end
def create
@collection_weapon = current_user.collection_weapons.build(collection_weapon_params)
if @collection_weapon.save
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapon,
view: :full
), status: :created
else
render_validation_error_response(@collection_weapon)
end
end
def update
if @collection_weapon.update(collection_weapon_params)
render json: Api::V1::CollectionWeaponBlueprint.render(
@collection_weapon,
view: :full
)
else
render_validation_error_response(@collection_weapon)
end
end
def destroy
@collection_weapon.destroy
head :no_content
end
# POST /collection/weapons/batch
# Creates multiple collection weapons in a single request
# Unlike characters, weapons can have duplicates (user can own multiple copies)
def batch
items = batch_weapon_params[:collection_weapons] || []
created = []
errors = []
ActiveRecord::Base.transaction do
items.each_with_index do |item_params, index|
collection_weapon = current_user.collection_weapons.build(item_params)
if collection_weapon.save
created << collection_weapon
else
errors << {
index: index,
weapon_id: item_params[:weapon_id],
error: collection_weapon.errors.full_messages.join(', ')
}
end
end
end
status = errors.any? ? :multi_status : :created
render json: Api::V1::CollectionWeaponBlueprint.render(
created,
root: :weapons,
meta: { created: created.size, errors: errors }
), status: status
end
# DELETE /collection/weapons/batch_destroy
# Deletes multiple collection weapons in a single request
def batch_destroy
ids = batch_destroy_params[:ids] || []
deleted_count = current_user.collection_weapons.where(id: ids).destroy_all.count
render json: {
meta: { deleted: deleted_count }
}, status: :ok
end
# POST /collection/weapons/import
# Imports weapons from game JSON data
#
# @param data [Hash] Game data containing weapon list
# @param update_existing [Boolean] Whether to update existing weapons (default: false)
# @param is_full_inventory [Boolean] Whether this represents the user's complete inventory (default: false)
# @param reconcile_deletions [Boolean] Whether to delete items not in the import (default: false)
def import
game_data = import_params[:data]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = WeaponImportService.new(
current_user,
game_data,
update_existing: import_params[:update_existing] == true,
is_full_inventory: import_params[:is_full_inventory] == true,
reconcile_deletions: import_params[:reconcile_deletions] == true,
filter: import_params[:filter]
)
result = service.import
status = result.success? ? :created : :multi_status
render json: {
success: result.success?,
created: result.created.size,
updated: result.updated.size,
skipped: result.skipped.size,
errors: result.errors,
reconciliation: result.reconciliation
}, status: status
end
# POST /collection/weapons/preview_sync
# Previews what would be deleted in a full sync operation
#
# @param data [Hash] Game data containing weapon list
# @return [JSON] List of items that would be deleted
def preview_sync
game_data = import_params[:data]
filter = import_params[:filter]
unless game_data.present?
return render json: { error: 'No data provided' }, status: :bad_request
end
service = WeaponImportService.new(current_user, game_data, filter: filter)
items_to_delete = service.preview_deletions
render json: {
will_delete: items_to_delete.map do |cw|
{
id: cw.id,
game_id: cw.game_id,
name: cw.weapon&.name_en,
granblue_id: cw.weapon&.granblue_id,
uncap_level: cw.uncap_level,
transcendence_step: cw.transcendence_step
}
end,
count: items_to_delete.size
}
end
private
def set_target_user
@target_user = User.find(params[:user_id])
rescue ActiveRecord::RecordNotFound
render json: { error: "User not found" }, status: :not_found
end
def check_collection_access
return if @target_user.nil? # Already handled by set_target_user
unless @target_user.collection_viewable_by?(current_user)
render json: { error: "You do not have permission to view this collection" }, status: :forbidden
end
end
def set_collection_weapon_for_read
@collection_weapon = @target_user.collection_weapons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('weapon', params[:id])
end
def set_collection_weapon_for_write
@collection_weapon = current_user.collection_weapons.find(params[:id])
rescue ActiveRecord::RecordNotFound
raise CollectionErrors::CollectionItemNotFound.new('weapon', params[:id])
end
def collection_weapon_params
params.require(:collection_weapon).permit(
:weapon_id, :uncap_level, :transcendence_step,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
:awakening_id, :awakening_level,
:ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:element
)
end
def batch_weapon_params
params.permit(collection_weapons: [
:weapon_id, :uncap_level, :transcendence_step,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key4_id,
:awakening_id, :awakening_level,
:ax_modifier1_id, :ax_strength1, :ax_modifier2_id, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:element
])
end
def import_params
{
update_existing: params[:update_existing],
is_full_inventory: params[:is_full_inventory],
reconcile_deletions: params[:reconcile_deletions],
data: params[:data]&.to_unsafe_h,
filter: params[:filter]&.to_unsafe_h
}
end
def batch_destroy_params
params.permit(ids: [])
end
def array_param(key)
params[key]&.to_s&.split(',')
end
end
end
end

View file

@ -0,0 +1,96 @@
# frozen_string_literal: true
module Api
module V1
class CrewGwParticipationsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_member!
before_action :set_participation, only: %i[show update]
before_action :authorize_crew_officer!, only: %i[create update]
# GET /crew/gw_participations
def index
participations = @crew.crew_gw_participations.includes(:gw_event).order('gw_events.start_date DESC')
render json: CrewGwParticipationBlueprint.render(participations, view: :with_event, root: :crew_gw_participations)
end
# GET /crew/gw_participations/:id
def show
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_individual_scores, root: :crew_gw_participation, current_user: current_user)
end
# GET /crew/gw_participations/by_event/:event_id
def by_event
# Support lookup by event_id (UUID) or event_number (integer)
event = if params[:event_id].match?(/\A\d+\z/)
GwEvent.find_by(event_number: params[:event_id])
else
GwEvent.find_by(id: params[:event_id])
end
return render json: { gw_event: nil, crew_gw_participation: nil, members_during_event: [] } unless event
participation = @crew.crew_gw_participations
.includes(:gw_event, gw_individual_scores: [{ crew_membership: :user }, :phantom_player])
.find_by(gw_event: event)
# Get all members who were active during the event (includes retired members who left after event started)
# Also include all currently active members for score entry purposes
# Uses joined_at (editable) for historical accuracy
members_during_event = @crew.crew_memberships
.includes(:user)
.active_during(event.start_date, event.end_date)
# Get all phantom players who were active during the event (excludes claimed/deleted phantoms)
phantom_players = @crew.phantom_players.not_deleted.active_during(event.start_date, event.end_date)
render json: {
gw_event: GwEventBlueprint.render_as_hash(event),
crew_gw_participation: participation ? CrewGwParticipationBlueprint.render_as_hash(participation, view: :with_individual_scores, current_user: current_user) : nil,
members_during_event: CrewMembershipBlueprint.render_as_hash(members_during_event, view: :with_user),
phantom_players: PhantomPlayerBlueprint.render_as_hash(phantom_players)
}
end
# POST /gw_events/:id/participations
def create
event = GwEvent.find(params[:id])
participation = @crew.crew_gw_participations.build(gw_event: event)
if participation.save
render json: CrewGwParticipationBlueprint.render(participation, view: :with_event, root: :crew_gw_participation), status: :created
else
render_validation_error_response(participation)
end
end
# PUT /crew/gw_participations/:id
def update
if @participation.update(participation_params)
render json: CrewGwParticipationBlueprint.render(@participation, view: :with_event, root: :crew_gw_participation)
else
render_validation_error_response(@participation)
end
end
private
def set_crew
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_participation
@participation = @crew.crew_gw_participations.find(params[:id])
end
def participation_params
params.require(:crew_gw_participation).permit(:preliminary_ranking, :final_ranking)
end
end
end
end

View file

@ -0,0 +1,78 @@
# frozen_string_literal: true
module Api
module V1
class CrewInvitationsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, only: %i[index create]
before_action :authorize_crew_officer!, only: %i[index create]
before_action :set_invitation, only: %i[accept reject]
# GET /crews/:crew_id/invitations
# List pending invitations for a crew (officers only)
def index
invitations = @crew.crew_invitations.pending.includes(:user, :invited_by)
render json: CrewInvitationBlueprint.render(invitations, view: :with_user, root: :invitations)
end
# POST /crews/:crew_id/invitations
# Send an invitation to a user (officers only)
def create
user = User.find_by(id: params[:user_id]) || User.find_by(username: params[:username])
raise ActiveRecord::RecordNotFound, 'User not found' unless user
raise CrewErrors::CannotInviteSelfError if user.id == current_user.id
raise CrewErrors::AlreadyInCrewError if user.crew.present?
# Check for existing pending invitation
existing = @crew.crew_invitations.pending.find_by(user: user)
raise CrewErrors::UserAlreadyInvitedError if existing
invitation = @crew.crew_invitations.build(
user: user,
invited_by: current_user
)
if invitation.save
render json: CrewInvitationBlueprint.render(invitation, view: :with_user, root: :invitation), status: :created
else
render_validation_error_response(invitation)
end
end
# GET /invitations/pending
# List pending invitations for current user
def pending
invitations = current_user.crew_invitations.active.includes(:crew, :invited_by)
render json: CrewInvitationBlueprint.render(invitations, view: :for_invitee, root: :invitations)
end
# POST /invitations/:id/accept
def accept
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
@invitation.accept!
render json: CrewBlueprint.render(current_user.crew, view: :full, root: :crew)
end
# POST /invitations/:id/reject
def reject
raise CrewErrors::InvitationNotFoundError unless @invitation.user_id == current_user.id
@invitation.reject!
head :no_content
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_invitation
@invitation = CrewInvitation.find(params[:id])
end
end
end
end

View file

@ -0,0 +1,149 @@
# frozen_string_literal: true
module Api
module V1
class CrewMembershipsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, except: %i[gw_scores]
before_action :set_crew_from_user, only: %i[gw_scores]
before_action :set_membership, only: %i[update destroy promote demote]
before_action :set_membership_for_scores, only: %i[gw_scores]
before_action :authorize_crew_officer!, only: %i[destroy history]
before_action :authorize_crew_captain!, only: %i[promote demote]
before_action :authorize_membership_update!, only: %i[update]
before_action :authorize_crew_member!, only: %i[gw_scores]
# PUT /crews/:crew_id/memberships/:id
def update
allowed_params = if current_user.crew_captain?
membership_params
else
membership_params.slice(:joined_at)
end
if @membership.update(allowed_params)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
else
render_validation_error_response(@membership)
end
end
# DELETE /crews/:crew_id/memberships/:id
def destroy
raise CrewErrors::CannotRemoveCaptainError if @membership.captain?
@membership.retire!
head :no_content
end
# POST /crews/:crew_id/memberships/:id/promote
def promote
raise CrewErrors::CannotRemoveCaptainError if @membership.captain?
# Check vice captain limit
current_vc_count = @crew.crew_memberships.where(role: :vice_captain, retired: false).count
raise CrewErrors::ViceCaptainLimitError if current_vc_count >= 3 && !@membership.vice_captain?
@membership.update!(role: :vice_captain)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
end
# POST /crews/:crew_id/memberships/:id/demote
def demote
raise CrewErrors::CannotDemoteCaptainError if @membership.captain?
@membership.update!(role: :member)
render json: CrewMembershipBlueprint.render(@membership, view: :with_user, root: :membership)
end
# GET /crews/:crew_id/memberships/by_user/:user_id
def history
memberships = @crew.crew_memberships
.where(user_id: params[:user_id])
.order(created_at: :desc)
render json: CrewMembershipBlueprint.render(memberships, view: :with_user, root: :memberships)
end
# GET /crew/memberships/:id/gw_scores
def gw_scores
# Find ALL memberships for this user in the crew (for boomerang players)
all_memberships = @crew.crew_memberships.where(user_id: @membership.user_id)
membership_ids = all_memberships.pluck(:id)
# Get all crew GW events to identify gaps
all_crew_events = @crew.crew_gw_participations
.joins(:gw_event)
.order('gw_events.event_number DESC')
.pluck('gw_events.id, gw_events.event_number, gw_events.element, gw_events.start_date, gw_events.end_date')
# Get scores across all membership periods
scores_by_event = GwIndividualScore
.joins(crew_gw_participation: :gw_event)
.where(crew_membership_id: membership_ids)
.group('gw_events.id')
.pluck('gw_events.id, SUM(gw_individual_scores.score)')
.to_h
# Build event scores with gap markers
event_scores = all_crew_events.map do |event_id, event_number, element, start_date, end_date|
score = scores_by_event[event_id]
{
gw_event: { id: event_id, event_number: event_number, element: element, start_date: start_date, end_date: end_date },
total_score: score&.to_i,
in_crew: score.present?
}
end
grand_total = event_scores.sum { |es| es[:total_score] || 0 }
# Build membership periods for context
membership_periods = all_memberships.order(created_at: :desc).map do |m|
{ id: m.id, joined_at: m.joined_at, retired_at: m.retired_at, retired: m.retired }
end
render json: {
member: CrewMembershipBlueprint.render_as_hash(@membership, view: :with_user),
event_scores: event_scores,
grand_total: grand_total,
membership_periods: membership_periods
}
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_crew_from_user
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_membership
@membership = @crew.crew_memberships.find(params[:id])
end
def set_membership_for_scores
# Try to find by username first, then fall back to ID
@membership = @crew.crew_memberships.joins(:user).find_by(users: { username: params[:id] }) ||
@crew.crew_memberships.find(params[:id])
end
def membership_params
params.require(:membership).permit(:role, :joined_at, :retired, :retired_at)
end
def authorize_membership_update!
# Officers can update any membership's joined_at
# Captains can update anything
return if current_user.crew_captain?
return if current_user.crew_officer?
raise Api::V1::UnauthorizedError
end
end
end
end

View file

@ -0,0 +1,185 @@
# frozen_string_literal: true
module Api
module V1
class CrewsController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, only: %i[show update members roster leave transfer_captain]
before_action :require_crew!, only: %i[show update members roster]
before_action :authorize_crew_member!, only: %i[show members]
before_action :authorize_crew_officer!, only: %i[update roster]
before_action :authorize_crew_captain!, only: %i[transfer_captain]
# GET /crew or GET /crews/:id
def show
render json: CrewBlueprint.render(@crew, view: :full, root: :crew, current_user: current_user)
end
# POST /crews
def create
raise CrewErrors::AlreadyInCrewError if current_user.crew.present?
@crew = Crew.new(crew_params)
ActiveRecord::Base.transaction do
@crew.save!
CrewMembership.create!(crew: @crew, user: current_user, role: :captain)
end
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user), status: :created
end
# PUT /crew
def update
if @crew.update(crew_params)
render json: CrewBlueprint.render(@crew, view: :full, root: :crew, current_user: current_user)
else
render_validation_error_response(@crew)
end
end
# GET /crew/members
# Params:
# filter: 'active' (default), 'retired', 'phantom', 'all'
def members
filter = params[:filter]&.to_sym || :active
case filter
when :active
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
phantoms = @crew.phantom_players.not_deleted.active.includes(:claimed_by).order(:name)
when :retired
members = @crew.crew_memberships.retired.includes(:user).order(retired_at: :desc)
phantoms = @crew.phantom_players.not_deleted.retired.includes(:claimed_by).order(:name)
when :phantom
members = []
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
when :all
members = @crew.crew_memberships.includes(:user).order(role: :desc, retired: :asc, created_at: :asc)
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
else
members = @crew.active_memberships.includes(:user).order(role: :desc, created_at: :asc)
phantoms = @crew.phantom_players.not_deleted.active.includes(:claimed_by).order(:name)
end
render json: {
members: CrewMembershipBlueprint.render_as_hash(members, view: :with_user),
phantoms: PhantomPlayerBlueprint.render_as_hash(phantoms, view: :with_claimed_by)
}
end
# POST /crew/leave
def leave
membership = current_user.active_crew_membership
raise CrewErrors::NotInCrewError unless membership
raise CrewErrors::CaptainCannotLeaveError if membership.captain?
membership.retire!
head :no_content
end
# GET /crew/roster
# Returns collection ownership for crew members based on requested item IDs
# Params: character_ids[], weapon_ids[], summon_ids[]
def roster
members = @crew.active_memberships.includes(:user)
render json: {
members: members.map { |m| build_member_roster(m) }
}
end
# POST /crews/:id/transfer_captain
def transfer_captain
new_captain_id = params[:user_id]
new_captain_membership = @crew.active_memberships.find_by(user_id: new_captain_id)
raise CrewErrors::MemberNotFoundError unless new_captain_membership
ActiveRecord::Base.transaction do
current_user.active_crew_membership.update!(role: :vice_captain)
new_captain_membership.update!(role: :captain)
end
render json: CrewBlueprint.render(@crew.reload, view: :full, root: :crew, current_user: current_user)
end
private
def set_crew
@crew = if params[:id]
Crew.find(params[:id])
else
current_user&.crew
end
end
def crew_params
params.require(:crew).permit(:name, :gamertag, :granblue_crew_id, :description)
end
def require_crew!
render_not_found_response('crew') unless @crew
end
def build_member_roster(membership)
user = membership.user
{
user_id: user.id,
username: user.username,
role: membership.role,
characters: find_collection_items(user, :characters),
weapons: find_collection_items(user, :weapons),
summons: find_collection_items(user, :summons)
}
end
def find_collection_items(user, type)
ids = params["#{type.to_s.singularize}_ids"]
return [] if ids.blank?
collection = case type
when :characters then user.collection_characters.includes(:character).where(character_id: ids)
when :weapons then user.collection_weapons.includes(:weapon).where(weapon_id: ids)
when :summons then user.collection_summons.includes(:summon).where(summon_id: ids)
end
collection.map do |item|
canonical = case type
when :characters then item.character
when :weapons then item.weapon
when :summons then item.summon
end
result = {
id: item_id_for(item, type),
uncap_level: item.uncap_level,
transcendence_step: item.transcendence_step,
flb: canonical&.flb,
ulb: canonical&.ulb
}
if type == :characters
result[:special] = canonical&.special
# For characters, transcendence availability is indicated by ulb on non-special chars
result[:transcendence] = !canonical&.special && canonical&.ulb
else
result[:transcendence] = canonical&.transcendence
end
result
end
end
def item_id_for(item, type)
case type
when :characters then item.character_id
when :weapons then item.weapon_id
when :summons then item.summon_id
end
end
end
end
end

View file

@ -31,10 +31,15 @@ module Api
raise Api::V1::UnauthorizedError unless current_user raise Api::V1::UnauthorizedError unless current_user
@favorite = Favorite.where(user_id: current_user.id, party_id: favorite_params[:party_id]).first @favorite = Favorite.where(user_id: current_user.id, party_id: favorite_params[:party_id]).first
render_not_found_response('favorite') unless @favorite return render_not_found_response('favorite') unless @favorite
render_error("Couldn't delete favorite") unless Favorite.destroy(@favorite.id) if Favorite.destroy(@favorite.id)
render json: FavoriteBlueprint.render(@favorite, root: :favorite, view: :destroyed) render json: FavoriteBlueprint.render(@favorite, root: :favorite, view: :destroyed)
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new("Couldn't delete favorite")
)
end
end end
private private

View file

@ -0,0 +1,134 @@
# frozen_string_literal: true
module Api
module V1
class GridArtifactsController < Api::V1::ApiController
before_action :find_grid_artifact, only: %i[update destroy sync]
before_action :find_party, only: %i[create update destroy sync]
before_action :find_grid_character, only: %i[create]
before_action :find_artifact, only: %i[create]
before_action :authorize_party_edit!, only: %i[create update destroy sync]
# POST /grid_artifacts
def create
# Check if grid_character already has an artifact
if @grid_character.grid_artifact.present?
@grid_character.grid_artifact.destroy
end
@grid_artifact = GridArtifact.new(
grid_artifact_params.merge(
grid_character_id: @grid_character.id,
artifact_id: @artifact.id
)
)
if @grid_artifact.save
render json: GridArtifactBlueprint.render(@grid_artifact, view: :nested, root: :grid_artifact), status: :created
else
render_validation_error_response(@grid_artifact)
end
end
# PATCH/PUT /grid_artifacts/:id
def update
if @grid_artifact.update(grid_artifact_params)
render json: GridArtifactBlueprint.render(@grid_artifact, view: :nested, root: :grid_artifact), status: :ok
else
render_validation_error_response(@grid_artifact)
end
end
# DELETE /grid_artifacts/:id
def destroy
if @grid_artifact.destroy
render json: GridArtifactBlueprint.render(@grid_artifact, view: :destroyed), status: :ok
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(@grid_artifact.errors.full_messages.join(', '))
)
end
end
# POST /grid_artifacts/:id/sync
def sync
unless @grid_artifact.collection_artifact.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection artifact linked')
)
end
@grid_artifact.sync_from_collection!
render json: GridArtifactBlueprint.render(@grid_artifact.reload,
root: :grid_artifact,
view: :nested)
end
private
def find_grid_artifact
@grid_artifact = GridArtifact.find_by(id: params[:id])
render_not_found_response('grid_artifact') unless @grid_artifact
end
def find_party
@party = if @grid_artifact
@grid_artifact.grid_character.party
else
Party.find_by(id: params[:party_id])
end
render_not_found_response('party') unless @party
end
def find_grid_character
@grid_character = GridCharacter.find_by(id: params.dig(:grid_artifact, :grid_character_id))
render_not_found_response('grid_character') unless @grid_character
end
def find_artifact
artifact_id = params.dig(:grid_artifact, :artifact_id)
@artifact = Artifact.find_by(id: artifact_id)
render_not_found_response('artifact') unless @artifact
end
def authorize_party_edit!
if @party.user.present?
authorize_user_party
else
authorize_anonymous_party
end
end
def authorize_user_party
return if current_user.present? && @party.user == current_user
render_unauthorized_response
end
def authorize_anonymous_party
provided_edit_key = edit_key.to_s.strip.force_encoding('UTF-8')
party_edit_key = @party.edit_key.to_s.strip.force_encoding('UTF-8')
return if valid_edit_key?(provided_edit_key, party_edit_key)
render_unauthorized_response
end
def valid_edit_key?(provided_edit_key, party_edit_key)
provided_edit_key.present? &&
provided_edit_key.bytesize == party_edit_key.bytesize &&
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end
def grid_artifact_params
params.require(:grid_artifact).permit(
:grid_character_id, :artifact_id, :collection_artifact_id,
:element, :proficiency, :level, :reroll_slot,
skill1: %i[modifier strength level],
skill2: %i[modifier strength level],
skill3: %i[modifier strength level],
skill4: %i[modifier strength level]
)
end
end
end
end

View file

@ -13,10 +13,12 @@ module Api
# #
# @see Api::V1::ApiController for shared API behavior. # @see Api::V1::ApiController for shared API behavior.
class GridCharactersController < Api::V1::ApiController class GridCharactersController < Api::V1::ApiController
before_action :find_grid_character, only: %i[update update_uncap_level destroy resolve] include IdResolvable
before_action :find_party, only: %i[create resolve update update_uncap_level destroy]
before_action :find_grid_character, only: %i[update update_uncap_level update_position destroy resolve sync]
before_action :find_party, only: %i[create resolve update update_uncap_level update_position swap destroy sync]
before_action :find_incoming_character, only: :create before_action :find_incoming_character, only: :create
before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level destroy] before_action :authorize_party_edit!, only: %i[create resolve update update_uncap_level update_position swap destroy sync]
## ##
# Creates a new grid character. # Creates a new grid character.
@ -80,17 +82,99 @@ module Api
# @return [void] # @return [void]
def update_uncap_level def update_uncap_level
@grid_character.uncap_level = character_params[:uncap_level] @grid_character.uncap_level = character_params[:uncap_level]
@grid_character.transcendence_step = character_params[:transcendence_step] @grid_character.transcendence_step = character_params[:transcendence_step] || 0
if @grid_character.save if @grid_character.save
render json: GridCharacterBlueprint.render(@grid_character, render json: GridCharacterBlueprint.render(@grid_character,
root: :grid_character, root: :grid_character,
view: :nested) view: :uncap)
else else
render_validation_error_response(@grid_character) render_validation_error_response(@grid_character)
end end
end end
##
# Updates the position of a GridCharacter.
#
# Moves a grid character to a new position, maintaining sequential filling for main slots.
# Validates that the target position is empty and within allowed bounds.
#
# @return [void]
def update_position
new_position = position_params[:position].to_i
new_container = position_params[:container]
# Validate position bounds (0-4 main, 5-6 extra)
unless valid_character_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for character")
)
end
# Check if target position is occupied
if GridCharacter.exists?(party_id: @party.id, position: new_position)
return render_unprocessable_entity_response(
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
)
end
old_position = @grid_character.position
@grid_character.position = new_position
# Compact positions if needed (for main slots)
reordered = compact_character_positions if should_compact_characters?(old_position, new_position)
if @grid_character.save
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
grid_character: GridCharacterBlueprint.render_as_hash(@grid_character.reload, view: :nested),
reordered: reordered || false
}, status: :ok
else
render_validation_error_response(@grid_character)
end
end
##
# Swaps positions between two GridCharacters.
#
# Exchanges the positions of two grid characters within the same party.
# Both characters must belong to the same party.
#
# @return [void]
def swap
source_id = swap_params[:source_id]
target_id = swap_params[:target_id]
source = GridCharacter.find_by(id: source_id, party_id: @party.id)
target = GridCharacter.find_by(id: target_id, party_id: @party.id)
unless source && target
return render_not_found_response('grid_character')
end
# Perform the swap
ActiveRecord::Base.transaction do
temp_position = -999
source_pos = source.position
target_pos = target.position
source.update!(position: temp_position)
target.update!(position: source_pos)
source.update!(position: target_pos)
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
swapped: {
source: GridCharacterBlueprint.render_as_hash(source.reload, view: :nested),
target: GridCharacterBlueprint.render_as_hash(target.reload, view: :nested)
}
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error_response(e.record)
end
## ##
# Resolves conflicts for grid characters. # Resolves conflicts for grid characters.
# #
@ -100,7 +184,7 @@ module Api
# #
# @return [void] # @return [void]
def resolve def resolve
incoming = Character.find_by(id: resolve_params[:incoming]) incoming = find_by_any_id(Character, resolve_params[:incoming])
render_not_found_response('character') and return unless incoming render_not_found_response('character') and return unless incoming
conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact conflicting = resolve_params[:conflicting].map { |id| GridCharacter.find_by(id: id) }.compact
@ -110,22 +194,11 @@ module Api
existing.destroy existing.destroy
end end
# Compute the default uncap level based on the incoming character's flags.
if incoming.special
uncap_level = 3
uncap_level = 5 if incoming.ulb
uncap_level = 4 if incoming.flb
else
uncap_level = 4
uncap_level = 6 if incoming.ulb
uncap_level = 5 if incoming.flb
end
grid_character = GridCharacter.create!( grid_character = GridCharacter.create!(
party_id: @party.id, party_id: @party.id,
character_id: incoming.id, character_id: incoming.id,
position: resolve_params[:position], position: resolve_params[:position],
uncap_level: uncap_level uncap_level: compute_max_uncap_level(incoming)
) )
render json: GridCharacterBlueprint.render(grid_character, render json: GridCharacterBlueprint.render(grid_character,
root: :grid_character, root: :grid_character,
@ -144,7 +217,33 @@ module Api
return render_not_found_response('grid_character') if grid_character.nil? return render_not_found_response('grid_character') if grid_character.nil?
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed) if grid_character.destroy if grid_character.destroy
render json: GridCharacterBlueprint.render(grid_character, view: :destroyed)
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(grid_character.errors.full_messages.join(', '))
)
end
end
##
# Syncs a grid character from its linked collection character.
#
# Copies all customizations from the collection character to this grid character.
# Returns 422 if no collection character is linked.
#
# @return [void]
def sync
unless @grid_character.collection_character.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection character linked')
)
end
@grid_character.sync_from_collection!
render json: GridCharacterBlueprint.render(@grid_character.reload,
root: :grid_character,
view: :nested)
end end
private private
@ -158,7 +257,8 @@ module Api
grid_character = GridCharacter.new( grid_character = GridCharacter.new(
character_params.except(:rings, :awakening).merge( character_params.except(:rings, :awakening).merge(
party_id: @party.id, party_id: @party.id,
character_id: @incoming_character.id character_id: @incoming_character.id,
uncap_level: compute_max_uncap_level(@incoming_character)
) )
) )
assign_transformed_attributes(grid_character, processed_params) assign_transformed_attributes(grid_character, processed_params)
@ -166,17 +266,37 @@ module Api
grid_character grid_character
end end
##
# Computes the maximum uncap level for a character based on its flags.
#
# Special characters (limited/seasonal) have a different uncap progression:
# - Base: 3, FLB: 4, ULB: 5
# Regular characters:
# - Base: 4, FLB: 5, ULB: 6
#
# @param character [Character] the character to compute max uncap for.
# @return [Integer] the maximum uncap level.
def compute_max_uncap_level(character)
if character.special
character.ulb ? 5 : character.flb ? 4 : 3
else
character.ulb ? 6 : character.flb ? 5 : 4
end
end
## ##
# Assigns raw attributes from the original parameters to the grid character. # Assigns raw attributes from the original parameters to the grid character.
# #
# These attributes (like new_rings and new_awakening) are used by model callbacks. # These attributes (like new_rings and new_awakening) are used by model callbacks.
# Note: We exclude :character_id and :party_id because they are already set correctly
# in build_new_grid_character using the resolved UUIDs, not the raw granblue_id from params.
# #
# @param grid_character [GridCharacter] the grid character instance. # @param grid_character [GridCharacter] the grid character instance.
# @return [void] # @return [void]
def assign_raw_attributes(grid_character) def assign_raw_attributes(grid_character)
grid_character.new_rings = character_params[:rings] if character_params[:rings].present? grid_character.new_rings = character_params[:rings] if character_params[:rings].present?
grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present? grid_character.new_awakening = character_params[:awakening] if character_params[:awakening].present?
grid_character.assign_attributes(character_params.except(:rings, :awakening)) grid_character.assign_attributes(character_params.except(:rings, :awakening, :character_id, :party_id))
end end
## ##
@ -315,8 +435,12 @@ module Api
# #
# @return [void] # @return [void]
def find_incoming_character def find_incoming_character
@incoming_character = Character.find_by(id: character_params[:character_id]) character_id = character_params[:character_id]
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new) unless @incoming_character @incoming_character = find_by_any_id(Character, character_id)
unless @incoming_character
render_unprocessable_entity_response(Api::V1::NoCharacterProvidedError.new)
end
end end
## ##
@ -374,6 +498,45 @@ module Api
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end end
##
# Validates if a character position is valid.
#
# @param position [Integer] the position to validate.
# @return [Boolean] true if the position is valid; false otherwise.
def valid_character_position?(position)
# Main slots (0-4), extra slots (5-7) for unlimited raids
(0..7).cover?(position)
end
##
# Determines if character positions should be compacted.
#
# @param old_position [Integer] the old position.
# @param new_position [Integer] the new position.
# @return [Boolean] true if compaction is needed; false otherwise.
def should_compact_characters?(old_position, new_position)
# Compact if moving from main slots (0-4) to extra (5-7) or vice versa
main_to_extra = (0..4).cover?(old_position) && (5..7).cover?(new_position)
extra_to_main = (5..7).cover?(old_position) && (0..4).cover?(new_position)
main_to_extra || extra_to_main
end
##
# Compacts character positions to maintain sequential filling.
#
# @return [Boolean] true if positions were reordered; false otherwise.
def compact_character_positions
main_characters = @party.characters.where(position: 0..4).order(:position)
ActiveRecord::Base.transaction do
main_characters.each_with_index do |char, index|
char.update!(position: index) if char.position != index
end
end
true
end
## ##
# Specifies and permits the allowed character parameters. # Specifies and permits the allowed character parameters.
# #
@ -383,6 +546,7 @@ module Api
:id, :id,
:party_id, :party_id,
:character_id, :character_id,
:collection_character_id,
:position, :position,
:uncap_level, :uncap_level,
:transcendence_step, :transcendence_step,
@ -393,6 +557,22 @@ module Api
) )
end end
##
# Specifies and permits the position update parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def position_params
params.permit(:position, :container)
end
##
# Specifies and permits the swap parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def swap_params
params.permit(:source_id, :target_id)
end
## ##
# Specifies and permits the allowed resolve parameters. # Specifies and permits the allowed resolve parameters.
# #

View file

@ -10,12 +10,14 @@ module Api
# #
# @see Api::V1::ApiController for shared API behavior. # @see Api::V1::ApiController for shared API behavior.
class GridSummonsController < Api::V1::ApiController class GridSummonsController < Api::V1::ApiController
include IdResolvable
attr_reader :party, :incoming_summon attr_reader :party, :incoming_summon
before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon resolve destroy] before_action :find_grid_summon, only: %i[update update_uncap_level update_quick_summon update_position resolve destroy sync]
before_action :find_party, only: %i[create update update_uncap_level update_quick_summon resolve destroy] before_action :find_party, only: %i[create update update_uncap_level update_quick_summon update_position swap resolve destroy sync]
before_action :find_incoming_summon, only: :create before_action :find_incoming_summon, only: :create
before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon destroy] before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_quick_summon update_position swap destroy sync]
## ##
# Creates a new grid summon. # Creates a new grid summon.
@ -28,10 +30,9 @@ module Api
# @return [void] # @return [void]
def create def create
# Build a new grid summon using permitted parameters merged with party and summon IDs. # Build a new grid summon using permitted parameters merged with party and summon IDs.
# Then, using `tap`, ensure that the uncap_level is set by using the max_uncap_level helper # Set the uncap_level to the summon's maximum uncap level regardless of what the client sent.
# if it hasn't already been provided.
grid_summon = build_grid_summon.tap do |gs| grid_summon = build_grid_summon.tap do |gs|
gs.uncap_level ||= max_uncap_level(gs) gs.uncap_level = max_uncap_level(gs.summon)
end end
# If the grid summon is valid (i.e. it passes all validations), then save it normally. # If the grid summon is valid (i.e. it passes all validations), then save it normally.
@ -82,7 +83,7 @@ module Api
new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0 new_transcendence_step = summon.transcendence && summon_params[:transcendence_step].present? ? summon_params[:transcendence_step] : 0
if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step) if @grid_summon.update(uncap_level: new_uncap_level, transcendence_step: new_transcendence_step)
render json: GridSummonBlueprint.render(@grid_summon, view: :nested, root: :grid_summon) render json: GridSummonBlueprint.render(@grid_summon, view: :uncap, root: :grid_summon)
else else
render_validation_error_response(@grid_summon) render_validation_error_response(@grid_summon)
end end
@ -114,6 +115,97 @@ module Api
render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons) render json: GridSummonBlueprint.render(summons, view: :nested, root: :summons)
end end
##
# Updates the position of a GridSummon.
#
# Moves a grid summon to a new position, optionally changing its container.
# Validates that the target position is empty and within allowed bounds.
#
# @return [void]
def update_position
new_position = position_params[:position].to_i
new_container = position_params[:container]
# Validate position bounds (-1 main, 0-3 sub, 4-5 subaura, 6 friend)
unless valid_summon_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for summon")
)
end
# Check if position is restricted (main summon, friend)
if restricted_summon_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Cannot move summon to restricted position #{new_position}")
)
end
# Check if target position is occupied
if GridSummon.exists?(party_id: @party.id, position: new_position)
return render_unprocessable_entity_response(
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
)
end
@grid_summon.position = new_position
if @grid_summon.save
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
grid_summon: GridSummonBlueprint.render_as_hash(@grid_summon.reload, view: :nested)
}, status: :ok
else
render_validation_error_response(@grid_summon)
end
end
##
# Swaps positions between two GridSummons.
#
# Exchanges the positions of two grid summons within the same party.
# Both summons must belong to the same party and not be in restricted positions.
#
# @return [void]
def swap
source_id = swap_params[:source_id]
target_id = swap_params[:target_id]
source = GridSummon.find_by(id: source_id, party_id: @party.id)
target = GridSummon.find_by(id: target_id, party_id: @party.id)
unless source && target
return render_not_found_response('grid_summon')
end
# Check if either position is restricted
if restricted_summon_position?(source.position) || restricted_summon_position?(target.position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Cannot swap summons in restricted positions")
)
end
# Perform the swap
ActiveRecord::Base.transaction do
temp_position = -999
source_pos = source.position
target_pos = target.position
source.update!(position: temp_position)
target.update!(position: source_pos)
source.update!(position: target_pos)
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
swapped: {
source: GridSummonBlueprint.render_as_hash(source.reload, view: :nested),
target: GridSummonBlueprint.render_as_hash(target.reload, view: :nested)
}
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error_response(e.record)
end
# #
# Destroys a grid summon. # Destroys a grid summon.
# #
@ -127,7 +219,30 @@ module Api
return render_not_found_response('grid_summon') if grid_summon.nil? return render_not_found_response('grid_summon') if grid_summon.nil?
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok if grid_summon.destroy if grid_summon.destroy
render json: GridSummonBlueprint.render(grid_summon, view: :destroyed), status: :ok
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(grid_summon.errors.full_messages.join(', '))
)
end
end
##
# Syncs a grid summon from its linked collection summon.
#
# @return [void]
def sync
unless @grid_summon.collection_summon.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection summon linked')
)
end
@grid_summon.sync_from_collection!
render json: GridSummonBlueprint.render(@grid_summon.reload,
root: :grid_summon,
view: :nested)
end end
## ##
@ -214,7 +329,7 @@ module Api
# #
# @return [void] # @return [void]
def find_incoming_summon def find_incoming_summon
@incoming_summon = Summon.find_by(id: summon_params[:summon_id]) @incoming_summon = find_by_any_id(Summon, summon_params[:summon_id])
end end
## ##
@ -331,13 +446,50 @@ module Api
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end end
##
# Validates if a summon position is valid.
#
# @param position [Integer] the position to validate.
# @return [Boolean] true if the position is valid; false otherwise.
def valid_summon_position?(position)
# Main (-1), sub slots (0-3), subaura (4-5), friend (6)
position == -1 || (0..6).cover?(position)
end
##
# Checks if a summon position is restricted (cannot be drag-drop target).
#
# @param position [Integer] the position to check.
# @return [Boolean] true if the position is restricted; false otherwise.
def restricted_summon_position?(position)
# Main summon (-1) and friend summon (6) are restricted
position == -1 || position == 6
end
## ##
# Defines and permits the whitelisted parameters for a grid summon. # Defines and permits the whitelisted parameters for a grid summon.
# #
# @return [ActionController::Parameters] The permitted parameters. # @return [ActionController::Parameters] The permitted parameters.
def summon_params def summon_params
params.require(:summon).permit(:id, :party_id, :summon_id, :position, :main, :friend, params.require(:summon).permit(:id, :party_id, :summon_id, :collection_summon_id,
:quick_summon, :uncap_level, :transcendence_step) :position, :main, :friend, :quick_summon,
:uncap_level, :transcendence_step)
end
##
# Specifies and permits the position update parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def position_params
params.permit(:position, :container)
end
##
# Specifies and permits the swap parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def swap_params
params.permit(:source_id, :target_id)
end end
end end
end end

View file

@ -10,10 +10,12 @@ module Api
# #
# @see Api::V1::ApiController for shared API behavior. # @see Api::V1::ApiController for shared API behavior.
class GridWeaponsController < Api::V1::ApiController class GridWeaponsController < Api::V1::ApiController
before_action :find_grid_weapon, only: %i[update update_uncap_level resolve destroy] include IdResolvable
before_action :find_party, only: %i[create update update_uncap_level resolve destroy]
before_action :find_grid_weapon, only: %i[update update_uncap_level update_position resolve destroy sync]
before_action :find_party, only: %i[create update update_uncap_level update_position swap resolve destroy sync]
before_action :find_incoming_weapon, only: %i[create resolve] before_action :find_incoming_weapon, only: %i[create resolve]
before_action :authorize_party_edit!, only: %i[create update update_uncap_level resolve destroy] before_action :authorize_party_edit!, only: %i[create update update_uncap_level update_position swap resolve destroy sync]
## ##
# Creates a new GridWeapon. # Creates a new GridWeapon.
@ -26,10 +28,13 @@ module Api
def create def create
return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil? return render_unprocessable_entity_response(Api::V1::NoWeaponProvidedError.new) if @incoming_weapon.nil?
position = weapon_params[:position]
collection_weapon_id = weapon_params[:collection_weapon_id]
grid_weapon = GridWeapon.new( grid_weapon = GridWeapon.new(
weapon_params.merge( weapon_params.merge(
party_id: @party.id, party_id: @party.id,
weapon_id: @incoming_weapon.id weapon_id: @incoming_weapon.id,
uncap_level: compute_default_uncap(@incoming_weapon, position, collection_weapon_id)
) )
) )
@ -72,13 +77,95 @@ module Api
requested_uncap = weapon_params[:uncap_level].to_i requested_uncap = weapon_params[:uncap_level].to_i
new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap new_uncap = requested_uncap > max_uncap ? max_uncap : requested_uncap
if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: weapon_params[:transcendence_step].to_i) if @grid_weapon.update(uncap_level: new_uncap, transcendence_step: (weapon_params[:transcendence_step] || 0).to_i)
render json: GridWeaponBlueprint.render(@grid_weapon, view: :full, root: :grid_weapon), status: :ok render json: GridWeaponBlueprint.render(@grid_weapon, view: :uncap, root: :grid_weapon), status: :ok
else else
render_validation_error_response(@grid_weapon) render_validation_error_response(@grid_weapon)
end end
end end
##
# Updates the position of a GridWeapon.
#
# Moves a grid weapon to a new position, optionally changing its container.
# Validates that the target position is empty and within allowed bounds.
#
# @return [void]
def update_position
new_position = position_params[:position].to_i
new_container = position_params[:container]
# Validate position bounds
unless valid_weapon_position?(new_position)
return render_unprocessable_entity_response(
Api::V1::InvalidPositionError.new("Invalid position #{new_position} for weapon")
)
end
# Check if target position is occupied
if GridWeapon.exists?(party_id: @party.id, position: new_position)
return render_unprocessable_entity_response(
Api::V1::PositionOccupiedError.new("Position #{new_position} is already occupied")
)
end
# Update position
old_position = @grid_weapon.position
@grid_weapon.position = new_position
# Update party attributes if needed
update_party_attributes_for_position(@grid_weapon, new_position)
if @grid_weapon.save
render json: {
party: PartyBlueprint.render_as_hash(@party, view: :full),
grid_weapon: GridWeaponBlueprint.render_as_hash(@grid_weapon, view: :full)
}, status: :ok
else
render_validation_error_response(@grid_weapon)
end
end
##
# Swaps positions between two GridWeapons.
#
# Exchanges the positions of two grid weapons within the same party.
# Both weapons must belong to the same party and be valid for swapping.
#
# @return [void]
def swap
source_id = swap_params[:source_id]
target_id = swap_params[:target_id]
source = GridWeapon.find_by(id: source_id, party_id: @party.id)
target = GridWeapon.find_by(id: target_id, party_id: @party.id)
unless source && target
return render_not_found_response('grid_weapon')
end
# Perform the swap
ActiveRecord::Base.transaction do
temp_position = -999
source_pos = source.position
target_pos = target.position
source.update!(position: temp_position)
target.update!(position: source_pos)
source.update!(position: target_pos)
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
swapped: {
source: GridWeaponBlueprint.render_as_hash(source.reload, view: :full),
target: GridWeaponBlueprint.render_as_hash(target.reload, view: :full)
}
}, status: :ok
rescue ActiveRecord::RecordInvalid => e
render_validation_error_response(e.record)
end
## ##
# Resolves conflicts by removing conflicting grid weapons and creating a new one. # Resolves conflicts by removing conflicting grid weapons and creating a new one.
# #
@ -88,7 +175,7 @@ module Api
# #
# @return [void] # @return [void]
def resolve def resolve
incoming = Weapon.find_by(id: resolve_params[:incoming]) incoming = find_by_any_id(Weapon, resolve_params[:incoming])
conflicting_ids = resolve_params[:conflicting] conflicting_ids = resolve_params[:conflicting]
conflicting_weapons = GridWeapon.where(id: conflicting_ids) conflicting_weapons = GridWeapon.where(id: conflicting_ids)
@ -101,11 +188,13 @@ module Api
end end
# Compute the default uncap level based on incoming weapon flags, maxing out at ULB. # Compute the default uncap level based on incoming weapon flags, maxing out at ULB.
new_uncap = compute_default_uncap(incoming) # For extra positions, force ULB for weapons with extra-capable series.
position = resolve_params[:position]
new_uncap = compute_default_uncap(incoming, position)
grid_weapon = GridWeapon.create!( grid_weapon = GridWeapon.create!(
party_id: @party.id, party_id: @party.id,
weapon_id: incoming.id, weapon_id: incoming.id,
position: resolve_params[:position], position: position,
uncap_level: new_uncap, uncap_level: new_uncap,
transcendence_step: 0 transcendence_step: 0
) )
@ -128,7 +217,30 @@ module Api
return render_not_found_response('grid_weapon') if grid_weapon.nil? return render_not_found_response('grid_weapon') if grid_weapon.nil?
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok if grid_weapon.destroy if grid_weapon.destroy
render json: GridWeaponBlueprint.render(grid_weapon, view: :destroyed), status: :ok
else
render_unprocessable_entity_response(
Api::V1::GranblueError.new(grid_weapon.errors.full_messages.join(', '))
)
end
end
##
# Syncs a grid weapon from its linked collection weapon.
#
# @return [void]
def sync
unless @grid_weapon.collection_weapon.present?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new('No collection weapon linked')
)
end
@grid_weapon.sync_from_collection!
render json: GridWeaponBlueprint.render(@grid_weapon.reload,
root: :grid_weapon,
view: :full)
end end
private private
@ -154,23 +266,38 @@ module Api
# Computes the default uncap level for an incoming weapon. # Computes the default uncap level for an incoming weapon.
# #
# This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags. # This method calculates the default uncap level by computing the maximum uncap level based on the weapon's flags.
# For extra positions (9-11), weapons with extra_prerequisite set will be forced to that uncap level.
# This logic is skipped for collection weapons which should retain their actual uncap level.
# #
# @param incoming [Weapon] the incoming weapon. # @param incoming [Weapon] the incoming weapon.
# @param position [Integer] the target position (optional).
# @param collection_weapon_id [String] the collection weapon ID if linking from collection (optional).
# @return [Integer] the default uncap level. # @return [Integer] the default uncap level.
def compute_default_uncap(incoming) def compute_default_uncap(incoming, position = nil, collection_weapon_id = nil)
compute_max_uncap_level(incoming) max_uncap = compute_max_uncap_level(incoming)
# Skip prerequisite logic for collection weapons - use their actual uncap level
return max_uncap if collection_weapon_id.present?
# Extra positions require minimum uncap for weapons with extra_prerequisite set
if position && GridWeapon::EXTRA_POSITIONS.include?(position.to_i) &&
incoming.extra_prerequisite.present?
return [incoming.extra_prerequisite, max_uncap].min
end
max_uncap
end end
## ##
# Normalizes the AX modifier fields for the weapon parameters. # Normalizes the AX modifier fields for the weapon parameters.
# #
# Sets ax_modifier1 and ax_modifier2 to nil if their integer values equal -1. # Sets ax_modifier1_id and ax_modifier2_id to nil if their integer values equal -1.
# #
# @return [void] # @return [void]
def normalize_ax_fields! def normalize_ax_fields!
params[:weapon][:ax_modifier1] = nil if weapon_params[:ax_modifier1].to_i == -1 params[:weapon][:ax_modifier1_id] = nil if weapon_params[:ax_modifier1_id].to_i == -1
params[:weapon][:ax_modifier2_id] = nil if weapon_params[:ax_modifier2_id].to_i == -1
params[:weapon][:ax_modifier2] = nil if weapon_params[:ax_modifier2].to_i == -1 params[:weapon][:befoulment_modifier_id] = nil if weapon_params[:befoulment_modifier_id].to_i == -1
end end
## ##
@ -280,7 +407,7 @@ module Api
# @return [void] # @return [void]
def find_incoming_weapon def find_incoming_weapon
if params.dig(:weapon, :weapon_id).present? if params.dig(:weapon, :weapon_id).present?
@incoming_weapon = Weapon.find_by(id: params.dig(:weapon, :weapon_id)) @incoming_weapon = find_by_any_id(Weapon, params.dig(:weapon, :weapon_id))
render_not_found_response('weapon') unless @incoming_weapon render_not_found_response('weapon') unless @incoming_weapon
else else
@incoming_weapon = nil @incoming_weapon = nil
@ -353,20 +480,63 @@ module Api
ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key) ActiveSupport::SecurityUtils.secure_compare(provided_edit_key, party_edit_key)
end end
##
# Validates if a weapon position is valid.
#
# @param position [Integer] the position to validate.
# @return [Boolean] true if the position is valid; false otherwise.
def valid_weapon_position?(position)
# Mainhand (-1), grid slots (0-8), extra slots (9-11)
position == -1 || (0..11).cover?(position)
end
##
# Updates party attributes based on the weapon's new position.
#
# @param grid_weapon [GridWeapon] the grid weapon being moved.
# @param new_position [Integer] the new position.
# @return [void]
def update_party_attributes_for_position(grid_weapon, new_position)
if new_position == -1
@party.element = grid_weapon.weapon.element
@party.save!
elsif GridWeapon::EXTRA_POSITIONS.include?(new_position)
@party.extra = true
@party.save!
end
end
## ##
# Specifies and permits the allowed weapon parameters. # Specifies and permits the allowed weapon parameters.
# #
# @return [ActionController::Parameters] the permitted parameters. # @return [ActionController::Parameters] the permitted parameters.
def weapon_params def weapon_params
params.require(:weapon).permit( params.require(:weapon).permit(
:id, :party_id, :weapon_id, :id, :party_id, :weapon_id, :collection_weapon_id,
:position, :mainhand, :uncap_level, :transcendence_step, :element, :position, :mainhand, :uncap_level, :transcendence_step, :element,
:weapon_key1_id, :weapon_key2_id, :weapon_key3_id, :weapon_key1_id, :weapon_key2_id, :weapon_key3_id,
:ax_modifier1, :ax_modifier2, :ax_strength1, :ax_strength2, :ax_modifier1_id, :ax_modifier2_id, :ax_strength1, :ax_strength2,
:befoulment_modifier_id, :befoulment_strength, :exorcism_level,
:awakening_id, :awakening_level :awakening_id, :awakening_level
) )
end end
##
# Specifies and permits the position update parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def position_params
params.permit(:position, :container)
end
##
# Specifies and permits the swap parameters.
#
# @return [ActionController::Parameters] the permitted parameters.
def swap_params
params.permit(:source_id, :target_id)
end
## ##
# Specifies and permits the resolve parameters. # Specifies and permits the resolve parameters.
# #

View file

@ -0,0 +1,60 @@
# frozen_string_literal: true
module Api
module V1
class GwCrewScoresController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_officer!
before_action :set_participation
before_action :set_score, only: %i[update destroy]
# POST /crew/gw_participations/:gw_participation_id/crew_scores
def create
score = @participation.gw_crew_scores.build(score_params)
if score.save
render json: GwCrewScoreBlueprint.render(score, root: :gw_crew_score), status: :created
else
render_validation_error_response(score)
end
end
# PUT /crew/gw_participations/:gw_participation_id/crew_scores/:id
def update
if @score.update(score_params)
render json: GwCrewScoreBlueprint.render(@score, root: :gw_crew_score)
else
render_validation_error_response(@score)
end
end
# DELETE /crew/gw_participations/:gw_participation_id/crew_scores/:id
def destroy
@score.destroy!
head :no_content
end
private
def set_crew
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_participation
@participation = @crew.crew_gw_participations.find(params[:gw_participation_id])
end
def set_score
@score = @participation.gw_crew_scores.find(params[:id])
end
def score_params
params.require(:crew_score).permit(:round, :crew_score, :opponent_score, :opponent_name, :opponent_granblue_id)
end
end
end
end

View file

@ -0,0 +1,67 @@
# frozen_string_literal: true
module Api
module V1
class GwEventsController < Api::V1::ApiController
before_action :restrict_access, only: %i[create update]
before_action :require_admin!, only: %i[create update]
before_action :set_event, only: %i[show update]
# GET /gw_events
def index
events = GwEvent.order(start_date: :desc)
# If user has a crew, include participation data for each event
participations_by_event = {}
if current_user&.crew
participations = current_user.crew.crew_gw_participations.includes(:gw_individual_scores)
participations.each do |p|
participations_by_event[p.gw_event_id] = p
end
end
render json: GwEventBlueprint.render(events, root: :gw_events, participations: participations_by_event)
end
# GET /gw_events/:id
def show
participation = current_user&.crew&.crew_gw_participations&.find_by(gw_event: @event)
render json: GwEventBlueprint.render(@event, view: :with_participation, participation: participation, root: :gw_event)
end
# POST /gw_events (admin only)
def create
event = GwEvent.new(event_params)
if event.save
render json: GwEventBlueprint.render(event, root: :gw_event), status: :created
else
render_validation_error_response(event)
end
end
# PUT /gw_events/:id (admin only)
def update
if @event.update(event_params)
render json: GwEventBlueprint.render(@event, root: :gw_event)
else
render_validation_error_response(@event)
end
end
private
def set_event
@event = GwEvent.find(params[:id])
end
def event_params
params.require(:gw_event).permit(:element, :start_date, :end_date, :event_number)
end
def require_admin!
raise Api::V1::UnauthorizedError unless current_user&.admin?
end
end
end
end

View file

@ -0,0 +1,156 @@
# frozen_string_literal: true
module Api
module V1
class GwIndividualScoresController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew
before_action :authorize_crew_member!
before_action :set_participation, except: %i[create_by_event batch_by_event]
before_action :set_or_create_participation_by_event, only: %i[create_by_event batch_by_event]
before_action :set_score, only: %i[update destroy]
# POST /crew/gw_participations/:gw_participation_id/individual_scores
def create
# Members can only record their own scores, officers can record anyone's
membership_id = score_params[:crew_membership_id]
unless can_record_score_for?(membership_id)
raise Api::V1::UnauthorizedError
end
score = @participation.gw_individual_scores.build(score_params)
score.recorded_by = current_user
if score.save
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score, current_user: current_user), status: :created
else
render_validation_error_response(score)
end
end
# PUT /crew/gw_participations/:gw_participation_id/individual_scores/:id
def update
unless can_record_score_for?(@score.crew_membership_id)
raise Api::V1::UnauthorizedError
end
if @score.update(score_params.except(:crew_membership_id))
render json: GwIndividualScoreBlueprint.render(@score, view: :with_member, root: :individual_score, current_user: current_user)
else
render_validation_error_response(@score)
end
end
# DELETE /crew/gw_participations/:gw_participation_id/individual_scores/:id
def destroy
unless can_record_score_for?(@score.crew_membership_id)
raise Api::V1::UnauthorizedError
end
@score.destroy!
head :no_content
end
# POST /crew/gw_participations/:gw_participation_id/individual_scores/batch
def batch
return render_unauthorized_response unless current_user.crew_officer?
process_batch_scores
end
# POST /crew/gw_events/:gw_event_id/individual_scores
# Auto-creates participation if needed, officers only
def create_by_event
return render_unauthorized_response unless current_user.crew_officer?
score = @participation.gw_individual_scores.build(score_params_with_player)
score.recorded_by = current_user
if score.save
render json: GwIndividualScoreBlueprint.render(score, view: :with_member, root: :individual_score, current_user: current_user), status: :created
else
render_validation_error_response(score)
end
end
# POST /crew/gw_events/:gw_event_id/individual_scores/batch
# Auto-creates participation if needed, officers only
def batch_by_event
return render_unauthorized_response unless current_user.crew_officer?
process_batch_scores
end
private
def set_crew
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_participation
@participation = @crew.crew_gw_participations.find(params[:gw_participation_id])
end
def set_or_create_participation_by_event
event = GwEvent.find(params[:gw_event_id])
@participation = @crew.crew_gw_participations.find_or_create_by!(gw_event: event)
end
def set_score
@score = @participation.gw_individual_scores.find(params[:id])
end
def score_params
params.require(:individual_score).permit(:crew_membership_id, :round, :score, :is_cumulative, :excused, :excuse_reason)
end
def score_params_with_player
params.require(:individual_score).permit(:crew_membership_id, :phantom_player_id, :round, :score, :is_cumulative, :excused, :excuse_reason)
end
def can_record_score_for?(membership_id)
return true if current_user.crew_officer?
# Regular members can only record their own scores
current_user.active_crew_membership&.id == membership_id
end
def process_batch_scores
scores_params = params.require(:scores)
results = []
errors = []
scores_params.each_with_index do |score_data, index|
score = @participation.gw_individual_scores.find_or_initialize_by(
crew_membership_id: score_data[:crew_membership_id],
phantom_player_id: score_data[:phantom_player_id],
round: score_data[:round]
)
score.assign_attributes(
score: score_data[:score],
is_cumulative: score_data[:is_cumulative] || false,
excused: score_data[:excused] || false,
excuse_reason: score_data[:excuse_reason],
recorded_by: current_user
)
if score.save
results << score
else
errors << { index: index, errors: score.errors.full_messages }
end
end
if errors.empty?
render json: GwIndividualScoreBlueprint.render(results, view: :with_member, root: :individual_scores, current_user: current_user), status: :created
else
render json: { individual_scores: GwIndividualScoreBlueprint.render_as_hash(results, view: :with_member, current_user: current_user), errors: errors },
status: :multi_status
end
end
end
end
end

View file

@ -27,6 +27,94 @@ module Api
6 => 5 6 => 5
}.freeze }.freeze
# GBF series_id to CharacterSeries slug mapping
GBF_SERIES_TO_SLUG = {
1 => 'summer',
2 => 'yukata',
3 => 'valentine',
4 => 'halloween',
5 => 'holiday',
6 => 'zodiac',
7 => 'grand',
8 => 'fantasy',
9 => 'collab',
10 => 'eternal',
11 => 'evoker',
12 => 'saint',
13 => 'formal'
}.freeze
# GBF series_id to WeaponSeries slug mapping
GBF_WEAPON_SERIES_TO_SLUG = {
1 => 'seraphic',
2 => 'grand',
3 => 'dark-opus',
4 => 'revenant',
5 => 'primal',
6 => 'beast',
7 => 'regalia',
8 => 'omega',
9 => 'olden-primal',
10 => 'hollowsky',
11 => 'xeno',
12 => 'rose',
13 => 'ultima',
14 => 'bahamut',
15 => 'epic',
16 => 'cosmos',
17 => 'superlative',
18 => 'vintage',
19 => 'class-champion',
20 => 'replica',
21 => 'relic',
22 => 'rusted',
23 => 'sephira',
24 => 'vyrmament',
25 => 'upgrader',
26 => 'astral',
27 => 'draconic',
28 => 'eternal-splendor',
29 => 'ancestral',
30 => 'new-world-foundation',
31 => 'ennead',
32 => 'militis',
33 => 'malice',
34 => 'menace',
35 => 'illustrious',
36 => 'proven',
37 => 'revans',
38 => 'world',
39 => 'exo',
40 => 'draconic-providence',
41 => 'celestial',
42 => 'omega-rebirth',
43 => 'collab',
44 => 'destroyer'
}.freeze
# GBF series_id to SummonSeries slug mapping
GBF_SUMMON_SERIES_TO_SLUG = {
1 => 'providence',
2 => 'genesis',
3 => 'magna',
4 => 'optimus',
5 => 'demi-optimus',
6 => 'archangel',
7 => 'arcarum',
8 => 'epic',
9 => 'carbuncle',
10 => 'dynamis',
12 => 'cryptid',
13 => 'six-dragons',
14 => 'summer',
15 => 'yukata',
16 => 'holiday',
17 => 'collab',
18 => 'bellum',
19 => 'crest',
20 => 'robur'
}.freeze
before_action :ensure_admin_role, only: %i[weapons summons characters] before_action :ensure_admin_role, only: %i[weapons summons characters]
## ##
@ -92,6 +180,20 @@ module Api
weapon.update!( weapon.update!(
"game_raw_#{lang}" => body.to_json "game_raw_#{lang}" => body.to_json
) )
# Parse series_id and assign WeaponSeries
series_id = body['series_id'] || body.dig('master', 'series_id')
if series_id
slug = GBF_WEAPON_SERIES_TO_SLUG[series_id.to_i]
if slug
series_record = WeaponSeries.find_by(slug: slug)
if series_record && weapon.weapon_series != series_record
weapon.update!(weapon_series: series_record)
Rails.logger.info "[IMPORT] Set series '#{slug}' for weapon #{weapon.granblue_id}"
end
end
end
render json: { message: 'Weapon gamedata updated successfully' }, status: :ok render json: { message: 'Weapon gamedata updated successfully' }, status: :ok
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}" Rails.logger.error "[IMPORT] Failed to update weapon gamedata: #{e.message}"
@ -121,6 +223,20 @@ module Api
summon.update!( summon.update!(
"game_raw_#{lang}" => body.to_json "game_raw_#{lang}" => body.to_json
) )
# Parse series_id and assign SummonSeries
series_id = body['series_id'] || body.dig('master', 'series_id')
if series_id
slug = GBF_SUMMON_SERIES_TO_SLUG[series_id.to_i]
if slug
series_record = SummonSeries.find_by(slug: slug)
if series_record && summon.summon_series != series_record
summon.update!(summon_series: series_record)
Rails.logger.info "[IMPORT] Set series '#{slug}' for summon #{summon.granblue_id}"
end
end
end
render json: { message: 'Summon gamedata updated successfully' }, status: :ok render json: { message: 'Summon gamedata updated successfully' }, status: :ok
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}" Rails.logger.error "[IMPORT] Failed to update summon gamedata: #{e.message}"
@ -154,6 +270,20 @@ module Api
character.update!( character.update!(
"game_raw_#{lang}" => body.to_json "game_raw_#{lang}" => body.to_json
) )
# Parse series_id and create CharacterSeriesMembership
series_id = body['series_id'] || body.dig('master', 'series_id')
if series_id
slug = GBF_SERIES_TO_SLUG[series_id.to_i]
if slug
series_record = CharacterSeries.find_by(slug: slug)
if series_record && !character.character_series_records.include?(series_record)
character.character_series_memberships.create!(character_series: series_record)
Rails.logger.info "[IMPORT] Added series '#{slug}' to character #{character.granblue_id}"
end
end
end
render json: { message: 'Character gamedata updated successfully' }, status: :ok render json: { message: 'Character gamedata updated successfully' }, status: :ok
rescue StandardError => e rescue StandardError => e
Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}" Rails.logger.error "[IMPORT] Failed to update character gamedata: #{e.message}"

View file

@ -3,10 +3,84 @@
module Api module Api
module V1 module V1
class JobAccessoriesController < Api::V1::ApiController class JobAccessoriesController < Api::V1::ApiController
def job before_action :doorkeeper_authorize!, only: %i[create update destroy]
accessories = JobAccessory.where('job_id = ?', params[:id]) before_action :ensure_editor_role, only: %i[create update destroy]
# GET /job_accessories
# Optional filter: ?accessory_type=1 (1=Shield, 2=Manatura)
def index
accessories = JobAccessory.includes(:job).all
accessories = accessories.where(accessory_type: params[:accessory_type]) if params[:accessory_type].present?
accessories = accessories.order(:accessory_type, :granblue_id)
render json: JobAccessoryBlueprint.render(accessories) render json: JobAccessoryBlueprint.render(accessories)
end end
# GET /job_accessories/:id
# Supports lookup by granblue_id or uuid
def show
accessory = find_accessory
return render_not_found_response('job_accessory') unless accessory
render json: JobAccessoryBlueprint.render(accessory)
end
# POST /job_accessories
def create
accessory = JobAccessory.new(job_accessory_params)
if accessory.save
render json: JobAccessoryBlueprint.render(accessory), status: :created
else
render_validation_error_response(accessory)
end
end
# PUT /job_accessories/:id
def update
accessory = find_accessory
return render_not_found_response('job_accessory') unless accessory
if accessory.update(job_accessory_params)
render json: JobAccessoryBlueprint.render(accessory)
else
render_validation_error_response(accessory)
end
end
# DELETE /job_accessories/:id
def destroy
accessory = find_accessory
return render_not_found_response('job_accessory') unless accessory
accessory.destroy
head :no_content
end
# GET /jobs/:id/accessories
# Legacy endpoint - get accessories for a specific job
def job
job = Job.find_by(granblue_id: params[:id]) || Job.find_by(id: params[:id])
return render_not_found_response('job') unless job
accessories = JobAccessory.where(job_id: job.id)
render json: JobAccessoryBlueprint.render(accessories)
end
private
def find_accessory
JobAccessory.find_by(granblue_id: params[:id]) || JobAccessory.find_by(id: params[:id])
end
def job_accessory_params
params.permit(:name_en, :name_jp, :granblue_id, :rarity, :release_date, :accessory_type, :job_id)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[JOB_ACCESSORIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
end end
end end
end end

View file

@ -3,16 +3,87 @@
module Api module Api
module V1 module V1
class JobSkillsController < Api::V1::ApiController class JobSkillsController < Api::V1::ApiController
before_action :doorkeeper_authorize!, only: %i[create update destroy download_image]
before_action :ensure_editor_role, only: %i[create update destroy download_image]
def all def all
render json: JobSkillBlueprint.render(JobSkill.includes(:job).all) render json: JobSkillBlueprint.render(JobSkill.includes(:job).all)
end end
# Returns skills that belong to a specific job
def job def job
job = Job.find_by(granblue_id: params[:id])
return render_not_found_response('job') unless job
@skills = JobSkill.includes(:job)
.where(job_id: job.id)
.order(:order)
render json: JobSkillBlueprint.render(@skills)
end
# Returns EMP skills from other jobs (for party skill selection)
def emp
@skills = JobSkill.includes(:job) @skills = JobSkill.includes(:job)
.where.not(job_id: params[:id]) .where.not(job_id: params[:id])
.where(emp: true) .where(emp: true)
render json: JobSkillBlueprint.render(@skills) render json: JobSkillBlueprint.render(@skills)
end end
# POST /jobs/:job_id/skills
def create
job = Job.find_by(granblue_id: params[:job_id])
return render_not_found_response('job') unless job
skill = job.skills.build(job_skill_params)
if skill.save
render json: JobSkillBlueprint.render(skill), status: :created
else
render_validation_error_response(skill)
end
end
# PUT /jobs/:job_id/skills/:id
def update
skill = JobSkill.find(params[:id])
if skill.update(job_skill_params)
render json: JobSkillBlueprint.render(skill)
else
render_validation_error_response(skill)
end
end
# DELETE /jobs/:job_id/skills/:id
def destroy
skill = JobSkill.find(params[:id])
skill.destroy
head :no_content
end
# POST /jobs/:job_id/skills/:id/download_image
def download_image
skill = JobSkill.find(params[:id])
return render json: { error: 'No image_id' }, status: :unprocessable_entity unless skill.image_id.present?
return render json: { error: 'No slug' }, status: :unprocessable_entity unless skill.slug.present?
downloader = Granblue::Downloaders::JobSkillDownloader.new(skill.image_id, slug: skill.slug, storage: :s3)
result = downloader.download
render json: { success: result[:success], filename: "#{skill.slug}.png" }
end
private
def job_skill_params
params.permit(:name_en, :name_jp, :slug, :color, :main, :base, :sub, :emp, :order,
:image_id, :action_id)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[JOB_SKILLS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
end end
end end
end end

View file

@ -3,8 +3,10 @@
module Api module Api
module V1 module V1
class JobsController < Api::V1::ApiController class JobsController < Api::V1::ApiController
before_action :set, only: %w[update_job update_job_skills destroy_job_skill] before_action :set_party, only: %w[update_job update_job_skills destroy_job_skill]
before_action :authorize, only: %w[update_job update_job_skills destroy_job_skill] before_action :authorize_party, only: %w[update_job update_job_skills destroy_job_skill]
before_action :set_job, only: %w[update]
before_action :ensure_editor_role, only: %w[create update]
def all def all
render json: JobBlueprint.render(Job.all) render json: JobBlueprint.render(Job.all)
@ -14,6 +16,28 @@ module Api
render json: JobBlueprint.render(Job.find_by(granblue_id: params[:id])) render json: JobBlueprint.render(Job.find_by(granblue_id: params[:id]))
end end
# POST /jobs
# Creates a new job record
def create
@job = Job.new(job_update_params)
if @job.save
render json: JobBlueprint.render(@job), status: :created
else
render_validation_error_response(@job)
end
end
# PATCH/PUT /jobs/:id
# Updates an existing job record
def update
if @job.update(job_update_params)
render json: JobBlueprint.render(@job)
else
render_validation_error_response(@job)
end
end
def update_job def update_job
if job_params[:job_id] != -1 if job_params[:job_id] != -1
# Extract job and find its main skills # Extract job and find its main skills
@ -51,7 +75,7 @@ module Api
end end
def update_job_skills def update_job_skills
throw NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id] raise Api::V1::NoJobSkillProvidedError unless job_params[:skill1_id] || job_params[:skill2_id] || job_params[:skill3_id]
# Determine which incoming keys contain new skills # Determine which incoming keys contain new skills
skill_keys = %w[skill1_id skill2_id skill3_id] skill_keys = %w[skill1_id skill2_id skill3_id]
@ -59,47 +83,47 @@ module Api
# If there are new skills, merge them with the existing skills # If there are new skills, merge them with the existing skills
unless new_skill_keys.empty? unless new_skill_keys.empty?
# Load skills ONCE upfront to avoid N+1 queries
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
new_skills_loaded = JobSkill.where(id: new_skill_ids).index_by(&:id)
# Validate all skills exist and are compatible
new_skill_ids.each do |id|
skill = new_skills_loaded[id]
raise ActiveRecord::RecordNotFound.new("Couldn't find JobSkill") unless skill
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job, skill)
end
existing_skills = { existing_skills = {
1 => @party.skill1, 1 => @party.skill1,
2 => @party.skill2, 2 => @party.skill2,
3 => @party.skill3 3 => @party.skill3
} }
new_skill_ids = new_skill_keys.map { |key| job_params[key] }
new_skill_ids.map do |id|
skill = JobSkill.find(id)
raise Api::V1::IncompatibleSkillError.new(job: @party.job, skill: skill) if mismatched_skill(@party.job,
skill)
end
positions = extract_positions_from_keys(new_skill_keys) positions = extract_positions_from_keys(new_skill_keys)
new_skills = merge_skills_with_existing_skills(existing_skills, new_skill_ids, positions) # Pass loaded skills instead of IDs
merged = merge_skills_with_loaded_skills(existing_skills, new_skill_ids.map { |id| new_skills_loaded[id] }, positions)
new_skill_ids = new_skills.each_with_object({}) do |(index, skill), memo| skill_ids_hash = merged.each_with_object({}) do |(index, skill), memo|
memo["skill#{index}_id"] = skill.id if skill memo["skill#{index}_id"] = skill&.id
end end
@party.attributes = new_skill_ids @party.attributes = skill_ids_hash
end end
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save! render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save!
end end
def destroy_job_skill def destroy_job_skill
position = job_params[:skill_position].to_i position = job_params[:skill_position].to_i
@party["skill#{position}_id"] = nil @party["skill#{position}_id"] = nil
render json: PartyBlueprint.render(@party, view: :jobs) if @party.save render json: PartyBlueprint.render(@party, view: :job_metadata) if @party.save
end end
private private
def merge_skills_with_existing_skills( def merge_skills_with_loaded_skills(existing_skills, new_skills, positions)
existing_skills, # new_skills is now an array of already-loaded JobSkill objects
new_skill_ids,
positions
)
new_skills = new_skill_ids.map { |id| JobSkill.find(id) }
new_skills.each_with_index do |skill, index| new_skills.each_with_index do |skill, index|
existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index]) existing_skills = place_skill_in_existing_skills(existing_skills, skill, positions[index])
end end
@ -177,12 +201,35 @@ module Api
end end
end end
def authorize def authorize_party
render_unauthorized_response if @party.user != current_user || @party.edit_key != edit_key render_unauthorized_response if @party.user != current_user || @party.edit_key != edit_key
end end
def set def set_party
@party = Party.where('id = ?', params[:id]).first @party = Party.find_by(shortcode: params[:id])
render_not_found_response('party') unless @party
end
def set_job
@job = Job.find_by(granblue_id: params[:id])
render_not_found_response('job') unless @job
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[JOBS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def job_update_params
params.permit(
:name_en, :name_jp, :granblue_id,
:proficiency1, :proficiency2, :row, :order,
:master_level, :ultimate_mastery,
:accessory, :accessory_type, :base_job_id
)
end end
def job_params def job_params

View file

@ -32,9 +32,9 @@ module Api
# Default maximum clear time in seconds # Default maximum clear time in seconds
DEFAULT_MAX_CLEAR_TIME = 5400 DEFAULT_MAX_CLEAR_TIME = 5400
before_action :set_from_slug, except: %w[create destroy update index favorites] before_action :set_from_slug, except: %w[create destroy update index favorites grid_update]
before_action :set, only: %w[update destroy] before_action :set, only: %w[update destroy grid_update]
before_action :authorize_party!, only: %w[update destroy] before_action :authorize_party!, only: %w[update destroy grid_update]
# Primary CRUD Actions # Primary CRUD Actions
@ -44,10 +44,8 @@ module Api
def create def create
party = Party.new(party_params) party = Party.new(party_params)
party.user = current_user if current_user party.user = current_user if current_user
if party_params && party_params[:raid_id].present? if party_params && party_params[:raid_id].present? && (raid = Raid.find_by(id: party_params[:raid_id]))
if (raid = Raid.find_by(id: party_params[:raid_id])) party.extra = raid.group.extra
party.extra = raid.group.extra
end
end end
if party.save if party.save
party.schedule_preview_generation if party.ready_for_preview? party.schedule_preview_generation if party.ready_for_preview?
@ -71,10 +69,8 @@ module Api
# Updates an existing party. # Updates an existing party.
def update def update
@party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id) @party.attributes = party_params.except(:skill1_id, :skill2_id, :skill3_id)
if party_params && party_params[:raid_id] if party_params && party_params[:raid_id] && (raid = Raid.find_by(id: party_params[:raid_id]))
if (raid = Raid.find_by(id: party_params[:raid_id])) @party.extra = raid.group.extra
@party.extra = raid.group.extra
end
end end
if @party.save if @party.save
render json: PartyBlueprint.render(@party, view: :full, root: :party) render json: PartyBlueprint.render(@party, view: :full, root: :party)
@ -85,7 +81,13 @@ module Api
# Deletes a party. # Deletes a party.
def destroy def destroy
render json: PartyBlueprint.render(@party, view: :destroyed, root: :checkin) if @party.destroy if @party.destroy
head :no_content
else
render_unprocessable_entity_response(
Api::V1::PartyDeletionFailedError.new(@party.errors.full_messages)
)
end
end end
# Extended Party Actions # Extended Party Actions
@ -93,7 +95,8 @@ module Api
# Creates a remixed copy of an existing party. # Creates a remixed copy of an existing party.
def remix def remix
new_party = @party.amoeba_dup new_party = @party.amoeba_dup
new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party, remix: true } new_party.attributes = { user: current_user, name: remixed_name(@party.name), source_party: @party,
remix: true }
new_party.local_id = party_params[:local_id] if party_params new_party.local_id = party_params[:local_id] if party_params
if new_party.save if new_party.save
new_party.schedule_preview_generation new_party.schedule_preview_generation
@ -103,11 +106,99 @@ module Api
end end
end end
# Batch updates grid items (weapons, characters, summons) atomically.
def grid_update
operations = grid_update_params[:operations]
options = grid_update_params[:options] || {}
# Validate all operations first
validation_errors = validate_grid_operations(operations)
if validation_errors.any?
return render_unprocessable_entity_response(
Api::V1::GranblueError.new("Validation failed: #{validation_errors.join(', ')}")
)
end
changes = []
ActiveRecord::Base.transaction do
operations.each do |operation|
change = apply_grid_operation(operation)
changes << change if change
end
# Compact character positions if needed
compact_party_character_positions if options[:maintain_character_sequence]
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
operations_applied: changes.count,
changes: changes
}, status: :ok
rescue StandardError => e
render_unprocessable_entity_response(
Api::V1::GranblueError.new("Grid update failed: #{e.message}")
)
end
# Syncs all linked grid items from their collection sources.
#
# POST /parties/:id/sync_all
def sync_all
@party = Party.find_by(id: params[:id])
return render_not_found_response('party') unless @party
return render_unauthorized_response unless authorized_to_edit?
synced = { characters: 0, weapons: 0, summons: 0, artifacts: 0 }
ActiveRecord::Base.transaction do
@party.characters.where.not(collection_character_id: nil).each do |gc|
gc.sync_from_collection!
synced[:characters] += 1
end
@party.weapons.where.not(collection_weapon_id: nil).each do |gw|
gw.sync_from_collection!
synced[:weapons] += 1
end
@party.summons.where.not(collection_summon_id: nil).each do |gs|
gs.sync_from_collection!
synced[:summons] += 1
end
GridArtifact.joins(:grid_character)
.where(grid_characters: { party_id: @party.id })
.where.not(collection_artifact_id: nil)
.each do |ga|
ga.sync_from_collection!
synced[:artifacts] += 1
end
end
render json: {
party: PartyBlueprint.render_as_hash(@party.reload, view: :full),
synced: synced
}, status: :ok
end
# Lists parties based on query parameters. # Lists parties based on query parameters.
def index def index
query = build_filtered_query(build_common_base_query) query = build_filtered_query(build_common_base_query)
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) @parties = query.paginate(page: params[:page], per_page: page_size)
render_paginated_parties(@parties)
# Preload current user's favorite party IDs to avoid N+1
favorite_party_ids = current_user ? current_user.favorites.pluck(:party_id).to_set : Set.new
render json: Api::V1::PartyBlueprint.render(
@parties,
view: :preview,
root: :results,
meta: pagination_meta(@parties),
current_user: current_user,
favorite_party_ids: favorite_party_ids
)
end end
# GET /api/v1/parties/favorites # GET /api/v1/parties/favorites
@ -115,12 +206,23 @@ module Api
raise Api::V1::UnauthorizedError unless current_user raise Api::V1::UnauthorizedError unless current_user
base_query = build_common_base_query base_query = build_common_base_query
.joins(:favorites) .joins(:favorites)
.where(favorites: { user_id: current_user.id }) .where(favorites: { user_id: current_user.id })
.distinct .distinct
query = build_filtered_query(base_query) query = build_filtered_query(base_query)
@parties = query.paginate(page: params[:page], per_page: COLLECTION_PER_PAGE) @parties = query.paginate(page: params[:page], per_page: page_size)
render_paginated_parties(@parties)
# All parties in this list are favorites, but preload for consistency
favorite_party_ids = current_user.favorites.pluck(:party_id).to_set
render json: Api::V1::PartyBlueprint.render(
@parties,
view: :preview,
root: :results,
meta: pagination_meta(@parties),
current_user: current_user,
favorite_party_ids: favorite_party_ids
)
end end
# Preview Management # Preview Management
@ -135,7 +237,8 @@ module Api
# Returns the current preview status of a party. # Returns the current preview status of a party.
def preview_status def preview_status
party = Party.find_by!(shortcode: params[:id]) party = Party.find_by!(shortcode: params[:id])
render json: { state: party.preview_state, generated_at: party.preview_generated_at, ready_for_preview: party.ready_for_preview? } render json: { state: party.preview_state, generated_at: party.preview_generated_at,
ready_for_preview: party.ready_for_preview? }
end end
# Forces regeneration of the party preview. # Forces regeneration of the party preview.
@ -157,15 +260,17 @@ module Api
def set_from_slug def set_from_slug
@party = Party.includes( @party = Party.includes(
:user, :job, { raid: :group }, :user, :job, { raid: :group },
{ characters: %i[character awakening] }, { characters: [:character, :awakening, :grid_artifact] },
{ weapons: { { weapons: {
weapon: [:awakenings], weapon: [:awakenings, :weapon_series],
awakening: {}, awakening: {},
weapon_key1: {}, weapon_key1: {},
weapon_key2: {}, weapon_key2: {},
weapon_key3: {} weapon_key3: {},
} ax_modifier1: {},
}, ax_modifier2: {},
befoulment_modifier: {}
} },
{ summons: :summon }, { summons: :summon },
:guidebook1, :guidebook2, :guidebook3, :guidebook1, :guidebook2, :guidebook3,
:source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory :source_party, :remixes, :skill0, :skill1, :skill2, :skill3, :accessory
@ -186,15 +291,141 @@ module Api
:user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility, :user_id, :local_id, :edit_key, :extra, :name, :description, :raid_id, :job_id, :visibility,
:accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id, :accessory_id, :skill0_id, :skill1_id, :skill2_id, :skill3_id,
:full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count, :full_auto, :auto_guard, :auto_summon, :charge_attack, :clear_time, :button_count,
:turn_count, :chain_count, :guidebook1_id, :guidebook2_id, :guidebook3_id, :turn_count, :chain_count, :summon_count, :video_url, :guidebook1_id, :guidebook2_id, :guidebook3_id,
characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level, characters_attributes: [:id, :party_id, :character_id, :position, :uncap_level,
:transcendence_step, :perpetuity, :awakening_id, :awakening_level, :transcendence_step, :perpetuity, :awakening_id, :awakening_level,
{ ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength], { ring1: %i[modifier strength], ring2: %i[modifier strength], ring3: %i[modifier strength], ring4: %i[modifier strength],
earring: %i[modifier strength] }], earring: %i[modifier strength] }],
summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step], summons_attributes: %i[id party_id summon_id position main friend quick_summon uncap_level transcendence_step],
weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1 ax_modifier2 ax_strength1 ax_strength2 awakening_id awakening_level] weapons_attributes: %i[id party_id weapon_id position mainhand uncap_level transcendence_step element weapon_key1_id weapon_key2_id weapon_key3_id ax_modifier1_id ax_modifier2_id ax_strength1 ax_strength2 befoulment_modifier_id befoulment_strength exorcism_level awakening_id awakening_level]
) )
end end
# Permits parameters for grid update operation.
def grid_update_params
params.permit(
operations: %i[type entity id source_id target_id position container],
options: %i[maintain_character_sequence validate_before_execute]
)
end
# Validates grid operations before executing.
def validate_grid_operations(operations)
errors = []
operations.each_with_index do |op, index|
case op[:type]
when 'move'
errors << "Operation #{index}: missing id" unless op[:id].present?
errors << "Operation #{index}: missing position" unless op[:position].present?
when 'swap'
errors << "Operation #{index}: missing source_id" unless op[:source_id].present?
errors << "Operation #{index}: missing target_id" unless op[:target_id].present?
when 'remove'
errors << "Operation #{index}: missing id" unless op[:id].present?
else
errors << "Operation #{index}: unknown operation type #{op[:type]}"
end
unless %w[weapon character summon].include?(op[:entity])
errors << "Operation #{index}: invalid entity type #{op[:entity]}"
end
end
errors
end
# Applies a single grid operation.
def apply_grid_operation(operation)
case operation[:type]
when 'move'
apply_move_operation(operation)
when 'swap'
apply_swap_operation(operation)
when 'remove'
apply_remove_operation(operation)
end
end
# Applies a move operation.
def apply_move_operation(operation)
model_class = grid_model_for_entity(operation[:entity])
item = model_class.find_by(id: operation[:id], party_id: @party.id)
return nil unless item
old_position = item.position
item.update!(position: operation[:position])
{
entity: operation[:entity],
id: operation[:id],
action: 'moved',
from: old_position,
to: operation[:position]
}
end
# Applies a swap operation.
def apply_swap_operation(operation)
model_class = grid_model_for_entity(operation[:entity])
source = model_class.find_by(id: operation[:source_id], party_id: @party.id)
target = model_class.find_by(id: operation[:target_id], party_id: @party.id)
return nil unless source && target
source_pos = source.position
target_pos = target.position
# Use a temporary position to avoid conflicts
source.update!(position: -999)
target.update!(position: source_pos)
source.update!(position: target_pos)
{
entity: operation[:entity],
id: operation[:source_id],
action: 'swapped',
with: operation[:target_id]
}
end
# Applies a remove operation.
def apply_remove_operation(operation)
model_class = grid_model_for_entity(operation[:entity])
item = model_class.find_by(id: operation[:id], party_id: @party.id)
return nil unless item
item.destroy
{
entity: operation[:entity],
id: operation[:id],
action: 'removed'
}
end
# Returns the model class for a given entity type.
def grid_model_for_entity(entity)
case entity
when 'weapon'
GridWeapon
when 'character'
GridCharacter
when 'summon'
GridSummon
end
end
# Compacts character positions to maintain sequential filling.
def compact_party_character_positions
main_characters = @party.characters.where(position: 0..4).order(:position)
main_characters.each_with_index do |char, index|
char.update!(position: index) if char.position != index
end
end
end end
end end
end end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Api
module V1
class PhantomClaimsController < Api::V1::ApiController
before_action :restrict_access
# GET /pending_phantom_claims
# Returns phantom players assigned to the current user that are pending confirmation
def index
phantoms = PhantomPlayer
.not_deleted
.includes(:crew, :claimed_by)
.where(claimed_by: current_user, claim_confirmed: false)
.order(created_at: :desc)
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_crew, root: :phantom_claims)
end
end
end
end

View file

@ -0,0 +1,154 @@
# frozen_string_literal: true
module Api
module V1
class PhantomPlayersController < Api::V1::ApiController
include CrewAuthorizationConcern
before_action :restrict_access
before_action :set_crew, except: %i[gw_scores]
before_action :set_crew_from_user, only: %i[gw_scores]
before_action :authorize_crew_member!, only: %i[index confirm_claim decline_claim gw_scores]
before_action :authorize_crew_officer!, only: %i[create bulk_create update destroy assign]
before_action :set_phantom, only: %i[show update destroy assign confirm_claim decline_claim]
before_action :set_phantom_for_scores, only: %i[gw_scores]
# GET /crews/:crew_id/phantom_players
def index
phantoms = @crew.phantom_players.not_deleted.includes(:claimed_by).order(:name)
render json: PhantomPlayerBlueprint.render(phantoms, view: :with_claimed_by, root: :phantom_players)
end
# GET /crews/:crew_id/phantom_players/:id
def show
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_scores, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players
def create
phantom = @crew.phantom_players.build(phantom_params)
if phantom.save
render json: PhantomPlayerBlueprint.render(phantom, root: :phantom_player), status: :created
else
render_validation_error_response(phantom)
end
end
# POST /crews/:crew_id/phantom_players/bulk_create
def bulk_create
phantoms = []
ActiveRecord::Base.transaction do
bulk_params[:phantom_players].each do |phantom_attrs|
phantom = @crew.phantom_players.build(phantom_attrs.permit(:name, :granblue_id, :notes, :joined_at))
phantom.save!
phantoms << phantom
end
end
render json: PhantomPlayerBlueprint.render(phantoms, root: :phantom_players), status: :created
rescue ActiveRecord::RecordInvalid => e
render json: { errors: e.record.errors.full_messages }, status: :unprocessable_entity
end
# PUT /crews/:crew_id/phantom_players/:id
def update
if @phantom.update(phantom_params)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
else
render_validation_error_response(@phantom)
end
end
# DELETE /crews/:crew_id/phantom_players/:id
def destroy
@phantom.destroy!
head :no_content
end
# POST /crews/:crew_id/phantom_players/:id/assign
def assign
user = User.find(params[:user_id])
@phantom.assign_to(user)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players/:id/confirm_claim
def confirm_claim
@phantom.confirm_claim!(current_user)
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# POST /crews/:crew_id/phantom_players/:id/decline_claim
def decline_claim
raise CrewErrors::NotClaimedByUserError unless @phantom.claimed_by == current_user
@phantom.unassign!
render json: PhantomPlayerBlueprint.render(@phantom, view: :with_claimed_by, root: :phantom_player)
end
# GET /crew/phantom_players/:id/gw_scores
def gw_scores
# Get all crew GW events to identify gaps
all_crew_events = @crew.crew_gw_participations
.joins(:gw_event)
.order('gw_events.event_number DESC')
.pluck('gw_events.id, gw_events.event_number, gw_events.element, gw_events.start_date, gw_events.end_date')
# Get scores for this phantom
scores_by_event = GwIndividualScore
.joins(crew_gw_participation: :gw_event)
.where(phantom_player_id: @phantom.id)
.group('gw_events.id')
.pluck('gw_events.id, SUM(gw_individual_scores.score)')
.to_h
# Build event scores with gap markers
event_scores = all_crew_events.map do |event_id, event_number, element, start_date, end_date|
score = scores_by_event[event_id]
{
gw_event: { id: event_id, event_number: event_number, element: element, start_date: start_date, end_date: end_date },
total_score: score&.to_i,
in_crew: score.present?
}
end
grand_total = event_scores.sum { |es| es[:total_score] || 0 }
render json: {
phantom: PhantomPlayerBlueprint.render_as_hash(@phantom),
event_scores: event_scores,
grand_total: grand_total
}
end
private
def set_crew
@crew = Crew.find(params[:crew_id])
end
def set_crew_from_user
@crew = current_user.crew
raise CrewErrors::NotInCrewError unless @crew
end
def set_phantom
@phantom = @crew.phantom_players.find(params[:id])
end
def set_phantom_for_scores
@phantom = @crew.phantom_players.find(params[:id])
end
def phantom_params
params.require(:phantom_player).permit(:name, :granblue_id, :notes, :joined_at, :retired, :retired_at)
end
def bulk_params
params.permit(phantom_players: %i[name granblue_id notes joined_at])
end
end
end
end

View file

@ -0,0 +1,77 @@
# frozen_string_literal: true
module Api
module V1
class RaidGroupsController < Api::V1::ApiController
before_action :set_raid_group, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /raid_groups
def index
groups = RaidGroup.includes(:raids).ordered
render json: RaidGroupBlueprint.render(groups, view: :full)
end
# GET /raid_groups/:id
def show
if @raid_group
render json: RaidGroupBlueprint.render(@raid_group, view: :full)
else
render json: { error: 'Raid group not found' }, status: :not_found
end
end
# POST /raid_groups
def create
raid_group = RaidGroup.new(raid_group_params)
if raid_group.save
render json: RaidGroupBlueprint.render(raid_group, view: :full), status: :created
else
render_validation_error_response(raid_group)
end
end
# PATCH/PUT /raid_groups/:id
def update
if @raid_group.update(raid_group_params)
render json: RaidGroupBlueprint.render(@raid_group, view: :full)
else
render_validation_error_response(@raid_group)
end
end
# DELETE /raid_groups/:id
def destroy
if @raid_group.raids.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete group with associated raids',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@raid_group.destroy!
head :no_content
end
end
private
def set_raid_group
@raid_group = RaidGroup.find_by(id: params[:id])
end
def raid_group_params
params.require(:raid_group).permit(
:name_en, :name_jp, :difficulty, :order, :section, :extra, :hl, :guidebooks, :unlimited
)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[RAID_GROUPS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
end
end
end

View file

@ -3,17 +3,99 @@
module Api module Api
module V1 module V1
class RaidsController < Api::V1::ApiController class RaidsController < Api::V1::ApiController
def all before_action :set_raid, only: %i[show update destroy]
render json: RaidBlueprint.render(Raid.includes(:group).all, view: :nested) before_action :ensure_editor_role, only: %i[create update destroy]
# GET /raids
def index
raids = Raid.includes(:group)
raids = apply_filters(raids)
raids = raids.ordered
render json: RaidBlueprint.render(raids, view: :nested)
end end
# GET /raids/:id
def show def show
raid = Raid.find_by(slug: params[:id]) if @raid
render json: RaidBlueprint.render(Raid.find_by(slug: params[:id]), view: :full) if raid render json: RaidBlueprint.render(@raid, view: :full)
else
render json: { error: 'Raid not found' }, status: :not_found
end
end end
# POST /raids
def create
raid = Raid.new(raid_params)
if raid.save
render json: RaidBlueprint.render(raid, view: :full), status: :created
else
render_validation_error_response(raid)
end
end
# PATCH/PUT /raids/:id
def update
if @raid.update(raid_params)
render json: RaidBlueprint.render(@raid, view: :full)
else
render_validation_error_response(@raid)
end
end
# DELETE /raids/:id
def destroy
if Party.where(raid_id: @raid.id).exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete raid with associated parties',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@raid.destroy!
head :no_content
end
end
# GET /raids/groups (legacy endpoint)
def groups def groups
render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).all, view: :full) render json: RaidGroupBlueprint.render(RaidGroup.includes(raids: :group).ordered, view: :full)
end
# Legacy alias for index
def all
index
end
private
def set_raid
@raid = Raid.find_by(slug: params[:id]) || Raid.find_by(id: params[:id])
end
def raid_params
params.require(:raid).permit(:name_en, :name_jp, :level, :element, :slug, :group_id)
end
def apply_filters(scope)
scope = scope.by_element(filter_params[:element]) if filter_params[:element].present?
scope = scope.by_group(filter_params[:group_id]) if filter_params[:group_id].present?
scope = scope.by_difficulty(filter_params[:difficulty]) if filter_params[:difficulty].present?
scope = scope.by_hl(filter_params[:hl]) if filter_params[:hl].present?
scope = scope.by_extra(filter_params[:extra]) if filter_params[:extra].present?
scope = scope.with_guidebooks if filter_params[:guidebooks] == 'true'
scope
end
def filter_params
params.permit(:element, :group_id, :difficulty, :hl, :extra, :guidebooks)
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[RAIDS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end end
end end
end end

View file

@ -55,6 +55,7 @@ module Api
def characters def characters
filters = search_params[:filters] filters = search_params[:filters]
locale = search_params[:locale] || 'en' locale = search_params[:locale] || 'en'
exclude = search_params[:exclude]
conditions = {} conditions = {}
if filters if filters
@ -68,7 +69,7 @@ module Api
conditions[:proficiency2] = conditions[:proficiency2] =
filters['proficiency2'] filters['proficiency2']
end end
# conditions[:series] = filters['series'] unless filters['series'].blank? || filters['series'].empty? conditions[:season] = filters['season'] unless filters['season'].blank? || filters['season'].empty?
end end
characters = if search_params[:query].present? && search_params[:query].length >= 2 characters = if search_params[:query].present? && search_params[:query].length >= 2
@ -78,19 +79,34 @@ module Api
Character.en_search(search_params[:query]).where(conditions) Character.en_search(search_params[:query]).where(conditions)
end end
else else
Character.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc')) Character.where(conditions)
end end
# Apply sorting if specified, otherwise use default
if search_params[:sort].present?
characters = apply_sort(characters, search_params[:sort], search_params[:order], locale)
elsif search_params[:query].blank?
characters = characters.order(Arel.sql('greatest(release_date, flb_date, ulb_date) desc'))
end
# Filter by series (array overlap)
if filters && filters['series'].present? && !filters['series'].empty?
series_values = Array(filters['series']).map(&:to_i)
characters = characters.where('series && ARRAY[?]::integer[]', series_values)
end
# Exclude already-owned characters (for collection modal)
if exclude.present? && exclude.any?
characters = characters.where.not(id: exclude)
end
count = characters.length count = characters.length
paginated = characters.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) paginated = characters.paginate(page: search_params[:page], per_page: search_page_size)
render json: CharacterBlueprint.render(paginated, render json: CharacterBlueprint.render(paginated,
view: :dates,
root: :results, root: :results,
meta: { meta: pagination_meta(paginated).merge(count: count))
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end end
def weapons def weapons
@ -105,7 +121,7 @@ module Api
conditions[:proficiency] = conditions[:proficiency] =
filters['proficiency1'] filters['proficiency1']
end end
conditions[:series] = filters['series'] unless filters['series'].blank? || filters['series'].empty? conditions[:weapon_series_id] = filters['series'] unless filters['series'].blank? || filters['series'].empty?
conditions[:extra] = filters['extra'] unless filters['extra'].blank? conditions[:extra] = filters['extra'] unless filters['extra'].blank?
end end
@ -116,19 +132,29 @@ module Api
Weapon.en_search(search_params[:query]).where(conditions) Weapon.en_search(search_params[:query]).where(conditions)
end end
else else
Weapon.where(conditions).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc')) Weapon.where(conditions)
end end
# Apply sorting if specified, otherwise use default
if search_params[:sort].present?
weapons = apply_sort(weapons, search_params[:sort], search_params[:order], locale)
elsif search_params[:query].blank?
weapons = weapons.order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
end
# Filter by promotions (array overlap)
if filters && filters['promotions'].present? && !filters['promotions'].empty?
promotions_values = Array(filters['promotions']).map(&:to_i)
weapons = weapons.where('promotions && ARRAY[?]::integer[]', promotions_values)
end
count = weapons.length count = weapons.length
paginated = weapons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) paginated = weapons.paginate(page: search_params[:page], per_page: search_page_size)
render json: WeaponBlueprint.render(paginated, render json: WeaponBlueprint.render(paginated,
view: :dates,
root: :results, root: :results,
meta: { meta: pagination_meta(paginated).merge(count: count))
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end end
def summons def summons
@ -149,19 +175,29 @@ module Api
Summon.en_search(search_params[:query]).where(conditions) Summon.en_search(search_params[:query]).where(conditions)
end end
else else
Summon.where(conditions).order(release_date: :desc).order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc')) Summon.where(conditions)
end end
# Apply sorting if specified, otherwise use default
if search_params[:sort].present?
summons = apply_sort(summons, search_params[:sort], search_params[:order], locale)
elsif search_params[:query].blank?
summons = summons.order(Arel.sql('greatest(release_date, flb_date, ulb_date, transcendence_date) desc'))
end
# Filter by promotions (array overlap)
if filters && filters['promotions'].present? && !filters['promotions'].empty?
promotions_values = Array(filters['promotions']).map(&:to_i)
summons = summons.where('promotions && ARRAY[?]::integer[]', promotions_values)
end
count = summons.length count = summons.length
paginated = summons.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) paginated = summons.paginate(page: search_params[:page], per_page: search_page_size)
render json: SummonBlueprint.render(paginated, render json: SummonBlueprint.render(paginated,
view: :dates,
root: :results, root: :results,
meta: { meta: pagination_meta(paginated).merge(count: count))
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end end
def job_skills def job_skills
@ -241,15 +277,63 @@ module Api
end end
count = skills.length count = skills.length
paginated = skills.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) paginated = skills.paginate(page: search_params[:page], per_page: search_page_size)
render json: JobSkillBlueprint.render(paginated, render json: JobSkillBlueprint.render(paginated,
root: :results, root: :results,
meta: { meta: pagination_meta(paginated).merge(count: count))
count: count, end
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE def jobs
}) filters = search_params[:filters]
locale = search_params[:locale] || 'en'
conditions = {}
if filters
conditions[:row] = filters['row'] unless filters['row'].blank? || filters['row'].empty?
unless filters['proficiency'].blank? || filters['proficiency'].empty?
# Filter by either proficiency1 or proficiency2 matching
proficiency_values = Array(filters['proficiency']).map(&:to_i)
conditions[:proficiency1] = proficiency_values
end
end
jobs = if search_params[:query].present? && search_params[:query].length >= 2
if locale == 'ja'
Job.ja_search(search_params[:query]).where(conditions)
else
Job.en_search(search_params[:query]).where(conditions)
end
else
Job.where(conditions)
end
# Filter by proficiency2 as well (OR condition)
if filters && filters['proficiency'].present? && !filters['proficiency'].empty?
proficiency_values = Array(filters['proficiency']).map(&:to_i)
jobs = jobs.or(Job.where(proficiency2: proficiency_values))
end
# Apply feature filters
if filters
jobs = jobs.where(master_level: true) if filters['masterLevel'] == true || filters['masterLevel'] == 'true'
jobs = jobs.where(ultimate_mastery: true) if filters['ultimateMastery'] == true || filters['ultimateMastery'] == 'true'
jobs = jobs.where(accessory: true) if filters['accessory'] == true || filters['accessory'] == 'true'
end
# Apply sorting if specified, otherwise use default (row, then order)
if search_params[:sort].present?
jobs = apply_job_sort(jobs, search_params[:sort], search_params[:order], locale)
else
jobs = jobs.order(:row, :order)
end
count = jobs.length
paginated = jobs.paginate(page: search_params[:page], per_page: search_page_size)
render json: JobBlueprint.render(paginated,
root: :results,
meta: pagination_meta(paginated).merge(count: count))
end end
def guidebooks def guidebooks
@ -261,27 +345,56 @@ module Api
end end
count = books.length count = books.length
paginated = books.paginate(page: search_params[:page], per_page: SEARCH_PER_PAGE) paginated = books.paginate(page: search_params[:page], per_page: search_page_size)
render json: GuidebookBlueprint.render(paginated, render json: GuidebookBlueprint.render(paginated,
root: :results, root: :results,
meta: { meta: pagination_meta(paginated).merge(count: count))
count: count,
total_pages: total_pages(count),
per_page: SEARCH_PER_PAGE
})
end end
private private
def total_pages(count)
count.to_f / SEARCH_PER_PAGE > 1 ? (count.to_f / SEARCH_PER_PAGE).ceil : 1
end
# Specify whitelisted properties that can be modified. # Specify whitelisted properties that can be modified.
def search_params def search_params
return {} unless params[:search].present?
params.require(:search).permit! params.require(:search).permit!
end end
# Apply sorting based on column name and order
def apply_sort(scope, column, order, locale)
sort_dir = order == 'desc' ? :desc : :asc
case column
when 'name'
name_col = locale == 'ja' ? :name_ja : :name_en
scope.order(name_col => sort_dir)
when 'element'
scope.order(element: sort_dir)
when 'rarity'
scope.order(rarity: sort_dir)
when 'last_updated'
scope.order(updated_at: sort_dir)
else
scope
end
end
# Apply sorting for jobs
def apply_job_sort(scope, column, order, locale)
sort_dir = order == 'desc' ? :desc : :asc
case column
when 'name'
name_col = locale == 'ja' ? :name_ja : :name_en
scope.order(name_col => sort_dir)
when 'row'
scope.order(row: sort_dir, order: :asc)
when 'proficiency'
scope.order(proficiency1: sort_dir)
else
scope.order(:row, :order)
end
end
end end
end end
end end

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
module Api
module V1
class SummonSeriesController < Api::V1::ApiController
before_action :set_summon_series, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /summon_series
def index
summon_series = SummonSeries.ordered
render json: SummonSeriesBlueprint.render(summon_series)
end
# GET /summon_series/:id
def show
render json: SummonSeriesBlueprint.render(@summon_series, view: :full)
end
# POST /summon_series
def create
summon_series = SummonSeries.new(summon_series_params)
if summon_series.save
render json: SummonSeriesBlueprint.render(summon_series, view: :full), status: :created
else
render_validation_error_response(summon_series)
end
end
# PATCH/PUT /summon_series/:id
def update
if @summon_series.update(summon_series_params)
render json: SummonSeriesBlueprint.render(@summon_series, view: :full)
else
render_validation_error_response(@summon_series)
end
end
# DELETE /summon_series/:id
def destroy
if @summon_series.summons.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete series with associated summons',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@summon_series.destroy!
head :no_content
end
end
private
def set_summon_series
# Support lookup by slug or UUID
@summon_series = SummonSeries.find_by(slug: params[:id]) || SummonSeries.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[SUMMON_SERIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def summon_series_params
params.require(:summon_series).permit(:name_en, :name_jp, :slug, :order)
end
end
end
end

View file

@ -3,16 +3,227 @@
module Api module Api
module V1 module V1
class SummonsController < Api::V1::ApiController class SummonsController < Api::V1::ApiController
before_action :set include IdResolvable
include BatchPreviewable
before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki]
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
# GET /summons/:id
def show def show
render json: SummonBlueprint.render(@summon) render json: SummonBlueprint.render(@summon, view: :full)
end
# POST /summons
# Creates a new summon record
def create
summon = Summon.new(summon_params)
if summon.save
render json: SummonBlueprint.render(summon, view: :full), status: :created
else
render_validation_error_response(summon)
end
end
# PATCH/PUT /summons/:id
# Updates an existing summon record
def update
if @summon.update(summon_params)
render json: SummonBlueprint.render(@summon, view: :full)
else
render_validation_error_response(@summon)
end
end
# GET /summons/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = SummonImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /summons/:id/download_image
# Synchronously downloads a single image for a summon
def download_image
size = params[:size]
transformation = params[:transformation]
force = params[:force] == true
# Validate size
valid_sizes = Granblue::Downloaders::SummonDownloader::SIZES
unless valid_sizes.include?(size)
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
end
# Validate transformation for summons (none, 02, 03, 04)
valid_transformations = [nil, '', '02', '03', '04']
if transformation.present? && !valid_transformations.include?(transformation)
return render json: { error: 'Invalid transformation. Must be one of: 02, 03, 04 (or empty for base)' }, status: :unprocessable_entity
end
# Build variant ID - summons don't have suffix for base
variant_id = transformation.present? ? "#{@summon.granblue_id}_#{transformation}" : @summon.granblue_id
begin
downloader = Granblue::Downloaders::SummonDownloader.new(
@summon.granblue_id,
storage: :s3,
force: force,
verbose: true
)
# Call the download_variant method directly for a single variant/size
downloader.send(:download_variant, variant_id, size)
render json: {
success: true,
summon_id: @summon.id,
granblue_id: @summon.granblue_id,
size: size,
transformation: transformation,
message: 'Image downloaded successfully'
}
rescue StandardError => e
Rails.logger.error "[SUMMONS] Image download error for #{@summon.id}: #{e.message}"
render json: { success: false, error: e.message }, status: :internal_server_error
end
end
# POST /summons/:id/download_images
# Triggers async image download for a summon
def download_images
# Queue the download job
DownloadSummonImagesJob.perform_later(
@summon.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadSummonImagesJob.update_status(
@summon.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
summon_id: @summon.id,
granblue_id: @summon.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /summons/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadSummonImagesJob.status(@summon.id)
render json: status.merge(
summon_id: @summon.id,
granblue_id: @summon.granblue_id
)
end
# GET /summons/:id/raw
# Returns raw wiki and game data for database viewing
def raw
render json: SummonBlueprint.render(@summon, view: :raw)
end
# POST /summons/batch_preview
# Fetches wiki data and suggestions for multiple wiki page names
def batch_preview
wiki_pages = params[:wiki_pages]
wiki_data = params[:wiki_data] || {}
unless wiki_pages.is_a?(Array) && wiki_pages.any?
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
end
# Limit to 10 pages
wiki_pages = wiki_pages.first(10)
results = wiki_pages.map do |wiki_page|
process_wiki_preview(wiki_page, :summon, wiki_raw: wiki_data[wiki_page])
end
render json: { results: results }
end
# POST /summons/:id/fetch_wiki
# Fetches and stores wiki data for this summon
def fetch_wiki
unless @summon.wiki_en.present?
return render json: { error: 'No wiki page configured for this summon' }, status: :unprocessable_entity
end
begin
wiki_text = Granblue::Parsers::Wiki.new.fetch(@summon.wiki_en)
# Handle redirects
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
@summon.update!(wiki_en: redirect_target)
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
end
@summon.update!(wiki_raw: wiki_text)
render json: SummonBlueprint.render(@summon, view: :raw)
rescue Granblue::WikiError => e
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
rescue StandardError => e
Rails.logger.error "[SUMMONS] Wiki fetch error for #{@summon.id}: #{e.message}"
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
end
end end
private private
def set def set
@summon = Summon.where(granblue_id: params[:id]).first @summon = find_by_any_id(Summon, params[:id])
render_not_found_response('summon') unless @summon
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[SUMMONS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def summon_params
params.require(:summon).permit(
:granblue_id, :name_en, :name_jp, :summon_id, :rarity, :element, :series,
:flb, :ulb, :transcendence, :subaura, :limit,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb, :max_hp_xlb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb, :max_atk_xlb,
:max_level,
:release_date, :flb_date, :ulb_date, :transcendence_date,
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
nicknames_en: [], nicknames_jp: [], promotions: []
)
end end
end end
end end

View file

@ -5,8 +5,9 @@ module Api
class UsersController < Api::V1::ApiController class UsersController < Api::V1::ApiController
class ForbiddenError < StandardError; end class ForbiddenError < StandardError; end
before_action :set, except: %w[create check_email check_username] before_action :set, except: %w[create check_email check_username me]
before_action :set_by_id, only: %w[update] before_action :set_by_id, only: %w[update]
before_action :doorkeeper_authorize!, only: %w[me]
MAX_CHARACTERS = 5 MAX_CHARACTERS = 5
MAX_SUMMONS = 8 MAX_SUMMONS = 8
@ -51,6 +52,12 @@ module Api
render json: UserBlueprint.render(@user, view: :minimal) render json: UserBlueprint.render(@user, view: :minimal)
end end
# GET /users/me - returns current user's settings including email
# This endpoint is ONLY for authenticated users viewing their own settings
def me
render json: UserBlueprint.render(current_user, view: :settings)
end
def show def show
if @user.nil? if @user.nil?
render_not_found_response('user') render_not_found_response('user')
@ -79,13 +86,14 @@ module Api
current_user: current_user, current_user: current_user,
options: { skip_privacy: skip_privacy } options: { skip_privacy: skip_privacy }
).build ).build
parties = query.paginate(page: params[:page], per_page: PartyConstants::COLLECTION_PER_PAGE) current_page_size = page_size
parties = query.paginate(page: params[:page], per_page: current_page_size)
count = query.count count = query.count
render json: UserBlueprint.render(@user, render json: UserBlueprint.render(@user,
view: :profile, view: :profile,
root: 'profile', root: 'profile',
parties: parties, parties: parties,
meta: { count: count, total_pages: (count.to_f / PartyConstants::COLLECTION_PER_PAGE).ceil, per_page: PartyConstants::COLLECTION_PER_PAGE }, meta: { count: count, total_pages: (count.to_f / current_page_size).ceil, per_page: current_page_size },
current_user: current_user current_user: current_user
) )
end end
@ -226,13 +234,18 @@ module Api
end end
def set_by_id def set_by_id
@user = User.find_by('id = ?', params[:id]) if params[:id] == 'me'
@user = current_user
else
@user = User.find_by('id = ?', params[:id])
end
end end
def user_params def user_params
params.require(:user).permit( params.require(:user).permit(
:username, :email, :password, :password_confirmation, :username, :email, :password, :password_confirmation,
:granblue_id, :picture, :element, :language, :gender, :private, :theme :granblue_id, :picture, :element, :language, :gender, :private, :theme, :show_gamertag,
:show_granblue_id, :collection_privacy
) )
end end
end end

View file

@ -4,17 +4,20 @@ module Api
module V1 module V1
class WeaponKeysController < Api::V1::ApiController class WeaponKeysController < Api::V1::ApiController
def all def all
conditions = {}.tap do |hash| weapon_keys = WeaponKey.all
hash[:series] = request.params['series'].to_i unless request.params['series'].blank?
hash[:slot] = request.params['slot'].to_i unless request.params['slot'].blank? # Filter by series - support both new slug-based and legacy integer-based filtering
hash[:group] = request.params['group'].to_i unless request.params['group'].blank? if request.params['series_slug'].present?
series = WeaponSeries.find_by(slug: request.params['series_slug'])
weapon_keys = weapon_keys.joins(:weapon_series).where(weapon_series: { id: series.id }) if series
elsif request.params['series'].present?
# Legacy integer support (will be deprecated)
weapon_keys = weapon_keys.where('? = ANY(series)', request.params['series'].to_i)
end end
# Build the query based on the conditions # Filter by slot and group
weapon_keys = WeaponKey.all weapon_keys = weapon_keys.where(slot: request.params['slot'].to_i) if request.params['slot'].present?
weapon_keys = weapon_keys.where('? = ANY(series)', conditions[:series]) if conditions.key?(:series) weapon_keys = weapon_keys.where(group: request.params['group'].to_i) if request.params['group'].present?
weapon_keys = weapon_keys.where(slot: conditions[:slot]) if conditions.key?(:slot)
weapon_keys = weapon_keys.where(group: conditions[:group]) if conditions.key?(:group)
render json: WeaponKeyBlueprint.render(weapon_keys) render json: WeaponKeyBlueprint.render(weapon_keys)
end end

View file

@ -0,0 +1,76 @@
# frozen_string_literal: true
module Api
module V1
class WeaponSeriesController < Api::V1::ApiController
before_action :set_weapon_series, only: %i[show update destroy]
before_action :ensure_editor_role, only: %i[create update destroy]
# GET /weapon_series
def index
weapon_series = WeaponSeries.ordered
render json: WeaponSeriesBlueprint.render(weapon_series)
end
# GET /weapon_series/:id
def show
render json: WeaponSeriesBlueprint.render(@weapon_series, view: :full)
end
# POST /weapon_series
def create
weapon_series = WeaponSeries.new(weapon_series_params)
if weapon_series.save
render json: WeaponSeriesBlueprint.render(weapon_series, view: :full), status: :created
else
render_validation_error_response(weapon_series)
end
end
# PATCH/PUT /weapon_series/:id
def update
if @weapon_series.update(weapon_series_params)
render json: WeaponSeriesBlueprint.render(@weapon_series, view: :full)
else
render_validation_error_response(@weapon_series)
end
end
# DELETE /weapon_series/:id
def destroy
if @weapon_series.weapons.exists?
render json: ErrorBlueprint.render(nil, error: {
message: 'Cannot delete series with associated weapons',
code: 'has_dependencies'
}), status: :unprocessable_entity
else
@weapon_series.destroy!
head :no_content
end
end
private
def set_weapon_series
# Support lookup by slug or UUID
@weapon_series = WeaponSeries.find_by(slug: params[:id]) || WeaponSeries.find(params[:id])
end
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[WEAPON_SERIES] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def weapon_series_params
params.require(:weapon_series).permit(
:name_en, :name_jp, :slug, :order,
:extra, :element_changeable, :has_weapon_keys,
:has_awakening, :augment_type
)
end
end
end
end

View file

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Api
module V1
class WeaponStatModifiersController < Api::V1::ApiController
# GET /weapon_stat_modifiers
def index
@modifiers = WeaponStatModifier.all
@modifiers = @modifiers.where(category: params[:category]) if params[:category].present?
render json: WeaponStatModifierBlueprint.render(@modifiers, root: :weapon_stat_modifiers)
end
# GET /weapon_stat_modifiers/:id
def show
@modifier = WeaponStatModifier.find(params[:id])
render json: WeaponStatModifierBlueprint.render(@modifier)
end
end
end
end

View file

@ -3,16 +3,228 @@
module Api module Api
module V1 module V1
class WeaponsController < Api::V1::ApiController class WeaponsController < Api::V1::ApiController
before_action :set include IdResolvable
include BatchPreviewable
before_action :set, only: %i[show download_image download_images download_status update raw fetch_wiki]
before_action :ensure_editor_role, only: %i[create update validate download_image download_images fetch_wiki batch_preview]
# GET /weapons/:id
def show def show
render json: WeaponBlueprint.render(@weapon) render json: WeaponBlueprint.render(@weapon, view: :full)
end
# POST /weapons
# Creates a new weapon record
def create
weapon = Weapon.new(weapon_params)
if weapon.save
render json: WeaponBlueprint.render(weapon, view: :full), status: :created
else
render_validation_error_response(weapon)
end
end
# PATCH/PUT /weapons/:id
# Updates an existing weapon record
def update
if @weapon.update(weapon_params)
render json: WeaponBlueprint.render(@weapon, view: :full)
else
render_validation_error_response(@weapon)
end
end
# GET /weapons/validate/:granblue_id
# Validates that a granblue_id has accessible images on Granblue servers
def validate
granblue_id = params[:granblue_id]
validator = WeaponImageValidator.new(granblue_id)
response_data = {
granblue_id: granblue_id,
exists_in_db: validator.exists_in_db?
}
if validator.valid?
render json: response_data.merge(
valid: true,
image_urls: validator.image_urls
)
else
render json: response_data.merge(
valid: false,
error: validator.error_message
)
end
end
# POST /weapons/:id/download_image
# Synchronously downloads a single image for a weapon
def download_image
size = params[:size]
transformation = params[:transformation]
force = params[:force] == true
# Validate size
valid_sizes = Granblue::Downloaders::WeaponDownloader::SIZES
unless valid_sizes.include?(size)
return render json: { error: "Invalid size. Must be one of: #{valid_sizes.join(', ')}" }, status: :unprocessable_entity
end
# Validate transformation for weapons (none, 02, 03)
valid_transformations = [nil, '', '02', '03']
if transformation.present? && !valid_transformations.include?(transformation)
return render json: { error: 'Invalid transformation. Must be one of: 02, 03 (or empty for base)' }, status: :unprocessable_entity
end
# Build variant ID - weapons don't have suffix for base
variant_id = transformation.present? ? "#{@weapon.granblue_id}_#{transformation}" : @weapon.granblue_id
begin
downloader = Granblue::Downloaders::WeaponDownloader.new(
@weapon.granblue_id,
storage: :s3,
force: force,
verbose: true
)
# Call the download_variant method directly for a single variant/size
downloader.send(:download_variant, variant_id, size)
render json: {
success: true,
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id,
size: size,
transformation: transformation,
message: 'Image downloaded successfully'
}
rescue StandardError => e
Rails.logger.error "[WEAPONS] Image download error for #{@weapon.id}: #{e.message}"
render json: { success: false, error: e.message }, status: :internal_server_error
end
end
# POST /weapons/:id/download_images
# Triggers async image download for a weapon
def download_images
# Queue the download job
DownloadWeaponImagesJob.perform_later(
@weapon.id,
force: params.dig(:options, :force) == true,
size: params.dig(:options, :size) || 'all'
)
# Set initial status
DownloadWeaponImagesJob.update_status(
@weapon.id,
'queued',
progress: 0,
images_downloaded: 0
)
render json: {
status: 'queued',
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id,
message: 'Image download job has been queued'
}, status: :accepted
end
# GET /weapons/:id/download_status
# Returns the status of an image download job
def download_status
status = DownloadWeaponImagesJob.status(@weapon.id)
render json: status.merge(
weapon_id: @weapon.id,
granblue_id: @weapon.granblue_id
)
end
# GET /weapons/:id/raw
# Returns raw wiki and game data for database viewing
def raw
render json: WeaponBlueprint.render(@weapon, view: :raw)
end
# POST /weapons/batch_preview
# Fetches wiki data and suggestions for multiple wiki page names
def batch_preview
wiki_pages = params[:wiki_pages]
wiki_data = params[:wiki_data] || {}
unless wiki_pages.is_a?(Array) && wiki_pages.any?
return render json: { error: 'wiki_pages must be a non-empty array' }, status: :unprocessable_entity
end
# Limit to 10 pages
wiki_pages = wiki_pages.first(10)
results = wiki_pages.map do |wiki_page|
process_wiki_preview(wiki_page, :weapon, wiki_raw: wiki_data[wiki_page])
end
render json: { results: results }
end
# POST /weapons/:id/fetch_wiki
# Fetches and stores wiki data for this weapon
def fetch_wiki
unless @weapon.wiki_en.present?
return render json: { error: 'No wiki page configured for this weapon' }, status: :unprocessable_entity
end
begin
wiki_text = Granblue::Parsers::Wiki.new.fetch(@weapon.wiki_en)
# Handle redirects
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
@weapon.update!(wiki_en: redirect_target)
wiki_text = Granblue::Parsers::Wiki.new.fetch(redirect_target)
end
@weapon.update!(wiki_raw: wiki_text)
render json: WeaponBlueprint.render(@weapon, view: :raw)
rescue Granblue::WikiError => e
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
rescue StandardError => e
Rails.logger.error "[WEAPONS] Wiki fetch error for #{@weapon.id}: #{e.message}"
render json: { error: "Failed to fetch wiki data: #{e.message}" }, status: :bad_gateway
end
end end
private private
def set def set
@weapon = Weapon.where(granblue_id: params[:id]).first @weapon = find_by_any_id(Weapon, params[:id])
render_not_found_response('weapon') unless @weapon
end
# Ensures the current user has editor role (role >= 7)
def ensure_editor_role
return if current_user&.role && current_user.role >= 7
Rails.logger.warn "[WEAPONS] Unauthorized access attempt by user #{current_user&.id}"
render json: { error: 'Unauthorized - Editor role required' }, status: :unauthorized
end
def weapon_params
params.require(:weapon).permit(
:granblue_id, :name_en, :name_jp, :rarity, :element, :proficiency, :series, :new_series,
:flb, :ulb, :transcendence, :extra, :extra_prerequisite, :limit, :ax, :gacha,
:min_hp, :max_hp, :max_hp_flb, :max_hp_ulb,
:min_atk, :max_atk, :max_atk_flb, :max_atk_ulb,
:max_level, :max_skill_level, :max_awakening_level,
:release_date, :flb_date, :ulb_date, :transcendence_date,
:wiki_en, :wiki_ja, :wiki_raw, :gamewith, :kamigame,
:recruits, :forged_from, :forge_chain_id, :forge_order,
nicknames_en: [], nicknames_jp: [], promotions: []
)
end end
end end
end end

View file

@ -0,0 +1,98 @@
# frozen_string_literal: true
# Provides batch wiki preview functionality for entity controllers
module BatchPreviewable
extend ActiveSupport::Concern
private
# Process a single wiki page and return preview data
# @param wiki_page [String] The wiki page name to fetch
# @param entity_type [Symbol] The type of entity (:character, :weapon, :summon)
# @param wiki_raw [String, nil] Pre-fetched wiki text (from client-side fetch)
# @return [Hash] Preview data including status, suggestions, and errors
def process_wiki_preview(wiki_page, entity_type, wiki_raw: nil)
result = {
wiki_page: wiki_page,
status: 'success'
}
begin
# Use provided wiki_raw or fetch from wiki
wiki_text = if wiki_raw.present?
wiki_raw
else
wiki = Granblue::Parsers::Wiki.new
wiki.fetch(wiki_page)
end
# Handle redirects (only if we fetched server-side)
if wiki_raw.blank?
redirect_match = wiki_text.match(/#REDIRECT \[\[(.*?)\]\]/)
if redirect_match
redirect_target = redirect_match[1]
result[:redirected_from] = wiki_page
result[:wiki_page] = redirect_target
wiki_text = wiki.fetch(redirect_target)
end
end
result[:wiki_raw] = wiki_text
# Parse suggestions based on entity type
suggestions = case entity_type
when :character
Granblue::Parsers::SuggestionParser.parse_character(wiki_text)
when :weapon
Granblue::Parsers::SuggestionParser.parse_weapon(wiki_text)
when :summon
Granblue::Parsers::SuggestionParser.parse_summon(wiki_text)
end
result[:granblue_id] = suggestions[:granblue_id] if suggestions[:granblue_id].present?
result[:suggestions] = suggestions
# Queue image download if we have a granblue_id
if suggestions[:granblue_id].present?
result[:image_status] = queue_image_download(suggestions[:granblue_id], entity_type)
else
result[:image_status] = 'no_id'
end
rescue Granblue::WikiError => e
result[:status] = 'error'
result[:error] = "Wiki page not found: #{e.message}"
rescue StandardError => e
Rails.logger.error "[BATCH_PREVIEW] Error processing #{wiki_page}: #{e.message}"
result[:status] = 'error'
result[:error] = "Failed to process wiki page: #{e.message}"
end
result
end
# Queue an image download job for the entity
# @param granblue_id [String] The granblue ID to download images for
# @param entity_type [Symbol] The type of entity
# @return [String] Status of the image download ('queued', 'skipped', 'error')
def queue_image_download(granblue_id, entity_type)
# Check if entity already exists in database
model_class = case entity_type
when :character then Character
when :weapon then Weapon
when :summon then Summon
end
existing = model_class.find_by(granblue_id: granblue_id)
if existing
# Entity exists, skip download (images likely already exist)
return 'exists'
end
# For now, we don't queue the download since the entity doesn't exist yet
# The image download will happen after the entity is created
'pending'
rescue StandardError => e
Rails.logger.error "[BATCH_PREVIEW] Error queueing image download: #{e.message}"
'error'
end
end

View file

@ -0,0 +1,20 @@
# frozen_string_literal: true
module CrewAuthorizationConcern
extend ActiveSupport::Concern
# Checks whether the current user is a member of the crew
def authorize_crew_member!
render_unauthorized_response unless current_user&.crew == @crew
end
# Checks whether the current user is an officer (captain or vice captain) of the crew
def authorize_crew_officer!
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_officer?
end
# Checks whether the current user is the captain of the crew
def authorize_crew_captain!
render_unauthorized_response unless current_user&.crew == @crew && current_user.crew_captain?
end
end

View file

@ -0,0 +1,23 @@
# frozen_string_literal: true
module IdResolvable
extend ActiveSupport::Concern
UUID_REGEX = /\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/i
private
def uuid_format?(id)
id.to_s.match?(UUID_REGEX)
end
def find_by_any_id(model_class, id)
return nil if id.blank?
if uuid_format?(id)
model_class.find_by(id: id)
else
model_class.find_by(granblue_id: id)
end
end
end

View file

@ -5,6 +5,8 @@ module PartyAuthorizationConcern
# Checks whether the current user (or provided edit key) is authorized to modify @party. # Checks whether the current user (or provided edit key) is authorized to modify @party.
def authorize_party! def authorize_party!
return render_not_found_response('party') unless @party
if @party.user.present? if @party.user.present?
render_unauthorized_response unless current_user.present? && @party.user == current_user render_unauthorized_response unless current_user.present? && @party.user == current_user
else else

View file

@ -9,7 +9,7 @@ module PartyQueryingConcern
Party.includes( Party.includes(
{ raid: :group }, { raid: :group },
:job, :job,
:user, { user: { active_crew_membership: :crew } },
:skill0, :skill0,
:skill1, :skill1,
:skill2, :skill2,
@ -18,7 +18,7 @@ module PartyQueryingConcern
:guidebook2, :guidebook2,
:guidebook3, :guidebook3,
{ characters: :character }, { characters: :character },
{ weapons: :weapon }, { weapons: { weapon: :weapon_series } },
{ summons: :summon } { summons: :summon }
) )
end end
@ -31,21 +31,6 @@ module PartyQueryingConcern
options: { apply_defaults: true }).build options: { apply_defaults: true }).build
end end
# Renders paginated parties using PartyBlueprint.
def render_paginated_parties(parties)
render json: Api::V1::PartyBlueprint.render(
parties,
view: :preview,
root: :results,
meta: {
count: parties.total_entries,
total_pages: parties.total_pages,
per_page: COLLECTION_PER_PAGE
},
current_user: current_user
)
end
# Returns a remixed party name based on the current party name and current_user language. # Returns a remixed party name based on the current party name and current_user language.
def remixed_name(name) def remixed_name(name)
blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name } blanked_name = { en: name.blank? ? 'Untitled team' : name, ja: name.blank? ? '無名の編成' : name }

View file

@ -1,18 +0,0 @@
# frozen_string_literal: true
class WikiError < StandardError
def initialize(code: nil, page: nil, message: nil)
super
@code = code
@page = page
@message = message
end
def to_hash
{
message: @message,
code: @code,
page: @page
}
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Api
module V1
class InvalidPositionError < GranblueError
def code
'invalid_position'
end
def message
@data || 'Invalid position specified'
end
end
end
end

View file

@ -0,0 +1,38 @@
# frozen_string_literal: true
module Api
module V1
class PartyDeletionFailedError < StandardError
attr_reader :errors
def initialize(errors = [])
@errors = errors
super(message)
end
def http_status
422
end
def code
'party_deletion_failed'
end
def message
if @errors.any?
"Failed to delete party: #{@errors.join(', ')}"
else
'Failed to delete party due to an unknown error'
end
end
def to_hash
{
message: message,
code: code,
errors: @errors
}
end
end
end
end

View file

@ -0,0 +1,15 @@
# frozen_string_literal: true
module Api
module V1
class PositionOccupiedError < GranblueError
def code
'position_occupied'
end
def message
@data || 'Position is already occupied'
end
end
end
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
module CollectionErrors
# Base class for all collection-related errors
class CollectionError < StandardError
attr_reader :http_status, :code
def initialize(message = nil, http_status: :unprocessable_entity, code: nil)
super(message)
@http_status = http_status
@code = code || self.class.name.demodulize.underscore
end
def to_hash
{
error: {
type: self.class.name.demodulize,
message: message,
code: code
}
}
end
end
# Raised when a collection item cannot be found
class CollectionItemNotFound < CollectionError
def initialize(item_type = 'item', item_id = nil)
message = item_id ? "Collection #{item_type} with ID #{item_id} not found" : "Collection #{item_type} not found"
super(message, http_status: :not_found)
end
end
# Raised when trying to add a duplicate character to collection
class DuplicateCharacter < CollectionError
def initialize(character_id = nil)
message = character_id ? "Character #{character_id} already exists in your collection" : "Character already exists in your collection"
super(message, http_status: :conflict)
end
end
# Raised when trying to add a duplicate job accessory to collection
class DuplicateJobAccessory < CollectionError
def initialize(accessory_id = nil)
message = accessory_id ? "Job accessory #{accessory_id} already exists in your collection" : "Job accessory already exists in your collection"
super(message, http_status: :conflict)
end
end
end

203
app/errors/crew_errors.rb Normal file
View file

@ -0,0 +1,203 @@
# frozen_string_literal: true
module CrewErrors
# Base class for all crew-related errors
class CrewError < StandardError
def http_status
:unprocessable_entity
end
def code
self.class.name.demodulize.underscore
end
def to_hash
{
message: message,
code: code
}
end
end
class AlreadyInCrewError < CrewError
def http_status
:unprocessable_entity
end
def code
'already_in_crew'
end
def message
'You are already in a crew'
end
end
class CaptainCannotLeaveError < CrewError
def http_status
:unprocessable_entity
end
def code
'captain_cannot_leave'
end
def message
'Captain must transfer ownership before leaving'
end
end
class CannotRemoveCaptainError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_remove_captain'
end
def message
'Cannot remove the captain from the crew'
end
end
class ViceCaptainLimitError < CrewError
def http_status
:unprocessable_entity
end
def code
'vice_captain_limit'
end
def message
'Crew can only have up to 3 vice captains'
end
end
class NotInCrewError < CrewError
def http_status
:unprocessable_entity
end
def code
'not_in_crew'
end
def message
'You are not in a crew'
end
end
class MemberNotFoundError < CrewError
def http_status
:not_found
end
def code
'member_not_found'
end
def message
'Member not found in this crew'
end
end
class CannotDemoteCaptainError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_demote_captain'
end
def message
'Cannot demote the captain'
end
end
class InvitationExpiredError < CrewError
def http_status
:gone
end
def code
'invitation_expired'
end
def message
'This invitation has expired'
end
end
class InvitationNotFoundError < CrewError
def http_status
:not_found
end
def code
'invitation_not_found'
end
def message
'Invitation not found'
end
end
class CannotInviteSelfError < CrewError
def http_status
:unprocessable_entity
end
def code
'cannot_invite_self'
end
def message
'You cannot invite yourself'
end
end
class UserAlreadyInvitedError < CrewError
def http_status
:conflict
end
def code
'user_already_invited'
end
def message
'User already has a pending invitation'
end
end
class NotClaimedByUserError < CrewError
def http_status
:forbidden
end
def code
'not_claimed_by_user'
end
def message
'This phantom player is not assigned to you'
end
end
class PhantomNotFoundError < CrewError
def http_status
:not_found
end
def code
'phantom_not_found'
end
def message
'Phantom player not found'
end
end
end

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
# Background job for downloading artifact images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadArtifactImagesJob.perform_later(artifact.id)
# # Poll status with: DownloadArtifactImagesJob.status(artifact.id)
class DownloadArtifactImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
artifact_id = job.arguments.first
Rails.logger.error "[DownloadArtifactImages] Artifact #{artifact_id} not found"
update_status(artifact_id, 'failed', error: 'Artifact not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'artifact_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for an artifact
#
# @param artifact_id [String] UUID of the artifact
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(artifact_id)
data = redis.get(redis_key(artifact_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(artifact_id)
"#{REDIS_KEY_PREFIX}:#{artifact_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(artifact_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(artifact_id), STATUS_TTL, data.to_json)
end
end
def perform(artifact_id, force: false, size: 'all')
Rails.logger.info "[DownloadArtifactImages] Starting download for artifact #{artifact_id}"
artifact = Artifact.find(artifact_id)
update_status(artifact_id, 'processing', progress: 0, images_downloaded: 0)
service = ArtifactImageDownloadService.new(
artifact,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadArtifactImages] Completed for artifact #{artifact_id}"
update_status(
artifact_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadArtifactImages] Failed for artifact #{artifact_id}: #{result.error}"
update_status(artifact_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(artifact_id, status, **attrs)
self.class.update_status(artifact_id, status, **attrs)
end
end

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
# Background job for downloading character images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadCharacterImagesJob.perform_later(character.id)
# # Poll status with: DownloadCharacterImagesJob.status(character.id)
class DownloadCharacterImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
character_id = job.arguments.first
Rails.logger.error "[DownloadCharacterImages] Character #{character_id} not found"
update_status(character_id, 'failed', error: 'Character not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'character_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a character
#
# @param character_id [String] UUID of the character
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(character_id)
data = redis.get(redis_key(character_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(character_id)
"#{REDIS_KEY_PREFIX}:#{character_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(character_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(character_id), STATUS_TTL, data.to_json)
end
end
def perform(character_id, force: false, size: 'all')
Rails.logger.info "[DownloadCharacterImages] Starting download for character #{character_id}"
character = Character.find(character_id)
update_status(character_id, 'processing', progress: 0, images_downloaded: 0)
service = CharacterImageDownloadService.new(
character,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadCharacterImages] Completed for character #{character_id}"
update_status(
character_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadCharacterImages] Failed for character #{character_id}: #{result.error}"
update_status(character_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(character_id, status, **attrs)
self.class.update_status(character_id, status, **attrs)
end
end

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
# Background job for downloading summon images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadSummonImagesJob.perform_later(summon.id)
# # Poll status with: DownloadSummonImagesJob.status(summon.id)
class DownloadSummonImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
summon_id = job.arguments.first
Rails.logger.error "[DownloadSummonImages] Summon #{summon_id} not found"
update_status(summon_id, 'failed', error: 'Summon not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'summon_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a summon
#
# @param summon_id [String] UUID of the summon
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(summon_id)
data = redis.get(redis_key(summon_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(summon_id)
"#{REDIS_KEY_PREFIX}:#{summon_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(summon_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(summon_id), STATUS_TTL, data.to_json)
end
end
def perform(summon_id, force: false, size: 'all')
Rails.logger.info "[DownloadSummonImages] Starting download for summon #{summon_id}"
summon = Summon.find(summon_id)
update_status(summon_id, 'processing', progress: 0, images_downloaded: 0)
service = SummonImageDownloadService.new(
summon,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadSummonImages] Completed for summon #{summon_id}"
update_status(
summon_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadSummonImages] Failed for summon #{summon_id}: #{result.error}"
update_status(summon_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(summon_id, status, **attrs)
self.class.update_status(summon_id, status, **attrs)
end
end

View file

@ -0,0 +1,87 @@
# frozen_string_literal: true
# Background job for downloading weapon images from Granblue servers to S3.
# Stores progress in Redis for status polling.
#
# @example Enqueue a download job
# job = DownloadWeaponImagesJob.perform_later(weapon.id)
# # Poll status with: DownloadWeaponImagesJob.status(weapon.id)
class DownloadWeaponImagesJob < ApplicationJob
queue_as :downloads
retry_on StandardError, wait: :exponentially_longer, attempts: 3
discard_on ActiveRecord::RecordNotFound do |job, _error|
weapon_id = job.arguments.first
Rails.logger.error "[DownloadWeaponImages] Weapon #{weapon_id} not found"
update_status(weapon_id, 'failed', error: 'Weapon not found')
end
# Status keys for Redis storage
REDIS_KEY_PREFIX = 'weapon_image_download'
STATUS_TTL = 1.hour.to_i
class << self
# Get the current status of a download job for a weapon
#
# @param weapon_id [String] UUID of the weapon
# @return [Hash] Status hash with :status, :progress, :images_downloaded, :images_total, :error
def status(weapon_id)
data = redis.get(redis_key(weapon_id))
return { status: 'not_found' } unless data
JSON.parse(data, symbolize_names: true)
end
def redis_key(weapon_id)
"#{REDIS_KEY_PREFIX}:#{weapon_id}"
end
def redis
@redis ||= Redis.new(url: ENV.fetch('REDIS_URL', 'redis://localhost:6379/0'))
end
def update_status(weapon_id, status, **attrs)
data = { status: status, updated_at: Time.current.iso8601 }.merge(attrs)
redis.setex(redis_key(weapon_id), STATUS_TTL, data.to_json)
end
end
def perform(weapon_id, force: false, size: 'all')
Rails.logger.info "[DownloadWeaponImages] Starting download for weapon #{weapon_id}"
weapon = Weapon.find(weapon_id)
update_status(weapon_id, 'processing', progress: 0, images_downloaded: 0)
service = WeaponImageDownloadService.new(
weapon,
force: force,
size: size,
storage: :s3
)
result = service.download
if result.success?
Rails.logger.info "[DownloadWeaponImages] Completed for weapon #{weapon_id}"
update_status(
weapon_id,
'completed',
progress: 100,
images_downloaded: result.total,
images_total: result.total,
images: result.images
)
else
Rails.logger.error "[DownloadWeaponImages] Failed for weapon #{weapon_id}: #{result.error}"
update_status(weapon_id, 'failed', error: result.error)
raise StandardError, result.error # Trigger retry
end
end
private
def update_status(weapon_id, status, **attrs)
self.class.update_status(weapon_id, status, **attrs)
end
end

36
app/models/artifact.rb Normal file
View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Artifact < ApplicationRecord
# Enums - using GranblueEnums::PROFICIENCY values (excluding None: 0)
# Sabre: 1, Dagger: 2, Axe: 3, Spear: 4, Bow: 5, Staff: 6, Melee: 7, Harp: 8, Gun: 9, Katana: 10
enum :proficiency, {
sabre: 1,
dagger: 2,
axe: 3,
spear: 4,
bow: 5,
staff: 6,
melee: 7,
harp: 8,
gun: 9,
katana: 10
}
enum :rarity, { standard: 0, quirk: 1 }
# Associations
has_many :collection_artifacts, dependent: :restrict_with_error
has_many :grid_artifacts, dependent: :restrict_with_error
# Validations
validates :granblue_id, presence: true, uniqueness: true
validates :name_en, presence: true
validates :proficiency, presence: true, if: :standard?
validates :proficiency, absence: true, if: :quirk?
validates :rarity, presence: true
# Scopes
scope :standard_artifacts, -> { where(rarity: :standard) }
scope :quirk_artifacts, -> { where(rarity: :quirk) }
scope :by_proficiency, ->(prof) { where(proficiency: prof) }
end

View file

@ -0,0 +1,103 @@
# frozen_string_literal: true
class ArtifactSkill < ApplicationRecord
# Enums
enum :skill_group, { group_i: 1, group_ii: 2, group_iii: 3 }
enum :polarity, { positive: 'positive', negative: 'negative' }
# Validations
validates :skill_group, presence: true
validates :modifier, presence: true, uniqueness: { scope: :skill_group }
validates :name_en, presence: true
validates :name_jp, presence: true
validates :polarity, presence: true
# Scopes
scope :for_slot, ->(slot) {
case slot
when 1, 2 then group_i
when 3 then group_ii
when 4 then group_iii
end
}
# Class methods for caching skill lookups
class << self
def cached_skills
@cached_skills ||= all.index_by { |s| [s.skill_group, s.modifier] }
end
def cached_by_game_name
@game_name_cache ||= begin
cache = {}
all.each do |skill|
# Use game names for matching, fall back to display names if not set
en_key = skill.game_name_en.presence || skill.name_en
jp_key = skill.game_name_jp.presence || skill.name_jp
cache[en_key] = skill
cache[jp_key] = skill
end
cache
end
end
def find_skill(group, modifier)
# Convert group number to enum key
group_key = case group
when 1 then 'group_i'
when 2 then 'group_ii'
when 3 then 'group_iii'
else group.to_s
end
cached_skills[[group_key, modifier]]
end
def find_by_game_name(name)
cached_by_game_name[name]
end
def clear_cache!
@cached_skills = nil
@game_name_cache = nil
end
end
# Calculate the current value of a skill given base strength and skill level
# @param base_strength [Numeric] The base strength value of the skill
# @param skill_level [Integer] The current skill level (1-5)
# @return [Numeric, nil] The calculated value
def calculate_value(base_strength, skill_level)
return base_strength if growth.nil?
base_strength + (growth * (skill_level - 1))
end
# Format a value with the appropriate suffix
# @param value [Numeric] The value to format
# @param locale [Symbol] :en or :jp
# @return [String] The formatted value with suffix
def format_value(value, locale = :en)
suffix = locale == :jp ? suffix_jp : suffix_en
"#{value}#{suffix}"
end
# Check if a strength value is valid for this skill
# @param strength [Numeric] The strength value to validate
# @return [Boolean]
def valid_strength?(strength)
return true if base_values.include?(nil) # Unknown values are always valid
base_values.include?(strength)
end
# Get the base strength value for a given quality tier
# @param quality [Integer] The quality tier (1-5)
# @return [Numeric, nil] The base strength value
def strength_for_quality(quality)
return nil if base_values.nil? || !base_values.is_a?(Array) || base_values.empty?
# Quality 1-5 maps to index 0-4
index = (quality - 1).clamp(0, base_values.size - 1)
base_values[index]
end
end

View file

@ -3,6 +3,9 @@
class Character < ApplicationRecord class Character < ApplicationRecord
include PgSearch::Model include PgSearch::Model
has_many :character_series_memberships, dependent: :destroy
has_many :character_series_records, through: :character_series_memberships, source: :character_series
multisearchable against: %i[name_en name_jp], multisearchable against: %i[name_en name_jp],
additional_attributes: lambda { |character| additional_attributes: lambda { |character|
{ {
@ -41,6 +44,20 @@ class Character < ApplicationRecord
{ slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 } { slug: 'character-multi', name_en: 'Multiattack', name_jp: '連続攻撃', order: 3 }
].freeze ].freeze
# Validations
validates :season,
numericality: { only_integer: true },
inclusion: { in: GranblueEnums::CHARACTER_SEASONS.values },
allow_nil: true
validate :validate_series_values
# Scopes
scope :by_season, ->(season) { where(season: season) }
scope :by_series, ->(series) { where('? = ANY(series)', series) }
scope :seasonal, -> { where.not(season: [nil, GranblueEnums::CHARACTER_SEASONS[:Standard]]) }
def blueprint def blueprint
CharacterBlueprint CharacterBlueprint
end end
@ -48,4 +65,93 @@ class Character < ApplicationRecord
def display_resource(character) def display_resource(character)
character.name_en character.name_en
end end
# Helper methods
def seasonal?
season.present? && season != GranblueEnums::CHARACTER_SEASONS[:Standard]
end
def season_name
return nil if season.nil?
GranblueEnums::CHARACTER_SEASONS.key(season)&.to_s
end
def series_names
# Use new lookup table if available
if character_series_records.loaded? ? character_series_records.any? : character_series_records.exists?
character_series_records.ordered.pluck(:name_en)
elsif series.present?
# Legacy fallback
series.filter_map { |s| GranblueEnums::CHARACTER_SERIES.key(s)&.to_s }
else
[]
end
end
def series_objects
character_series_records.ordered
end
def series_slugs
character_series_records.pluck(:slug)
end
# Mapping from legacy integer values to slugs
LEGACY_SERIES_TO_SLUG = {
1 => 'grand',
2 => 'zodiac',
3 => 'promo',
4 => 'collab',
5 => 'eternal',
6 => 'evoker',
7 => 'saint',
8 => 'fantasy',
9 => 'summer',
10 => 'yukata',
11 => 'valentine',
12 => 'halloween',
13 => 'formal',
14 => 'holiday',
15 => 'event'
}.freeze
# Virtual attribute to set character_series by array of IDs, slugs, or legacy integers
# Supports multiple formats for flexibility during migration
def series=(values)
return if values.blank?
# Ensure it's an array
values = Array(values)
values.each do |value|
next if value.blank?
# Try to find the series record
series_record = if value.is_a?(Integer)
# Legacy integer - convert to slug first
slug = LEGACY_SERIES_TO_SLUG[value]
slug ? CharacterSeries.find_by(slug: slug) : nil
else
# String - try UUID first, then slug
CharacterSeries.find_by(id: value) || CharacterSeries.find_by(slug: value)
end
next unless series_record
# Create membership if it doesn't exist
character_series_memberships.find_or_initialize_by(character_series: series_record)
end
end
private
def validate_series_values
return if series.blank?
invalid_values = series.reject { |s| GranblueEnums::CHARACTER_SERIES.values.include?(s) }
return if invalid_values.empty?
errors.add(:series, "contains invalid values: #{invalid_values.join(', ')}")
end
end end

View file

@ -0,0 +1,24 @@
# frozen_string_literal: true
class CharacterSeries < ApplicationRecord
has_many :character_series_memberships, dependent: :destroy
has_many :characters, through: :character_series_memberships
validates :name_en, presence: true
validates :name_jp, presence: true
validates :slug, presence: true, uniqueness: true
validates :order, numericality: { only_integer: true }
scope :ordered, -> { order(:order) }
# Slug constants for commonly referenced series
GRAND = 'grand'
ZODIAC = 'zodiac'
ETERNAL = 'eternal'
EVOKER = 'evoker'
SAINT = 'saint'
def blueprint
CharacterSeriesBlueprint
end
end

View file

@ -0,0 +1,8 @@
# frozen_string_literal: true
class CharacterSeriesMembership < ApplicationRecord
belongs_to :character
belongs_to :character_series
validates :character_id, uniqueness: { scope: :character_series_id }
end

View file

@ -0,0 +1,92 @@
# frozen_string_literal: true
class CollectionArtifact < ApplicationRecord
include ArtifactSkillValidations
# Associations
belongs_to :user
belongs_to :artifact
has_many :grid_artifacts, dependent: :nullify
before_destroy :orphan_grid_items
# Enums - using GranblueEnums::ELEMENTS values (excluding Null)
# Wind: 1, Fire: 2, Water: 3, Earth: 4, Dark: 5, Light: 6
enum :element, {
wind: 1,
fire: 2,
water: 3,
earth: 4,
dark: 5,
light: 6
}
# Proficiency enum - only used for quirk artifacts (game assigns random proficiency)
enum :proficiency, {
sabre: 1,
dagger: 2,
axe: 3,
spear: 4,
bow: 5,
staff: 6,
melee: 7,
harp: 8,
gun: 9,
katana: 10
}
# Validations
validates :element, presence: true
validates :level, presence: true, inclusion: { in: 1..5 }
validates :nickname, length: { maximum: 50 }, allow_blank: true
validates :proficiency, presence: true, if: :quirk_artifact?
validates :proficiency, absence: true, unless: :quirk_artifact?
validates :reroll_slot, inclusion: { in: 1..4 }, allow_nil: true
# Scopes
scope :by_element, ->(el) { where(element: el) }
scope :by_artifact, ->(artifact_id) { where(artifact_id: artifact_id) }
# Filter by proficiency - handles both quirk (instance) and standard (artifact) proficiencies
scope :by_proficiency, ->(prof) {
joins(:artifact).where(
'collection_artifacts.proficiency IN (?) OR (collection_artifacts.proficiency IS NULL AND artifacts.proficiency IN (?))',
Array(prof), Array(prof)
)
}
scope :by_rarity, ->(rar) { joins(:artifact).where(artifacts: { rarity: rar }) }
scope :standard_only, -> { joins(:artifact).where(artifacts: { rarity: :standard }) }
scope :quirk_only, -> { joins(:artifact).where(artifacts: { rarity: :quirk }) }
# Filter by skill modifier in a specific slot (1-4)
# Uses OR logic when multiple modifiers are provided
scope :with_skill_in_slot, ->(slot, modifiers) {
return all if modifiers.blank?
modifiers = Array(modifiers).map(&:to_s)
column = "skill#{slot}"
# Build OR conditions for multiple modifiers
conditions = modifiers.map { |_| "#{column}->>'modifier' = ?" }.join(' OR ')
where(conditions, *modifiers)
}
# Returns the effective proficiency - from instance for quirk, from artifact for standard
def effective_proficiency
quirk_artifact? ? proficiency : artifact&.proficiency
end
private
def quirk_artifact?
artifact&.quirk?
end
##
# Marks all linked grid artifacts as orphaned before destroying this collection artifact.
#
# @return [void]
def orphan_grid_items
grid_artifacts.update_all(orphaned: true, collection_artifact_id: nil)
end
end

View file

@ -0,0 +1,92 @@
class CollectionCharacter < ApplicationRecord
belongs_to :user
belongs_to :character
belongs_to :awakening, optional: true
before_save :add_default_awakening
validates :character_id, uniqueness: { scope: :user_id,
message: "already exists in your collection" }
validates :uncap_level, inclusion: { in: 0..5 }
validates :transcendence_step, inclusion: { in: 0..10 }
validates :awakening_level, inclusion: { in: 1..10 }
validate :validate_rings
validate :validate_awakening_compatibility
validate :validate_awakening_level
validate :validate_transcendence_requirements
scope :by_element, ->(element) { joins(:character).where(characters: { element: element }) }
scope :by_rarity, ->(rarity) { joins(:character).where(characters: { rarity: rarity }) }
scope :by_race, ->(races) {
joins(:character).where('characters.race1 IN (?) OR characters.race2 IN (?)', races, races)
}
scope :by_proficiency, ->(proficiencies) {
joins(:character).where('characters.proficiency1 IN (?) OR characters.proficiency2 IN (?)', proficiencies, proficiencies)
}
scope :by_gender, ->(genders) { joins(:character).where(characters: { gender: genders }) }
scope :transcended, -> { where('transcendence_step > 0') }
scope :with_awakening, -> { where.not(awakening_id: nil) }
# Sorting scopes
scope :sorted_by, ->(sort_key) {
case sort_key
when 'name_asc'
joins(:character).order('characters.name_en ASC NULLS LAST')
when 'name_desc'
joins(:character).order('characters.name_en DESC NULLS LAST')
when 'element_asc'
joins(:character).order('characters.element ASC')
when 'element_desc'
joins(:character).order('characters.element DESC')
when 'proficiency_asc'
joins(:character).order('characters.proficiency1 ASC')
when 'proficiency_desc'
joins(:character).order('characters.proficiency1 DESC')
else
order(created_at: :desc) # Default: newest first
end
}
def blueprint
Api::V1::CollectionCharacterBlueprint
end
private
def validate_rings
[ring1, ring2, ring3, ring4, earring].each_with_index do |ring, index|
next unless ring['modifier'].present? || ring['strength'].present?
if ring['modifier'].blank? || ring['strength'].blank?
errors.add(:base, "Ring #{index + 1} must have both modifier and strength")
end
end
end
def validate_awakening_compatibility
return unless awakening.present?
unless awakening.object_type == 'Character'
errors.add(:awakening, "must be a character awakening")
end
end
def validate_awakening_level
if awakening_level.present? && awakening_level > 1 && awakening_id.blank?
errors.add(:awakening_level, "cannot be set without an awakening")
end
end
def validate_transcendence_requirements
if transcendence_step.present? && transcendence_step > 0 && uncap_level < 5
errors.add(:transcendence_step, "requires uncap level 5 (current: #{uncap_level})")
end
end
def add_default_awakening
return unless awakening.nil?
self.awakening = Awakening.where(slug: 'character-balanced').sole
end
end

View file

@ -0,0 +1,14 @@
class CollectionJobAccessory < ApplicationRecord
belongs_to :user
belongs_to :job_accessory
validates :job_accessory_id, uniqueness: { scope: :user_id,
message: "already exists in your collection" }
scope :by_job, ->(job_id) { joins(:job_accessory).where(job_accessories: { job_id: job_id }) }
scope :by_job_accessory, ->(job_accessory_id) { where(job_accessory_id: job_accessory_id) }
def blueprint
Api::V1::CollectionJobAccessoryBlueprint
end
end

View file

@ -0,0 +1,46 @@
class CollectionSummon < ApplicationRecord
belongs_to :user
belongs_to :summon
has_many :grid_summons, dependent: :nullify
before_destroy :orphan_grid_items
validates :uncap_level, inclusion: { in: 0..5 }
validates :transcendence_step, inclusion: { in: 0..10 }
validate :validate_transcendence_requirements
scope :by_summon, ->(summon_id) { where(summon_id: summon_id) }
scope :by_element, ->(element) { joins(:summon).where(summons: { element: element }) }
scope :by_rarity, ->(rarity) { joins(:summon).where(summons: { rarity: rarity }) }
scope :transcended, -> { where('transcendence_step > 0') }
scope :max_uncapped, -> { where(uncap_level: 5) }
def blueprint
Api::V1::CollectionSummonBlueprint
end
private
def validate_transcendence_requirements
return unless transcendence_step.present? && transcendence_step > 0
if uncap_level < 5
errors.add(:transcendence_step, "requires uncap level 5 (current: #{uncap_level})")
end
# Some summons might not support transcendence
if summon.present? && !summon.transcendence
errors.add(:transcendence_step, "not available for this summon")
end
end
##
# Marks all linked grid summons as orphaned before destroying this collection summon.
#
# @return [void]
def orphan_grid_items
grid_summons.update_all(orphaned: true, collection_summon_id: nil)
end
end

Some files were not shown because too many files have changed in this diff Show more