This commit is contained in:
Alexander Karpov 2025-10-20 13:50:49 +03:00
parent 2414f2f761
commit 2c08bf7282
35 changed files with 2432 additions and 286 deletions

381
MusicBand_LoadTest.jmx Normal file
View File

@ -0,0 +1,381 @@
<?xml version="1.0" encoding="UTF-8"?>
<jmeterTestPlan version="1.2" properties="5.0" jmeter="5.6.3">
<hashTree>
<TestPlan guiclass="TestPlanGui" testclass="TestPlan" testname="Music Band IS Load Test" enabled="true">
<stringProp name="TestPlan.comments"></stringProp>
<boolProp name="TestPlan.functional_mode">false</boolProp>
<boolProp name="TestPlan.serialize_threadgroups">false</boolProp>
<elementProp name="TestPlan.user_defined_variables" elementType="Arguments" guiclass="ArgumentsPanel" testclass="Arguments" testname="User Defined Variables" enabled="true">
<collectionProp name="Arguments.arguments">
<elementProp name="BASE_URL" elementType="Argument">
<stringProp name="Argument.name">BASE_URL</stringProp>
<stringProp name="Argument.value">http://localhost:8080/is1/api</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="TestPlan.user_define_classpath"></stringProp>
</TestPlan>
<hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="1. User Registration" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">10</stringProp>
<stringProp name="ThreadGroup.ramp_time">2</stringProp>
<longProp name="ThreadGroup.start_time">1</longProp>
<longProp name="ThreadGroup.end_time">1</longProp>
<boolProp name="ThreadGroup.scheduler">false</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
<stringProp name="ThreadGroup.delay"></stringProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Register User" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;username&quot;: &quot;testuser${__threadNum}&quot;,&#xd;
&quot;password&quot;: &quot;password123&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.contentEncoding"></stringProp>
<stringProp name="HTTPSampler.path">/is1/api/auth/register</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.auto_redirects">false</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
<boolProp name="HTTPSampler.DO_MULTIPART_POST">false</boolProp>
<stringProp name="HTTPSampler.embedded_url_re"></stringProp>
<stringProp name="HTTPSampler.connect_timeout"></stringProp>
<stringProp name="HTTPSampler.response_timeout"></stringProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Token" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
<stringProp name="JSONPostProcessor.defaultValues">NOTFOUND</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
</hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="2. Concurrent CRUD Operations" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">10</stringProp>
<stringProp name="ThreadGroup.ramp_time">2</stringProp>
<stringProp name="ThreadGroup.delay">5</stringProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
<stringProp name="ThreadGroup.duration"></stringProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Login" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;username&quot;: &quot;testuser${__threadNum}&quot;,&#xd;
&quot;password&quot;: &quot;password123&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.protocol">http</stringProp>
<stringProp name="HTTPSampler.path">/is1/api/auth/login</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.follow_redirects">true</boolProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Token" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Band" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;name&quot;: &quot;Test Band ${__threadNum}_${__time(,)}&quot;,&#xd;
&quot;genre&quot;: &quot;ROCK&quot;,&#xd;
&quot;coordinates&quot;: {&#xd;
&quot;x&quot;: ${__Random(1,1000)},&#xd;
&quot;y&quot;: ${__Random(1,1000)}&#xd;
},&#xd;
&quot;numberOfParticipants&quot;: ${__Random(1,10)},&#xd;
&quot;singlesCount&quot;: ${__Random(1,50)},&#xd;
&quot;albumsCount&quot;: ${__Random(1,20)},&#xd;
&quot;establishmentDate&quot;: &quot;20${__Random(10,23)}-01-${__Random(10,28)}T10:00:00+03:00&quot;,&#xd;
&quot;description&quot;: &quot;Test band ${__threadNum}&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/is1/api/music-bands</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${token}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Band ID" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">bandId</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.id</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Update Band" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;name&quot;: &quot;Updated Band ${__threadNum}_${__time(,)}&quot;,&#xd;
&quot;genre&quot;: &quot;JAZZ&quot;,&#xd;
&quot;coordinates&quot;: {&#xd;
&quot;x&quot;: ${__Random(1,1000)},&#xd;
&quot;y&quot;: ${__Random(1,1000)}&#xd;
},&#xd;
&quot;numberOfParticipants&quot;: ${__Random(1,10)},&#xd;
&quot;singlesCount&quot;: ${__Random(1,50)},&#xd;
&quot;albumsCount&quot;: ${__Random(1,20)},&#xd;
&quot;establishmentDate&quot;: &quot;20${__Random(10,23)}-02-${__Random(10,28)}T10:00:00+03:00&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/is1/api/music-bands/${bandId}</stringProp>
<stringProp name="HTTPSampler.method">PUT</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${token}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Delete Band" enabled="true">
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/is1/api/music-bands/${bandId}</stringProp>
<stringProp name="HTTPSampler.method">DELETE</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${token}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
</hashTree>
</hashTree>
<ThreadGroup guiclass="ThreadGroupGui" testclass="ThreadGroup" testname="3. Unique Constraint Test" enabled="true">
<stringProp name="ThreadGroup.on_sample_error">continue</stringProp>
<elementProp name="ThreadGroup.main_controller" elementType="LoopController" guiclass="LoopControlPanel" testclass="LoopController" testname="Loop Controller" enabled="true">
<boolProp name="LoopController.continue_forever">false</boolProp>
<stringProp name="LoopController.loops">1</stringProp>
</elementProp>
<stringProp name="ThreadGroup.num_threads">5</stringProp>
<stringProp name="ThreadGroup.ramp_time">0</stringProp>
<stringProp name="ThreadGroup.delay">10</stringProp>
<boolProp name="ThreadGroup.scheduler">true</boolProp>
</ThreadGroup>
<hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Login Admin" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;username&quot;: &quot;admin&quot;,&#xd;
&quot;password&quot;: &quot;admin&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/is1/api/auth/login</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
<JSONPostProcessor guiclass="JSONPostProcessorGui" testclass="JSONPostProcessor" testname="Extract Token" enabled="true">
<stringProp name="JSONPostProcessor.referenceNames">token</stringProp>
<stringProp name="JSONPostProcessor.jsonPathExprs">$.token</stringProp>
<stringProp name="JSONPostProcessor.match_numbers">1</stringProp>
</JSONPostProcessor>
<hashTree/>
</hashTree>
<HTTPSamplerProxy guiclass="HttpTestSampleGui" testclass="HTTPSamplerProxy" testname="Create Band with Same Unique Fields" enabled="true">
<boolProp name="HTTPSampler.postBodyRaw">true</boolProp>
<elementProp name="HTTPsampler.Arguments" elementType="Arguments">
<collectionProp name="Arguments.arguments">
<elementProp name="" elementType="HTTPArgument">
<boolProp name="HTTPArgument.always_encode">false</boolProp>
<stringProp name="Argument.value">{&#xd;
&quot;name&quot;: &quot;Unique Test Band&quot;,&#xd;
&quot;genre&quot;: &quot;ROCK&quot;,&#xd;
&quot;coordinates&quot;: {&quot;x&quot;: 100, &quot;y&quot;: 100},&#xd;
&quot;numberOfParticipants&quot;: 5,&#xd;
&quot;singlesCount&quot;: 10,&#xd;
&quot;establishmentDate&quot;: &quot;2023-01-01T10:00:00+03:00&quot;&#xd;
}</stringProp>
<stringProp name="Argument.metadata">=</stringProp>
</elementProp>
</collectionProp>
</elementProp>
<stringProp name="HTTPSampler.domain">localhost</stringProp>
<stringProp name="HTTPSampler.port">8080</stringProp>
<stringProp name="HTTPSampler.path">/is1/api/music-bands</stringProp>
<stringProp name="HTTPSampler.method">POST</stringProp>
<boolProp name="HTTPSampler.use_keepalive">true</boolProp>
</HTTPSamplerProxy>
<hashTree>
<HeaderManager guiclass="HeaderPanel" testclass="HeaderManager" testname="HTTP Header Manager" enabled="true">
<collectionProp name="HeaderManager.headers">
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Content-Type</stringProp>
<stringProp name="Header.value">application/json</stringProp>
</elementProp>
<elementProp name="" elementType="Header">
<stringProp name="Header.name">Authorization</stringProp>
<stringProp name="Header.value">Bearer ${token}</stringProp>
</elementProp>
</collectionProp>
</HeaderManager>
<hashTree/>
</hashTree>
</hashTree>
<ResultCollector guiclass="SummaryReport" testclass="ResultCollector" testname="Summary Report" enabled="true">
<boolProp name="ResultCollector.error_logging">false</boolProp>
<objProp>
<name>saveConfig</name>
<value class="SampleSaveConfiguration">
<time>true</time>
<latency>true</latency>
<timestamp>true</timestamp>
<success>true</success>
<label>true</label>
<code>true</code>
<message>true</message>
<threadName>true</threadName>
<dataType>true</dataType>
<encoding>false</encoding>
<assertions>true</assertions>
<subresults>true</subresults>
<responseData>false</responseData>
<samplerData>false</samplerData>
<xml>false</xml>
<fieldNames>true</fieldNames>
<responseHeaders>false</responseHeaders>
<requestHeaders>false</requestHeaders>
<responseDataOnError>false</responseDataOnError>
<saveAssertionResultsFailureMessage>true</saveAssertionResultsFailureMessage>
<assertionsResultsToSave>0</assertionsResultsToSave>
<bytes>true</bytes>
<sentBytes>true</sentBytes>
<url>true</url>
<threadCounts>true</threadCounts>
<idleTime>true</idleTime>
<connectTime>true</connectTime>
</value>
</objProp>
<stringProp name="filename"></stringProp>
</ResultCollector>
<hashTree/>
</hashTree>
</hashTree>
</jmeterTestPlan>

57
bands.json Normal file
View File

@ -0,0 +1,57 @@
{
"bands": [
{
"name": "The Rolling Stones",
"genre": "ROCK",
"coordinates": {
"x": 100,
"y": 200
},
"numberOfParticipants": 4,
"singlesCount": 50,
"albumsCount": 30,
"establishmentDate": "1962-07-12T10:00:00+03:00",
"description": "British rock band formed in London",
"bestAlbum": {
"name": "Sticky Fingers",
"tracks": 10
}
},
{
"name": "Pink Floyd",
"genre": "PSYCHEDELIC_CLOUD_RAP",
"coordinates": {
"x": 150,
"y": 250
},
"numberOfParticipants": 5,
"singlesCount": 30,
"albumsCount": 15,
"establishmentDate": "1965-01-01T12:00:00+03:00",
"description": "Progressive rock band",
"bestAlbum": {
"name": "The Dark Side of the Moon",
"tracks": 10
},
"frontMan": {
"name": "David Gilmour",
"eyeColor": "BLUE",
"hairColor": "BROWN",
"height": 183.0,
"nationality": "SOUTH_KOREA",
"location": {
"x": 10,
"y": 20.5,
"z": 30
}
}
},
{
"name": "Miles Davis Quintet",
"genre": "JAZZ",
"coordinates": {
"x": 300,
"y": 400
},
"numberOfParticipants": 5,
"singlesCount": 20,

46
bands.xml Normal file
View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<MusicBandsWrapper>
<bands>
<MusicBand>
<name>Queen</name>
<genre>ROCK</genre>
<coordinates>
<x>500</x>
<y>600</y>
</coordinates>
<numberOfParticipants>4</numberOfParticipants>
<singlesCount>40</singlesCount>
<albumsCount>15</albumsCount>
<establishmentDate>1970-06-27T10:00:00+03:00</establishmentDate>
<description>British rock band</description>
<bestAlbum>
<name>A Night at the Opera</name>
<tracks>12</tracks>
</bestAlbum>
<frontMan>
<name>Freddie Mercury</name>
<eyeColor>BROWN</eyeColor>
<hairColor>BROWN</hairColor>
<height>177.0</height>
<nationality>THAILAND</nationality>
</frontMan>
</MusicBand>
<MusicBand>
<name>Led Zeppelin</name>
<genre>ROCK</genre>
<coordinates>
<x>700</x>
<y>800</y>
</coordinates>
<numberOfParticipants>4</numberOfParticipants>
<singlesCount>25</singlesCount>
<albumsCount>9</albumsCount>
<establishmentDate>1968-09-07T11:00:00+03:00</establishmentDate>
<description>English rock band</description>
<bestAlbum>
<name>Led Zeppelin IV</name>
<tracks>8</tracks>
</bestAlbum>
</MusicBand>
</bands>
</MusicBandsWrapper>

57
bands.yaml Normal file
View File

@ -0,0 +1,57 @@
bands:
- name: "Nirvana"
genre: ROCK
coordinates:
x: 900
y: 1000
numberOfParticipants: 3
singlesCount: 15
albumsCount: 3
establishmentDate: "1987-01-01T09:00:00+03:00"
description: "American grunge band"
bestAlbum:
name: "Nevermind"
tracks: 12
frontMan:
name: "Kurt Cobain"
eyeColor: BLUE
height: 175.0
nationality: NORTH_KOREA
- name: "The Beatles"
genre: ROCK
coordinates:
x: 1100
y: 1200
numberOfParticipants: 4
singlesCount: 63
albumsCount: 13
establishmentDate: "1960-08-18T10:30:00+03:00"
description: "English rock band"
bestAlbum:
name: "Abbey Road"
tracks: 17
frontMan:
name: "John Lennon"
eyeColor: BROWN
hairColor: BROWN
height: 179.0
nationality: INDIA
location:
x: 5
y: 10.0
z: 15
- name: "Coltrane Quartet"
genre: JAZZ
coordinates:
x: 1300
y: 1400
numberOfParticipants: 4
singlesCount: 12
albumsCount: 20
establishmentDate: "1960-05-01T13:00:00+03:00"
description: "Jazz quartet"
bestAlbum:
name: "A Love Supreme"
tracks: 4

View File

@ -1,35 +1,53 @@
#!/bin/bash
# Build script for Music Band Information System
echo "Building Music Band Information System..."
# Clean previous builds
echo "Cleaning previous builds..."
mvn clean
# Compile and package
echo "Compiling and packaging..."
mvn package -DskipTests
# Check if build was successful
if [ $? -eq 0 ]; then
echo "Build successful! WAR file created at target/is1.war"
echo "File size: $(ls -lh target/is1.war | awk '{print $5}')"
# Copy to WildFly deployments directory
echo ""
echo "Deploying to WildFly..."
cp target/is1.war wildfly/standalone/deployments/
if [ $? -eq 0 ]; then
echo "WAR file successfully copied to wildfly/standalone/deployments/"
echo "Application will be auto-deployed when WildFly starts."
else
echo "Failed to copy WAR file to WildFly deployments directory"
echo "Make sure wildfly/standalone/deployments/ directory exists"
fi
else
echo "Build failed!"
set -e
if [ ! -f "pom.xml" ]; then
echo "Error: pom.xml not found. Please run this script from the project root."
exit 1
fi
fi
echo "[1/5] Cleaning previous builds..."
mvn clean
echo "Clean completed"
echo ""
echo "[2/5] Compiling sources..."
mvn compile
echo "Compilation completed"
echo ""
echo "[3/5] Running tests..."
mvn test -DskipTests
echo "Tests completed (skipped)"
echo ""
echo "[4/5] Packaging WAR file..."
mvn package -DskipTests
echo "Packaging completed"
echo ""
if [ ! -f "target/is1.war" ]; then
echo "Error: WAR file not created. Build failed."
exit 1
fi
WAR_SIZE=$(ls -lh target/is1.war | awk '{print $5}')
echo "WAR file created: target/is1.war (${WAR_SIZE})"
echo ""
echo "[5/5] Deploying to WildFly..."
WILDFLY_DEPLOYMENTS="wildfly/standalone/deployments"
if [ -d "$WILDFLY_DEPLOYMENTS" ]; then
cp target/is1.war "$WILDFLY_DEPLOYMENTS/"
echo "WAR file copied to: $WILDFLY_DEPLOYMENTS/is1.war"
else
echo "WildFly deployments directory not found at: $WILDFLY_DEPLOYMENTS"
fi
echo ""
echo "✓ Build successful"
echo "✓ WAR file: target/is1.war (${WAR_SIZE})"
echo "✓ Ready for deployment"
echo ""

42
pom.xml
View File

@ -7,7 +7,7 @@
<groupId>ru.akarpov</groupId>
<artifactId>is1</artifactId>
<version>1.0.0</version>
<version>2.0.0</version>
<packaging>war</packaging>
<properties>
@ -49,6 +49,44 @@
<artifactId>jackson-datatype-jsr310</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-xml</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.dataformat</groupId>
<artifactId>jackson-dataformat-yaml</artifactId>
<version>2.15.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.11.5</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.mindrot</groupId>
<artifactId>jbcrypt</artifactId>
<version>0.4</version>
</dependency>
</dependencies>
<build>
@ -73,4 +111,4 @@
</plugin>
</plugins>
</build>
</project>
</project>

View File

@ -1,13 +1,11 @@
-- Database schema for Music Band Information System
-- Create sequences for auto-generated IDs
CREATE SEQUENCE IF NOT EXISTS coordinates_seq START 1;
CREATE SEQUENCE IF NOT EXISTS location_seq START 1;
CREATE SEQUENCE IF NOT EXISTS album_seq START 1;
CREATE SEQUENCE IF NOT EXISTS person_seq START 1;
CREATE SEQUENCE IF NOT EXISTS music_band_seq START 1;
CREATE SEQUENCE IF NOT EXISTS users_seq START 1;
CREATE SEQUENCE IF NOT EXISTS import_operation_seq START 1;
-- Create tables
CREATE TABLE IF NOT EXISTS coordinates (
id BIGINT PRIMARY KEY DEFAULT nextval('coordinates_seq'),
x BIGINT NOT NULL,
@ -37,6 +35,23 @@ CREATE TABLE IF NOT EXISTS person (
nationality VARCHAR(20) NOT NULL CHECK (nationality IN ('INDIA', 'THAILAND', 'SOUTH_KOREA', 'NORTH_KOREA'))
);
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY DEFAULT nextval('users_seq'),
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL CHECK (role IN ('USER', 'ADMIN')),
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS import_operation (
id BIGINT PRIMARY KEY DEFAULT nextval('import_operation_seq'),
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
status VARCHAR(20) NOT NULL CHECK (status IN ('SUCCESS', 'FAILED')),
objects_count INTEGER,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS music_band (
id BIGINT PRIMARY KEY DEFAULT nextval('music_band_seq'),
name VARCHAR(255) NOT NULL CHECK (name <> ''),
@ -49,16 +64,33 @@ CREATE TABLE IF NOT EXISTS music_band (
best_album_id BIGINT REFERENCES album(id) ON DELETE SET NULL,
albums_count INTEGER CHECK (albums_count IS NULL OR albums_count > 0),
establishment_date TIMESTAMP WITH TIME ZONE NOT NULL,
front_man_id BIGINT REFERENCES person(id) ON DELETE SET NULL
front_man_id BIGINT REFERENCES person(id) ON DELETE SET NULL,
created_by BIGINT REFERENCES users(id) ON DELETE SET NULL
);
-- Create indexes for better performance
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.tables
WHERE table_name = 'music_band'
) AND NOT EXISTS (
SELECT 1 FROM music_band WHERE created_by IS NOT NULL LIMIT 1
) THEN
UPDATE music_band
SET created_by = (SELECT id FROM users WHERE username = 'admin' LIMIT 1)
WHERE created_by IS NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS idx_music_band_name ON music_band(name);
CREATE INDEX IF NOT EXISTS idx_music_band_genre ON music_band(genre);
CREATE INDEX IF NOT EXISTS idx_music_band_participants ON music_band(number_of_participants);
CREATE INDEX IF NOT EXISTS idx_music_band_establishment_date ON music_band(establishment_date);
CREATE INDEX IF NOT EXISTS idx_music_band_created_by ON music_band(created_by);
CREATE INDEX IF NOT EXISTS idx_import_operation_user ON import_operation(user_id);
CREATE INDEX IF NOT EXISTS idx_import_operation_status ON import_operation(status);
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
-- Database functions for special operations
CREATE OR REPLACE FUNCTION calculate_average_albums_count()
RETURNS DOUBLE PRECISION AS $$
BEGIN
@ -112,4 +144,8 @@ BEGIN
RAISE EXCEPTION 'Band with ID % not found or cannot remove participant (minimum 1 required)', band_id;
END IF;
END;
$$ LANGUAGE plpgsql;
$$ LANGUAGE plpgsql;
INSERT INTO users (username, password_hash, role)
VALUES ('admin', '$2a$10$SIVWLONFduZBQmozzHmVbO21zvCfXeg649BvXLwbYxL/8EOBGCqSG', 'ADMIN')
ON CONFLICT (username) DO NOTHING;

View File

@ -0,0 +1,49 @@
package ru.akarpov.is1.dto;
public class AuthResponse {
private String token;
private Long userId;
private String username;
private String role;
public AuthResponse() {}
public AuthResponse(String token, Long userId, String username, String role) {
this.token = token;
this.userId = userId;
this.username = username;
this.role = role;
}
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getRole() {
return role;
}
public void setRole(String role) {
this.role = role;
}
}

View File

@ -0,0 +1,42 @@
package ru.akarpov.is1.dto;
import java.util.Date;
public class ImportHistoryResponse {
private Long id;
private String username;
private String status;
private Integer objectsCount;
private String errorMessage;
private Date createdAt;
public ImportHistoryResponse() {}
public ImportHistoryResponse(Long id, String username, String status, Integer objectsCount,
String errorMessage, Date createdAt) {
this.id = id;
this.username = username;
this.status = status;
this.objectsCount = objectsCount;
this.errorMessage = errorMessage;
this.createdAt = createdAt;
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Integer getObjectsCount() { return objectsCount; }
public void setObjectsCount(Integer objectsCount) { this.objectsCount = objectsCount; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
}

View File

@ -0,0 +1,27 @@
package ru.akarpov.is1.dto;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank
private String username;
@NotBlank
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -0,0 +1,30 @@
package ru.akarpov.is1.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public class RegisterRequest {
@NotBlank
@Size(min = 3, max = 50)
private String username;
@NotBlank
@Size(min = 6)
private String password;
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}

View File

@ -0,0 +1,69 @@
package ru.akarpov.is1.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotNull;
import java.util.Date;
@Entity
@Table(name = "import_operation")
@NamedQueries({
@NamedQuery(name = "ImportOperation.findByUser",
query = "SELECT io FROM ImportOperation io WHERE io.userId = :userId ORDER BY io.createdAt DESC"),
@NamedQuery(name = "ImportOperation.findAll",
query = "SELECT io FROM ImportOperation io ORDER BY io.createdAt DESC")
})
public class ImportOperation {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "import_operation_seq_gen")
@SequenceGenerator(name = "import_operation_seq_gen", sequenceName = "import_operation_seq", allocationSize = 1)
private Long id;
@NotNull
@Column(name = "user_id", nullable = false)
private Long userId;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false)
private ImportStatus status;
@Column(name = "objects_count")
private Integer objectsCount;
@Column(name = "error_message", columnDefinition = "TEXT")
private String errorMessage;
@NotNull
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at", nullable = false, updatable = false)
private Date createdAt;
public ImportOperation() {
this.createdAt = new Date();
}
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = new Date();
}
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public ImportStatus getStatus() { return status; }
public void setStatus(ImportStatus status) { this.status = status; }
public Integer getObjectsCount() { return objectsCount; }
public void setObjectsCount(Integer objectsCount) { this.objectsCount = objectsCount; }
public String getErrorMessage() { return errorMessage; }
public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
}

View File

@ -0,0 +1,6 @@
package ru.akarpov.is1.entity;
public enum ImportStatus {
SUCCESS,
FAILED
}

View File

@ -7,7 +7,6 @@ import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import java.time.OffsetDateTime;
import java.time.ZonedDateTime;
import java.util.Date;
@Entity
@ -20,7 +19,11 @@ import java.util.Date;
@NamedQuery(name = "MusicBand.findWithMaxName",
query = "SELECT mb FROM MusicBand mb WHERE mb.name = (SELECT MAX(mb2.name) FROM MusicBand mb2)"),
@NamedQuery(name = "MusicBand.groupByParticipants",
query = "SELECT mb.numberOfParticipants, COUNT(mb) FROM MusicBand mb GROUP BY mb.numberOfParticipants")
query = "SELECT mb.numberOfParticipants, COUNT(mb) FROM MusicBand mb GROUP BY mb.numberOfParticipants"),
@NamedQuery(name = "MusicBand.checkNameGenreUnique",
query = "SELECT COUNT(mb) FROM MusicBand mb WHERE mb.name = :name AND mb.genre = :genre AND mb.id <> :excludeId"),
@NamedQuery(name = "MusicBand.checkEstablishmentDateUnique",
query = "SELECT COUNT(mb) FROM MusicBand mb WHERE mb.establishmentDate = :establishmentDate AND mb.id <> :excludeId")
})
public class MusicBand {
@ -70,17 +73,18 @@ public class MusicBand {
@Column(name = "albums_count")
private Integer albumsCount;
@NotNull
@JsonFormat(shape = JsonFormat.Shape.STRING)
@Column(name = "establishment_date", nullable = false)
private OffsetDateTime establishmentDate;
@OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY, orphanRemoval = true)
@JoinColumn(name = "front_man_id")
private Person frontMan;
@Column(name = "created_by")
private Long createdBy;
public MusicBand() {
this.creationDate = new Date();
}
@ -125,7 +129,9 @@ public class MusicBand {
public OffsetDateTime getEstablishmentDate() { return establishmentDate; }
public void setEstablishmentDate(OffsetDateTime establishmentDate) { this.establishmentDate = establishmentDate; }
public Person getFrontMan() { return frontMan; }
public void setFrontMan(Person frontMan) { this.frontMan = frontMan; }
}
public Long getCreatedBy() { return createdBy; }
public void setCreatedBy(Long createdBy) { this.createdBy = createdBy; }
}

View File

@ -0,0 +1,6 @@
package ru.akarpov.is1.entity;
public enum Role {
USER,
ADMIN
}

View File

@ -0,0 +1,63 @@
package ru.akarpov.is1.entity;
import jakarta.persistence.*;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.Date;
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "users_seq_gen")
@SequenceGenerator(name = "users_seq_gen", sequenceName = "users_seq", allocationSize = 1)
private Long id;
@NotNull
@NotBlank
@Size(min = 3, max = 50)
@Column(name = "username", unique = true, nullable = false, length = 50)
private String username;
@NotNull
@NotBlank
@Column(name = "password_hash", nullable = false)
private String passwordHash;
@NotNull
@Enumerated(EnumType.STRING)
@Column(name = "role", nullable = false)
private Role role;
@NotNull
@Temporal(TemporalType.TIMESTAMP)
@Column(name = "created_at", nullable = false, updatable = false)
private Date createdAt;
public User() {
this.createdAt = new Date();
}
@PrePersist
protected void onCreate() {
if (createdAt == null) {
createdAt = new Date();
}
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public String getPasswordHash() { return passwordHash; }
public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; }
public Role getRole() { return role; }
public void setRole(Role role) { this.role = role; }
public Date getCreatedAt() { return createdAt; }
public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; }
}

View File

@ -0,0 +1,38 @@
package ru.akarpov.is1.repository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import ru.akarpov.is1.entity.ImportOperation;
import java.util.List;
@ApplicationScoped
public class ImportOperationRepository {
@PersistenceContext(unitName = "musicBandPU")
private EntityManager em;
public ImportOperation save(ImportOperation operation) {
if (operation.getId() == null) {
em.persist(operation);
em.flush();
return operation;
} else {
ImportOperation merged = em.merge(operation);
em.flush();
return merged;
}
}
public List<ImportOperation> findByUser(Long userId) {
return em.createNamedQuery("ImportOperation.findByUser", ImportOperation.class)
.setParameter("userId", userId)
.getResultList();
}
public List<ImportOperation> findAll() {
return em.createNamedQuery("ImportOperation.findAll", ImportOperation.class)
.getResultList();
}
}

View File

@ -1,6 +1,9 @@
package ru.akarpov.is1.repository;
import ru.akarpov.is1.entity.MusicBand;
import ru.akarpov.is1.entity.MusicGenre;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
@ -13,4 +16,6 @@ public interface MusicBandRepository extends GenericRepository<MusicBand, Long>
Map<Long, Long> groupByNumberOfParticipants();
MusicBand addSingle(Long bandId);
MusicBand removeParticipant(Long bandId);
Long checkNameGenreUnique(String name, MusicGenre genre, Long excludeId);
Long checkEstablishmentDateUnique(OffsetDateTime establishmentDate, Long excludeId);
}

View File

@ -2,9 +2,12 @@ package ru.akarpov.is1.repository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import ru.akarpov.is1.entity.MusicBand;
import ru.akarpov.is1.entity.MusicGenre;
import java.time.OffsetDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@ -16,16 +19,19 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
private static final Logger logger = Logger.getLogger(MusicBandRepositoryImpl.class.getName());
@PersistenceContext
@PersistenceContext(unitName = "musicBandPU")
private EntityManager em;
@Override
public MusicBand save(MusicBand entity) {
if (entity.getId() == 0) {
em.persist(entity);
em.flush();
return entity;
} else {
return em.merge(entity);
MusicBand merged = em.merge(entity);
em.flush();
return merged;
}
}
@ -60,7 +66,8 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
} else {
em.remove(em.merge(entity));
}
logger.info("MusicBand marked for deletion");
em.flush();
logger.info("MusicBand deleted");
}
@Override
@ -69,7 +76,8 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
MusicBand band = em.find(MusicBand.class, id);
if (band != null) {
em.remove(band);
logger.info("MusicBand with ID " + id + " marked for deletion");
em.flush();
logger.info("MusicBand with ID " + id + " deleted");
} else {
logger.warning("MusicBand with ID " + id + " not found for deletion");
}
@ -156,7 +164,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
@Override
public MusicBand addSingle(Long bandId) {
logger.info("Adding single to band ID: " + bandId);
MusicBand band = em.find(MusicBand.class, bandId);
MusicBand band = em.find(MusicBand.class, bandId, LockModeType.PESSIMISTIC_WRITE);
if (band == null) {
logger.warning("Band not found with ID: " + bandId);
return null;
@ -165,6 +173,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
int oldCount = band.getSinglesCount();
band.setSinglesCount(oldCount + 1);
MusicBand merged = em.merge(band);
em.flush();
logger.info("Single added to band ID " + bandId + ": " + oldCount + " -> " + merged.getSinglesCount());
return merged;
@ -173,7 +182,7 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
@Override
public MusicBand removeParticipant(Long bandId) {
logger.info("Removing participant from band ID: " + bandId);
MusicBand band = em.find(MusicBand.class, bandId);
MusicBand band = em.find(MusicBand.class, bandId, LockModeType.PESSIMISTIC_WRITE);
if (band == null || band.getNumberOfParticipants() <= 1) {
if (band == null) {
logger.warning("Band not found with ID: " + bandId);
@ -186,8 +195,26 @@ public class MusicBandRepositoryImpl implements MusicBandRepository {
long oldCount = band.getNumberOfParticipants();
band.setNumberOfParticipants(oldCount - 1);
MusicBand merged = em.merge(band);
em.flush();
logger.info("Participant removed from band ID " + bandId + ": " + oldCount + " -> " + merged.getNumberOfParticipants());
return merged;
}
@Override
public Long checkNameGenreUnique(String name, MusicGenre genre, Long excludeId) {
return em.createNamedQuery("MusicBand.checkNameGenreUnique", Long.class)
.setParameter("name", name)
.setParameter("genre", genre)
.setParameter("excludeId", excludeId)
.getSingleResult();
}
@Override
public Long checkEstablishmentDateUnique(OffsetDateTime establishmentDate, Long excludeId) {
return em.createNamedQuery("MusicBand.checkEstablishmentDateUnique", Long.class)
.setParameter("establishmentDate", establishmentDate)
.setParameter("excludeId", excludeId)
.getSingleResult();
}
}

View File

@ -0,0 +1,51 @@
package ru.akarpov.is1.repository;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.persistence.EntityManager;
import jakarta.persistence.NoResultException;
import jakarta.persistence.PersistenceContext;
import ru.akarpov.is1.entity.User;
import java.util.Optional;
@ApplicationScoped
public class UserRepository {
@PersistenceContext(unitName = "musicBandPU")
private EntityManager em;
public User save(User user) {
if (user.getId() == null) {
em.persist(user);
em.flush();
return user;
} else {
User merged = em.merge(user);
em.flush();
return merged;
}
}
public Optional<User> findById(Long id) {
User user = em.find(User.class, id);
return Optional.ofNullable(user);
}
public Optional<User> findByUsername(String username) {
try {
User user = em.createQuery("SELECT u FROM User u WHERE u.username = :username", User.class)
.setParameter("username", username)
.getSingleResult();
return Optional.of(user);
} catch (NoResultException e) {
return Optional.empty();
}
}
public boolean existsByUsername(String username) {
Long count = em.createQuery("SELECT COUNT(u) FROM User u WHERE u.username = :username", Long.class)
.setParameter("username", username)
.getSingleResult();
return count > 0;
}
}

View File

@ -0,0 +1,55 @@
package ru.akarpov.is1.rest;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import ru.akarpov.is1.dto.AuthResponse;
import ru.akarpov.is1.dto.LoginRequest;
import ru.akarpov.is1.dto.RegisterRequest;
import ru.akarpov.is1.service.AuthService;
import java.util.Map;
@Path("/auth")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class AuthResource {
@Inject
private AuthService authService;
@POST
@Path("/register")
public Response register(RegisterRequest request) {
try {
AuthResponse response = authService.register(request);
return Response.status(Response.Status.CREATED).entity(response).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", e.getMessage()))
.build();
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Registration failed: " + e.getMessage()))
.build();
}
}
@POST
@Path("/login")
public Response login(LoginRequest request) {
try {
AuthResponse response = authService.login(request);
return Response.ok(response).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", e.getMessage()))
.build();
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Login failed: " + e.getMessage()))
.build();
}
}
}

View File

@ -0,0 +1,96 @@
package ru.akarpov.is1.rest;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import ru.akarpov.is1.dto.ImportHistoryResponse;
import ru.akarpov.is1.entity.ImportOperation;
import ru.akarpov.is1.security.UserPrincipal;
import ru.akarpov.is1.service.ImportService;
import java.util.List;
import java.util.Map;
@Path("/import")
@Produces(MediaType.APPLICATION_JSON)
public class ImportResource {
@Inject
private ImportService importService;
@POST
@Consumes(MediaType.TEXT_PLAIN)
public Response importBands(@Context HttpServletRequest request,
@QueryParam("format") String format,
String fileContent) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
if (format == null || format.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "Format parameter is required"))
.build();
}
if (fileContent == null || fileContent.trim().isEmpty()) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", "File content is required"))
.build();
}
ImportOperation operation = importService.importBands(fileContent, format, principal.getUserId());
if (operation.getStatus().name().equals("SUCCESS")) {
return Response.ok(Map.of(
"message", "Import completed successfully",
"operationId", operation.getId(),
"objectsCount", operation.getObjectsCount()
)).build();
} else {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of(
"error", operation.getErrorMessage(),
"operationId", operation.getId()
))
.build();
}
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Import processing failed: " + e.getMessage()))
.build();
}
}
@GET
@Path("/history")
public Response getImportHistory(@Context HttpServletRequest request) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
List<ImportHistoryResponse> history = importService.getImportHistory(
principal.getUserId(),
principal.isAdmin()
);
return Response.ok(history).build();
} catch (Exception e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR)
.entity(Map.of("error", "Failed to retrieve history: " + e.getMessage()))
.build();
}
}
}

View File

@ -1,10 +1,13 @@
package ru.akarpov.is1.rest;
import jakarta.inject.Inject;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import ru.akarpov.is1.entity.MusicBand;
import ru.akarpov.is1.security.UserPrincipal;
import ru.akarpov.is1.service.MusicBandService;
import java.util.List;
@ -70,11 +73,19 @@ public class MusicBandResource {
}
@POST
public Response createBand(MusicBand musicBand) {
public Response createBand(@Context HttpServletRequest request, MusicBand musicBand) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
musicBand.setCreatedBy(principal.getUserId());
MusicBand createdBand = musicBandService.create(musicBand);
return Response.status(Response.Status.CREATED).entity(createdBand).build();
} catch (MusicBandService.ValidationException e) {
} catch (MusicBandService.ValidationException | MusicBandService.BusinessConstraintException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", e.getMessage()))
.build();
@ -87,15 +98,37 @@ public class MusicBandResource {
@PUT
@Path("/{id}")
public Response updateBand(@PathParam("id") Long id, MusicBand musicBand) {
public Response updateBand(@Context HttpServletRequest request,
@PathParam("id") Long id,
MusicBand musicBand) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
Optional<MusicBand> existingBand = musicBandService.findById(id);
if (existingBand.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Music band not found"))
.build();
}
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
return Response.status(Response.Status.FORBIDDEN)
.entity(Map.of("error", "You can only edit your own bands"))
.build();
}
MusicBand updatedBand = musicBandService.update(id, musicBand);
return Response.ok(updatedBand).build();
} catch (IllegalArgumentException e) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", e.getMessage()))
.build();
} catch (MusicBandService.ValidationException e) {
} catch (MusicBandService.ValidationException | MusicBandService.BusinessConstraintException e) {
return Response.status(Response.Status.BAD_REQUEST)
.entity(Map.of("error", e.getMessage()))
.build();
@ -108,8 +141,28 @@ public class MusicBandResource {
@DELETE
@Path("/{id}")
public Response deleteBand(@PathParam("id") Long id) {
public Response deleteBand(@Context HttpServletRequest request, @PathParam("id") Long id) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
Optional<MusicBand> existingBand = musicBandService.findById(id);
if (existingBand.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Music band not found"))
.build();
}
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
return Response.status(Response.Status.FORBIDDEN)
.entity(Map.of("error", "You can only delete your own bands"))
.build();
}
musicBandService.delete(id);
return Response.noContent().build();
} catch (Exception e) {
@ -166,8 +219,28 @@ public class MusicBandResource {
@POST
@Path("/{id}/add-single")
public Response addSingle(@PathParam("id") Long id) {
public Response addSingle(@Context HttpServletRequest request, @PathParam("id") Long id) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
Optional<MusicBand> existingBand = musicBandService.findById(id);
if (existingBand.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Music band not found"))
.build();
}
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
return Response.status(Response.Status.FORBIDDEN)
.entity(Map.of("error", "You can only modify your own bands"))
.build();
}
musicBandService.addSingle(id);
return Response.ok(Map.of("message", "Single added successfully", "bandId", id)).build();
} catch (IllegalArgumentException e) {
@ -183,8 +256,28 @@ public class MusicBandResource {
@POST
@Path("/{id}/remove-participant")
public Response removeParticipant(@PathParam("id") Long id) {
public Response removeParticipant(@Context HttpServletRequest request, @PathParam("id") Long id) {
try {
UserPrincipal principal = (UserPrincipal) request.getAttribute("userPrincipal");
if (principal == null) {
return Response.status(Response.Status.UNAUTHORIZED)
.entity(Map.of("error", "Authentication required"))
.build();
}
Optional<MusicBand> existingBand = musicBandService.findById(id);
if (existingBand.isEmpty()) {
return Response.status(Response.Status.NOT_FOUND)
.entity(Map.of("error", "Music band not found"))
.build();
}
if (!principal.isAdmin() && !principal.getUserId().equals(existingBand.get().getCreatedBy())) {
return Response.status(Response.Status.FORBIDDEN)
.entity(Map.of("error", "You can only modify your own bands"))
.build();
}
musicBandService.removeParticipant(id);
return Response.ok(Map.of("message", "Participant removed successfully", "bandId", id)).build();
} catch (IllegalArgumentException e) {

View File

@ -0,0 +1,77 @@
package ru.akarpov.is1.security;
import jakarta.inject.Inject;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import ru.akarpov.is1.entity.Role;
import java.io.IOException;
public class AuthenticationFilter implements Filter {
@Inject
private JwtUtil jwtUtil;
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpServletResponse httpResponse = (HttpServletResponse) response;
String path = httpRequest.getRequestURI();
if (path.startsWith("/is1/api/auth/") ||
path.startsWith("/is1/api/test-password/") ||
path.equals("/is1/") ||
path.startsWith("/is1/index.html") ||
path.startsWith("/is1/css/") ||
path.startsWith("/is1/js/") ||
path.startsWith("/is1/websocket/") ||
path.equals("/is1/api/test-db/connection")) {
chain.doFilter(request, response);
return;
}
if ("OPTIONS".equalsIgnoreCase(httpRequest.getMethod())) {
chain.doFilter(request, response);
return;
}
String authHeader = httpRequest.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("{\"error\":\"Missing or invalid Authorization header\"}");
return;
}
String token = authHeader.substring(7);
if (!jwtUtil.validateToken(token)) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("{\"error\":\"Invalid or expired token\"}");
return;
}
try {
Long userId = jwtUtil.getUserId(token);
String username = jwtUtil.getUsername(token);
Role role = jwtUtil.getRole(token);
UserPrincipal principal = new UserPrincipal(userId, username, role);
httpRequest.setAttribute("userPrincipal", principal);
chain.doFilter(request, response);
} catch (Exception e) {
httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
httpResponse.getWriter().write("{\"error\":\"Authentication failed\"}");
}
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {}
@Override
public void destroy() {}
}

View File

@ -0,0 +1,61 @@
package ru.akarpov.is1.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import io.jsonwebtoken.security.Keys;
import jakarta.enterprise.context.ApplicationScoped;
import ru.akarpov.is1.entity.Role;
import java.security.Key;
import java.util.Date;
@ApplicationScoped
public class JwtUtil {
private static final String SECRET_KEY = "your-very-secure-secret-key-that-is-at-least-256-bits-long-for-hs256";
private static final long EXPIRATION_TIME = 86400000;
private final Key key = Keys.hmacShaKeyFor(SECRET_KEY.getBytes());
public String generateToken(Long userId, String username, Role role) {
return Jwts.builder()
.setSubject(username)
.claim("userId", userId)
.claim("role", role.name())
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + EXPIRATION_TIME))
.signWith(key, SignatureAlgorithm.HS256)
.compact();
}
public Claims parseToken(String token) {
return Jwts.parserBuilder()
.setSigningKey(key)
.build()
.parseClaimsJws(token)
.getBody();
}
public boolean validateToken(String token) {
try {
Claims claims = parseToken(token);
return claims.getExpiration().after(new Date());
} catch (Exception e) {
return false;
}
}
public Long getUserId(String token) {
Claims claims = parseToken(token);
return claims.get("userId", Long.class);
}
public String getUsername(String token) {
return parseToken(token).getSubject();
}
public Role getRole(String token) {
Claims claims = parseToken(token);
String roleName = claims.get("role", String.class);
return Role.valueOf(roleName);
}
}

View File

@ -0,0 +1,16 @@
package ru.akarpov.is1.security;
import jakarta.enterprise.context.ApplicationScoped;
import org.mindrot.jbcrypt.BCrypt;
@ApplicationScoped
public class PasswordUtil {
public String hashPassword(String plainPassword) {
return BCrypt.hashpw(plainPassword, BCrypt.gensalt(10));
}
public boolean checkPassword(String plainPassword, String hashedPassword) {
return BCrypt.checkpw(plainPassword, hashedPassword);
}
}

View File

@ -0,0 +1,31 @@
package ru.akarpov.is1.security;
import ru.akarpov.is1.entity.Role;
public class UserPrincipal {
private final Long userId;
private final String username;
private final Role role;
public UserPrincipal(Long userId, String username, Role role) {
this.userId = userId;
this.username = username;
this.role = role;
}
public Long getUserId() {
return userId;
}
public String getUsername() {
return username;
}
public Role getRole() {
return role;
}
public boolean isAdmin() {
return role == Role.ADMIN;
}
}

View File

@ -0,0 +1,63 @@
package ru.akarpov.is1.service;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import ru.akarpov.is1.dto.AuthResponse;
import ru.akarpov.is1.dto.LoginRequest;
import ru.akarpov.is1.dto.RegisterRequest;
import ru.akarpov.is1.entity.Role;
import ru.akarpov.is1.entity.User;
import ru.akarpov.is1.repository.UserRepository;
import ru.akarpov.is1.security.JwtUtil;
import ru.akarpov.is1.security.PasswordUtil;
import java.util.Optional;
@ApplicationScoped
public class AuthService {
@Inject
private UserRepository userRepository;
@Inject
private PasswordUtil passwordUtil;
@Inject
private JwtUtil jwtUtil;
@Transactional
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByUsername(request.getUsername())) {
throw new IllegalArgumentException("Username already exists");
}
User user = new User();
user.setUsername(request.getUsername());
user.setPasswordHash(passwordUtil.hashPassword(request.getPassword()));
user.setRole(Role.USER);
user = userRepository.save(user);
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
return new AuthResponse(token, user.getId(), user.getUsername(), user.getRole().name());
}
@Transactional
public AuthResponse login(LoginRequest request) {
Optional<User> userOpt = userRepository.findByUsername(request.getUsername());
if (userOpt.isEmpty()) {
throw new IllegalArgumentException("Invalid username or password");
}
User user = userOpt.get();
if (!passwordUtil.checkPassword(request.getPassword(), user.getPasswordHash())) {
throw new IllegalArgumentException("Invalid username or password");
}
String token = jwtUtil.generateToken(user.getId(), user.getUsername(), user.getRole());
return new AuthResponse(token, user.getId(), user.getUsername(), user.getRole().name());
}
}

View File

@ -0,0 +1,127 @@
package ru.akarpov.is1.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.transaction.Transactional;
import ru.akarpov.is1.dto.ImportHistoryResponse;
import ru.akarpov.is1.entity.*;
import ru.akarpov.is1.repository.ImportOperationRepository;
import ru.akarpov.is1.repository.UserRepository;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;
@ApplicationScoped
public class ImportService {
private static final Logger logger = Logger.getLogger(ImportService.class.getName());
@Inject
private MusicBandService musicBandService;
@Inject
private ImportOperationRepository importOperationRepository;
@Inject
private UserRepository userRepository;
@Transactional
public ImportOperation importBands(String fileContent, String fileFormat, Long userId) {
List<MusicBand> bands;
ImportOperation operation = new ImportOperation();
operation.setUserId(userId);
try {
bands = parseFile(fileContent, fileFormat);
if (bands.isEmpty()) {
throw new IllegalArgumentException("No valid bands found in file");
}
for (MusicBand band : bands) {
band.setCreatedBy(userId);
musicBandService.create(band);
}
operation.setStatus(ImportStatus.SUCCESS);
operation.setObjectsCount(bands.size());
logger.info("Successfully imported " + bands.size() + " bands for user " + userId);
} catch (Exception e) {
operation.setStatus(ImportStatus.FAILED);
operation.setErrorMessage(e.getMessage());
logger.log(Level.SEVERE, "Import failed for user " + userId, e);
}
return importOperationRepository.save(operation);
}
private List<MusicBand> parseFile(String content, String format) throws IOException {
ObjectMapper mapper;
switch (format.toLowerCase()) {
case "json":
mapper = new ObjectMapper();
mapper.findAndRegisterModules();
break;
case "xml":
mapper = new XmlMapper();
mapper.findAndRegisterModules();
break;
case "yaml":
case "yml":
mapper = new YAMLMapper();
mapper.findAndRegisterModules();
break;
default:
throw new IllegalArgumentException("Unsupported format: " + format);
}
MusicBandsWrapper wrapper = mapper.readValue(content, MusicBandsWrapper.class);
return wrapper.getBands() != null ? wrapper.getBands() : new ArrayList<>();
}
@Transactional
public List<ImportHistoryResponse> getImportHistory(Long userId, boolean isAdmin) {
List<ImportOperation> operations = isAdmin ?
importOperationRepository.findAll() :
importOperationRepository.findByUser(userId);
List<ImportHistoryResponse> responses = new ArrayList<>();
for (ImportOperation op : operations) {
String username = userRepository.findById(op.getUserId())
.map(User::getUsername)
.orElse("Unknown");
responses.add(new ImportHistoryResponse(
op.getId(),
username,
op.getStatus().name(),
op.getObjectsCount(),
op.getErrorMessage(),
op.getCreatedAt()
));
}
return responses;
}
public static class MusicBandsWrapper {
private List<MusicBand> bands;
public List<MusicBand> getBands() {
return bands;
}
public void setBands(List<MusicBand> bands) {
this.bands = bands;
}
}
}

View File

@ -33,6 +33,7 @@ public class MusicBandService {
try {
validateMusicBand(musicBand);
checkBusinessConstraints(musicBand, 0L);
logger.fine("Validation passed for band: " + musicBand.getName());
MusicBand created = musicBandRepository.save(musicBand);
@ -42,7 +43,7 @@ public class MusicBandService {
logger.fine("WebSocket broadcast sent for created band ID: " + created.getId());
return created;
} catch (ValidationException e) {
} catch (ValidationException | BusinessConstraintException e) {
logger.warning("Validation failed for band: " + e.getMessage());
throw e;
} catch (Exception e) {
@ -51,27 +52,32 @@ public class MusicBandService {
}
}
@Transactional
public Optional<MusicBand> findById(Long id) {
logger.fine("Finding band by ID: " + id);
return musicBandRepository.findById(id);
}
@Transactional
public List<MusicBand> findAll(int page, int size) {
logger.fine("Finding all bands - page: " + page + ", size: " + size);
return musicBandRepository.findAll(page, size);
}
@Transactional
public List<MusicBand> findAllSorted(String sortBy, boolean ascending, int page, int size) {
logger.fine("Finding all bands sorted by: " + sortBy + " (" + (ascending ? "ASC" : "DESC") + ")");
validateSortField(sortBy);
return musicBandRepository.findAllSorted(sortBy, ascending, page, size);
}
@Transactional
public List<MusicBand> findByNameContaining(String name, int page, int size) {
logger.fine("Finding bands by name containing: " + name);
return musicBandRepository.findByNameContainingSorted(name, "id", true, page, size);
}
@Transactional
public List<MusicBand> findByNameContainingSorted(String name, String sortBy, boolean ascending, int page, int size) {
logger.fine("Finding bands by name containing: " + name + ", sorted by: " + sortBy);
validateSortField(sortBy);
@ -91,8 +97,10 @@ public class MusicBandService {
updatedBand.setId(id);
updatedBand.setCreationDate(existingBand.get().getCreationDate());
updatedBand.setCreatedBy(existingBand.get().getCreatedBy());
validateMusicBand(updatedBand);
checkBusinessConstraints(updatedBand, id);
logger.fine("Validation passed for band update: " + id);
MusicBand updated = musicBandRepository.save(updatedBand);
@ -102,7 +110,7 @@ public class MusicBandService {
logger.fine("WebSocket broadcast sent for updated band ID: " + updated.getId());
return updated;
} catch (ValidationException | IllegalArgumentException e) {
} catch (ValidationException | BusinessConstraintException | IllegalArgumentException e) {
logger.warning("Update failed for band ID " + id + ": " + e.getMessage());
throw e;
} catch (Exception e) {
@ -135,12 +143,14 @@ public class MusicBandService {
}
}
@Transactional
public long count() {
long count = musicBandRepository.count();
logger.fine("Total band count: " + count);
return count;
}
@Transactional
public Double getAverageAlbumsCount() {
logger.fine("Calculating average albums count");
Double average = musicBandRepository.getAverageAlbumsCount();
@ -148,6 +158,7 @@ public class MusicBandService {
return average != null ? average : 0.0;
}
@Transactional
public MusicBand findWithMaxName() {
logger.fine("Finding band with max name");
MusicBand band = musicBandRepository.findWithMaxName();
@ -155,6 +166,7 @@ public class MusicBandService {
return band;
}
@Transactional
public Map<Long, Long> groupByNumberOfParticipants() {
logger.fine("Grouping bands by number of participants");
Map<Long, Long> groups = musicBandRepository.groupByNumberOfParticipants();
@ -225,6 +237,33 @@ public class MusicBandService {
}
}
private void checkBusinessConstraints(MusicBand musicBand, Long excludeId) throws BusinessConstraintException {
Long nameGenreCount = musicBandRepository.checkNameGenreUnique(
musicBand.getName(),
musicBand.getGenre(),
excludeId
);
if (nameGenreCount > 0) {
throw new BusinessConstraintException(
"Band with name '" + musicBand.getName() +
"' and genre '" + musicBand.getGenre() + "' already exists"
);
}
Long establishmentDateCount = musicBandRepository.checkEstablishmentDateUnique(
musicBand.getEstablishmentDate(),
excludeId
);
if (establishmentDateCount > 0) {
throw new BusinessConstraintException(
"Band with establishment date '" + musicBand.getEstablishmentDate() +
"' already exists"
);
}
}
private void validateSortField(String sortBy) {
Set<String> allowedFields = Set.of("id", "name", "genre", "numberOfParticipants",
"singlesCount", "albumsCount", "establishmentDate", "creationDate");
@ -239,4 +278,10 @@ public class MusicBandService {
super(message);
}
}
public static class BusinessConstraintException extends RuntimeException {
public BusinessConstraintException(String message) {
super(message);
}
}
}

View File

@ -13,6 +13,8 @@
<class>ru.akarpov.is1.entity.Album</class>
<class>ru.akarpov.is1.entity.Person</class>
<class>ru.akarpov.is1.entity.Location</class>
<class>ru.akarpov.is1.entity.User</class>
<class>ru.akarpov.is1.entity.ImportOperation</class>
<properties>
<property name="eclipselink.target-server" value="JBoss"/>
@ -24,9 +26,9 @@
<property name="eclipselink.logging.parameters" value="true"/>
<property name="eclipselink.connection-pool.default.initial" value="1"/>
<property name="eclipselink.connection-pool.default.min" value="1"/>
<property name="eclipselink.connection-pool.default.max" value="5"/>
<property name="eclipselink.jpa.persistence-context.reference-mode" value="FORCE_WEAK"/>
<property name="eclipselink.connection-pool.default.max" value="10"/>
<property name="eclipselink.cache.shared.default" value="false"/>
<property name="eclipselink.flush-clear.cache" value="Drop"/>
</properties>
</persistence-unit>

View File

@ -20,4 +20,14 @@
<url-pattern>/*</url-pattern>
</filter-mapping>
<filter>
<filter-name>AuthenticationFilter</filter-name>
<filter-class>ru.akarpov.is1.security.AuthenticationFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>AuthenticationFilter</filter-name>
<url-pattern>/api/*</url-pattern>
</filter-mapping>
</web-app>

View File

@ -38,6 +38,95 @@ body {
line-height: 1.55;
}
.auth-screen {
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
}
.auth-container {
width: min(450px, 100%);
padding: 40px 36px;
}
.auth-container .brand.centered {
justify-content: center;
margin-bottom: 40px;
}
.auth-form h2 {
margin: 0 0 32px 0;
text-align: center;
font-size: 22px;
font-weight: 700;
}
.auth-form .form-group {
margin-bottom: 20px;
}
.auth-form .form-group label {
display: block;
margin-bottom: 8px;
color: var(--text);
font-size: 14px;
font-weight: 500;
}
.auth-form .form-group input {
width: 100%;
padding: 14px 16px;
background: #0e1017;
color: var(--text);
border: 1px solid var(--line);
border-radius: 10px;
font-size: 15px;
transition: all 0.2s;
}
.auth-form .form-group input:focus {
outline: none;
border-color: var(--focus);
box-shadow: 0 0 0 3px rgba(45,212,191,0.15);
}
.auth-form button[type="submit"] {
margin-top: 24px;
}
.auth-switch {
text-align: center;
margin-top: 24px;
color: var(--muted);
font-size: 14px;
line-height: 1.6;
}
.link-btn {
appearance: none;
background: none;
border: none;
color: var(--brand);
cursor: pointer;
font: inherit;
padding: 0;
text-decoration: underline;
font-weight: 600;
transition: color 0.2s;
}
.link-btn:hover {
color: var(--brand-2);
}
.full-width {
width: 100%;
padding: 14px 20px;
font-size: 15px;
font-weight: 600;
}
.app-shell { max-width: 1200px; padding: 24px; margin: 0 auto; }
.card {
@ -62,7 +151,7 @@ body {
.brand-text h1 { font-size: 18px; margin: 0; letter-spacing: 0.3px; }
.muted { color: var(--muted); font-size: 12px; }
.top-nav { display: flex; gap: 8px; flex-wrap: wrap; }
.top-nav { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.nav-btn {
appearance: none;
@ -101,13 +190,20 @@ body {
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 12px;
padding: 12px 14px;
background: #0e1017;
color: var(--text);
border: 1px solid var(--line);
border-radius: 10px;
transition: all 0.2s;
font-size: 14px;
}
.field input[type="file"],
.form-group input[type="file"] {
padding: 10px 14px;
}
.field input:focus,
.field select:focus,
.form-group input:focus,
@ -115,7 +211,7 @@ body {
.form-group textarea:focus {
outline: none;
border-color: var(--focus);
box-shadow: 0 0 0 2px rgba(45,212,191,0.2);
box-shadow: 0 0 0 3px rgba(45,212,191,0.15);
}
.field-row { display: flex; gap: 8px; }
@ -203,6 +299,23 @@ body {
.pagination { display: flex; align-items: center; justify-content: center; gap: 10px; padding-top: 10px; }
.pagination .muted { font-size: 12px; }
.import-container {
max-width: 600px;
margin: 20px auto;
}
.import-info {
background: var(--panel-soft);
padding: 16px;
border-radius: 10px;
margin-bottom: 20px;
border: 1px solid var(--line);
}
.import-info p {
margin: 8px 0;
}
.modal {
display: none; position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,.7); backdrop-filter: blur(4px);
@ -230,12 +343,33 @@ body {
.modal-title { margin: 0 0 8px 0; }
.close { position: absolute; right: 12px; top: 12px; z-index: 1; }
.form { margin-top: 10px; }
.form-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); }
.form-group label { display: block; margin: 6px 0 6px; color: var(--muted); font-size: 12px; }
.subhead { margin: 16px 0 6px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }
.form { margin-top: 16px; }
.form-grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(240px,1fr)); }
.form-group { margin-bottom: 4px; }
.form-group label {
display: block;
margin: 0 0 8px 0;
color: var(--muted);
font-size: 13px;
font-weight: 500;
}
.subhead {
margin: 24px 0 12px;
color: var(--muted);
font-size: 12px;
text-transform: uppercase;
letter-spacing: .08em;
font-weight: 600;
}
.form-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.form-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--line);
}
.toast {
position: fixed;
@ -443,4 +577,5 @@ body {
.section-head { flex-direction: column; align-items: flex-start; }
.controls { width: 100%; }
.filters { width: 100%; flex-wrap: wrap; }
.auth-container { padding: 24px; }
}

View File

@ -11,13 +11,67 @@
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@500;700&display=swap" rel="stylesheet" />
</head>
<body>
<div class="app-shell">
<div id="authScreen" class="auth-screen">
<div class="auth-container card">
<div class="brand centered">
<div class="brand-mark" aria-hidden="true"></div>
<div class="brand-text">
<h1>Music Band IS</h1>
<span class="muted">Information System</span>
</div>
</div>
<div id="loginForm" class="auth-form">
<h2>Login</h2>
<form id="loginFormElement">
<div class="form-group">
<label for="loginUsername">Username</label>
<input type="text" id="loginUsername" required autocomplete="username" />
</div>
<div class="form-group">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" required autocomplete="current-password" />
</div>
<button type="submit" class="btn btn-primary full-width">Login</button>
</form>
<p class="auth-switch">
Don't have an account?
<button type="button" class="link-btn" id="showRegisterBtn">Register</button>
</p>
</div>
<div id="registerForm" class="auth-form" style="display: none;">
<h2>Register</h2>
<form id="registerFormElement">
<div class="form-group">
<label for="registerUsername">Username</label>
<input type="text" id="registerUsername" required minlength="3" maxlength="50" autocomplete="username" />
</div>
<div class="form-group">
<label for="registerPassword">Password</label>
<input type="password" id="registerPassword" required minlength="6" autocomplete="new-password" />
</div>
<div class="form-group">
<label for="registerPasswordConfirm">Confirm Password</label>
<input type="password" id="registerPasswordConfirm" required minlength="6" autocomplete="new-password" />
</div>
<button type="submit" class="btn btn-primary full-width">Register</button>
</form>
<p class="auth-switch">
Already have an account?
<button type="button" class="link-btn" id="showLoginBtn">Login</button>
</p>
</div>
</div>
</div>
<div id="appScreen" class="app-shell" style="display: none;">
<header class="site-header card">
<div class="brand">
<div class="brand-mark" aria-hidden="true"></div>
<div class="brand-text">
<h1>Music Band IS</h1>
<span class="muted">admin panel</span>
<span class="muted" id="userInfo">admin panel</span>
</div>
</div>
@ -25,11 +79,13 @@
<button id="showBandsBtn" class="nav-btn active" aria-current="page">All Bands</button>
<button id="showStatisticsBtn" class="nav-btn">Statistics</button>
<button id="showSpecialOpsBtn" class="nav-btn">Special Ops</button>
<button id="showImportBtn" class="nav-btn">Import</button>
<button id="showHistoryBtn" class="nav-btn">History</button>
<button id="logoutBtn" class="btn btn-ghost">Logout</button>
</nav>
</header>
<main class="site-main">
<!-- Bands -->
<section id="bandsSection" class="section card active" aria-labelledby="bandsTitle">
<div class="section-head">
<h2 id="bandsTitle">Bands</h2>
@ -84,7 +140,6 @@
</div>
</section>
<!-- Statistics -->
<section id="statisticsSection" class="section card" aria-labelledby="statsTitle">
<div class="section-head">
<h2 id="statsTitle">Statistics</h2>
@ -111,7 +166,6 @@
</div>
</section>
<!-- Special Ops -->
<section id="specialOpsSection" class="section card" aria-labelledby="opsTitle">
<div class="section-head">
<h2 id="opsTitle">Special Operations</h2>
@ -135,10 +189,62 @@
</div>
</div>
</section>
<section id="importSection" class="section card" aria-labelledby="importTitle">
<div class="section-head">
<h2 id="importTitle">Import Bands</h2>
</div>
<div class="import-container">
<div class="import-info">
<p>Upload a file containing band data in JSON, XML, or YAML format.</p>
<p class="muted">The format will be automatically detected based on file extension.</p>
</div>
<div class="form-group">
<label for="importFile">Select File</label>
<input type="file" id="importFile" accept=".json,.xml,.yaml,.yml" />
</div>
<div class="form-group">
<label for="importFormat">Format</label>
<select id="importFormat">
<option value="">Auto-detect</option>
<option value="json">JSON</option>
<option value="xml">XML</option>
<option value="yaml">YAML</option>
</select>
</div>
<button id="importBtn" class="btn btn-primary">Import</button>
</div>
</section>
<section id="historySection" class="section card" aria-labelledby="historyTitle">
<div class="section-head">
<h2 id="historyTitle">Import History</h2>
<button id="refreshHistoryBtn" class="btn btn-ghost">Refresh</button>
</div>
<div class="table-container">
<table id="historyTable" class="data-table">
<thead>
<tr>
<th>ID</th>
<th>User</th>
<th>Status</th>
<th>Objects</th>
<th>Date</th>
<th>Error</th>
</tr>
</thead>
<tbody id="historyTableBody"></tbody>
</table>
</div>
</section>
</main>
</div>
<!-- Modal -->
<div id="bandModal" class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-content card">
<button class="close icon-btn" aria-label="Close">&times;</button>
@ -270,4 +376,4 @@
<script src="js/app.js"></script>
</body>
</html>
</html>

View File

@ -9,23 +9,180 @@ class MusicBandApp {
this.editingBandId = null;
this.websocket = null;
this.reconnectAttempts = 0;
this.token = localStorage.getItem('token');
this.user = JSON.parse(localStorage.getItem('user') || 'null');
this.toastEl = document.getElementById('toast');
this.initializeApp();
}
initializeApp() {
if (this.token && this.user) {
this.showAppScreen();
} else {
this.showAuthScreen();
}
}
showAuthScreen() {
document.getElementById('authScreen').style.display = 'grid';
document.getElementById('appScreen').style.display = 'none';
this.initializeAuthListeners();
}
showAppScreen() {
document.getElementById('authScreen').style.display = 'none';
document.getElementById('appScreen').style.display = 'block';
document.getElementById('userInfo').textContent =
`${this.user.username} (${this.user.role})`;
this.initializeEventListeners();
this.loadBands();
setTimeout(() => {
this.initializeWebSocket();
}, 500);
}
console.log('[App] MusicBandApp initialized');
initializeAuthListeners() {
document.getElementById('showRegisterBtn')?.addEventListener('click', () => {
document.getElementById('loginForm').style.display = 'none';
document.getElementById('registerForm').style.display = 'block';
});
document.getElementById('showLoginBtn')?.addEventListener('click', () => {
document.getElementById('registerForm').style.display = 'none';
document.getElementById('loginForm').style.display = 'block';
});
document.getElementById('loginFormElement')?.addEventListener('submit', (e) => {
e.preventDefault();
this.login();
});
document.getElementById('registerFormElement')?.addEventListener('submit', (e) => {
e.preventDefault();
this.register();
});
}
async login() {
const username = document.getElementById('loginUsername').value.trim();
const password = document.getElementById('loginPassword').value;
if (!username || !password) {
this.showError('Please fill in all fields');
return;
}
try {
const response = await fetch(`${this.API_BASE}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
this.token = data.token;
this.user = {
userId: data.userId,
username: data.username,
role: data.role
};
localStorage.setItem('token', this.token);
localStorage.setItem('user', JSON.stringify(this.user));
this.showSuccess('Login successful!');
this.showAppScreen();
} catch (error) {
this.showError(error.message);
}
}
async register() {
const username = document.getElementById('registerUsername').value.trim();
const password = document.getElementById('registerPassword').value;
const passwordConfirm = document.getElementById('registerPasswordConfirm').value;
if (!username || !password || !passwordConfirm) {
this.showError('Please fill in all fields');
return;
}
if (password !== passwordConfirm) {
this.showError('Passwords do not match');
return;
}
if (password.length < 6) {
this.showError('Password must be at least 6 characters');
return;
}
try {
const response = await fetch(`${this.API_BASE}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Registration failed');
}
this.token = data.token;
this.user = {
userId: data.userId,
username: data.username,
role: data.role
};
localStorage.setItem('token', this.token);
localStorage.setItem('user', JSON.stringify(this.user));
this.showSuccess('Registration successful!');
this.showAppScreen();
} catch (error) {
this.showError(error.message);
}
}
logout() {
this.token = null;
this.user = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
if (this.websocket) {
this.websocket.close();
this.websocket = null;
}
this.showAuthScreen();
this.showInfo('Logged out successfully');
}
initializeEventListeners() {
document.getElementById('logoutBtn').addEventListener('click', () => this.logout());
document.getElementById('showBandsBtn').addEventListener('click', (e) => this.showSection('bandsSection', e.currentTarget));
document.getElementById('showStatisticsBtn').addEventListener('click', (e) => this.showSection('statisticsSection', e.currentTarget));
document.getElementById('showSpecialOpsBtn').addEventListener('click', (e) => this.showSection('specialOpsSection', e.currentTarget));
document.getElementById('showImportBtn').addEventListener('click', (e) => this.showSection('importSection', e.currentTarget));
document.getElementById('showHistoryBtn').addEventListener('click', (e) => {
this.showSection('historySection', e.currentTarget);
this.loadImportHistory();
});
document.getElementById('addBandBtn').addEventListener('click', () => this.showAddBandModal());
document.getElementById('nameFilter').addEventListener('input', (e) => this.filterBands(e.target.value));
@ -43,6 +200,9 @@ class MusicBandApp {
document.getElementById('addSingleBtn').addEventListener('click', () => this.addSingle());
document.getElementById('removeParticipantBtn').addEventListener('click', () => this.removeParticipant());
document.getElementById('importBtn').addEventListener('click', () => this.importFile());
document.getElementById('refreshHistoryBtn').addEventListener('click', () => this.loadImportHistory());
document.querySelector('.close').addEventListener('click', () => this.closeModal());
document.getElementById('cancelBtn').addEventListener('click', () => this.closeModal());
document.getElementById('bandForm').addEventListener('submit', (e) => this.saveBand(e));
@ -51,19 +211,21 @@ class MusicBandApp {
if (e.target === document.getElementById('bandModal')) this.closeModal();
if (e.target === document.getElementById('viewModal')) this.closeViewModal();
});
console.log('[App] Event listeners initialized');
}
async makeRequest(url, options = {}) {
console.log(`[API] ${options.method || 'GET'} ${url}`);
const headers = {
'Content-Type': 'application/json',
...options.headers
};
if (this.token && !url.includes('/auth/')) {
headers['Authorization'] = `Bearer ${this.token}`;
}
try {
const response = await fetch(url, {
headers: {
'Content-Type': 'application/json',
...options.headers
},
headers,
...options
});
@ -73,9 +235,7 @@ class MusicBandApp {
if (contentType && contentType.includes('application/json')) {
try {
data = await response.json();
console.log(`[API] Response:`, data);
} catch (e) {
console.warn('[API] Failed to parse JSON response');
data = {};
}
} else {
@ -83,15 +243,18 @@ class MusicBandApp {
}
if (!response.ok) {
if (response.status === 401) {
this.showError('Session expired. Please login again.');
this.logout();
throw new Error('Unauthorized');
}
const errorMessage = this.extractErrorMessage(data, response.status);
console.error(`[API] Error ${response.status}: ${errorMessage}`);
throw new Error(errorMessage);
}
return data;
} catch (error) {
if (error.name === 'TypeError' && error.message.includes('fetch')) {
console.error('[API] Network error - server unreachable');
throw new Error('Failed to connect to server. Please check your connection.');
}
throw error;
@ -99,33 +262,19 @@ class MusicBandApp {
}
extractErrorMessage(data, status) {
if (data.error) {
return data.error;
}
if (data.message) {
return data.message;
}
if (data.error) return data.error;
if (data.message) return data.message;
switch (status) {
case 400:
return 'Invalid request data. Please check your input.';
case 401:
return 'Authentication required.';
case 403:
return 'Access denied.';
case 404:
return 'Resource not found.';
case 409:
return 'Conflict with existing data.';
case 422:
return 'Validation failed. Please check your input.';
case 500:
return 'Internal server error. Please try again later.';
case 503:
return 'Service temporarily unavailable.';
default:
return `Request failed with status ${status}`;
case 400: return 'Invalid request data. Please check your input.';
case 401: return 'Authentication required.';
case 403: return 'Access denied.';
case 404: return 'Resource not found.';
case 409: return 'Conflict with existing data.';
case 422: return 'Validation failed. Please check your input.';
case 500: return 'Internal server error. Please try again later.';
case 503: return 'Service temporarily unavailable.';
default: return `Request failed with status ${status}`;
}
}
@ -134,12 +283,9 @@ class MusicBandApp {
document.querySelectorAll('.nav-btn').forEach((b) => b.classList.remove('active'));
document.getElementById(sectionId).classList.add('active');
btn.classList.add('active');
console.log(`[App] Showing section: ${sectionId}`);
}
async loadBands() {
console.log(`[App] Loading bands - page: ${this.currentPage}, filter: '${this.currentFilter}'`);
try {
const params = new URLSearchParams({
page: this.currentPage,
@ -157,8 +303,6 @@ class MusicBandApp {
const bands = data.bands || [];
const totalCount = data.totalCount || 0;
console.log(`[App] Loaded ${bands.length} bands (total: ${totalCount})`);
this.renderBandsTable(bands);
this.updatePagination(totalCount);
@ -166,7 +310,6 @@ class MusicBandApp {
this.showInfo('No bands found matching your filter');
}
} catch (error) {
console.error('[App] Error loading bands:', error);
this.showError(error.message);
this.renderBandsTable([]);
this.updatePagination(0);
@ -186,6 +329,8 @@ class MusicBandApp {
bands.forEach((band) => {
const row = document.createElement('tr');
const canEdit = this.user.role === 'ADMIN' || this.user.userId === band.createdBy;
row.innerHTML = `
<td>${band.id}</td>
<td>${this.escapeHtml(band.name || '')}</td>
@ -197,14 +342,17 @@ class MusicBandApp {
<td>
<div class="field-row">
<button class="btn btn-primary btn-small" data-action="view">View</button>
<button class="btn btn-ghost btn-small" data-action="edit">Edit</button>
<button class="btn btn-ghost btn-small" data-action="del">Delete</button>
${canEdit ? '<button class="btn btn-ghost btn-small" data-action="edit">Edit</button>' : ''}
${canEdit ? '<button class="btn btn-ghost btn-small" data-action="del">Delete</button>' : ''}
</div>
</td>
`;
row.querySelector('[data-action="view"]').addEventListener('click', () => this.viewBand(band.id));
row.querySelector('[data-action="edit"]').addEventListener('click', () => this.editBand(band.id));
row.querySelector('[data-action="del"]').addEventListener('click', () => this.deleteBand(band.id));
if (canEdit) {
row.querySelector('[data-action="edit"]').addEventListener('click', () => this.editBand(band.id));
row.querySelector('[data-action="del"]').addEventListener('click', () => this.deleteBand(band.id));
}
tbody.appendChild(row);
});
}
@ -226,7 +374,6 @@ class MusicBandApp {
filterBands(filter) {
this.currentFilter = filter.trim();
this.currentPage = 0;
console.log(`[App] Filtering bands: '${this.currentFilter}'`);
this.loadBands();
}
@ -234,7 +381,6 @@ class MusicBandApp {
this.currentSort = sortBy;
this.currentAscending = ascending;
this.currentPage = 0;
console.log(`[App] Sorting by: ${sortBy} (${ascending ? 'ASC' : 'DESC'})`);
document.getElementById('sortAscBtn').classList.toggle('active', ascending);
document.getElementById('sortDescBtn').classList.toggle('active', !ascending);
this.loadBands();
@ -253,7 +399,6 @@ class MusicBandApp {
}
showAddBandModal() {
console.log('[App] Opening add band modal');
this.editingBandId = null;
document.getElementById('modalTitle').textContent = 'Add New Band';
document.getElementById('bandForm').reset();
@ -272,8 +417,6 @@ class MusicBandApp {
}
async editBand(id) {
console.log(`[App] Opening edit modal for band ID: ${id}`);
try {
const band = await this.makeRequest(`${this.API_BASE}/music-bands/${id}`);
this.editingBandId = id;
@ -287,8 +430,6 @@ class MusicBandApp {
}
async viewBand(id) {
console.log(`[App] Viewing band ID: ${id}`);
try {
const band = await this.makeRequest(`${this.API_BASE}/music-bands/${id}`);
this.showViewModal(band);
@ -458,15 +599,11 @@ class MusicBandApp {
}
convertFromZonedDateTime(zonedDateTime) {
if (!zonedDateTime) {
return '';
}
if (!zonedDateTime) return '';
try {
const date = new Date(zonedDateTime);
if (isNaN(date.getTime())) {
return '';
}
if (isNaN(date.getTime())) return '';
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
@ -476,21 +613,16 @@ class MusicBandApp {
return `${year}-${month}-${day}T${hours}:${minutes}`;
} catch (e) {
console.error('[App] Error converting date:', e);
return '';
}
}
convertToZonedDateTime(datetimeLocalValue) {
if (!datetimeLocalValue) {
return null;
}
if (!datetimeLocalValue) return null;
try {
const date = new Date(datetimeLocalValue);
if (isNaN(date.getTime())) {
throw new Error('Invalid date format');
}
if (isNaN(date.getTime())) throw new Error('Invalid date format');
const offsetMinutes = date.getTimezoneOffset();
const offsetHours = Math.abs(Math.floor(offsetMinutes / 60));
@ -507,57 +639,50 @@ class MusicBandApp {
return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offset}`;
} catch (e) {
console.error('[App] Error converting date:', e);
throw new Error('Invalid date format');
}
}
async saveBand(event) {
event.preventDefault();
console.log('[App] Saving band...');
async saveBand(event) {
event.preventDefault();
this.clearFormErrors();
this.clearFormErrors();
const bandData = this.collectFormData();
console.log('[App] Collected form data:', bandData);
const bandData = this.collectFormData();
if (!this.validateFormData(bandData)) {
return;
if (!this.validateFormData(bandData)) {
return;
}
const submitButton = event.target.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Saving...';
submitButton.disabled = true;
try {
const url = this.editingBandId
? `${this.API_BASE}/music-bands/${this.editingBandId}`
: `${this.API_BASE}/music-bands`;
const method = this.editingBandId ? 'PUT' : 'POST';
const savedBand = await this.makeRequest(url, {
method,
body: JSON.stringify(bandData)
});
this.closeModal();
await this.loadBands();
this.showSuccess(this.editingBandId ? 'Band updated successfully!' : 'Band created successfully!');
} catch (error) {
this.showError(error.message);
this.highlightErrorFields(error.message);
} finally {
submitButton.textContent = originalText;
submitButton.disabled = false;
}
}
const submitButton = event.target.querySelector('button[type="submit"]');
const originalText = submitButton.textContent;
submitButton.textContent = 'Saving...';
submitButton.disabled = true;
try {
const url = this.editingBandId
? `${this.API_BASE}/music-bands/${this.editingBandId}`
: `${this.API_BASE}/music-bands`;
const method = this.editingBandId ? 'PUT' : 'POST';
const savedBand = await this.makeRequest(url, {
method,
body: JSON.stringify(bandData)
});
console.log('[App] Band saved successfully:', savedBand);
this.closeModal();
await this.loadBands();
this.showSuccess(this.editingBandId ? 'Band updated successfully!' : 'Band created successfully!');
} catch (error) {
console.error('[App] Error saving band:', error);
this.showError(error.message);
this.highlightErrorFields(error.message);
} finally {
submitButton.textContent = originalText;
submitButton.disabled = false;
}
}
validateFormData(data) {
const errors = [];
@ -647,7 +772,6 @@ async saveBand(event) {
}
if (errors.length > 0) {
console.warn('[App] Validation failed:', errors);
this.showError('Validation failed:\n• ' + errors.join('\n• '));
return false;
}
@ -770,39 +894,30 @@ async saveBand(event) {
return isNaN(parsed) ? null : parsed;
}
async deleteBand(id) {
const confirmed = await this.showConfirm(
'Delete Band',
'Are you sure you want to delete this band? This action cannot be undone and will also delete all related data.'
);
async deleteBand(id) {
const confirmed = await this.showConfirm(
'Delete Band',
'Are you sure you want to delete this band? This action cannot be undone and will also delete all related data.'
);
if (!confirmed) return;
if (!confirmed) {
return;
}
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${id}`, { method: 'DELETE' });
console.log(`[App] Deleting band ID: ${id}`);
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${id}`, { method: 'DELETE' });
console.log(`[App] Band ${id} deleted successfully`);
if (this.currentPage > 0) {
const bands = document.querySelectorAll('#bandsTableBody tr');
if (bands.length <= 1) {
this.currentPage = Math.max(0, this.currentPage - 1);
if (this.currentPage > 0) {
const bands = document.querySelectorAll('#bandsTableBody tr');
if (bands.length <= 1) {
this.currentPage = Math.max(0, this.currentPage - 1);
}
}
await this.loadBands();
this.showSuccess('Band deleted successfully!');
} catch (error) {
this.showError(error.message);
}
await this.loadBands();
this.showSuccess('Band deleted successfully!');
} catch (error) {
console.error('[App] Error deleting band:', error);
this.showError(error.message);
}
}
closeModal() {
document.getElementById('bandModal').style.display = 'none';
@ -810,36 +925,28 @@ async deleteBand(id) {
}
async calculateAverageAlbums() {
console.log('[App] Calculating average albums count');
try {
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/average-albums`);
const avg = (data.averageAlbumsCount ?? 0).toFixed(2);
document.getElementById('avgAlbumsCount').textContent = avg;
this.showSuccess(`Average albums count: ${avg}`);
} catch (error) {
console.error('[App] Error calculating average:', error);
this.showError(error.message);
}
}
async findBandWithMaxName() {
console.log('[App] Finding band with max name');
try {
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/max-name`);
const name = data.name ?? 'No bands found';
document.getElementById('maxNameBand').textContent = name;
this.showSuccess(`Band with max name: ${name}`);
} catch (error) {
console.error('[App] Error finding max name:', error);
this.showError(error.message);
}
}
async groupByParticipants() {
console.log('[App] Grouping bands by participants');
try {
const data = await this.makeRequest(`${this.API_BASE}/music-bands/statistics/group-by-participants`);
const lines = Object.entries(data).map(([participants, count]) =>
@ -849,55 +956,139 @@ async deleteBand(id) {
lines.join('<br>') : 'No data available';
this.showSuccess('Bands grouped by participants');
} catch (error) {
console.error('[App] Error grouping bands:', error);
this.showError(error.message);
}
}
async addSingle() {
const bandId = this.parseInteger(document.getElementById('addSingleBandId').value);
if (!bandId || bandId <= 0) {
this.showError('Please enter a valid band ID');
return;
async addSingle() {
const bandId = this.parseInteger(document.getElementById('addSingleBandId').value);
if (!bandId || bandId <= 0) {
this.showError('Please enter a valid band ID');
return;
}
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/add-single`, { method: 'POST' });
document.getElementById('addSingleBandId').value = '';
await this.loadBands();
this.showSuccess(`Single added to band #${bandId}!`);
} catch (error) {
this.showError(error.message);
}
}
console.log(`[App] Adding single to band ID: ${bandId}`);
async removeParticipant() {
const bandId = this.parseInteger(document.getElementById('removeParticipantBandId').value);
if (!bandId || bandId <= 0) {
this.showError('Please enter a valid band ID');
return;
}
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/add-single`, { method: 'POST' });
document.getElementById('addSingleBandId').value = '';
await this.loadBands();
this.showSuccess(`Single added to band #${bandId}!`);
} catch (error) {
console.error('[App] Error adding single:', error);
this.showError(error.message);
}
}
async removeParticipant() {
const bandId = this.parseInteger(document.getElementById('removeParticipantBandId').value);
if (!bandId || bandId <= 0) {
this.showError('Please enter a valid band ID');
return;
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/remove-participant`, { method: 'POST' });
document.getElementById('removeParticipantBandId').value = '';
await this.loadBands();
this.showSuccess(`Participant removed from band #${bandId}!`);
} catch (error) {
this.showError(error.message);
}
}
console.log(`[App] Removing participant from band ID: ${bandId}`);
async importFile() {
const fileInput = document.getElementById('importFile');
const formatSelect = document.getElementById('importFormat');
try {
await this.makeRequest(`${this.API_BASE}/music-bands/${bandId}/remove-participant`, { method: 'POST' });
document.getElementById('removeParticipantBandId').value = '';
if (!fileInput.files || fileInput.files.length === 0) {
this.showError('Please select a file to import');
return;
}
await this.loadBands();
const file = fileInput.files[0];
let format = formatSelect.value;
this.showSuccess(`Participant removed from band #${bandId}!`);
} catch (error) {
console.error('[App] Error removing participant:', error);
this.showError(error.message);
if (!format) {
const ext = file.name.split('.').pop().toLowerCase();
if (['json', 'xml', 'yaml', 'yml'].includes(ext)) {
format = ext === 'yml' ? 'yaml' : ext;
} else {
this.showError('Could not detect file format. Please select format manually.');
return;
}
}
try {
const fileContent = await this.readFileAsText(file);
const response = await fetch(`${this.API_BASE}/import?format=${format}`, {
method: 'POST',
headers: {
'Content-Type': 'text/plain',
'Authorization': `Bearer ${this.token}`
},
body: fileContent
});
const data = await response.json();
if (response.ok) {
fileInput.value = '';
this.showSuccess(`Import completed successfully! ${data.objectsCount} bands imported.`);
await this.loadBands();
} else {
const errorMsg = data.error || 'Import failed';
const opId = data.operationId ? ` (Operation ID: ${data.operationId})` : '';
this.showError(`Import failed: ${errorMsg}${opId}`);
}
} catch (error) {
this.showError(`Import error: ${error.message}`);
}
}
}
readFileAsText(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => resolve(e.target.result);
reader.onerror = () => reject(new Error('Failed to read file'));
reader.readAsText(file);
});
}
async loadImportHistory() {
try {
const history = await this.makeRequest(`${this.API_BASE}/import/history`);
this.renderImportHistory(history);
} catch (error) {
this.showError(error.message);
}
}
renderImportHistory(history) {
const tbody = document.getElementById('historyTableBody');
tbody.innerHTML = '';
if (history.length === 0) {
const row = document.createElement('tr');
row.innerHTML = '<td colspan="6" style="text-align: center; color: var(--muted);">No import history</td>';
tbody.appendChild(row);
return;
}
history.forEach((item) => {
const row = document.createElement('tr');
const statusClass = item.status === 'SUCCESS' ? 'text-success' : 'text-error';
row.innerHTML = `
<td>${item.id}</td>
<td>${this.escapeHtml(item.username)}</td>
<td class="${statusClass}">${item.status}</td>
<td>${item.objectsCount ?? '—'}</td>
<td>${new Date(item.createdAt).toLocaleString()}</td>
<td>${item.errorMessage ? this.escapeHtml(item.errorMessage) : '—'}</td>
`;
tbody.appendChild(row);
});
}
initializeWebSocket() {
if (this.websocket && this.websocket.readyState === WebSocket.OPEN) {
return;
@ -907,64 +1098,50 @@ async removeParticipant() {
const wsUrl = `${protocol}//${location.host}/is1/websocket/bands`;
try {
console.log('[WS] Connecting to:', wsUrl);
this.websocket = new WebSocket(wsUrl);
this.websocket.onopen = () => {
console.log('[WS] Connection established');
this.reconnectAttempts = 0;
};
this.websocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('[WS] Message received:', data);
if (data.type === 'band_update') {
this.handleBandUpdate(data.action);
}
} catch (e) {
console.warn('[WS] Failed to parse message:', e);
}
} catch (e) {}
};
this.websocket.onclose = (event) => {
console.log('[WS] Connection closed:', event.code, event.reason);
this.websocket = null;
this.reconnectAttempts = (this.reconnectAttempts || 0) + 1;
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000);
console.log(`[WS] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})...`);
setTimeout(() => this.initializeWebSocket(), delay);
};
this.websocket.onerror = (error) => {
console.error('[WS] Error:', error);
if (this.websocket) {
this.websocket.close();
}
};
} catch (e) {
console.warn('[WS] Failed to connect:', e);
setTimeout(() => this.initializeWebSocket(), 5000);
}
}
handleBandUpdate(action) {
console.log('[WS] Handling band update:', action);
if (['create', 'update', 'delete'].includes(action)) {
this.loadBands();
if (action === 'create') {
this.showInfo('New band added by another user');
} else if (action === 'update') {
this.showInfo('Band updated by another user');
} else if (action === 'delete') {
this.showInfo('Band deleted by another user');
handleBandUpdate(action) {
if (['create', 'update', 'delete'].includes(action)) {
this.loadBands();
if (action === 'create') {
this.showInfo('New band added by another user');
} else if (action === 'update') {
this.showInfo('Band updated by another user');
} else if (action === 'delete') {
this.showInfo('Band deleted by another user');
}
}
}
}
showError(message) {
this.showToast(message, 'error');